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

Use Capsule for WEBP saving #8386

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
27 changes: 12 additions & 15 deletions Tests/test_file_webp.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def test_read_rgb(self) -> None:
def _roundtrip(
self, tmp_path: Path, mode: str, epsilon: float, args: dict[str, Any] = {}
) -> None:
temp_file = str(tmp_path / "temp.webp")
temp_file = tmp_path / "temp.webp"

hopper(mode).save(temp_file, **args)
with Image.open(temp_file) as image:
Expand Down Expand Up @@ -116,7 +116,7 @@ def test_write_method(self, tmp_path: Path) -> None:
assert buffer_no_args.getbuffer() != buffer_method.getbuffer()

def test_save_all(self, tmp_path: Path) -> None:
temp_file = str(tmp_path / "temp.webp")
temp_file = tmp_path / "temp.webp"
im = Image.new("RGB", (1, 1))
im2 = Image.new("RGB", (1, 1), "#f00")
im.save(temp_file, save_all=True, append_images=[im2])
Expand Down Expand Up @@ -151,18 +151,16 @@ def test_write_unsupported_mode_P(self, tmp_path: Path) -> None:

@pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system")
def test_write_encoding_error_message(self, tmp_path: Path) -> None:
temp_file = str(tmp_path / "temp.webp")
im = Image.new("RGB", (15000, 15000))
with pytest.raises(ValueError) as e:
im.save(temp_file, method=0)
im.save(tmp_path / "temp.webp", method=0)
assert str(e.value) == "encoding error 6"

@pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system")
def test_write_encoding_error_bad_dimension(self, tmp_path: Path) -> None:
temp_file = str(tmp_path / "temp.webp")
im = Image.new("L", (16384, 16384))
with pytest.raises(ValueError) as e:
im.save(temp_file)
im.save(tmp_path / "temp.webp")
assert (
str(e.value)
== "encoding error 5: Image size exceeds WebP limit of 16383 pixels"
Expand All @@ -187,9 +185,8 @@ def test_WebPAnimDecoder_with_invalid_args(self) -> None:
def test_no_resource_warning(self, tmp_path: Path) -> None:
file_path = "Tests/images/hopper.webp"
with Image.open(file_path) as image:
temp_file = str(tmp_path / "temp.webp")
with warnings.catch_warnings():
image.save(temp_file)
image.save(tmp_path / "temp.webp")

def test_file_pointer_could_be_reused(self) -> None:
file_path = "Tests/images/hopper.webp"
Expand All @@ -204,27 +201,27 @@ def test_file_pointer_could_be_reused(self) -> None:
def test_invalid_background(
self, background: int | tuple[int, ...], tmp_path: Path
) -> None:
temp_file = str(tmp_path / "temp.webp")
temp_file = tmp_path / "temp.webp"
im = hopper()
with pytest.raises(OSError):
im.save(temp_file, save_all=True, append_images=[im], background=background)

def test_background_from_gif(self, tmp_path: Path) -> None:
out_webp = tmp_path / "temp.webp"

homm marked this conversation as resolved.
Show resolved Hide resolved
# Save L mode GIF with background
with Image.open("Tests/images/no_palette_with_background.gif") as im:
out_webp = str(tmp_path / "temp.webp")
im.save(out_webp, save_all=True)

# Save P mode GIF with background
with Image.open("Tests/images/chi.gif") as im:
original_value = im.convert("RGB").getpixel((1, 1))

# Save as WEBP
out_webp = str(tmp_path / "temp.webp")
im.save(out_webp, save_all=True)

# Save as GIF
out_gif = str(tmp_path / "temp.gif")
out_gif = tmp_path / "temp.gif"
with Image.open(out_webp) as im:
im.save(out_gif)

Expand All @@ -234,18 +231,18 @@ def test_background_from_gif(self, tmp_path: Path) -> None:
assert difference < 5

def test_duration(self, tmp_path: Path) -> None:
out_webp = tmp_path / "temp.webp"

with Image.open("Tests/images/dispose_bgnd.gif") as im:
assert im.info["duration"] == 1000

out_webp = str(tmp_path / "temp.webp")
im.save(out_webp, save_all=True)

with Image.open(out_webp) as reloaded:
reloaded.load()
assert reloaded.info["duration"] == 1000

def test_roundtrip_rgba_palette(self, tmp_path: Path) -> None:
temp_file = str(tmp_path / "temp.webp")
temp_file = tmp_path / "temp.webp"
im = Image.new("RGBA", (1, 1)).convert("P")
assert im.mode == "P"
assert im.palette is not None
Expand Down
34 changes: 6 additions & 28 deletions src/PIL/WebPImagePlugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@
SUPPORTED = False


_VALID_WEBP_MODES = {"RGBX": True, "RGBA": True, "RGB": True}

_VALID_WEBP_LEGACY_MODES = {"RGB": True, "RGBA": True}

_VP8_MODES_BY_IDENTIFIER = {
b"VP8 ": "RGB",
b"VP8X": "RGBA",
Expand Down Expand Up @@ -243,31 +239,16 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:

for idx in range(nfr):
ims.seek(idx)
ims.load()

# Make sure image mode is supported
frame = ims
rawmode = ims.mode
if ims.mode not in _VALID_WEBP_MODES:
alpha = (
"A" in ims.mode
or "a" in ims.mode
or (ims.mode == "P" and "A" in ims.im.getpalettemode())
)
rawmode = "RGBA" if alpha else "RGB"
frame = ims.convert(rawmode)

if rawmode == "RGB":
# For faster conversion, use RGBX
rawmode = "RGBX"
if frame.mode not in ("RGBX", "RGBA", "RGB"):
frame = frame.convert("RGBA" if im.has_transparency_data else "RGB")
homm marked this conversation as resolved.
Show resolved Hide resolved

# Append the frame to the animation encoder
enc.add(
frame.tobytes("raw", rawmode),
frame.getim(),
round(timestamp),
frame.size[0],
frame.size[1],
rawmode,
lossless,
quality,
alpha_quality,
Expand All @@ -285,7 +266,7 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
im.seek(cur_idx)

# Force encoder to flush frames
enc.add(None, round(timestamp), 0, 0, "", lossless, quality, alpha_quality, 0)
enc.add(None, round(timestamp), lossless, quality, alpha_quality, 0)

# Get the final output from the encoder
data = enc.assemble(icc_profile, exif, xmp)
Expand All @@ -310,17 +291,14 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
method = im.encoderinfo.get("method", 4)
exact = 1 if im.encoderinfo.get("exact") else 0

if im.mode not in _VALID_WEBP_LEGACY_MODES:
if im.mode not in ("RGBX", "RGBA", "RGB"):
im = im.convert("RGBA" if im.has_transparency_data else "RGB")

data = _webp.WebPEncode(
im.tobytes(),
im.size[0],
im.size[1],
im.getim(),
lossless,
float(quality),
float(alpha_quality),
im.mode,
icc_profile,
method,
exact,
Expand Down
121 changes: 70 additions & 51 deletions src/_webp.c
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,46 @@ HandleMuxError(WebPMuxError err, char *chunk) {
return NULL;
}

/* -------------------------------------------------------------------- */
/* Frame import */
/* -------------------------------------------------------------------- */

static int
import_frame_libwebp(WebPPicture *frame, Imaging im) {
UINT32 mask = 0;

if (strcmp(im->mode, "RGBA") && strcmp(im->mode, "RGB") &&
strcmp(im->mode, "RGBX")) {
PyErr_SetString(PyExc_ValueError, "unsupported image mode");
return -1;
}

if (strcmp(im->mode, "RGBA")) {
mask = MASK_UINT32_CHANNEL_3;
}

frame->width = im->xsize;
frame->height = im->ysize;
frame->use_argb = 1; // Don't convert RGB pixels to YUV

if (!WebPPictureAlloc(frame)) {
PyErr_SetString(PyExc_MemoryError, "can't allocate picture frame");
return -2;
}

for (int y = 0; y < im->ysize; ++y) {
UINT8 *src = (UINT8 *)im->image32[y];
UINT32 *dst = frame->argb + frame->argb_stride * y;
for (int x = 0; x < im->xsize; ++x) {
UINT32 pix =
MAKE_UINT32(src[x * 4 + 2], src[x * 4 + 1], src[x * 4], src[x * 4 + 3]);
dst[x] = pix | mask;
}
}

return 0;
}

/* -------------------------------------------------------------------- */
/* WebP Animation Support */
/* -------------------------------------------------------------------- */
Expand Down Expand Up @@ -180,30 +220,24 @@ _anim_encoder_dealloc(PyObject *self) {

PyObject *
_anim_encoder_add(PyObject *self, PyObject *args) {
uint8_t *rgb;
Py_ssize_t size;
PyObject *i0;
Imaging im;
int timestamp;
int width;
int height;
char *mode;
int lossless;
float quality_factor;
float alpha_quality_factor;
int method;
ImagingSectionCookie cookie;
WebPConfig config;
WebPAnimEncoderObject *encp = (WebPAnimEncoderObject *)self;
WebPAnimEncoder *enc = encp->enc;
WebPPicture *frame = &(encp->frame);

if (!PyArg_ParseTuple(
args,
"z#iiisiffi",
(char **)&rgb,
&size,
"Oiiffi",
&i0,
&timestamp,
&width,
&height,
&mode,
&lossless,
&quality_factor,
&alpha_quality_factor,
Expand All @@ -213,11 +247,18 @@ _anim_encoder_add(PyObject *self, PyObject *args) {
}

// Check for NULL frame, which sets duration of final frame
if (!rgb) {
if (i0 == Py_None) {
WebPAnimEncoderAdd(enc, NULL, timestamp, NULL);
Py_RETURN_NONE;
}

if (!PyCapsule_IsValid(i0, IMAGING_MAGIC)) {
PyErr_Format(PyExc_TypeError, "Expected '%s' Capsule", IMAGING_MAGIC);
return NULL;
}

im = (Imaging)PyCapsule_GetPointer(i0, IMAGING_MAGIC);

// Setup config for this frame
if (!WebPConfigInit(&config)) {
PyErr_SetString(PyExc_RuntimeError, "failed to initialize config!");
Expand All @@ -234,20 +275,15 @@ _anim_encoder_add(PyObject *self, PyObject *args) {
return NULL;
}

// Populate the frame with raw bytes passed to us
frame->width = width;
frame->height = height;
frame->use_argb = 1; // Don't convert RGB pixels to YUV
if (strcmp(mode, "RGBA") == 0) {
WebPPictureImportRGBA(frame, rgb, 4 * width);
} else if (strcmp(mode, "RGBX") == 0) {
WebPPictureImportRGBX(frame, rgb, 4 * width);
} else {
WebPPictureImportRGB(frame, rgb, 3 * width);
if (import_frame_libwebp(frame, im)) {
return NULL;
}

// Add the frame to the encoder
if (!WebPAnimEncoderAdd(enc, frame, timestamp, &config)) {
ImagingSectionEnter(&cookie);
int ok = WebPAnimEncoderAdd(enc, frame, timestamp, &config);
ImagingSectionLeave(&cookie);

if (!ok) {
PyErr_SetString(PyExc_RuntimeError, WebPAnimEncoderGetError(enc));
return NULL;
}
Expand Down Expand Up @@ -572,26 +608,21 @@ static PyTypeObject WebPAnimDecoder_Type = {

PyObject *
WebPEncode_wrapper(PyObject *self, PyObject *args) {
int width;
int height;
int lossless;
float quality_factor;
float alpha_quality_factor;
int method;
int exact;
uint8_t *rgb;
Imaging im;
PyObject *i0;
uint8_t *icc_bytes;
uint8_t *exif_bytes;
uint8_t *xmp_bytes;
uint8_t *output;
char *mode;
Py_ssize_t size;
Py_ssize_t icc_size;
Py_ssize_t exif_size;
Py_ssize_t xmp_size;
size_t ret_size;
int rgba_mode;
int channels;
int ok;
ImagingSectionCookie cookie;
WebPConfig config;
Expand All @@ -600,15 +631,11 @@ WebPEncode_wrapper(PyObject *self, PyObject *args) {

if (!PyArg_ParseTuple(
args,
"y#iiiffss#iis#s#",
(char **)&rgb,
&size,
&width,
&height,
"Oiffs#iis#s#",
&i0,
&lossless,
&quality_factor,
&alpha_quality_factor,
&mode,
&icc_bytes,
&icc_size,
&method,
Expand All @@ -621,15 +648,12 @@ WebPEncode_wrapper(PyObject *self, PyObject *args) {
return NULL;
}

rgba_mode = strcmp(mode, "RGBA") == 0;
if (!rgba_mode && strcmp(mode, "RGB") != 0) {
Py_RETURN_NONE;
if (!PyCapsule_IsValid(i0, IMAGING_MAGIC)) {
PyErr_Format(PyExc_TypeError, "Expected '%s' Capsule", IMAGING_MAGIC);
return NULL;
}

channels = rgba_mode ? 4 : 3;
if (size < width * height * channels) {
Py_RETURN_NONE;
}
im = (Imaging)PyCapsule_GetPointer(i0, IMAGING_MAGIC);

// Setup config for this frame
if (!WebPConfigInit(&config)) {
Expand All @@ -652,14 +676,9 @@ WebPEncode_wrapper(PyObject *self, PyObject *args) {
PyErr_SetString(PyExc_ValueError, "could not initialise picture");
return NULL;
}
pic.width = width;
pic.height = height;
pic.use_argb = 1; // Don't convert RGB pixels to YUV

if (rgba_mode) {
WebPPictureImportRGBA(&pic, rgb, channels * width);
} else {
WebPPictureImportRGB(&pic, rgb, channels * width);
if (import_frame_libwebp(&pic, im)) {
return NULL;
}

WebPMemoryWriterInit(&writer);
Expand Down
Loading