Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions doc/intro.rst
Original file line number Diff line number Diff line change
Expand Up @@ -154,13 +154,11 @@ many image formats::

assert np.array_equal(a1, a2)

The :meth:`PIL.Image.fromarray` method can be used to convert a pyvips image
to a PIL image via a NumPy array::
Use :meth:`.pil` to convert a pyvips image to a PIL image::

import pyvips
import PIL.Image
image = pyvips.Image.black(100, 100, bands=3)
pil_image = PIL.Image.fromarray(image.numpy())
pil_image = image.pil()


Calling libvips operations
Expand Down Expand Up @@ -415,4 +413,3 @@ where possible, so you won't have 100 copies in memory.

If you want to avoid the copies, you'll need to call drawing operations
yourself.

8 changes: 7 additions & 1 deletion examples/generate_type_stubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,12 @@ def generate_stub() -> str:
"""

from __future__ import annotations
from typing import Dict, List, Optional, Tuple, TypeVar, Union, overload
from typing import Dict, List, Optional, Tuple, TypeVar, Union, overload, TYPE_CHECKING

if TYPE_CHECKING:
from PIL.Image import Image as PILImage # type: ignore
else:
class PILImage: ...

# Exception classes
class Error(Exception): ...
Expand Down Expand Up @@ -441,6 +446,7 @@ def tolist(self) -> List[List[float]]: ...
# numpy is optional dependency - use TYPE_CHECKING guard
def __array__(self, dtype: Optional[str] = None, copy: Optional[bool] = None) -> object: ...
def numpy(self, dtype: Optional[str] = None) -> object: ...
def pil(self) -> PILImage: ...

# Hand-written bindings with type hints
def floor(self) -> Image: ...
Expand Down
8 changes: 7 additions & 1 deletion pyvips/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ To regenerate after libvips updates:
"""

from __future__ import annotations
from typing import Dict, List, Optional, Tuple, TypeVar, Union, overload
from typing import Dict, List, Optional, Tuple, TypeVar, Union, overload, TYPE_CHECKING

if TYPE_CHECKING:
from PIL.Image import Image as PILImage # type: ignore
else:
class PILImage: ...

# Exception classes
class Error(Exception): ...
Expand Down Expand Up @@ -184,6 +189,7 @@ class Image(VipsObject):
# numpy is optional dependency - use TYPE_CHECKING guard
def __array__(self, dtype: Optional[str] = None, copy: Optional[bool] = None) -> object: ...
def numpy(self, dtype: Optional[str] = None) -> object: ...
def pil(self) -> PILImage: ...

# Hand-written bindings with type hints
def floor(self) -> Image: ...
Expand Down
52 changes: 52 additions & 0 deletions pyvips/vimage.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# wrap VipsImage

import array
import numbers
import struct
import sys

import pyvips
from pyvips import ffi, glib_lib, vips_lib, Error, _to_bytes, \
Expand Down Expand Up @@ -1279,6 +1281,56 @@ def numpy(self, dtype=None):
"""
return self.__array__(dtype=dtype)

def pil(self):
"""Convert the image to a PIL Image.

This uses :meth:`PIL.Image.fromarray` for most formats and falls back
to raw-mode conversion for formats not natively supported by that method.

Notes:

- Pillow stores RGB/RGBA data as 8-bit, so 16-bit inputs will be
converted by keeping the high byte of each channel.
- Pillow expands LA inputs to RGBA by duplicating the L channel into RGB.

