Skip to content

Commit

Permalink
feat: container for images (#159)
Browse files Browse the repository at this point in the history
Closes #158.

### Summary of Changes

* New class `safeds.data.image.containers.Image` to store images
* An `Image` can be created from and saved to JPEG and PNG files
* Methods that create a plot for `Table` and `Column` now return an
`Image` instead of plotting them directly in an interactive environment
* An `Image` can be displayed in an interactive environment

---------

Co-authored-by: megalinter-bot <[email protected]>
  • Loading branch information
lars-reimann and megalinter-bot authored Apr 4, 2023
1 parent 26eee03 commit ed7ae34
Show file tree
Hide file tree
Showing 18 changed files with 485 additions and 113 deletions.
157 changes: 73 additions & 84 deletions poetry.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ packages = [

[tool.poetry.dependencies]
python = "^3.10"
ipython = "^8.8.0"
matplotlib = "^3.6.3"
pandas = "^1.5.3"
pillow = "^9.5.0"
scikit-learn = "^1.2.0"
seaborn = "^0.12.2"
ipython = "^8.8.0"
matplotlib = "^3.6.3"

[tool.poetry.group.dev.dependencies]
pytest = "^7.2.1"
Expand Down
1 change: 1 addition & 0 deletions src/safeds/data/image/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Work with image data."""
7 changes: 7 additions & 0 deletions src/safeds/data/image/containers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Classes that can store image data."""

from ._image import Image

__all__ = [
"Image",
]
153 changes: 153 additions & 0 deletions src/safeds/data/image/containers/_image.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
from __future__ import annotations

import io
from pathlib import Path
from typing import BinaryIO

from PIL.Image import Image as PillowImage
from PIL.Image import open as open_image

from safeds.data.image.typing import ImageFormat


class Image:
"""
A container for image data.
Parameters
----------
data : BinaryIO
The image data as bytes.
"""

@staticmethod
def from_jpeg_file(path: str) -> Image:
"""
Create an image from a JPEG file.
Parameters
----------
path : str
The path to the JPEG file.
Returns
-------
image : Image
The image.
"""
return Image(
data=Path(path).open("rb"),
format_=ImageFormat.JPEG,
)

@staticmethod
def from_png_file(path: str) -> Image:
"""
Create an image from a PNG file.
Parameters
----------
path : str
The path to the PNG file.
Returns
-------
image : Image
The image.
"""
return Image(
data=Path(path).open("rb"),
format_=ImageFormat.PNG,
)

def __init__(self, data: BinaryIO, format_: ImageFormat):
data.seek(0)

self._image: PillowImage = open_image(data, formats=[format_.value])
self._format: ImageFormat = format_

# ------------------------------------------------------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------------------------------------------------------

@property
def format(self) -> ImageFormat:
"""
Get the image format.
Returns
-------
format : ImageFormat
The image format.
"""
return self._format

# ------------------------------------------------------------------------------------------------------------------
# Conversion
# ------------------------------------------------------------------------------------------------------------------

def to_jpeg_file(self, path: str) -> None:
"""
Save the image as a JPEG file.
Parameters
----------
path : str
The path to the JPEG file.
"""
Path(path).parent.mkdir(parents=True, exist_ok=True)
self._image.save(path, format="jpeg")

def to_png_file(self, path: str) -> None:
"""
Save the image as a PNG file.
Parameters
----------
path : str
The path to the PNG file.
"""
Path(path).parent.mkdir(parents=True, exist_ok=True)
self._image.save(path, format="png")

# ------------------------------------------------------------------------------------------------------------------
# IPython integration
# ------------------------------------------------------------------------------------------------------------------

def _repr_jpeg_(self) -> bytes | None:
"""
Return a JPEG image as bytes.
If the image is not a JPEG, return None.
Returns
-------
jpeg : bytes
The image as JPEG.
"""
if self._format != ImageFormat.JPEG:
return None

buffer = io.BytesIO()
self._image.save(buffer, format="jpeg")
buffer.seek(0)
return buffer.read()

def _repr_png_(self) -> bytes | None:
"""
Return a PNG image as bytes.
If the image is not a PNG, return None.
Returns
-------
png : bytes
The image as PNG.
"""
if self._format != ImageFormat.PNG:
return None

buffer = io.BytesIO()
self._image.save(buffer, format="png")
buffer.seek(0)
return buffer.read()
7 changes: 7 additions & 0 deletions src/safeds/data/image/typing/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Types used to distinguish different image formats."""

from ._image_format import ImageFormat

__all__ = [
"ImageFormat",
]
8 changes: 8 additions & 0 deletions src/safeds/data/image/typing/_image_format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from enum import Enum


class ImageFormat(Enum):
"""Images formats supported by us."""

JPEG = "jpeg"
PNG = "png"
66 changes: 49 additions & 17 deletions src/safeds/data/tabular/containers/_column.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import io
from numbers import Number
from typing import TYPE_CHECKING, Any

Expand All @@ -9,6 +10,8 @@
import seaborn as sns
from IPython.core.display_functions import DisplayHandle, display

from safeds.data.image.containers import Image
from safeds.data.image.typing import ImageFormat
from safeds.data.tabular.typing import ColumnType
from safeds.exceptions import (
ColumnLengthMismatchError,
Expand Down Expand Up @@ -470,10 +473,15 @@ def variance(self) -> float:
# Plotting
# ------------------------------------------------------------------------------------------------------------------

def boxplot(self) -> None:
def boxplot(self) -> Image:
"""
Plot this column in a boxplot. This function can only plot real numerical data.
Returns
-------
plot: Image
The plot as an image.
Raises
------
TypeError
Expand All @@ -487,13 +495,28 @@ def boxplot(self) -> None:
"The column contains complex data. Boxplots cannot plot the imaginary part of complex "
"data. Please provide a Column with only real numbers",
)

fig = plt.figure()
ax = sns.boxplot(data=self._data)
ax.set(xlabel=self.name)
plt.tight_layout()
plt.show()

def histogram(self) -> None:
"""Plot a column in a histogram."""
buffer = io.BytesIO()
fig.savefig(buffer, format="png")
plt.close() # Prevents the figure from being displayed directly
buffer.seek(0)
return Image(buffer, ImageFormat.PNG)

def histogram(self) -> Image:
"""
Plot a column in a histogram.
Returns
-------
plot: Image
The plot as an image.
"""
fig = plt.figure()
ax = sns.histplot(data=self._data)
ax.set_xticks(ax.get_xticks())
ax.set(xlabel=self.name)
Expand All @@ -503,23 +526,17 @@ def histogram(self) -> None:
horizontalalignment="right",
) # rotate the labels of the x Axis to prevent the chance of overlapping of the labels
plt.tight_layout()
plt.show()

