Skip to content

Commit

Permalink
feat: Add ImageDataset and Layer for ConvolutionalNeuralNetworks (#645
Browse files Browse the repository at this point in the history
)

Closes #579, #580, #581 

### Summary of Changes

feat: added `Convolutional2DLayer`, `ConvolutionalTranspose2DLayer`,
`FlattenLayer`, `MaxPooling2DLayer` and `AvgPooling2DLayer`
feat: added `InputConversionImage`, `OutputConversionImageToColumn`,
`OutputConversionImageToTable` and `OutputConversionImageToImage`
feat: added generic `ImageDataset`
feat: added class `ImageSize` and methods `ImageList.sizes` and
`Image.size` to get the sizes of the respective images
feat: added ability to iterate over `SingleSizeImageList`
feat: added param to return filenames in `ImageList.from_files`
feat: added option `None` for no activation function in `ForwardLayer`
feat: added `Image.__array__` to convert a `Image` to a `numpy.ndarray`
feat: added equals check to `OneHotEncoder`
fix: fixed bug #581 in removing
the Softmax function from the last layer in `NeuralNetworkClassifier`
refactor: move `image.utils` to `image._utils`
refactor: extracted test devices from `test_image` to `helpers.devices`

---------

Co-authored-by: megalinter-bot <[email protected]>
  • Loading branch information
Marsmaennchen221 and megalinter-bot authored May 6, 2024
1 parent 3261b26 commit 5b6d219
Show file tree
Hide file tree
Showing 48 changed files with 4,140 additions and 173 deletions.
File renamed without changes.
11 changes: 8 additions & 3 deletions src/safeds/data/image/containers/_empty_image_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down
37 changes: 35 additions & 2 deletions src/safeds/data/image/containers/_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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()
)
Expand All @@ -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.
Expand Down Expand Up @@ -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:
"""
Expand Down
57 changes: 52 additions & 5 deletions src/safeds/data/image/containers/_image_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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):
Expand Down Expand Up @@ -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.
Expand All @@ -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
-------
Expand All @@ -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
Expand All @@ -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]
Expand All @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
16 changes: 14 additions & 2 deletions src/safeds/data/image/containers/_multi_size_image_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
58 changes: 54 additions & 4 deletions src/safeds/data/image/containers/_single_size_image_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 5b6d219

Please sign in to comment.