Skip to content

Commit 5c76e7e

Browse files
wiredfoolradarherehugovk
authored
Image -> Arrow support (#8330)
Co-authored-by: Andrew Murray <[email protected]> Co-authored-by: Hugo van Kemenade <[email protected]>
1 parent 7d50816 commit 5c76e7e

File tree

17 files changed

+1165
-1
lines changed

17 files changed

+1165
-1
lines changed

.ci/install.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ python3 -m pip install -U pytest
3636
python3 -m pip install -U pytest-cov
3737
python3 -m pip install -U pytest-timeout
3838
python3 -m pip install pyroma
39+
# optional test dependency, only install if there's a binary package.
40+
# fails on beta 3.14 and PyPy
41+
python3 -m pip install --only-binary=:all: pyarrow || true
3942

4043
if [[ $(uname) != CYGWIN* ]]; then
4144
python3 -m pip install numpy

.github/workflows/macos-install.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ python3 -m pip install -U pytest-cov
3030
python3 -m pip install -U pytest-timeout
3131
python3 -m pip install pyroma
3232
python3 -m pip install numpy
33+
# optional test dependency, only install if there's a binary package.
34+
# fails on beta 3.14 and PyPy
35+
python3 -m pip install --only-binary=:all: pyarrow || true
3336

3437
# libavif
3538
pushd depends && ./install_libavif.sh && popd

.github/workflows/test-windows.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ jobs:
8888
run: |
8989
python3 -m pip install PyQt6
9090
91+
- name: Install PyArrow dependency
92+
run: |
93+
python3 -m pip install --only-binary=:all: pyarrow || true
94+
9195
- name: Install dependencies
9296
id: install
9397
run: |

Tests/test_arrow.py

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from PIL import Image
6+
7+
from .helper import hopper
8+
9+
10+
@pytest.mark.parametrize(
11+
"mode, dest_modes",
12+
(
13+
("L", ["I", "F", "LA", "RGB", "RGBA", "RGBX", "CMYK", "YCbCr", "HSV"]),
14+
("I", ["L", "F"]), # Technically I;32 can work for any 4x8bit storage.
15+
("F", ["I", "L", "LA", "RGB", "RGBA", "RGBX", "CMYK", "YCbCr", "HSV"]),
16+
("LA", ["L", "F"]),
17+
("RGB", ["L", "F"]),
18+
("RGBA", ["L", "F"]),
19+
("RGBX", ["L", "F"]),
20+
("CMYK", ["L", "F"]),
21+
("YCbCr", ["L", "F"]),
22+
("HSV", ["L", "F"]),
23+
),
24+
)
25+
def test_invalid_array_type(mode: str, dest_modes: list[str]) -> None:
26+
img = hopper(mode)
27+
for dest_mode in dest_modes:
28+
with pytest.raises(ValueError):
29+
Image.fromarrow(img, dest_mode, img.size)
30+
31+
32+
def test_invalid_array_size() -> None:
33+
img = hopper("RGB")
34+
35+
assert img.size != (10, 10)
36+
with pytest.raises(ValueError):
37+
Image.fromarrow(img, "RGB", (10, 10))
38+
39+
40+
def test_release_schema() -> None:
41+
# these should not error out, valgrind should be clean
42+
img = hopper("L")
43+
schema = img.__arrow_c_schema__()
44+
del schema
45+
46+
47+
def test_release_array() -> None:
48+
# these should not error out, valgrind should be clean
49+
img = hopper("L")
50+
array, schema = img.__arrow_c_array__()
51+
del array
52+
del schema
53+
54+
55+
def test_readonly() -> None:
56+
img = hopper("L")
57+
reloaded = Image.fromarrow(img, img.mode, img.size)
58+
assert reloaded.readonly == 1
59+
reloaded._readonly = 0
60+
assert reloaded.readonly == 1
61+
62+
63+
def test_multiblock_l_image() -> None:
64+
block_size = Image.core.get_block_size()
65+
66+
# check a 2 block image in single channel mode
67+
size = (4096, 2 * block_size // 4096)
68+
img = Image.new("L", size, 128)
69+
70+
with pytest.raises(ValueError):
71+
(schema, arr) = img.__arrow_c_array__()
72+
73+
74+
def test_multiblock_rgba_image() -> None:
75+
block_size = Image.core.get_block_size()
76+
77+
# check a 2 block image in 4 channel mode
78+
size = (4096, (block_size // 4096) // 2)
79+
img = Image.new("RGBA", size, (128, 127, 126, 125))
80+
81+
with pytest.raises(ValueError):
82+
(schema, arr) = img.__arrow_c_array__()
83+
84+
85+
def test_multiblock_l_schema() -> None:
86+
block_size = Image.core.get_block_size()
87+
88+
# check a 2 block image in single channel mode
89+
size = (4096, 2 * block_size // 4096)
90+
img = Image.new("L", size, 128)
91+
92+
with pytest.raises(ValueError):
93+
img.__arrow_c_schema__()
94+
95+
96+
def test_multiblock_rgba_schema() -> None:
97+
block_size = Image.core.get_block_size()
98+
99+
# check a 2 block image in 4 channel mode
100+
size = (4096, (block_size // 4096) // 2)
101+
img = Image.new("RGBA", size, (128, 127, 126, 125))
102+
103+
with pytest.raises(ValueError):
104+
img.__arrow_c_schema__()
105+
106+
107+
def test_singleblock_l_image() -> None:
108+
Image.core.set_use_block_allocator(1)
109+
110+
block_size = Image.core.get_block_size()
111+
112+
# check a 2 block image in 4 channel mode
113+
size = (4096, 2 * (block_size // 4096))
114+
img = Image.new("L", size, 128)
115+
assert img.im.isblock()
116+
117+
(schema, arr) = img.__arrow_c_array__()
118+
assert schema
119+
assert arr
120+
121+
Image.core.set_use_block_allocator(0)
122+
123+
124+
def test_singleblock_rgba_image() -> None:
125+
Image.core.set_use_block_allocator(1)
126+
block_size = Image.core.get_block_size()
127+
128+
# check a 2 block image in 4 channel mode
129+
size = (4096, (block_size // 4096) // 2)
130+
img = Image.new("RGBA", size, (128, 127, 126, 125))
131+
assert img.im.isblock()
132+
133+
(schema, arr) = img.__arrow_c_array__()
134+
assert schema
135+
assert arr
136+
Image.core.set_use_block_allocator(0)
137+
138+
139+
def test_singleblock_l_schema() -> None:
140+
Image.core.set_use_block_allocator(1)
141+
block_size = Image.core.get_block_size()
142+
143+
# check a 2 block image in single channel mode
144+
size = (4096, 2 * block_size // 4096)
145+
img = Image.new("L", size, 128)
146+
assert img.im.isblock()
147+
148+
schema = img.__arrow_c_schema__()
149+
assert schema
150+
Image.core.set_use_block_allocator(0)
151+
152+
153+
def test_singleblock_rgba_schema() -> None:
154+
Image.core.set_use_block_allocator(1)
155+
block_size = Image.core.get_block_size()
156+
157+
# check a 2 block image in 4 channel mode
158+
size = (4096, (block_size // 4096) // 2)
159+
img = Image.new("RGBA", size, (128, 127, 126, 125))
160+
assert img.im.isblock()
161+
162+
schema = img.__arrow_c_schema__()
163+
assert schema
164+
Image.core.set_use_block_allocator(0)

Tests/test_pyarrow.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from __future__ import annotations
2+
3+
from typing import Any # undone
4+
5+
import pytest
6+
7+
from PIL import Image
8+
9+
from .helper import (
10+
assert_deep_equal,
11+
assert_image_equal,
12+
hopper,
13+
)
14+
15+
pyarrow = pytest.importorskip("pyarrow", reason="PyArrow not installed")
16+
17+
TEST_IMAGE_SIZE = (10, 10)
18+
19+
20+
def _test_img_equals_pyarray(
21+
img: Image.Image, arr: Any, mask: list[int] | None
22+
) -> None:
23+
assert img.height * img.width == len(arr)
24+
px = img.load()
25+
assert px is not None
26+
for x in range(0, img.size[0], int(img.size[0] / 10)):
27+
for y in range(0, img.size[1], int(img.size[1] / 10)):
28+
if mask:
29+
for ix, elt in enumerate(mask):
30+
pixel = px[x, y]
31+
assert isinstance(pixel, tuple)
32+
assert pixel[ix] == arr[y * img.width + x].as_py()[elt]
33+
else:
34+
assert_deep_equal(px[x, y], arr[y * img.width + x].as_py())
35+
36+
37+
# really hard to get a non-nullable list type
38+
fl_uint8_4_type = pyarrow.field(
39+
"_", pyarrow.list_(pyarrow.field("_", pyarrow.uint8()).with_nullable(False), 4)
40+
).type
41+
42+
43+
@pytest.mark.parametrize(
44+
"mode, dtype, mask",
45+
(
46+
("L", pyarrow.uint8(), None),
47+
("I", pyarrow.int32(), None),
48+
("F", pyarrow.float32(), None),
49+
("LA", fl_uint8_4_type, [0, 3]),
50+
("RGB", fl_uint8_4_type, [0, 1, 2]),
51+
("RGBA", fl_uint8_4_type, None),
52+
("RGBX", fl_uint8_4_type, None),
53+
("CMYK", fl_uint8_4_type, None),
54+
("YCbCr", fl_uint8_4_type, [0, 1, 2]),
55+
("HSV", fl_uint8_4_type, [0, 1, 2]),
56+
),
57+
)
58+
def test_to_array(mode: str, dtype: Any, mask: list[int] | None) -> None:
59+
img = hopper(mode)
60+
61+
# Resize to non-square
62+
img = img.crop((3, 0, 124, 127))
63+
assert img.size == (121, 127)
64+
65+
arr = pyarrow.array(img)
66+
_test_img_equals_pyarray(img, arr, mask)
67+
assert arr.type == dtype
68+
69+
reloaded = Image.fromarrow(arr, mode, img.size)
70+
71+
assert reloaded
72+
73+
assert_image_equal(img, reloaded)
74+
75+
76+
def test_lifetime() -> None:
77+
# valgrind shouldn't error out here.
78+
# arrays should be accessible after the image is deleted.
79+
80+
img = hopper("L")
81+
82+
arr_1 = pyarrow.array(img)
83+
arr_2 = pyarrow.array(img)
84+
85+
del img
86+
87+
assert arr_1.sum().as_py() > 0
88+
del arr_1
89+
90+
assert arr_2.sum().as_py() > 0
91+
del arr_2
92+
93+
94+
def test_lifetime2() -> None:
95+
# valgrind shouldn't error out here.
96+
# img should remain after the arrays are collected.
97+
98+
img = hopper("L")
99+
100+
arr_1 = pyarrow.array(img)
101+
arr_2 = pyarrow.array(img)
102+
103+
assert arr_1.sum().as_py() > 0
104+
del arr_1
105+
106+
assert arr_2.sum().as_py() > 0
107+
del arr_2
108+
109+
img2 = img.copy()
110+
px = img2.load()
111+
assert px # make mypy happy
112+
assert isinstance(px[0, 0], int)

docs/reference/Image.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ Constructing images
7979

8080
.. autofunction:: new
8181
.. autofunction:: fromarray
82+
.. autofunction:: fromarrow
8283
.. autofunction:: frombytes
8384
.. autofunction:: frombuffer
8485

@@ -370,6 +371,8 @@ Protocols
370371

371372
.. autoclass:: SupportsArrayInterface
372373
:show-inheritance:
374+
.. autoclass:: SupportsArrowArrayInterface
375+
:show-inheritance:
373376
.. autoclass:: SupportsGetData
374377
:show-inheritance:
375378

0 commit comments

Comments
 (0)