Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding support for Pillow Image Object in toga #2154

Closed
wants to merge 11 commits into from
45 changes: 45 additions & 0 deletions changes/2142.feature.rst
dr-gavitron marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
Added Pillow support in toga
============================
toga.Image
----------
`toga.Image` can now take Pillow Image Object. simply set pil_image parameter to the Pillow Object
code-block::python
from PIL import Image as PIL_Image
import toga
myimg = PIL_Image.open("path/to/image.png")
image = toga.Image(pil_image=myimg)

Also to convert a `toga.Image` object as Pillow Object
code-block::python
pil_image = image.as_format(PIL_Image.Image)
#or
pil_image = image.as_format(format=PIL_Image.Image)

toga.ImageView
--------------
pass a Pillow Object in `toga.ImageView` and it will show the image
code-block::python
from PIL import Image as PIL_Image
import toga
myimg = PIL_Image.open("path/to/image.png")
imageview = toga.ImageView(myimg)

Aslo to extract image as Pillow Object from the `imageview`
code-block::python
pil_img = imageview.as_image(PIL_Image.Image)
#or
pil_img = imageview.as_image(format=PIL_Image.Image)

toga.Canvas
-----------
`Canvas.as_image` now can return Pillow object if PIL.Image.Image set in the format parameter
code-block::python
from PIL import Image as PIL_Image
pil_img = canvas.as_image(PIL_Image.Image)
#or
pil_img = canvas.as_image(format=PIL_Image.Image)

When conversion format is `None`
------------------------------
Also when `format` is `None` in `Image.as_format` or `ImageView.as_image` or `Canvas.as_image`
it will return a `toga.Image` object by default
82 changes: 73 additions & 9 deletions core/src/toga/images.py
Original file line number Diff line number Diff line change
@@ -1,52 +1,87 @@
from __future__ import annotations

from typing import Any

from pathlib import Path

import toga
from toga.platform import get_platform_factory

from io import BytesIO
import time, os

try:
from PIL import Image as PIL_Image
PIL_ImportError_Message = None
except ImportError as e:
PIL_Image = None
PIL_ImportError_Message = e
class Image:
def __init__(
self,
path: str | None | Path = None,
*,
data: bytes | None = None,
pil_image: Any | None = None,
):
"""Create a new image.

An image must be provided either a ``path`` or ``data``, but not both.
An image must be one of ``path``, ``data`` or ``pil_image``

:param path: Path to the image to load. This can be specified as a string, or as
a :any:`pathlib.Path` object. The path can be an absolute file system path,
or a path relative to the module that defines your Toga application class.
:param data: A bytes object with the contents of an image in a supported format.
:param pil_image: PIL.Image object created from an image of a supported format.
:raises FileNotFoundError: If a path is provided, but that path does not exist.
:raises ValueError: If the path or data cannot be loaded as an image.
:raises TypeError: If pil_image is provided but the type of the object is not PIL.Image
"""
if path is None and data is None:
raise ValueError("Either path or data must be set.")
if path is not None and data is not None:
raise ValueError("Only either path or data can be set.")

# At first we will create a list with these three variable, then count how many None is in that list.
# If the number of None is 1 -> raises ValueError. One and only One of the three variables must be set but here two of them are set
# If the number of None is 3 -> raises ValueError. One and only One of the three variables must be set but here none of them are set
# If the number of None is 2 -> Ok. Check the validity of the value of that non-None variable
none_count = [path, data, pil_image].count(None)
if none_count != 2:
raise ValueError("One and Only one of the three args (path, data, pil_image) must be set.")
# checking validity of the arg(one of the three)
if path is not None:
if isinstance(path, Path):
self.path = path
else:
self.path = Path(path)
self.data = None
else:
self.path = None
self.pil_image = None
if data is not None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fairly certain this leaves open a path toga.Image() won't raise an error. The test suite should catch this... but the test suite isn't currently running, because of other problems with the test suite.

self.data = data
self.path = None
self.pil_image = None
if pil_image is not None:
if PIL_Image == None:
raise ImportError(PIL_ImportError_Message)
if not PIL_Image.isImageType(pil_image):
raise TypeError("pil_image is not a PIL.Image type.")
self.pil_image = pil_image
self.data = None
self.path = None


