Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added convert_mode param when saving #3172

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
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
Binary file added Tests/images/pil123rgba_red.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions Tests/test_file_gif.py
Original file line number Diff line number Diff line change
@@ -1360,6 +1360,18 @@ def test_save_I(tmp_path: Path) -> None:
assert_image_equal(reloaded.convert("L"), im.convert("L"))


def test_save_wrong_modes() -> None:
out = BytesIO()
for mode in ["CMYK"]:
img = Image.new(mode, (20, 20))
with pytest.raises(ValueError):
img.save(out, "GIF")

for mode in ["CMYK", "LA"]:
img = Image.new(mode, (20, 20))
img.save(out, "GIF", convert_mode=True)


def test_getdata(monkeypatch: pytest.MonkeyPatch) -> None:
# Test getheader/getdata against legacy values.
# Create a 'P' image with holes in the palette.
21 changes: 16 additions & 5 deletions Tests/test_file_jpeg.py
Original file line number Diff line number Diff line change
@@ -752,13 +752,24 @@ def test_save_correct_modes(self, mode: str) -> None:
img = Image.new(mode, (20, 20))
img.save(out, "JPEG")

@pytest.mark.parametrize("mode", ("LA", "La", "RGBA", "RGBa", "P"))
def test_save_wrong_modes(self, mode: str) -> None:
def test_save_wrong_modes(self, tmp_path: Path) -> None:
# ref https://github.com/python-pillow/Pillow/issues/2005
out = BytesIO()
img = Image.new(mode, (20, 20))
with pytest.raises(OSError):
img.save(out, "JPEG")
for mode in ["LA", "La", "RGBA", "RGBa", "P", "I"]:
img = Image.new(mode, (20, 20))
with pytest.raises(OSError):
img.save(out, "JPEG")

for mode in ["LA", "RGBA", "P", "I"]:
img = Image.new(mode, (20, 20))
img.save(out, "JPEG", convert_mode=True)

temp_file = str(tmp_path / "temp.jpg")
with Image.open("Tests/images/pil123rgba.png") as img:
img.save(temp_file, convert_mode=True, fill_color="red")

with Image.open(temp_file) as reloaded:
assert_image_similar_tofile(reloaded, "Tests/images/pil123rgba_red.jpg", 4)

def test_save_tiff_with_dpi(self, tmp_path: Path) -> None:
# Arrange
8 changes: 8 additions & 0 deletions Tests/test_file_png.py
Original file line number Diff line number Diff line change
@@ -243,6 +243,14 @@ def test_load_transparent_rgb(self) -> None:
# image has 876 transparent pixels
assert im.getchannel("A").getcolors()[0][0] == 876

def test_save_CMYK(self) -> None:
out = BytesIO()
im = Image.new("CMYK", (20, 20))
with pytest.raises(IOError):
im.save(out, "PNG")

im.save(out, "PNG", convert_mode=True)