buffer = io.BytesIO()
fig.savefig(buffer, format="png")
plt.close() # Prevents the figure from being displayed directly
buffer.seek(0)
return Image(buffer, ImageFormat.PNG)

# ------------------------------------------------------------------------------------------------------------------
# Other
# IPython integration
# ------------------------------------------------------------------------------------------------------------------

def _count_missing_values(self) -> int:
"""
Return the number of null values in the column.
Returns
-------
count : int
The number of null values.
"""
return self._data.isna().sum()

def _ipython_display_(self) -> DisplayHandle:
"""
Return a display object for the column to be used in Jupyter Notebooks.
Expand All @@ -534,3 +551,18 @@ def _ipython_display_(self) -> DisplayHandle:

with pd.option_context("display.max_rows", tmp.shape[0], "display.max_columns", tmp.shape[1]):
return display(tmp)

# ------------------------------------------------------------------------------------------------------------------
# Other
# ------------------------------------------------------------------------------------------------------------------

def _count_missing_values(self) -> int:
"""
Return the number of null values in the column.
Returns
-------
count : int
The number of null values.
"""
return self._data.isna().sum()
2 changes: 1 addition & 1 deletion src/safeds/data/tabular/containers/_row.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ def count(self) -> int:
return len(self._data)

# ------------------------------------------------------------------------------------------------------------------
# Other
# IPython integration
# ------------------------------------------------------------------------------------------------------------------

def _ipython_display_(self) -> DisplayHandle:
Expand Down
Loading

0 comments on commit ed7ae34

Please sign in to comment.