Skip to content
Merged
Show file tree
Hide file tree
Changes from 55 commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
5890e40
WIP - Initial Pillow->Arrow support
wiredfool Aug 24, 2024
d44212d
WIP - Non working struct encoding
wiredfool Aug 24, 2024
bdd4b3a
Export as fixed width pixels, or just pixel values for single channel.
wiredfool Aug 24, 2024
56780ce
Tests, lifetime changes
wiredfool Aug 25, 2024
6ec855e
Lifetime check
wiredfool Aug 25, 2024
e1ef083
fix macoxisim
wiredfool Aug 25, 2024
97eb7c0
WIP -- First light of round trip of image -> arrow -> image
wiredfool Jan 21, 2025
f1349e9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 21, 2025
9d584a1
Lint
wiredfool Jan 25, 2025
2b88b1c
Typing Lint
wiredfool Jan 25, 2025
af64250
Lint
wiredfool Jan 25, 2025
244dded
Typing Lint
wiredfool Jan 25, 2025
dbe0304
Tests for destructors of the PyCapsules
wiredfool Jan 25, 2025
388da5c
Test rejection of incorrect modes
wiredfool Jan 25, 2025
55f5351
Test for size, add offset support
wiredfool Jan 25, 2025
ad492ee
Pull readonly in from the C level
wiredfool Jan 25, 2025
d02417e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jan 25, 2025
6fad11a
added mutex around refcount, renamed arrow_borrow to refcount
wiredfool Jan 30, 2025
4fc8328
remove unused code
wiredfool Feb 3, 2025
e7bd152
Error handling:
wiredfool Feb 3, 2025
9f94d4f
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 3, 2025
13e3301
Fix handling of capsule destruct sequencing
wiredfool Feb 3, 2025
2401757
Add a way to force the use of the old block allocator
wiredfool Feb 3, 2025
be3b0fd
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 3, 2025
159ffe3
Fix mutex for Free-tread pythons
wiredfool Feb 3, 2025
91e2759
split pyarrow tests out from plain loopback arrow tests
wiredfool Feb 14, 2025
05ae6e9
install pyarrow for those test platforms where it's available
wiredfool Feb 14, 2025
9ff0465
merge from upstream/main
wiredfool Feb 14, 2025
dd18fac
fix yml
wiredfool Feb 14, 2025
b2210d1
mac/linux: install binary only pyarrow, and don't fail if there's no …
wiredfool Feb 18, 2025
a0927be
merge from main
wiredfool Feb 18, 2025
9021829
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 18, 2025
038f62c
fix macos pyarrow test install
wiredfool Feb 18, 2025
01998fc
Added docs
wiredfool Feb 18, 2025
4ea8ac8
Fix the windows test matrix?
wiredfool Feb 18, 2025
e81b669
Lint
wiredfool Feb 18, 2025
7ac90fa
PyCapsules aren't actually importable in python.
wiredfool Feb 18, 2025
2418a23
Yaml
wiredfool Feb 18, 2025
a129efd
Workflow yaml
wiredfool Feb 18, 2025
afc16e5
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 18, 2025
bba1b13
docs tweaks
wiredfool Feb 18, 2025
19eb3e4
environment reference in test-windows.yml
wiredfool Feb 18, 2025
cba8e09
Fix pre-commit.ci lint.
wiredfool Feb 18, 2025
7e59428
Take 4: not environment variables
wiredfool Feb 18, 2025
a8d819c
Mypy error -- can't have a bare tuple
wiredfool Feb 18, 2025
7d498c3
Mypy error -- doesn't like the none return
wiredfool Feb 18, 2025
e4ad2c0
doc indent
wiredfool Feb 18, 2025
cb14672
le-sigh
wiredfool Feb 18, 2025
bafb968
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 18, 2025
cca80e2
make mypy happy
wiredfool Feb 19, 2025
ae187ca
Apply suggestions from code review
wiredfool Mar 29, 2025
42d0682
re-add include item
wiredfool Mar 29, 2025
48bbc64
Test Typing, consistency/style issues
wiredfool Mar 30, 2025
088f80d
Fix mypy
wiredfool Mar 30, 2025
b729f64
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 30, 2025
3e28e05
Types and typos
hugovk Mar 31, 2025
1678641
Apply suggestions from code review
hugovk Apr 1, 2025
e56e01c
Merge branch 'main' into arrow
radarhere Apr 1, 2025
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
3 changes: 3 additions & 0 deletions .ci/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ python3 -m pip install -U pytest
python3 -m pip install -U pytest-cov
python3 -m pip install -U pytest-timeout
python3 -m pip install pyroma
# optional test dependency, only install if there's a binary package.
# fails on beta 3.14 and PyPy
python3 -m pip install --only-binary=:all: pyarrow || true