PIL is a runtime dependency of this function.
"""
try:
from PIL import Image as PILImage
except ImportError as err:
raise ImportError('PIL not available') from err

if self.bands == 1 or self.format != 'ushort':
return PILImage.fromarray(self.numpy())

data = self.write_to_memory()

if self.bands == 2:
mode = 'RGBA'
rawmode = 'LA;16B'
# Pillow only accepts LA;16B. Byteswap on little-endian systems.
if sys.byteorder == 'little':
swapped = array.array('H')
swapped.frombytes(data)
swapped.byteswap()
data = swapped.tobytes()
elif self.bands == 3:
mode = 'RGB'
rawmode = 'RGB;16L' if sys.byteorder == 'little' else 'RGB;16B'
elif self.bands == 4:
mode = 'RGBA'
rawmode = 'RGBA;16L' if sys.byteorder == 'little' else 'RGBA;16B'
else:
raise ValueError('PIL does not support 16-bit images with more than 4 bands')

return PILImage.frombytes(
mode,
(self.width, self.height),
data,
'raw',
rawmode
)

def __repr__(self):
if (
self.interpretation == "matrix"
Expand Down
184 changes: 184 additions & 0 deletions tests/test_image.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# vim: set fileencoding=utf-8 :

import struct
import sys

import pyvips
import pytest
from helpers import JPEG_FILE, UHDR_FILE, skip_if_no
Expand Down Expand Up @@ -394,6 +397,187 @@ def test_from_PIL(self):
assert im.min() == 0
assert im.max() == 0

def test_to_PIL_16bit(self):
try:
import PIL.Image
except ImportError:
pytest.skip('PIL not available')

endian = '<' if sys.byteorder == 'little' else '>'
data = struct.pack(
f'{endian}6H',
0x1234,
0x5678,
0x9ABC,
0xDEF0,
0x1111,
0x2222
)
im = pyvips.Image.new_from_memory(data, 2, 1, 3, 'ushort')

try:
import numpy as np
except ImportError:
np = None

if np is not None:
with pytest.raises(TypeError):
PIL.Image.fromarray(im.numpy())

pim = im.pil()
assert pim.mode == 'RGB'
assert pim.size == (2, 1)
assert pim.getpixel((0, 0)) == (0x12, 0x56, 0x9A)
assert pim.getpixel((1, 0)) == (0xDE, 0x11, 0x22)

def test_to_PIL_16bit_rawmode_rgb_rgba(self, monkeypatch):
try:
import PIL.Image as PILImage
except ImportError:
pytest.skip('PIL not available')

seen = []

def fake_frombytes(mode, size, data, decoder_name, rawmode):
seen.append((mode, rawmode))
return PILImage.new(mode, size)

monkeypatch.setattr(PILImage, "frombytes", fake_frombytes)

data = struct.pack('6H', 0x1234, 0x5678, 0x9ABC, 0xDEF0, 0x1111, 0x2222)
im = pyvips.Image.new_from_memory(data, 2, 1, 3, 'ushort')
im.pil()

data = struct.pack('8H', 0x1234, 0x5678, 0x9ABC, 0xDEF0,
0x1111, 0x2222, 0x3333, 0x4444)
im = pyvips.Image.new_from_memory(data, 2, 1, 4, 'ushort')
im.pil()

assert seen[0][0] == 'RGB'
assert seen[1][0] == 'RGBA'
assert seen[0][1] in ('RGB;16L', 'RGB;16B')
assert seen[1][1] in ('RGBA;16L', 'RGBA;16B')

def test_to_PIL_8bit_modes(self):
try:
import PIL.Image
except ImportError:
pytest.skip('PIL not available')

try:
import numpy as np
except ImportError:
pytest.skip('numpy not available')

im = pyvips.Image.new_from_memory(bytes([0, 128, 255, 64]),
2, 2, 1, 'uchar')
pim = im.pil()
assert pim.mode == 'L'
assert pim.size == (2, 2)
assert pim.getpixel((0, 0)) == 0
assert pim.getpixel((1, 0)) == 128

im = pyvips.Image.new_from_memory(bytes([10, 20, 30, 40]),
2, 1, 2, 'uchar')
pim = im.pil()
assert pim.mode == 'LA'
assert pim.getpixel((0, 0)) == (10, 20)
assert pim.getpixel((1, 0)) == (30, 40)

im = pyvips.Image.new_from_memory(bytes([1, 2, 3, 4, 5, 6]),
2, 1, 3, 'uchar')
pim = im.pil()
assert pim.mode == 'RGB'
assert pim.getpixel((0, 0)) == (1, 2, 3)
assert pim.getpixel((1, 0)) == (4, 5, 6)

im = pyvips.Image.new_from_memory(bytes([1, 2, 3, 4, 5, 6, 7, 8]),
2, 1, 4, 'uchar')
pim = im.pil()
assert pim.mode == 'RGBA'
assert pim.getpixel((0, 0)) == (1, 2, 3, 4)
assert pim.getpixel((1, 0)) == (5, 6, 7, 8)

def test_to_PIL_16bit_la(self):
try:
import PIL.Image
except ImportError:
pytest.skip('PIL not available')

endian = '<' if sys.byteorder == 'little' else '>'
data = struct.pack(
f'{endian}4H',
0x1234,
0x5678,
0x9ABC,
0xDEF0
)
im = pyvips.Image.new_from_memory(data, 2, 1, 2, 'ushort')

pim = im.pil()
assert pim.mode == 'RGBA'
assert pim.size == (2, 1)
assert pim.getpixel((0, 0)) == (0x12, 0x12, 0x12, 0x56)
assert pim.getpixel((1, 0)) == (0x9A, 0x9A, 0x9A, 0xDE)

def test_to_PIL_16bit_rgba(self):
try:
import PIL.Image
except ImportError:
pytest.skip('PIL not available')

endian = '<' if sys.byteorder == 'little' else '>'
data = struct.pack(
f'{endian}8H',
0x1234,
0x5678,
0x9ABC,
0xDEF0,
0x1111,
0x2222,
0x3333,
0x4444
)
im = pyvips.Image.new_from_memory(data, 2, 1, 4, 'ushort')

pim = im.pil()
assert pim.mode == 'RGBA'
assert pim.size == (2, 1)
assert pim.getpixel((0, 0)) == (0x12, 0x56, 0x9A, 0xDE)
assert pim.getpixel((1, 0)) == (0x11, 0x22, 0x33, 0x44)

def test_to_PIL_16bit_l(self):
try:
import PIL.Image
except ImportError:
pytest.skip('PIL not available')

try:
import numpy as np
except ImportError:
pytest.skip('numpy not available')

endian = '<' if sys.byteorder == 'little' else '>'
data = struct.pack(f'{endian}2H', 0x1234, 0x9ABC)
im = pyvips.Image.new_from_memory(data, 2, 1, 1, 'ushort')

pim = im.pil()
assert pim.size == (2, 1)
assert pim.getpixel((0, 0)) == 0x1234
assert pim.getpixel((1, 0)) == 0x9ABC

def test_to_PIL_16bit_5band_unsupported(self):
try:
import PIL.Image
except ImportError:
pytest.skip('PIL not available')

data = struct.pack('5H', 0x1111, 0x2222, 0x3333, 0x4444, 0x5555)
im = pyvips.Image.new_from_memory(data, 1, 1, 5, 'ushort')

with pytest.raises(ValueError):
im.pil()

@skip_if_no('uhdrload')
def test_gainmap(self):
def crop_gainmap(image):
Expand Down