self.factory = get_platform_factory()
if self.data is not None:
self._impl = self.factory.Image(interface=self, data=self.data)
else:
if self.path is not None:
self.path = toga.App.app.paths.app / self.path
if not self.path.is_file():
raise FileNotFoundError(f"Image file {self.path} does not exist")
self._impl = self.factory.Image(interface=self, path=self.path)
if self.pil_image is not None:
img_buffer = BytesIO()
self.pil_image.save(img_buffer, format=self.pil_image.format)
img_buffer.seek(0)
self._impl = self.factory.Image(interface=self, data=img_buffer.read())




Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should be 2 lines between classes. This is picked up automatically when you install pre-commit.

@property
def width(self) -> int:
Expand All @@ -67,3 +102,32 @@ def save(self, path: str | Path):
:param path: Path where to save the image.
"""
self._impl.save(path)

def as_format(self, format: Any|None=None):
"""
get the image as specified format if supported
:param format: None or A supported type of Image
Supported types are `PIL.Image.Image`,
:returns: toga.Image if format is None, or the specified format if the format is supported
```
from PIL import Image
pil_image = toga_image.as_format(Image.Image)
```
"""
if format == None:
return self
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not clear to me why as_format() shouldn't require a single argument - the format you want the image in.

It's also not clear to me why as_format(toga.Image) is something anyone would ever invoke... on toga.Image.

elif PIL_Image != None and format == PIL_Image.Image:
# saving into temporary file
unique_time = str(time.time())
temp_file = f"._toga_Image_as_format_PIL_Image_{unique_time}_"
temp_file_path = os.path.join(toga.App.app.paths.app, temp_file)
self.save(temp_file_path)
# creating PIL.Image.Image from temporary file
pil_image = PIL_Image.open(temp_file_path)
# deleting the temporary file
os.remove(temp_file_path)

return pil_image
else:
raise TypeError(f"Unknown conversion format: {format}")

23 changes: 18 additions & 5 deletions core/src/toga/widgets/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from abc import ABC, abstractmethod
from contextlib import contextmanager
from math import pi
from typing import Protocol
from typing import Protocol, Any

from travertino.colors import Color

Expand All @@ -17,6 +17,12 @@
from .. import Image
from .base import Widget

from io import BytesIO
try:
from PIL import Image as PIL_Image
except:
PIL_Image = None

#######################################################################################
# Simple drawing objects
#######################################################################################
Expand Down Expand Up @@ -1445,11 +1451,18 @@ def measure_text(
# As image
###########################################################################

def as_image(self) -> toga.Image:
def as_image(self, format: Any | None = None) -> toga.Image:

"""Render the canvas as an Image.

:returns: A :class:`toga.Image` containing the canvas content."""
return Image(data=self._impl.get_image_data())
:param format: None or type of the image that will be returned
Supported Types: PIL.Image.Image, toga.Image
:returns: Object of the specified Image type. If unspecified returns toga.Image"""
if format == None:
return Image(data=self._impl.get_image_data())
elif PIL_Image != None and format==PIL_Image.Image:
return PIL_Image.open(BytesIO(self._impl.get_image_data()))
else:
raise TypeError(f"Unsupported image format: {format}")

###########################################################################
# 2023-07 Backwards compatibility
Expand Down
33 changes: 32 additions & 1 deletion core/src/toga/widgets/imageview.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@

from pathlib import Path

from typing import Any

from travertino.size import at_least

from toga.images import Image
from toga.style.pack import NONE
from toga.widgets.base import Widget

try:
from PIL import Image as PIL_Image
except ImportError as e:
PIL_Image = None


