Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
list[float]) [JoshCLWren]
- add hand-written binding type hints for common methods [JoshCLWren]
- add test coverage for type stubs [JoshCLWren]
- add `Image.pil()` to convert to a PIL image [jonashaag]

## Version 3.1.1 (released 9 December 2025)

Expand Down
5 changes: 3 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,9 @@ Stylecheck:

Type checking:

pyvips includes type hints via PEP 561 type stub files (``pyvips/__init__.pyi``).
To enable type checking in your project, install a type checker like mypy:
pyvips includes type hints via PEP 561 type stub files
(``pyvips/__init__.pyi``). To enable type checking in your project,
install a type checker like mypy:

.. code-block:: shell

Expand Down
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.

998 changes: 499 additions & 499 deletions doc/vimage.rst

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions examples/generate_type_stubs.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,8 @@ def generate_stub() -> str:

import numpy as np # type: ignore[import-not-found]

from PIL.Image import Image as PILImage # type: ignore

from .enums import {enums}

class _ArrayInterface(Protocol):
Expand Down Expand Up @@ -258,6 +260,7 @@ def __array__(self, dtype: np.dtype | None = None, copy: bool | None = None) ->
vips_lib: Any
gobject_lib: Any


# Exception classes
class Error(Exception): ...

Expand Down Expand Up @@ -406,6 +409,7 @@ def copy(self, *, width: int = ..., height: int = ..., bands: int = ..., format:
def tolist(self) -> list[list[float]]: ...
def __array__(self, dtype: np.dtype | str | None = None, copy: bool | None = None) -> np.ndarray: ...
def numpy(self, dtype: np.dtype | str | None = None) -> np.ndarray: ...
def pil(self) -> PILImage: ...

# Hand-written bindings with type hints
def floor(self) -> Image: ...
Expand Down
2 changes: 1 addition & 1 deletion examples/watermark.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
# tile to the size of the image page, then tile again to the full image size
text = text.embed(10, 10, text.width + 20, text.width + 20)
page_height = im.get_page_height()
text = text.replicate(int(1 + im.width / text.width),
text = text.replicate(int(1 + im.width / text.width),
int(1 + page_height / text.height))
text = text.crop(0, 0, im.width, page_height)
text = text.replicate(1, int(1 + im.height / text.height))
Expand Down
9 changes: 9 additions & 0 deletions pyvips/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ from typing import Any, Callable, Protocol, TypeAlias

import numpy as np # type: ignore[import-not-found]

from PIL.Image import Image as PILImage # type: ignore

from .enums import Access as Access, Align as Align, Angle as Angle, Angle45 as Angle45, BandFormat as BandFormat, BlendMode as BlendMode, Coding as Coding, Combine as Combine, CombineMode as CombineMode, CompassDirection as CompassDirection, Direction as Direction, Extend as Extend, FailOn as FailOn, ForeignDzContainer as ForeignDzContainer, ForeignDzDepth as ForeignDzDepth, ForeignDzLayout as ForeignDzLayout, ForeignHeifCompression as ForeignHeifCompression, ForeignHeifEncoder as ForeignHeifEncoder, ForeignKeep as ForeignKeep, ForeignPdfPageBox as ForeignPdfPageBox, ForeignPngFilter as ForeignPngFilter, ForeignPpmFormat as ForeignPpmFormat, ForeignSubsample as ForeignSubsample, ForeignTiffCompression as ForeignTiffCompression, ForeignTiffPredictor as ForeignTiffPredictor, ForeignTiffResunit as ForeignTiffResunit, ForeignWebpPreset as ForeignWebpPreset, Intent as Intent, Interesting as Interesting, Interpretation as Interpretation, Kernel as Kernel, OperationBoolean as OperationBoolean, OperationComplex as OperationComplex, OperationComplex2 as OperationComplex2, OperationComplexget as OperationComplexget, OperationMath as OperationMath, OperationMath2 as OperationMath2, OperationMorphology as OperationMorphology, OperationRelational as OperationRelational, OperationRound as OperationRound, PCS as PCS, Precision as Precision, RegionShrink as RegionShrink, SdfShape as SdfShape, Size as Size, TextWrap as TextWrap

class _ArrayInterface(Protocol):
Expand Down Expand Up @@ -51,6 +53,7 @@ glib_lib: Any
vips_lib: Any
gobject_lib: Any


# Exception classes
class Error(Exception): ...

Expand Down Expand Up @@ -199,6 +202,7 @@ class Image(VipsObject):
def tolist(self) -> list[list[float]]: ...
def __array__(self, dtype: np.dtype | str | None = None, copy: bool | None = None) -> np.ndarray: ...
def numpy(self, dtype: np.dtype | str | None = None) -> np.ndarray: ...
def pil(self) -> PILImage: ...

# Hand-written bindings with type hints
def floor(self) -> Image: ...
Expand Down Expand Up @@ -506,6 +510,11 @@ class Image(VipsObject):
def msb(self, *, band: int = ...) -> Image: ...
def multiply(self, right: Image) -> Image: ...
@staticmethod
def niftiload(filename: str | Path, *, memory: bool = ..., access: str | Access = ..., fail_on: str | FailOn = ..., revalidate: bool = ..., flags: bool = ...) -> Image: ...
@staticmethod
def niftiload_source(source: Source, *, memory: bool = ..., access: str | Access = ..., fail_on: str | FailOn = ..., revalidate: bool = ..., flags: bool = ...) -> Image: ...
def niftisave(self, filename: str | Path, *, keep: int = ..., background: list[float] = ..., page_height: int = ..., profile: str | Path = ...) -> None: ...
@staticmethod
def openexrload(filename: str | Path, *, memory: bool = ..., access: str | Access = ..., fail_on: str | FailOn = ..., revalidate: bool = ..., flags: bool = ...) -> Image: ...
@staticmethod
def openslideload(filename: str | Path, *, level: int = ..., autocrop: bool = ..., associated: str = ..., attach_associated: bool = ..., rgb: bool = ..., memory: bool = ..., access: str | Access = ..., fail_on: str | FailOn = ..., revalidate: bool = ..., flags: bool = ...) -> Image: ...
Expand Down
53 changes: 53 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,57 @@ 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
201 changes: 201 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,204 @@ def test_from_PIL(self):
assert im.min() == 0
assert im.max() == 0

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

try:
import numpy as np # noqa: F401
except ImportError:
pytest.skip('numpy 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')

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')

try:
import numpy as np # noqa: F401
except ImportError:
pytest.skip('numpy 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 # noqa: F401
except ImportError:
pytest.skip('PIL not available')

try:
import numpy as np # noqa: F401
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 # noqa: F401
except ImportError:
pytest.skip('PIL not available')

try:
import numpy as np # noqa: F401
except ImportError:
pytest.skip('numpy 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 # noqa: F401
except ImportError:
pytest.skip('PIL not available')

try:
import numpy as np # noqa: F401
except ImportError:
pytest.skip('numpy 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 # noqa: F401
except ImportError:
pytest.skip('PIL not available')

try:
import numpy as np # noqa: F401
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 # noqa: F401
except ImportError:
pytest.skip('PIL not available')

try:
import numpy as np # noqa: F401
except ImportError:
pytest.skip('numpy 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
Loading