if [[ $(uname) != CYGWIN* ]]; then
python3 -m pip install numpy
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/macos-install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ python3 -m pip install -U pytest-cov
python3 -m pip install -U pytest-timeout
python3 -m pip install pyroma
python3 -m pip install numpy
# optional test dependency, only install if there's a binary package.
# fails on beta 3.14 and PyPy
python3 -m pip install --only-binary=:all: pyarrow || true

# extra test images
pushd depends && ./install_extra_test_images.sh && popd
5 changes: 4 additions & 1 deletion .github/workflows/test-windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ jobs:
include:
# Test the oldest Python on 32-bit
- { python-version: "3.9", architecture: "x86", os: "windows-2019" }

timeout-minutes: 30

name: Python ${{ matrix.python-version }} (${{ matrix.architecture }})
Expand Down Expand Up @@ -88,6 +87,10 @@ jobs:
run: |
python3 -m pip install PyQt6

- name: Install PyArrow dependency
run: |
python3 -m pip install --only-binary=:all: pyarrow || true

- name: Install dependencies
id: install
run: |
Expand Down
164 changes: 164 additions & 0 deletions Tests/test_arrow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
from __future__ import annotations

import pytest

from PIL import Image

from .helper import hopper


@pytest.mark.parametrize(
"mode, dest_modes",
(
("L", ["I", "F", "LA", "RGB", "RGBA", "RGBX", "CMYK", "YCbCr", "HSV"]),
("I", ["L", "F"]), # Technically I;32 can work for any 4x8bit storage.
("F", ["I", "L", "LA", "RGB", "RGBA", "RGBX", "CMYK", "YCbCr", "HSV"]),
("LA", ["L", "F"]),
("RGB", ["L", "F"]),
("RGBA", ["L", "F"]),
("RGBX", ["L", "F"]),
("CMYK", ["L", "F"]),
("YCbCr", ["L", "F"]),
("HSV", ["L", "F"]),
),
)
def test_invalid_array_type(mode: str, dest_modes: list[str]) -> None:
img = hopper(mode)
for dest_mode in dest_modes:
with pytest.raises(ValueError):
Image.fromarrow(img, dest_mode, img.size)


def test_invalid_array_size() -> None:
img = hopper("RGB")

assert img.size != (10, 10)
with pytest.raises(ValueError):
Image.fromarrow(img, "RGB", (10, 10))


def test_release_schema() -> None:
# these should not error out, valgrind should be clean
img = hopper("L")
schema = img.__arrow_c_schema__()
del schema


def test_release_array() -> None:
# these should not error out, valgrind should be clean
img = hopper("L")
array, schema = img.__arrow_c_array__()
del array
del schema


def test_readonly() -> None:
img = hopper("L")
reloaded = Image.fromarrow(img, img.mode, img.size)
assert reloaded.readonly == 1
reloaded._readonly = 0
assert reloaded.readonly == 1


def test_multiblock_l_image() -> None:
block_size = Image.core.get_block_size()