def rehint_imageview(image, style, scale=1):
"""Compute the size hints for an ImageView based on the image.
Expand Down Expand Up @@ -63,7 +70,7 @@ def rehint_imageview(image, style, scale=1):
class ImageView(Widget):
def __init__(
self,
image: Image | Path | str | None = None,
image: Image | Path | str | Any | None = None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you're using Any, there's no need to specify anything else, because any type is a subset of Any.

id=None,
style=None,
):
Expand All @@ -79,6 +86,12 @@ def __init__(
# Prime the image attribute
self._image = None
self._impl = self.factory.ImageView(interface=self)
# checking if the image is PIL.Image.Image, if it is, then convert it to toga.Image
if PIL_Image!=None and PIL_Image.isImageType(image):
image = Image(pil_image = image)



self.image = image

@property
Expand Down Expand Up @@ -124,3 +137,21 @@ def image(self, image):

self._impl.set_image(self._image)
self.refresh()

def as_image(self, format: Any | None=None):
'''
get the image from ImageView as specified format if supported
:param format: None or A supported type of Image
Supported types are `PIL.Image.Image`,
:returns: toga.Image if format is None, or the specified format if the format is supported
```
from PIL import Image
pil_image = imageview.as_image(Image.Image)
```
'''
if format == None:
return self.image
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If two branches have the same implementation, perhaps consider whether an or is needed.

elif PIL_Image!=None and format==PIL_Image.Image:
return self.image.as_format(PIL_Image.Image)
else:
return TypeError(f"Unknown conversion format: {format}")
20 changes: 20 additions & 0 deletions core/tests/test_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,23 @@ def test_image_save():

image.save(save_path)
assert_action_performed_with(image, "save", path=save_path)

def test_pil_support():
from PIL import Image as PIL_Image

pil_img = PIL_Image.open("resources/toga.png")
toga_img = toga.Image("resources/toga.png")
toga_img_from_pil_img = toga.Image(pil_image = pil_img)
dr-gavitron marked this conversation as resolved.
Show resolved Hide resolved

assert toga_img.width == toga_img_from_pil_img.width, "PIL support is faulty"
dr-gavitron marked this conversation as resolved.
Show resolved Hide resolved
assert toga_img.height == toga_img_from_pil_img.height, "PIL support is faulty"

pil_img2 = toga_img_from_pil_img.as_format(PIL_Image.Image)

assert type(pil_img2) == type(pil_img), "Image.as_format(PIL_Image.Image) is faulty"
assert pil_img2.size == pil_img.size, "Image.as_format(PIL_Image.Image) is faulty"

def test_as_format_none():
img = toga.Image("resources/toga.png")
img2 = img.as_format()
assert img == img2, "Image.as_format should return self when nothing is provided as arg, but failed"
dr-gavitron marked this conversation as resolved.
Show resolved Hide resolved
5 changes: 5 additions & 0 deletions core/tests/widgets/canvas/test_canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ def test_as_image(widget):
assert image is not None
assert_action_performed(widget, "get image data")

from PIL import Image as PIL_Image
image = widget.as_image(format=PIL_Image.Image)
assert type(image) == PIL_Image.Image
assert_action_performed(widget, "get image data as Pillow Image Object")
dr-gavitron marked this conversation as resolved.
Show resolved Hide resolved


def test_deprecated_drawing_operations(widget):
"""Deprecated simple drawing operations raise a warning"""
Expand Down
18 changes: 18 additions & 0 deletions core/tests/widgets/test_imageview.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,21 @@ def test_rehint_empty_image(params):
assert width == 0
assert height == 0
assert aspect_ratio is None

def test_pil_support():
from PIL import Image as PIL_Image
pil_img = PIL_Image.open("resources/toga.png")
toga_img = PIL_Image.open("resources/toga.png")

imageview = toga.ImageView(pil_img)
assert type(imageview.image) == toga.Image, "Internal conversion from PIL_Image.Image to toga.Image is faulty"
assert (imageview.image.width, imageview.image.height) == (toga_img.width,toga_img.height) == pil_img.size, "PIL support for imageview is faulty"
dr-gavitron marked this conversation as resolved.
Show resolved Hide resolved

pil_img2 = imageview.as_image(PIL_Image.Image)
assert pil_img2.size == pil_img2.size, "ImageView.as_image(PIL_Image.Image) is faulty"

def test_as_image_format_is_none():
img = toga.Image("resources/toga.png")
imageview = toga.ImageView(image=img)
img2 = imageview.as_image()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As with Image, this should be an argument error.

assert (img2.width, img2.height) == (img.width, img.height), "ImageView.as_image should return toga.Image when nothing is provided as parameter, but failed"