Skip to content

Commit

Permalink
FEAT: Allow mode=None when writing with pillow (#722)
Browse files Browse the repository at this point in the history
  • Loading branch information
FirefoxMetzger committed Jan 11, 2022
1 parent c57bf85 commit 7be2712
Show file tree
Hide file tree
Showing 5 changed files with 110 additions and 53 deletions.
4 changes: 4 additions & 0 deletions imageio/config/extensions.py
Expand Up @@ -63,6 +63,10 @@ def __init__(
self.name = name
self.description = description
self.external_link = external_link
self.default_priority = priority.copy()

def reset(self) -> None:
self.priority = self.default_priority.copy()


extension_list = [
Expand Down
15 changes: 10 additions & 5 deletions imageio/core/format.py
Expand Up @@ -614,18 +614,23 @@ def sort(self, *names):
"contain dots `.` or commas `,`."
)

if len(names) == 0:
should_reset = len(names) == 0
if should_reset:
names = _original_order

sane_names = [name.strip().upper() for name in names if name != ""]

# enforce order for every extension that uses it
flat_extensions = [
ext for ext_list in known_extensions.values() for ext in ext_list
]
for extension in flat_extensions:
if should_reset:
extension.reset()
continue

# enforce order for every extension that uses it
for name in reversed(sane_names):
for extension in flat_extensions:
for plugin in [x for x in extension.priority]:
for name in reversed(sane_names):
for plugin in [x for x in extension.default_priority]:
if plugin.endswith(name):
extension.priority.remove(plugin)
extension.priority.insert(0, plugin)
Expand Down
102 changes: 54 additions & 48 deletions imageio/plugins/pillow.py
Expand Up @@ -57,32 +57,16 @@ def _is_multichannel(mode: str) -> bool:
"""
multichannel = {
"1": False,
"L": False,
"P": False,
"RGB": True,
"RGBA": True,
"CMYK": True,
"YCbCr": True,
"LAB": True,
"HSV": True,
"I": False,
"F": False,
"LA": True,
"PA": True,
"RGBX": True,
"RGBa": True,
"La": False,
"I;16": False,
"I:16L": False,
"I;16N": False,
"BGR;15": True,
"BGR;16": True,
"BGR;24": True,
"BGR;32": True,
}

return multichannel[mode]
if mode in multichannel:
return multichannel[mode]

return Image.getmodebands(mode) > 1


def _exif_orientation_transform(orientation, mode):
Expand Down Expand Up @@ -237,6 +221,10 @@ def iter(self, *, mode=None, rotate=False, apply_gamma=False):
def _apply_transforms(self, image, mode, rotate, apply_gamma):
if mode is not None:
image = image.convert(mode)
elif image.format == "GIF":
# adjust for pillow9 changes
# see: https://github.com/python-pillow/Pillow/issues/5929
image = image.convert(image.palette.mode)
image = np.asarray(image)

meta = self.get_meta()
Expand All @@ -255,7 +243,7 @@ def _apply_transforms(self, image, mode, rotate, apply_gamma):

return image

def write(self, image, *, mode="RGB", format=None, **kwargs):
def write(self, image: np.ndarray, *, mode=None, format=None, **kwargs):
"""
Write an ndimage to the URI specified in path.
Expand All @@ -271,9 +259,10 @@ def write(self, image, *, mode="RGB", format=None, **kwargs):
----------
image : ndarray
The ndimage to write.
mode : {str}
Specify the image's color format; default is RGB. Possible modes can
be found at:
mode : {str, None}
Specify the image's color format. If None (default), the mode is
inferred from the array's shape and dtype. Possible modes can be
found at:
https://pillow.readthedocs.io/en/stable/handbook/concepts.html#modes
format : {str, None}
Optional format override. If omitted, the format to use is
Expand All @@ -286,33 +275,50 @@ def write(self, image, *, mode="RGB", format=None, **kwargs):
<https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html>`_
for each writer.
Notes
-----
When writing batches of very narrow (2-4 pixels wide) gray images set
the ``mode`` explicitly to avoid the batch being identified as a colored
image.
"""

# ensure that the image has (at least) one batch dimension
if image.ndim == 3 and _is_multichannel(mode):
image = image[None, ...]
save_all = False
elif image.ndim == 2 and not _is_multichannel(mode):
image = image[None, ...]
save_all = False
save_args = {
"format": format,
}

# check if ndimage is a batch of frames/pages (e.g. for writing GIF)
# if mode is given, use it; otherwise fall back to image.ndim only
if mode is not None:
is_batch = image.ndim > 3 if _is_multichannel(mode) else image.ndim > 2
elif image.ndim == 2:
is_batch = False
elif image.ndim == 3 and image.shape[-1] in [2, 3, 4]:
# Note: this makes a channel-last assumption
# (pillow seems to make it as well)
is_batch = False
else:
is_batch = True

if is_batch:
save_args["save_all"] = True
primary_image = Image.fromarray(image[0], mode=mode)

append_images = list()
for frame in image[1:]:
pil_frame = Image.fromarray(frame, mode=mode)
if "bits" in kwargs:
pil_frame = pil_frame.quantize(colors=2 ** kwargs["bits"])
append_images.append(pil_frame)
save_args["append_images"] = append_images
else:
save_all = True

pil_images = list()
for frame in image:
pil_frame = Image.fromarray(frame, mode=mode)
if "bits" in kwargs:
pil_frame = pil_frame.quantize(colors=2 ** kwargs["bits"])

pil_images.append(pil_frame)

pil_images[0].save(
self._request.get_file(),
save_all=save_all,
append_images=pil_images[1:],
format=format,
**kwargs,
)
primary_image = Image.fromarray(image, mode=mode)

if "bits" in kwargs:
primary_image = primary_image.quantize(colors=2 ** kwargs["bits"])

save_args.update(kwargs)
primary_image.save(self._request.get_file(), **save_args)

if self._request._uri_type == URI_BYTES:
return self._request.get_file().getvalue()
Expand Down
11 changes: 11 additions & 0 deletions tests/test_core.py
Expand Up @@ -984,4 +984,15 @@ def test_imopen_explicit_plugin_input(clear_plugins, tmp_path):
iio.v3.imopen(tmp_path / "foo.tiff", "w", legacy_mode=True, plugin=PillowPlugin)


def test_sort_order_restore():
# this is a proxy test; only test PNG instead of all formats
# since that already reproduces the issue

old_order = iio.config.known_extensions[".png"][0].priority.copy()
iio.formats.sort()
new_order = iio.config.known_extensions[".png"][0].priority.copy()

assert old_order == new_order


run_tests_if_main()
31 changes: 31 additions & 0 deletions tests/test_pillow.py
Expand Up @@ -504,3 +504,34 @@ def test_initialization_failure(image_files: Path):
with pytest.raises(OSError):
# pillow can not handle npy
iio.v3.imread(image_files / "chelsea_jpg.npy", plugin="pillow")


def test_boolean_reading(tmp_path):
# Bugfix: https://github.com/imageio/imageio/issues/721
expected = np.arange(256 * 256).reshape((256, 256)) % 2 == 0

Image.fromarray(expected).save(tmp_path / "iio.png")

actual = iio.v3.imread(tmp_path / "iio.png")
assert np.allclose(actual, expected)


def test_boolean_writing(tmp_path):
# Bugfix: https://github.com/imageio/imageio/issues/721
expected = np.arange(256 * 256).reshape((256, 256)) % 2 == 0

iio.v3.imwrite(tmp_path / "iio.png", expected)

actual = np.asarray(Image.open(tmp_path / "iio.png"))
# actual = iio.v3.imread(tmp_path / "iio.png")
assert np.allclose(actual, expected)


def test_quantized_gif(image_files: Path, tmp_path):
original = iio.v3.imread(image_files / "newtonscradle.gif")

iio.v3.imwrite(tmp_path / "quantized.gif", original, plugin="pillow", bits=4)
quantized = iio.v3.imread(tmp_path / "quantized.gif")

for original_frame, quantized_frame in zip(original, quantized):
assert len(np.unique(quantized_frame)) <= len(np.unique(original_frame))

0 comments on commit 7be2712

Please sign in to comment.