# check a 2 block image in single channel mode
size = (4096, 2 * block_size // 4096)
img = Image.new("L", size, 128)

with pytest.raises(ValueError):
(schema, arr) = img.__arrow_c_array__()


def test_multiblock_rgba_image() -> None:
block_size = Image.core.get_block_size()

# check a 2 block image in 4 channel mode
size = (4096, (block_size // 4096) // 2)
img = Image.new("RGBA", size, (128, 127, 126, 125))

with pytest.raises(ValueError):
(schema, arr) = img.__arrow_c_array__()


def test_multiblock_l_schema() -> None:
block_size = Image.core.get_block_size()

# check a 2 block image in single channel mode
size = (4096, 2 * block_size // 4096)
img = Image.new("L", size, 128)

with pytest.raises(ValueError):
img.__arrow_c_schema__()


def test_multiblock_rgba_schema() -> None:
block_size = Image.core.get_block_size()

# check a 2 block image in 4 channel mode
size = (4096, (block_size // 4096) // 2)
img = Image.new("RGBA", size, (128, 127, 126, 125))

with pytest.raises(ValueError):
img.__arrow_c_schema__()


def test_singleblock_l_image() -> None:
Image.core.set_use_block_allocator(1)

block_size = Image.core.get_block_size()

# check a 2 block image in 4 channel mode
size = (4096, 2 * (block_size // 4096))
img = Image.new("L", size, 128)
assert img.im.isblock()

(schema, arr) = img.__arrow_c_array__()
assert schema
assert arr

Image.core.set_use_block_allocator(0)


def test_singleblock_rgba_image() -> None:
Image.core.set_use_block_allocator(1)
block_size = Image.core.get_block_size()

# check a 2 block image in 4 channel mode
size = (4096, (block_size // 4096) // 2)
img = Image.new("RGBA", size, (128, 127, 126, 125))
assert img.im.isblock()

(schema, arr) = img.__arrow_c_array__()
assert schema
assert arr
Image.core.set_use_block_allocator(0)


def test_singleblock_l_schema() -> None:
Image.core.set_use_block_allocator(1)
block_size = Image.core.get_block_size()

# check a 2 block image in single channel mode
size = (4096, 2 * block_size // 4096)
img = Image.new("L", size, 128)
assert img.im.isblock()

schema = img.__arrow_c_schema__()
assert schema
Image.core.set_use_block_allocator(0)


def test_singleblock_rgba_schema() -> None:
Image.core.set_use_block_allocator(1)
block_size = Image.core.get_block_size()

# check a 2 block image in 4 channel mode
size = (4096, (block_size // 4096) // 2)
img = Image.new("RGBA", size, (128, 127, 126, 125))
assert img.im.isblock()

schema = img.__arrow_c_schema__()
assert schema
Image.core.set_use_block_allocator(0)
110 changes: 110 additions & 0 deletions Tests/test_pyarrow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
from __future__ import annotations

from typing import Any # undone

import pytest

from PIL import Image

from .helper import (
assert_deep_equal,
assert_image_equal,
hopper,
)

pyarrow = pytest.importorskip("pyarrow", reason="PyArrow not installed")

TEST_IMAGE_SIZE = (10, 10)


def _test_img_equals_pyarray(img: Image.Image, arr: Any, mask) -> None:
assert img.height * img.width == len(arr)
px = img.load()
assert px is not None
for x in range(0, img.size[0], int(img.size[0] / 10)):
for y in range(0, img.size[1], int(img.size[1] / 10)):
if mask:
for ix, elt in enumerate(mask):
pixel = px[x, y]
assert isinstance(pixel, tuple)
assert pixel[ix] == arr[y * img.width + x].as_py()[elt]
else:
assert_deep_equal(px[x, y], arr[y * img.width + x].as_py())


# really hard to get a non-nullable list type
fl_uint8_4_type = pyarrow.field(
"_", pyarrow.list_(pyarrow.field("_", pyarrow.uint8()).with_nullable(False), 4)
).type


@pytest.mark.parametrize(
"mode, dtype, mask",
(
("L", pyarrow.uint8(), None),
("I", pyarrow.int32(), None),
("F", pyarrow.float32(), None),
("LA", fl_uint8_4_type, [0, 3]),
("RGB", fl_uint8_4_type, [0, 1, 2]),
("RGBA", fl_uint8_4_type, None),
("RGBX", fl_uint8_4_type, None),
("CMYK", fl_uint8_4_type, None),
("YCbCr", fl_uint8_4_type, [0, 1, 2]),
("HSV", fl_uint8_4_type, [0, 1, 2]),
),
)
def test_to_array(mode: str, dtype: Any, mask: Any) -> None:
img = hopper(mode)

# Resize to non-square
img = img.crop((3, 0, 124, 127))
assert img.size == (121, 127)

arr = pyarrow.array(img)
_test_img_equals_pyarray(img, arr, mask)
assert arr.type == dtype

reloaded = Image.fromarrow(arr, mode, img.size)

assert reloaded

assert_image_equal(img, reloaded)


def test_lifetime() -> None:
# valgrind shouldn't error out here.
# arrays should be accessible after the image is deleted.

img = hopper("L")

arr_1 = pyarrow.array(img)
arr_2 = pyarrow.array(img)

del img

assert arr_1.sum().as_py() > 0
del arr_1

assert arr_2.sum().as_py() > 0
del arr_2


def test_lifetime2() -> None:
# valgrind shouldn't error out here.
# img should remain after the arrays are collected.

img = hopper("L")

arr_1 = pyarrow.array(img)
arr_2 = pyarrow.array(img)

assert arr_1.sum().as_py() > 0
del arr_1

assert arr_2.sum().as_py() > 0
del arr_2

img2 = img.copy()
px = img2.load()
assert px # make mypy happy
assert isinstance(px[0, 0], int)
3 changes: 3 additions & 0 deletions docs/reference/Image.rst
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ Constructing images

.. autofunction:: new
.. autofunction:: fromarray
.. autofunction:: fromarrow
.. autofunction:: frombytes
.. autofunction:: frombuffer

Expand Down Expand Up @@ -370,6 +371,8 @@ Protocols

.. autoclass:: SupportsArrayInterface
:show-inheritance:
.. autoclass:: SupportsArrowArrayInterface
:show-inheritance:
.. autoclass:: SupportsGetData
:show-inheritance:

Expand Down
Loading
Loading