def test_save_p_transparent_palette(self, tmp_path: Path) -> None:
in_file = "Tests/images/pil123p.png"
with Image.open(in_file) as im:
6 changes: 6 additions & 0 deletions Tests/test_file_webp.py
Original file line number Diff line number Diff line change
@@ -94,6 +94,12 @@ def _roundtrip(
target = target.convert(self.rgb_mode)
assert_image_similar(image, target, epsilon)

def test_save_convert_mode(self) -> None:
out = io.BytesIO()
for mode in ["CMYK", "I", "L", "LA", "P"]:
img = Image.new(mode, (20, 20))
img.save(out, "WEBP", convert_mode=True)

def test_write_rgb(self, tmp_path: Path) -> None:
"""
Can we write a RGB mode file to webp without error?
72 changes: 72 additions & 0 deletions Tests/test_image.py
Original file line number Diff line number Diff line change
@@ -19,6 +19,7 @@
ImageDraw,
ImageFile,
ImagePalette,
TiffImagePlugin,
UnidentifiedImageError,
features,
)
@@ -466,6 +467,77 @@ def test_registered_extensions(self) -> None:
for ext in [".cur", ".icns", ".tif", ".tiff"]:
assert ext in extensions

def test_supported_modes(self) -> None:
for format in Image.MIME.keys():
try:
save_handler = Image.SAVE[format]
except KeyError:
continue
plugin = sys.modules[save_handler.__module__]
if not hasattr(plugin, "_supported_modes"):
continue

# Check that the supported modes list is accurate
supported_modes = plugin._supported_modes()
for mode in [
"1",
"L",
"P",
"RGB",
"RGBA",
"CMYK",
"YCbCr",
"LAB",
"HSV",
"I",
"F",
"LA",
"La",
"RGBX",
"RGBa",
]:
out = io.BytesIO()
im = Image.new(mode, (100, 100))
if mode in supported_modes:
im.save(out, format)
else:
with pytest.raises(Exception):
im.save(out, format)

def test_no_supported_modes_method(self, tmp_path: Path) -> None:
assert not hasattr(TiffImagePlugin, "_supported_modes")

temp_file = str(tmp_path / "temp.tiff")

im = hopper()
im.save(temp_file, convert_mode=True)

@pytest.mark.parametrize(
"mode, modes",
(
("P", ["RGB"]),
("P", ["L"]), # converting to a non-preferred mode
("LA", ["P"]),
("I", ["L"]),
("RGB", ["L"]),
("RGB", ["CMYK"]),
),
)
def test_convert_mode(self, mode: str, modes: list[str]) -> None:
im = Image.new(mode, (100, 100))
assert im._convert_mode(modes) is not None

@pytest.mark.parametrize(
"mode, modes",
(
("P", []), # no mode
("P", ["P"]), # same mode
),
)
def test_convert_mode_noop(self, mode: str, modes: list[str]) -> None:
im = Image.new(mode, (100, 100))
assert im._convert_mode(modes) is None

def test_effect_mandelbrot(self) -> None:
# Arrange
size = (512, 512)
4 changes: 4 additions & 0 deletions src/PIL/GifImagePlugin.py
Original file line number Diff line number Diff line change
@@ -1187,6 +1187,10 @@ def write(self, data: Buffer) -> int:
return fp.data


def _supported_modes() -> list[str]:
return ["RGB", "RGBA", "P", "I", "F", "LA", "L", "1"]


# --------------------------------------------------------------------
# Registry

77 changes: 72 additions & 5 deletions src/PIL/Image.py
Original file line number Diff line number Diff line change
@@ -2500,10 +2500,6 @@
# may mutate self!
self._ensure_mutable()

save_all = params.pop("save_all", False)
self.encoderinfo = {**getattr(self, "encoderinfo", {}), **params}
self.encoderconfig: tuple[Any, ...] = ()

preinit()

filename_ext = os.path.splitext(filename)[1].lower()
@@ -2520,11 +2516,22 @@

if format.upper() not in SAVE:
init()
if save_all:
if params.pop("save_all", False):
save_handler = SAVE_ALL[format.upper()]
else:
save_handler = SAVE[format.upper()]

if params.get("convert_mode"):
plugin = sys.modules[save_handler.__module__]
if hasattr(plugin, "_supported_modes"):
modes = plugin._supported_modes()
converted_im = self._convert_mode(modes, params)
if converted_im:
return converted_im.save(fp, format, **params)

self.encoderinfo = {**getattr(self, "encoderinfo", {}), **params}
self.encoderconfig: tuple[Any, ...] = ()

created = False
if open_fp:
created = not os.path.exists(filename)
@@ -2556,6 +2563,66 @@
if open_fp:
fp.close()

def _convert_mode(
self, modes: list[str], params: dict[str, Any] = {}
) -> Image | None:
if not modes or self.mode in modes:
return None
if self.mode == "P":
preferred_modes = []
if "A" in self.im.getpalettemode():
preferred_modes.append("RGBA")

Check warning on line 2574 in src/PIL/Image.py

Codecov / codecov/patch

src/PIL/Image.py#L2574

Added line #L2574 was not covered by tests
preferred_modes.append("RGB")
else:
preferred_modes = {
"CMYK": ["RGB"],
"RGB": ["CMYK"],
"RGBX": ["RGB"],
"RGBa": ["RGBA", "RGB"],
"RGBA": ["RGB"],
"LA": ["RGBA", "P", "L"],
"La": ["LA", "L"],
"L": ["RGB"],
"F": ["I"],
"I": ["L", "RGB"],
"1": ["L"],
"YCbCr": ["RGB"],
"LAB": ["RGB"],
"HSV": ["RGB"],
}.get(self.mode, [])
for new_mode in preferred_modes:
if new_mode in modes:
break
else:
new_mode = modes[0]
if self.mode == "LA" and new_mode == "P":
alpha = self.getchannel("A")
# Convert the image into P mode but only use 255 colors
# in the palette out of 256.
im = self.convert("L").convert("P", palette=Palette.ADAPTIVE, colors=255)
# Set all pixel values below 128 to 255, and the rest to 0.
mask = eval(alpha, lambda px: 255 if px < 128 else 0)
# Paste the color of index 255 and use alpha as a mask.
im.paste(255, mask)
# The transparency index is 255.
im.info["transparency"] = 255
return im

elif self.mode == "I":
im = self.point([i // 256 for i in range(65536)], "L")
return im.convert(new_mode) if new_mode != "L" else im

elif self.mode in ("RGBA", "LA") and new_mode in ("RGB", "L"):
fill_color = params.get("fill_color", "white")
background = new(new_mode, self.size, fill_color)
background.paste(self, self.getchannel("A"))
return background

elif new_mode:
return self.convert(new_mode)

return None

Check warning on line 2624 in src/PIL/Image.py

Codecov / codecov/patch

src/PIL/Image.py#L2624

Added line #L2624 was not covered by tests

def seek(self, frame: int) -> None:
"""
Seeks to the given frame in this sequence file. If you seek
4 changes: 4 additions & 0 deletions src/PIL/JpegImagePlugin.py
Original file line number Diff line number Diff line change
@@ -893,6 +893,10 @@ def jpeg_factory(
return im


def _supported_modes() -> list[str]:
return ["RGB", "CMYK", "YCbCr", "RGBX", "L", "1"]


# ---------------------------------------------------------------------
# Registry stuff

4 changes: 4 additions & 0 deletions src/PIL/PngImagePlugin.py
Original file line number Diff line number Diff line change
@@ -1532,6 +1532,10 @@ def append(fp: IO[bytes], cid: bytes, *data: bytes) -> None:
return chunks


def _supported_modes() -> list[str]:
return ["RGB", "RGBA", "P", "I", "LA", "L", "1"]


# --------------------------------------------------------------------
# Registry

19 changes: 19 additions & 0 deletions src/PIL/WebPImagePlugin.py
Original file line number Diff line number Diff line change
@@ -312,6 +312,25 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
fp.write(data)


def _supported_modes() -> list[str]:
return [
"RGB",
"RGBA",
"RGBa",
"RGBX",
"CMYK",
"YCbCr",
"HSV",
"I",
"F",
"P",
"LA",
"LAB",
"L",
"1",
]


Image.register_open(WebPImageFile.format, WebPImageFile, _accept)
if SUPPORTED:
Image.register_save(WebPImageFile.format, _save)