From 7b725a8fc4e97f150793c8be529c9ae0c80dd64f Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 15 Mar 2025 13:04:26 +1100 Subject: [PATCH 01/15] DXT3 images are read in RGBA mode --- docs/handbook/image-file-formats.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index a1d93821f..b0e20fa84 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -68,7 +68,7 @@ by DirectX. DXT1 and DXT5 pixel formats can be read, only in ``RGBA`` mode. .. versionadded:: 3.4.0 - DXT3 images can be read in ``RGB`` mode and DX10 images can be read in + DXT3 images can be read in ``RGBA`` mode and DX10 images can be read in ``RGB`` and ``RGBA`` mode. .. versionadded:: 6.0.0 From e1cd9ad5ac17b3923f8842cb0696c94065688f64 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 6 Mar 2025 20:45:49 +1100 Subject: [PATCH 02/15] Use maxsplit --- src/PIL/GimpPaletteFile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/GimpPaletteFile.py b/src/PIL/GimpPaletteFile.py index 1b7a394c0..74f870ca7 100644 --- a/src/PIL/GimpPaletteFile.py +++ b/src/PIL/GimpPaletteFile.py @@ -45,7 +45,7 @@ class GimpPaletteFile: msg = "bad palette file" raise SyntaxError(msg) - v = tuple(map(int, s.split()[:3])) + v = tuple(map(int, s.split(maxsplit=3)[:3])) if len(v) != 3: msg = "bad palette entry" raise ValueError(msg) From 1f6fd3b994f9a810246fa28a863cb849eba4586c Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 6 Mar 2025 20:49:37 +1100 Subject: [PATCH 03/15] Only convert to int if there are enough items --- src/PIL/GimpPaletteFile.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/PIL/GimpPaletteFile.py b/src/PIL/GimpPaletteFile.py index 74f870ca7..f1b3844be 100644 --- a/src/PIL/GimpPaletteFile.py +++ b/src/PIL/GimpPaletteFile.py @@ -45,12 +45,12 @@ class GimpPaletteFile: msg = "bad palette file" raise SyntaxError(msg) - v = tuple(map(int, s.split(maxsplit=3)[:3])) - if len(v) != 3: + v = s.split(maxsplit=3) + if len(v) < 3: msg = "bad palette entry" raise ValueError(msg) - palette[i] = o8(v[0]) + o8(v[1]) + o8(v[2]) + palette[i] = o8(int(v[0])) + o8(int(v[1])) + o8(int(v[2])) self.palette = b"".join(palette) From 6e597a1ca742ea0fbfaa75f3e02a129d6faa3643 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 6 Mar 2025 22:08:59 +1100 Subject: [PATCH 04/15] Do not force palette length to be 256 --- Tests/test_file_gimppalette.py | 1 + src/PIL/GimpPaletteFile.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_gimppalette.py b/Tests/test_file_gimppalette.py index e8d5f1705..b362fc8ff 100644 --- a/Tests/test_file_gimppalette.py +++ b/Tests/test_file_gimppalette.py @@ -32,3 +32,4 @@ def test_get_palette() -> None: # Assert assert mode == "RGB" + assert len(palette) / 3 == 11 diff --git a/src/PIL/GimpPaletteFile.py b/src/PIL/GimpPaletteFile.py index f1b3844be..b289ecb52 100644 --- a/src/PIL/GimpPaletteFile.py +++ b/src/PIL/GimpPaletteFile.py @@ -27,12 +27,11 @@ class GimpPaletteFile: rawmode = "RGB" def __init__(self, fp: IO[bytes]) -> None: - palette = [o8(i) * 3 for i in range(256)] - if not fp.readline().startswith(b"GIMP Palette"): msg = "not a GIMP palette file" raise SyntaxError(msg) + palette: list[int] = [] for i in range(256): s = fp.readline() if not s: @@ -40,6 +39,7 @@ class GimpPaletteFile: # skip fields and comment lines if re.match(rb"\w+:|#", s): + palette.append(o8(i) * 3) continue if len(s) > 100: msg = "bad palette file" @@ -50,7 +50,7 @@ class GimpPaletteFile: msg = "bad palette entry" raise ValueError(msg) - palette[i] = o8(int(v[0])) + o8(int(v[1])) + o8(int(v[2])) + palette.append(o8(int(v[0])) + o8(int(v[1])) + o8(int(v[2]))) self.palette = b"".join(palette) From ca0c940cb1b932c67bf166dc600bd1eb5b264bde Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 6 Mar 2025 22:09:35 +1100 Subject: [PATCH 05/15] Do not add palette entries when reading other lines --- Tests/test_file_gimppalette.py | 2 +- src/PIL/GimpPaletteFile.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_gimppalette.py b/Tests/test_file_gimppalette.py index b362fc8ff..ff9cc91c5 100644 --- a/Tests/test_file_gimppalette.py +++ b/Tests/test_file_gimppalette.py @@ -32,4 +32,4 @@ def test_get_palette() -> None: # Assert assert mode == "RGB" - assert len(palette) / 3 == 11 + assert len(palette) / 3 == 8 diff --git a/src/PIL/GimpPaletteFile.py b/src/PIL/GimpPaletteFile.py index b289ecb52..bbbe2781a 100644 --- a/src/PIL/GimpPaletteFile.py +++ b/src/PIL/GimpPaletteFile.py @@ -32,14 +32,13 @@ class GimpPaletteFile: raise SyntaxError(msg) palette: list[int] = [] - for i in range(256): + for _ in range(256): s = fp.readline() if not s: break # skip fields and comment lines if re.match(rb"\w+:|#", s): - palette.append(o8(i) * 3) continue if len(s) > 100: msg = "bad palette file" From 669a288beb222e61b6e9107940f755517e0ac2ff Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 6 Mar 2025 22:33:43 +1100 Subject: [PATCH 06/15] Convert all entries to bytes at once --- src/PIL/GimpPaletteFile.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/PIL/GimpPaletteFile.py b/src/PIL/GimpPaletteFile.py index bbbe2781a..0f079f457 100644 --- a/src/PIL/GimpPaletteFile.py +++ b/src/PIL/GimpPaletteFile.py @@ -18,8 +18,6 @@ from __future__ import annotations import re from typing import IO -from ._binary import o8 - class GimpPaletteFile: """File handler for GIMP's palette format.""" @@ -49,9 +47,9 @@ class GimpPaletteFile: msg = "bad palette entry" raise ValueError(msg) - palette.append(o8(int(v[0])) + o8(int(v[1])) + o8(int(v[2]))) + palette += (int(v[i]) for i in range(3)) - self.palette = b"".join(palette) + self.palette = bytes(palette) def getpalette(self) -> tuple[bytes, str]: return self.palette, self.rawmode From 3dbd0e57bae0dbe2a3f2c006fb767417a2c5419e Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Mon, 10 Mar 2025 10:14:38 +1100 Subject: [PATCH 07/15] Added DXT1 encoding --- Tests/test_file_dds.py | 31 ++++++- setup.py | 1 + src/PIL/DdsImagePlugin.py | 61 ++++++++----- src/_imaging.c | 3 + src/encode.c | 18 ++++ src/libImaging/BcnEncode.c | 173 +++++++++++++++++++++++++++++++++++++ src/libImaging/Imaging.h | 2 + 7 files changed, 262 insertions(+), 27 deletions(-) create mode 100644 src/libImaging/BcnEncode.c diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 7cc4d79d4..7a6099ce7 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -9,7 +9,12 @@ import pytest from PIL import DdsImagePlugin, Image -from .helper import assert_image_equal, assert_image_equal_tofile, hopper +from .helper import ( + assert_image_equal, + assert_image_equal_tofile, + assert_image_similar_tofile, + hopper, +) TEST_FILE_DXT1 = "Tests/images/dxt1-rgb-4bbp-noalpha_MipMaps-1.dds" TEST_FILE_DXT3 = "Tests/images/dxt3-argb-8bbp-explicitalpha_MipMaps-1.dds" @@ -389,5 +394,25 @@ def test_save(mode: str, test_file: str, tmp_path: Path) -> None: assert im.mode == mode im.save(out) - with Image.open(out) as reloaded: - assert_image_equal(im, reloaded) + assert_image_equal_tofile(im, out) + + +def test_save_dxt1(tmp_path: Path) -> None: + out = str(tmp_path / "temp.dds") + with Image.open(TEST_FILE_DXT1) as im: + im.convert("RGB").save(out, pixel_format="DXT1") + assert_image_similar_tofile(im, out, 1.84) + + im_alpha = im.copy() + im_alpha.putpixel((0, 0), (0, 0, 0, 0)) + im_alpha.save(out, pixel_format="DXT1") + with Image.open(out) as reloaded: + assert reloaded.getpixel((0, 0)) == (0, 0, 0, 0) + + im_l = im.convert("L") + im_l.save(out, pixel_format="DXT1") + assert_image_similar_tofile(im_l.convert("RGBA"), out, 9.25) + + im_alpha.convert("LA").save(out, pixel_format="DXT1") + with Image.open(out) as reloaded: + assert reloaded.getpixel((0, 0)) == (0, 0, 0, 0) diff --git a/setup.py b/setup.py index a85731db9..9fac993b1 100644 --- a/setup.py +++ b/setup.py @@ -68,6 +68,7 @@ _LIB_IMAGING = ( "Reduce", "Bands", "BcnDecode", + "BcnEncode", "BitDecode", "Blend", "Chops", diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index cdae8dfee..718f376e8 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -518,30 +518,43 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: msg = f"cannot write mode {im.mode} as DDS" raise OSError(msg) - alpha = im.mode[-1] == "A" - if im.mode[0] == "L": - pixel_flags = DDPF.LUMINANCE - rawmode = im.mode - if alpha: - rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF] - else: - rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000] - else: - pixel_flags = DDPF.RGB - rawmode = im.mode[::-1] - rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF] - - if alpha: - r, g, b, a = im.split() - im = Image.merge("RGBA", (a, r, g, b)) - if alpha: - pixel_flags |= DDPF.ALPHAPIXELS - rgba_mask.append(0xFF000000 if alpha else 0) - - flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PITCH | DDSD.PIXELFORMAT + flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT bitcount = len(im.getbands()) * 8 - pitch = (im.width * bitcount + 7) // 8 + raw = im.encoderinfo.get("pixel_format") != "DXT1" + if raw: + codec_name = "raw" + flags |= DDSD.PITCH + pitch = (im.width * bitcount + 7) // 8 + alpha = im.mode[-1] == "A" + if im.mode[0] == "L": + pixel_flags = DDPF.LUMINANCE + rawmode = im.mode + if alpha: + rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF] + else: + rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000] + else: + pixel_flags = DDPF.RGB + rawmode = im.mode[::-1] + rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF] + + if alpha: + r, g, b, a = im.split() + im = Image.merge("RGBA", (a, r, g, b)) + if alpha: + pixel_flags |= DDPF.ALPHAPIXELS + rgba_mask.append(0xFF000000 if alpha else 0) + + fourcc = 0 + else: + codec_name = "bcn" + flags |= DDSD.LINEARSIZE + pitch = (im.width + 3) * 4 + rawmode = None + rgba_mask = [0, 0, 0, 0] + pixel_flags = DDPF.FOURCC + fourcc = D3DFMT.DXT1 fp.write( o32(DDS_MAGIC) + struct.pack( @@ -556,11 +569,11 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: ) + struct.pack("11I", *((0,) * 11)) # reserved # pfsize, pfflags, fourcc, bitcount - + struct.pack("<4I", 32, pixel_flags, 0, bitcount) + + struct.pack("<4I", 32, pixel_flags, fourcc, bitcount) + struct.pack("<4I", *rgba_mask) # dwRGBABitMask + struct.pack("<5I", DDSCAPS.TEXTURE, 0, 0, 0, 0) ) - ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, rawmode)]) + ImageFile._save(im, fp, [ImageFile._Tile(codec_name, (0, 0) + im.size, 0, rawmode)]) def _accept(prefix: bytes) -> bool: diff --git a/src/_imaging.c b/src/_imaging.c index fa38dcc05..330a7eef4 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -4041,6 +4041,8 @@ PyImaging_ZipDecoderNew(PyObject *self, PyObject *args); /* Encoders (in encode.c) */ extern PyObject * +PyImaging_BcnEncoderNew(PyObject *self, PyObject *args); +extern PyObject * PyImaging_EpsEncoderNew(PyObject *self, PyObject *args); extern PyObject * PyImaging_GifEncoderNew(PyObject *self, PyObject *args); @@ -4109,6 +4111,7 @@ static PyMethodDef functions[] = { /* Codecs */ {"bcn_decoder", (PyCFunction)PyImaging_BcnDecoderNew, METH_VARARGS}, + {"bcn_encoder", (PyCFunction)PyImaging_BcnEncoderNew, METH_VARARGS}, {"bit_decoder", (PyCFunction)PyImaging_BitDecoderNew, METH_VARARGS}, {"eps_encoder", (PyCFunction)PyImaging_EpsEncoderNew, METH_VARARGS}, {"fli_decoder", (PyCFunction)PyImaging_FliDecoderNew, METH_VARARGS}, diff --git a/src/encode.c b/src/encode.c index 13d5cdaf7..d27783f0c 100644 --- a/src/encode.c +++ b/src/encode.c @@ -350,6 +350,24 @@ get_packer(ImagingEncoderObject *encoder, const char *mode, const char *rawmode) return 0; } +/* -------------------------------------------------------------------- */ +/* BCN */ +/* -------------------------------------------------------------------- */ + +PyObject * +PyImaging_BcnEncoderNew(PyObject *self, PyObject *args) { + ImagingEncoderObject *encoder; + + encoder = PyImaging_EncoderNew(0); + if (encoder == NULL) { + return NULL; + } + + encoder->encode = ImagingBcnEncode; + + return (PyObject *)encoder; +} + /* -------------------------------------------------------------------- */ /* EPS */ /* -------------------------------------------------------------------- */ diff --git a/src/libImaging/BcnEncode.c b/src/libImaging/BcnEncode.c new file mode 100644 index 000000000..42a46358c --- /dev/null +++ b/src/libImaging/BcnEncode.c @@ -0,0 +1,173 @@ +/* + * The Python Imaging Library + * + * encoder for DXT1-compressed data + * + * Format documentation: + * https://web.archive.org/web/20170802060935/http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt + * + */ + +#include "Imaging.h" + +#include "Bcn.h" + +typedef struct { + UINT8 color[3]; +} rgb; + +typedef struct { + UINT8 color[3]; + int alpha; +} rgba; + +static rgb +decode_565(UINT16 x) { + rgb item; + int r, g, b; + r = (x & 0xf800) >> 8; + r |= r >> 5; + item.color[0] = r; + g = (x & 0x7e0) >> 3; + g |= g >> 6; + item.color[1] = g; + b = (x & 0x1f) << 3; + b |= b >> 5; + item.color[2] = b; + return item; +} + +static UINT16 +encode_565(rgba item) { + UINT8 r, g, b; + r = item.color[0] >> (8 - 5); + g = item.color[1] >> (8 - 6); + b = item.color[2] >> (8 - 5); + return (r << (5 + 6)) | (g << 5) | b; +} + +int +ImagingBcnEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { + UINT8 *dst = buf; + + for (;;) { + int i, j, k; + UINT16 color_min = 0, color_max = 0; + rgb color_min_rgb, color_max_rgb; + rgba block[16], *current_rgba; + + // Determine the min and max colors in this 4x4 block + int has_alpha_channel = + strcmp(im->mode, "RGBA") == 0 || strcmp(im->mode, "LA") == 0; + int first = 1; + int transparency = 0; + for (i = 0; i < 4; i++) { + for (j = 0; j < 4; j++) { + int x = state->x + i * im->pixelsize; + int y = state->y + j; + if (x >= state->xsize * im->pixelsize || y >= state->ysize) { + // The 4x4 block extends past the edge of the image + continue; + } + + current_rgba = &block[i + j * 4]; + for (k = 0; k < 3; k++) { + current_rgba->color[k] = + (UINT8)im->image[y][x + (im->pixelsize == 1 ? 0 : k)]; + } + if (has_alpha_channel) { + if ((UINT8)im->image[y][x + 3] == 0) { + current_rgba->alpha = 0; + transparency = 1; + continue; + } else { + current_rgba->alpha = 1; + } + } + + UINT16 color = encode_565(*current_rgba); + if (first || color < color_min) { + color_min = color; + } + if (first || color > color_max) { + color_max = color; + } + first = 0; + } + } + + if (transparency) { + *dst++ = color_min; + *dst++ = color_min >> 8; + } + *dst++ = color_max; + *dst++ = color_max >> 8; + if (!transparency) { + *dst++ = color_min; + *dst++ = color_min >> 8; + } + + color_min_rgb = decode_565(color_min); + color_max_rgb = decode_565(color_max); + for (i = 0; i < 4; i++) { + UINT8 l = 0; + for (j = 3; j > -1; j--) { + current_rgba = &block[i * 4 + j]; + if (transparency && !current_rgba->alpha) { + l |= 3 << (j * 2); + continue; + } + + float distance = 0; + int total = 0; + for (k = 0; k < 3; k++) { + float denom = + (float)abs(color_max_rgb.color[k] - color_min_rgb.color[k]); + if (denom != 0) { + distance += + abs(current_rgba->color[k] - color_min_rgb.color[k]) / + denom; + total += 1; + } + } + if (total == 0) { + continue; + } + distance *= 6 / total; + if (transparency) { + if (distance < 1.5) { + // color_max + } else if (distance < 4.5) { + l |= 2 << (j * 2); // 1/2 * color_min + 1/2 * color_max + } else { + l |= 1 << (j * 2); // color_min + } + } else { + if (distance < 1) { + l |= 1 << (j * 2); // color_min + } else if (distance < 3) { + l |= 3 << (j * 2); // 1/3 * color_min + 2/3 * color_max + } else if (distance < 5) { + l |= 2 << (j * 2); // 2/3 * color_min + 1/3 * color_max + } else { + // color_max + } + } + } + *dst++ = l; + } + + state->x += im->pixelsize * 4; + + if (state->x >= state->xsize * im->pixelsize) { + state->x = 0; + state->y += 4; + if (state->y >= state->ysize) { + state->errcode = IMAGING_CODEC_END; + break; + } + } + } + + return dst - buf; +} diff --git a/src/libImaging/Imaging.h b/src/libImaging/Imaging.h index 0c2d3fc2e..0fc191d15 100644 --- a/src/libImaging/Imaging.h +++ b/src/libImaging/Imaging.h @@ -567,6 +567,8 @@ typedef int (*ImagingCodec)( extern int ImagingBcnDecode(Imaging im, ImagingCodecState state, UINT8 *buffer, Py_ssize_t bytes); extern int +ImagingBcnEncode(Imaging im, ImagingCodecState state, UINT8 *buffer, int bytes); +extern int ImagingBitDecode(Imaging im, ImagingCodecState state, UINT8 *buffer, Py_ssize_t bytes); extern int ImagingEpsEncode(Imaging im, ImagingCodecState state, UINT8 *buffer, int bytes); From 9430bbe5a133c6e5ccb2ce23e0ea1f7f6adca0fe Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 13 Mar 2025 23:53:08 +1100 Subject: [PATCH 08/15] Added DXT5 saving --- Tests/test_file_dds.py | 29 +++- src/PIL/DdsImagePlugin.py | 28 ++-- src/encode.c | 11 +- src/libImaging/BcnEncode.c | 289 ++++++++++++++++++++++++------------- 4 files changed, 237 insertions(+), 120 deletions(-) diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 7a6099ce7..17d88451f 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -398,21 +398,48 @@ def test_save(mode: str, test_file: str, tmp_path: Path) -> None: def test_save_dxt1(tmp_path: Path) -> None: + # RGB out = str(tmp_path / "temp.dds") with Image.open(TEST_FILE_DXT1) as im: im.convert("RGB").save(out, pixel_format="DXT1") assert_image_similar_tofile(im, out, 1.84) + # RGBA im_alpha = im.copy() im_alpha.putpixel((0, 0), (0, 0, 0, 0)) im_alpha.save(out, pixel_format="DXT1") with Image.open(out) as reloaded: assert reloaded.getpixel((0, 0)) == (0, 0, 0, 0) + # L im_l = im.convert("L") im_l.save(out, pixel_format="DXT1") - assert_image_similar_tofile(im_l.convert("RGBA"), out, 9.25) + assert_image_similar_tofile(im_l.convert("RGBA"), out, 6.07) + # LA im_alpha.convert("LA").save(out, pixel_format="DXT1") with Image.open(out) as reloaded: assert reloaded.getpixel((0, 0)) == (0, 0, 0, 0) + + +def test_save_dxt5(tmp_path: Path) -> None: + # RGB + out = str(tmp_path / "temp.dds") + with Image.open(TEST_FILE_DXT1) as im: + im.convert("RGB").save(out, pixel_format="DXT5") + assert_image_similar_tofile(im, out, 1.84) + + # RGBA + with Image.open(TEST_FILE_DXT5) as im_rgba: + im_rgba.save(out, pixel_format="DXT5") + assert_image_similar_tofile(im_rgba, out, 3.69) + + # L + im_l = im.convert("L") + im_l.save(out, pixel_format="DXT5") + assert_image_similar_tofile(im_l.convert("RGBA"), out, 6.07) + + # LA + im_la = im_rgba.convert("LA") + im_la.save(out, pixel_format="DXT5") + assert_image_similar_tofile(im_la.convert("RGBA"), out, 8.32) diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index 718f376e8..2d097fd16 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -520,8 +520,16 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT bitcount = len(im.getbands()) * 8 - raw = im.encoderinfo.get("pixel_format") != "DXT1" - if raw: + pixel_format = im.encoderinfo.get("pixel_format") + if pixel_format in ("DXT1", "DXT5"): + codec_name = "bcn" + flags |= DDSD.LINEARSIZE + pitch = (im.width + 3) * 4 + args = pixel_format + rgba_mask = [0, 0, 0, 0] + pixel_flags = DDPF.FOURCC + fourcc = D3DFMT.DXT1 if pixel_format == "DXT1" else D3DFMT.DXT5 + else: codec_name = "raw" flags |= DDSD.PITCH pitch = (im.width * bitcount + 7) // 8 @@ -529,14 +537,14 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: alpha = im.mode[-1] == "A" if im.mode[0] == "L": pixel_flags = DDPF.LUMINANCE - rawmode = im.mode + args = im.mode if alpha: rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF] else: rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000] else: pixel_flags = DDPF.RGB - rawmode = im.mode[::-1] + args = im.mode[::-1] rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF] if alpha: @@ -546,15 +554,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: pixel_flags |= DDPF.ALPHAPIXELS rgba_mask.append(0xFF000000 if alpha else 0) - fourcc = 0 - else: - codec_name = "bcn" - flags |= DDSD.LINEARSIZE - pitch = (im.width + 3) * 4 - rawmode = None - rgba_mask = [0, 0, 0, 0] - pixel_flags = DDPF.FOURCC - fourcc = D3DFMT.DXT1 + fourcc = D3DFMT.UNKNOWN fp.write( o32(DDS_MAGIC) + struct.pack( @@ -573,7 +573,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + struct.pack("<4I", *rgba_mask) # dwRGBABitMask + struct.pack("<5I", DDSCAPS.TEXTURE, 0, 0, 0, 0) ) - ImageFile._save(im, fp, [ImageFile._Tile(codec_name, (0, 0) + im.size, 0, rawmode)]) + ImageFile._save(im, fp, [ImageFile._Tile(codec_name, (0, 0) + im.size, 0, args)]) def _accept(prefix: bytes) -> bool: diff --git a/src/encode.c b/src/encode.c index d27783f0c..e228237f2 100644 --- a/src/encode.c +++ b/src/encode.c @@ -27,6 +27,7 @@ #include "thirdparty/pythoncapi_compat.h" #include "libImaging/Imaging.h" +#include "libImaging/Bcn.h" #include "libImaging/Gif.h" #ifdef HAVE_UNISTD_H @@ -358,13 +359,21 @@ PyObject * PyImaging_BcnEncoderNew(PyObject *self, PyObject *args) { ImagingEncoderObject *encoder; - encoder = PyImaging_EncoderNew(0); + char *mode; + char *pixel_format; + if (!PyArg_ParseTuple(args, "ss", &mode, &pixel_format)) { + return NULL; + } + + encoder = PyImaging_EncoderNew(sizeof(BCNSTATE)); if (encoder == NULL) { return NULL; } encoder->encode = ImagingBcnEncode; + ((BCNSTATE *)encoder->state.context)->pixel_format = pixel_format; + return (PyObject *)encoder; } diff --git a/src/libImaging/BcnEncode.c b/src/libImaging/BcnEncode.c index 42a46358c..57353246a 100644 --- a/src/libImaging/BcnEncode.c +++ b/src/libImaging/BcnEncode.c @@ -17,8 +17,7 @@ typedef struct { } rgb; typedef struct { - UINT8 color[3]; - int alpha; + UINT8 color[4]; } rgba; static rgb @@ -46,116 +45,198 @@ encode_565(rgba item) { return (r << (5 + 6)) | (g << 5) | b; } +static void +encode_bc1_color(Imaging im, ImagingCodecState state, UINT8 *dst, int separate_alpha) { + int i, j, k; + UINT16 color_min = 0, color_max = 0; + rgb color_min_rgb, color_max_rgb; + rgba block[16], *current_rgba; + + // Determine the min and max colors in this 4x4 block + int first = 1; + int transparency = 0; + for (i = 0; i < 4; i++) { + for (j = 0; j < 4; j++) { + int x = state->x + i * im->pixelsize; + int y = state->y + j; + if (x >= state->xsize * im->pixelsize || y >= state->ysize) { + // The 4x4 block extends past the edge of the image + continue; + } + + current_rgba = &block[i + j * 4]; + for (k = 0; k < 3; k++) { + current_rgba->color[k] = + (UINT8)im->image[y][x + (im->pixelsize == 1 ? 0 : k)]; + } + if (separate_alpha) { + if ((UINT8)im->image[y][x + 3] == 0) { + current_rgba->color[3] = 0; + transparency = 1; + continue; + } else { + current_rgba->color[3] = 1; + } + } + + UINT16 color = encode_565(*current_rgba); + if (first || color < color_min) { + color_min = color; + } + if (first || color > color_max) { + color_max = color; + } + first = 0; + } + } + + if (transparency) { + *dst++ = color_min; + *dst++ = color_min >> 8; + } + *dst++ = color_max; + *dst++ = color_max >> 8; + if (!transparency) { + *dst++ = color_min; + *dst++ = color_min >> 8; + } + + color_min_rgb = decode_565(color_min); + color_max_rgb = decode_565(color_max); + for (i = 0; i < 4; i++) { + UINT8 l = 0; + for (j = 3; j > -1; j--) { + current_rgba = &block[i * 4 + j]; + if (transparency && !current_rgba->color[3]) { + l |= 3 << (j * 2); + continue; + } + + float distance = 0; + int total = 0; + for (k = 0; k < 3; k++) { + float denom = + (float)abs(color_max_rgb.color[k] - color_min_rgb.color[k]); + if (denom != 0) { + distance += + abs(current_rgba->color[k] - color_min_rgb.color[k]) / denom; + total += 1; + } + } + if (total == 0) { + continue; + } + if (transparency) { + distance *= 4 / total; + if (distance < 1) { + // color_max + } else if (distance < 3) { + l |= 2 << (j * 2); // 1/2 * color_min + 1/2 * color_max + } else { + l |= 1 << (j * 2); // color_min + } + } else { + distance *= 6 / total; + if (distance < 1) { + l |= 1 << (j * 2); // color_min + } else if (distance < 3) { + l |= 3 << (j * 2); // 1/3 * color_min + 2/3 * color_max + } else if (distance < 5) { + l |= 2 << (j * 2); // 2/3 * color_min + 1/3 * color_max + } else { + // color_max + } + } + } + *dst++ = l; + } +} + +static void +encode_bc3_alpha(Imaging im, ImagingCodecState state, UINT8 *dst) { + int i, j; + UINT8 alpha_min = 0, alpha_max = 0; + UINT8 block[16], current_alpha; + + // Determine the min and max colors in this 4x4 block + int first = 1; + for (i = 0; i < 4; i++) { + for (j = 0; j < 4; j++) { + int x = state->x + i * im->pixelsize; + int y = state->y + j; + if (x >= state->xsize * im->pixelsize || y >= state->ysize) { + // The 4x4 block extends past the edge of the image + continue; + } + + current_alpha = (UINT8)im->image[y][x + 3]; + block[i + j * 4] = current_alpha; + + if (first || current_alpha < alpha_min) { + alpha_min = current_alpha; + } + if (first || current_alpha > alpha_max) { + alpha_max = current_alpha; + } + first = 0; + } + } + + *dst++ = alpha_min; + *dst++ = alpha_max; + + float denom = (float)abs(alpha_max - alpha_min); + for (i = 0; i < 2; i++) { + UINT32 l = 0; + for (j = 7; j > -1; j--) { + current_alpha = block[i * 8 + j]; + if (!current_alpha) { + l |= 6 << (j * 3); + continue; + } else if (current_alpha == 255 || denom == 0) { + l |= 7 << (j * 3); + continue; + } + + float distance = abs(current_alpha - alpha_min) / denom * 10; + if (distance < 3) { + l |= 2 << (j * 3); // 4/5 * alpha_min + 1/5 * alpha_max + } else if (distance < 5) { + l |= 3 << (j * 3); // 3/5 * alpha_min + 2/5 * alpha_max + } else if (distance < 7) { + l |= 4 << (j * 3); // 2/5 * alpha_min + 3/5 * alpha_max + } else { + l |= 5 << (j * 3); // 1/5 * alpha_min + 4/5 * alpha_max + } + } + *dst++ = l; + *dst++ = l >> 8; + *dst++ = l >> 16; + } +} + int ImagingBcnEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { + char *pixel_format = ((BCNSTATE *)state->context)->pixel_format; + int n = strcmp(pixel_format, "DXT5") == 0 ? 3 : 1; + int has_alpha_channel = + strcmp(im->mode, "RGBA") == 0 || strcmp(im->mode, "LA") == 0; + UINT8 *dst = buf; for (;;) { - int i, j, k; - UINT16 color_min = 0, color_max = 0; - rgb color_min_rgb, color_max_rgb; - rgba block[16], *current_rgba; - - // Determine the min and max colors in this 4x4 block - int has_alpha_channel = - strcmp(im->mode, "RGBA") == 0 || strcmp(im->mode, "LA") == 0; - int first = 1; - int transparency = 0; - for (i = 0; i < 4; i++) { - for (j = 0; j < 4; j++) { - int x = state->x + i * im->pixelsize; - int y = state->y + j; - if (x >= state->xsize * im->pixelsize || y >= state->ysize) { - // The 4x4 block extends past the edge of the image - continue; - } - - current_rgba = &block[i + j * 4]; - for (k = 0; k < 3; k++) { - current_rgba->color[k] = - (UINT8)im->image[y][x + (im->pixelsize == 1 ? 0 : k)]; - } - if (has_alpha_channel) { - if ((UINT8)im->image[y][x + 3] == 0) { - current_rgba->alpha = 0; - transparency = 1; - continue; - } else { - current_rgba->alpha = 1; - } - } - - UINT16 color = encode_565(*current_rgba); - if (first || color < color_min) { - color_min = color; - } - if (first || color > color_max) { - color_max = color; - } - first = 0; - } - } - - if (transparency) { - *dst++ = color_min; - *dst++ = color_min >> 8; - } - *dst++ = color_max; - *dst++ = color_max >> 8; - if (!transparency) { - *dst++ = color_min; - *dst++ = color_min >> 8; - } - - color_min_rgb = decode_565(color_min); - color_max_rgb = decode_565(color_max); - for (i = 0; i < 4; i++) { - UINT8 l = 0; - for (j = 3; j > -1; j--) { - current_rgba = &block[i * 4 + j]; - if (transparency && !current_rgba->alpha) { - l |= 3 << (j * 2); - continue; - } - - float distance = 0; - int total = 0; - for (k = 0; k < 3; k++) { - float denom = - (float)abs(color_max_rgb.color[k] - color_min_rgb.color[k]); - if (denom != 0) { - distance += - abs(current_rgba->color[k] - color_min_rgb.color[k]) / - denom; - total += 1; - } - } - if (total == 0) { - continue; - } - distance *= 6 / total; - if (transparency) { - if (distance < 1.5) { - // color_max - } else if (distance < 4.5) { - l |= 2 << (j * 2); // 1/2 * color_min + 1/2 * color_max - } else { - l |= 1 << (j * 2); // color_min - } - } else { - if (distance < 1) { - l |= 1 << (j * 2); // color_min - } else if (distance < 3) { - l |= 3 << (j * 2); // 1/3 * color_min + 2/3 * color_max - } else if (distance < 5) { - l |= 2 << (j * 2); // 2/3 * color_min + 1/3 * color_max - } else { - // color_max - } + if (n == 3) { + if (has_alpha_channel) { + encode_bc3_alpha(im, state, dst); + dst += 8; + } else { + for (int i = 0; i < 8; i++) { + *dst++ = 0xff; } } - *dst++ = l; } + encode_bc1_color(im, state, dst, n == 1 && has_alpha_channel); + dst += 8; state->x += im->pixelsize * 4; From 9f619b814f65a99c2107852027ecf7db4ddec7ec Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 15 Mar 2025 00:21:34 +1100 Subject: [PATCH 09/15] Added BC3 loading and saving --- Tests/test_file_dds.py | 14 ++++++++++++++ src/PIL/DdsImagePlugin.py | 17 +++++++++++++++-- src/libImaging/BcnEncode.c | 2 +- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 17d88451f..3c7c8e604 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -12,6 +12,7 @@ from PIL import DdsImagePlugin, Image from .helper import ( assert_image_equal, assert_image_equal_tofile, + assert_image_similar, assert_image_similar_tofile, hopper, ) @@ -114,6 +115,19 @@ def test_sanity_ati1_bc4u(image_path: str) -> None: assert_image_equal_tofile(im, TEST_FILE_ATI1.replace(".dds", ".png")) +def test_dx10_bc3(tmp_path: Path) -> None: + out = str(tmp_path / "temp.dds") + with Image.open(TEST_FILE_DXT5) as im: + im.save(out, pixel_format="BC3") + + with Image.open(out) as reloaded: + assert reloaded.format == "DDS" + assert reloaded.mode == "RGBA" + assert reloaded.size == (256, 256) + + assert_image_similar(im, reloaded, 3.69) + + @pytest.mark.parametrize( "image_path", ( diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index 2d097fd16..a5e0a712b 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -419,6 +419,10 @@ class DdsImageFile(ImageFile.ImageFile): self._mode = "RGBA" self.pixel_format = "BC1" n = 1 + elif dxgi_format in (DXGI_FORMAT.BC3_TYPELESS, DXGI_FORMAT.BC3_UNORM): + self._mode = "RGBA" + self.pixel_format = "BC3" + n = 3 elif dxgi_format in (DXGI_FORMAT.BC4_TYPELESS, DXGI_FORMAT.BC4_UNORM): self._mode = "L" self.pixel_format = "BC4" @@ -521,14 +525,18 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT bitcount = len(im.getbands()) * 8 pixel_format = im.encoderinfo.get("pixel_format") - if pixel_format in ("DXT1", "DXT5"): + if pixel_format in ("DXT1", "BC3", "DXT5"): codec_name = "bcn" flags |= DDSD.LINEARSIZE pitch = (im.width + 3) * 4 args = pixel_format rgba_mask = [0, 0, 0, 0] pixel_flags = DDPF.FOURCC - fourcc = D3DFMT.DXT1 if pixel_format == "DXT1" else D3DFMT.DXT5 + fourcc = {"DXT1": D3DFMT.DXT1, "BC3": D3DFMT.DX10, "DXT5": D3DFMT.DXT5}[ + pixel_format + ] + if fourcc == D3DFMT.DX10: + dxgi_format = DXGI_FORMAT.BC3_TYPELESS else: codec_name = "raw" flags |= DDSD.PITCH @@ -573,6 +581,11 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: + struct.pack("<4I", *rgba_mask) # dwRGBABitMask + struct.pack("<5I", DDSCAPS.TEXTURE, 0, 0, 0, 0) ) + if fourcc == D3DFMT.DX10: + fp.write( + # dxgi_format, 2D resource, misc, array size, straight alpha + struct.pack("<5I", dxgi_format, 3, 0, 0, 1) + ) ImageFile._save(im, fp, [ImageFile._Tile(codec_name, (0, 0) + im.size, 0, args)]) diff --git a/src/libImaging/BcnEncode.c b/src/libImaging/BcnEncode.c index 57353246a..66f9f39b1 100644 --- a/src/libImaging/BcnEncode.c +++ b/src/libImaging/BcnEncode.c @@ -218,7 +218,7 @@ encode_bc3_alpha(Imaging im, ImagingCodecState state, UINT8 *dst) { int ImagingBcnEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { char *pixel_format = ((BCNSTATE *)state->context)->pixel_format; - int n = strcmp(pixel_format, "DXT5") == 0 ? 3 : 1; + int n = strcmp(pixel_format, "DXT1") == 0 ? 1 : 3; int has_alpha_channel = strcmp(im->mode, "RGBA") == 0 || strcmp(im->mode, "LA") == 0; From f1a61a1e76cfbc21cd5345f4b87067ace7347ddf Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 15 Mar 2025 12:50:30 +1100 Subject: [PATCH 10/15] Added DXT3 saving --- Tests/test_file_dds.py | 23 ++++++++++++++++++ src/PIL/DdsImagePlugin.py | 20 +++++++++------ src/encode.c | 9 +++---- src/libImaging/BcnEncode.c | 50 ++++++++++++++++++++++++++++++++------ 4 files changed, 83 insertions(+), 19 deletions(-) diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 3c7c8e604..5ef9fbf05 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -436,6 +436,29 @@ def test_save_dxt1(tmp_path: Path) -> None: assert reloaded.getpixel((0, 0)) == (0, 0, 0, 0) +def test_save_dxt3(tmp_path: Path) -> None: + # RGB + out = str(tmp_path / "temp.dds") + with Image.open(TEST_FILE_DXT3) as im: + im_rgb = im.convert("RGB") + im_rgb.save(out, pixel_format="DXT3") + assert_image_similar_tofile(im_rgb.convert("RGBA"), out, 1.26) + + # RGBA + im.save(out, pixel_format="DXT3") + assert_image_similar_tofile(im, out, 3.81) + + # L + im_l = im.convert("L") + im_l.save(out, pixel_format="DXT3") + assert_image_similar_tofile(im_l.convert("RGBA"), out, 5.89) + + # LA + im_la = im.convert("LA") + im_la.save(out, pixel_format="DXT3") + assert_image_similar_tofile(im_la.convert("RGBA"), out, 8.44) + + def test_save_dxt5(tmp_path: Path) -> None: # RGB out = str(tmp_path / "temp.dds") diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index a5e0a712b..c30672c86 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -525,18 +525,24 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT bitcount = len(im.getbands()) * 8 pixel_format = im.encoderinfo.get("pixel_format") - if pixel_format in ("DXT1", "BC3", "DXT5"): + args: tuple[int] | str + if pixel_format in ("DXT1", "DXT3", "BC3", "DXT5"): codec_name = "bcn" flags |= DDSD.LINEARSIZE pitch = (im.width + 3) * 4 - args = pixel_format rgba_mask = [0, 0, 0, 0] pixel_flags = DDPF.FOURCC - fourcc = {"DXT1": D3DFMT.DXT1, "BC3": D3DFMT.DX10, "DXT5": D3DFMT.DXT5}[ - pixel_format - ] - if fourcc == D3DFMT.DX10: - dxgi_format = DXGI_FORMAT.BC3_TYPELESS + if pixel_format == "DXT1": + fourcc = D3DFMT.DXT1 + args = (1,) + elif pixel_format == "DXT3": + fourcc = D3DFMT.DXT3 + args = (2,) + else: + fourcc = D3DFMT.DXT5 if pixel_format == "DXT5" else D3DFMT.DX10 + args = (3,) + if fourcc == D3DFMT.DX10: + dxgi_format = DXGI_FORMAT.BC3_TYPELESS else: codec_name = "raw" flags |= DDSD.PITCH diff --git a/src/encode.c b/src/encode.c index e228237f2..7c365a74f 100644 --- a/src/encode.c +++ b/src/encode.c @@ -360,19 +360,18 @@ PyImaging_BcnEncoderNew(PyObject *self, PyObject *args) { ImagingEncoderObject *encoder; char *mode; - char *pixel_format; - if (!PyArg_ParseTuple(args, "ss", &mode, &pixel_format)) { + int n; + if (!PyArg_ParseTuple(args, "si", &mode, &n)) { return NULL; } - encoder = PyImaging_EncoderNew(sizeof(BCNSTATE)); + encoder = PyImaging_EncoderNew(0); if (encoder == NULL) { return NULL; } encoder->encode = ImagingBcnEncode; - - ((BCNSTATE *)encoder->state.context)->pixel_format = pixel_format; + encoder->state.state = n; return (PyObject *)encoder; } diff --git a/src/libImaging/BcnEncode.c b/src/libImaging/BcnEncode.c index 66f9f39b1..4e0da322e 100644 --- a/src/libImaging/BcnEncode.c +++ b/src/libImaging/BcnEncode.c @@ -10,8 +10,6 @@ #include "Imaging.h" -#include "Bcn.h" - typedef struct { UINT8 color[3]; } rgb; @@ -57,14 +55,18 @@ encode_bc1_color(Imaging im, ImagingCodecState state, UINT8 *dst, int separate_a int transparency = 0; for (i = 0; i < 4; i++) { for (j = 0; j < 4; j++) { + current_rgba = &block[i + j * 4]; + int x = state->x + i * im->pixelsize; int y = state->y + j; if (x >= state->xsize * im->pixelsize || y >= state->ysize) { // The 4x4 block extends past the edge of the image + for (k = 0; k < 3; k++) { + current_rgba->color[k] = 0; + } continue; } - current_rgba = &block[i + j * 4]; for (k = 0; k < 3; k++) { current_rgba->color[k] = (UINT8)im->image[y][x + (im->pixelsize == 1 ? 0 : k)]; @@ -152,6 +154,36 @@ encode_bc1_color(Imaging im, ImagingCodecState state, UINT8 *dst, int separate_a } } +static void +encode_bc2_block(Imaging im, ImagingCodecState state, UINT8 *dst) { + int i, j; + UINT8 block[16], current_alpha; + for (i = 0; i < 4; i++) { + for (j = 0; j < 4; j++) { + int x = state->x + i * im->pixelsize; + int y = state->y + j; + if (x >= state->xsize * im->pixelsize || y >= state->ysize) { + // The 4x4 block extends past the edge of the image + block[i + j * 4] = 0; + continue; + } + + current_alpha = (UINT8)im->image[y][x + 3]; + block[i + j * 4] = current_alpha; + } + } + + for (i = 0; i < 4; i++) { + UINT16 l = 0; + for (j = 3; j > -1; j--) { + current_alpha = block[i * 4 + j]; + l |= current_alpha << (j * 4); + } + *dst++ = l; + *dst++ = l >> 8; + } +} + static void encode_bc3_alpha(Imaging im, ImagingCodecState state, UINT8 *dst) { int i, j; @@ -166,6 +198,7 @@ encode_bc3_alpha(Imaging im, ImagingCodecState state, UINT8 *dst) { int y = state->y + j; if (x >= state->xsize * im->pixelsize || y >= state->ysize) { // The 4x4 block extends past the edge of the image + block[i + j * 4] = 0; continue; } @@ -217,17 +250,20 @@ encode_bc3_alpha(Imaging im, ImagingCodecState state, UINT8 *dst) { int ImagingBcnEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { - char *pixel_format = ((BCNSTATE *)state->context)->pixel_format; - int n = strcmp(pixel_format, "DXT1") == 0 ? 1 : 3; + int n = state->state; int has_alpha_channel = strcmp(im->mode, "RGBA") == 0 || strcmp(im->mode, "LA") == 0; UINT8 *dst = buf; for (;;) { - if (n == 3) { + if (n == 2 || n == 3) { if (has_alpha_channel) { - encode_bc3_alpha(im, state, dst); + if (n == 2) { + encode_bc2_block(im, state, dst); + } else { + encode_bc3_alpha(im, state, dst); + } dst += 8; } else { for (int i = 0; i < 8; i++) { From b0315cc6039e1eabacc36b7af0677f69378f26bd Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 15 Mar 2025 12:56:32 +1100 Subject: [PATCH 11/15] Added BC2 loading and saving --- Tests/test_file_dds.py | 13 +++++++++++++ src/PIL/DdsImagePlugin.py | 18 ++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 5ef9fbf05..3239065d7 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -115,6 +115,19 @@ def test_sanity_ati1_bc4u(image_path: str) -> None: assert_image_equal_tofile(im, TEST_FILE_ATI1.replace(".dds", ".png")) +def test_dx10_bc2(tmp_path: Path) -> None: + out = str(tmp_path / "temp.dds") + with Image.open(TEST_FILE_DXT3) as im: + im.save(out, pixel_format="BC2") + + with Image.open(out) as reloaded: + assert reloaded.format == "DDS" + assert reloaded.mode == "RGBA" + assert reloaded.size == (256, 256) + + assert_image_similar(im, reloaded, 3.81) + + def test_dx10_bc3(tmp_path: Path) -> None: out = str(tmp_path / "temp.dds") with Image.open(TEST_FILE_DXT5) as im: diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index c30672c86..d65e3fc65 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -419,6 +419,10 @@ class DdsImageFile(ImageFile.ImageFile): self._mode = "RGBA" self.pixel_format = "BC1" n = 1 + elif dxgi_format in (DXGI_FORMAT.BC2_TYPELESS, DXGI_FORMAT.BC2_UNORM): + self._mode = "RGBA" + self.pixel_format = "BC2" + n = 2 elif dxgi_format in (DXGI_FORMAT.BC3_TYPELESS, DXGI_FORMAT.BC3_UNORM): self._mode = "RGBA" self.pixel_format = "BC3" @@ -526,7 +530,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: bitcount = len(im.getbands()) * 8 pixel_format = im.encoderinfo.get("pixel_format") args: tuple[int] | str - if pixel_format in ("DXT1", "DXT3", "BC3", "DXT5"): + if pixel_format in ("DXT1", "BC2", "DXT3", "BC3", "DXT5"): codec_name = "bcn" flags |= DDSD.LINEARSIZE pitch = (im.width + 3) * 4 @@ -538,10 +542,16 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: elif pixel_format == "DXT3": fourcc = D3DFMT.DXT3 args = (2,) - else: - fourcc = D3DFMT.DXT5 if pixel_format == "DXT5" else D3DFMT.DX10 + elif pixel_format == "DXT5": + fourcc = D3DFMT.DXT5 args = (3,) - if fourcc == D3DFMT.DX10: + else: + fourcc = D3DFMT.DX10 + if pixel_format == "BC2": + args = (2,) + dxgi_format = DXGI_FORMAT.BC2_TYPELESS + else: + args = (3,) dxgi_format = DXGI_FORMAT.BC3_TYPELESS else: codec_name = "raw" From cd11792c15c60db34b1d506c816d753174422f86 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Sat, 15 Mar 2025 20:16:33 +1100 Subject: [PATCH 12/15] Added BC5 saving --- Tests/test_file_dds.py | 20 +++++++++++++++++++- src/PIL/DdsImagePlugin.py | 13 +++++++++++-- src/libImaging/BcnEncode.c | 38 +++++++++++++++++++++++--------------- 3 files changed, 53 insertions(+), 18 deletions(-) diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 3239065d7..9a6042660 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -402,7 +402,7 @@ def test_not_implemented(test_file: str) -> None: def test_save_unsupported_mode(tmp_path: Path) -> None: out = str(tmp_path / "temp.dds") im = hopper("HSV") - with pytest.raises(OSError): + with pytest.raises(OSError, match="cannot write mode HSV as DDS"): im.save(out) @@ -424,6 +424,13 @@ def test_save(mode: str, test_file: str, tmp_path: Path) -> None: assert_image_equal_tofile(im, out) +def test_save_unsupported_pixel_format(tmp_path: Path) -> None: + out = str(tmp_path / "temp.dds") + im = hopper() + with pytest.raises(OSError, match="cannot write pixel format UNKNOWN"): + im.save(out, pixel_format="UNKNOWN") + + def test_save_dxt1(tmp_path: Path) -> None: # RGB out = str(tmp_path / "temp.dds") @@ -493,3 +500,14 @@ def test_save_dxt5(tmp_path: Path) -> None: im_la = im_rgba.convert("LA") im_la.save(out, pixel_format="DXT5") assert_image_similar_tofile(im_la.convert("RGBA"), out, 8.32) + + +def test_save_dx10_bc5(tmp_path: Path) -> None: + out = str(tmp_path / "temp.dds") + with Image.open(TEST_FILE_DX10_BC5_TYPELESS) as im: + im.save(out, pixel_format="BC5") + assert_image_similar_tofile(im, out, 9.56) + + im = hopper("L") + with pytest.raises(OSError, match="only RGB mode can be written as BC5"): + im.save(out, pixel_format="BC5") diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index d65e3fc65..26307817c 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -530,7 +530,7 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: bitcount = len(im.getbands()) * 8 pixel_format = im.encoderinfo.get("pixel_format") args: tuple[int] | str - if pixel_format in ("DXT1", "BC2", "DXT3", "BC3", "DXT5"): + if pixel_format: codec_name = "bcn" flags |= DDSD.LINEARSIZE pitch = (im.width + 3) * 4 @@ -550,9 +550,18 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: if pixel_format == "BC2": args = (2,) dxgi_format = DXGI_FORMAT.BC2_TYPELESS - else: + elif pixel_format == "BC3": args = (3,) dxgi_format = DXGI_FORMAT.BC3_TYPELESS + elif pixel_format == "BC5": + args = (5,) + dxgi_format = DXGI_FORMAT.BC5_TYPELESS + if im.mode != "RGB": + msg = "only RGB mode can be written as BC5" + raise OSError(msg) + else: + msg = f"cannot write pixel format {pixel_format}" + raise OSError(msg) else: codec_name = "raw" flags |= DDSD.PITCH diff --git a/src/libImaging/BcnEncode.c b/src/libImaging/BcnEncode.c index 4e0da322e..2bad73b92 100644 --- a/src/libImaging/BcnEncode.c +++ b/src/libImaging/BcnEncode.c @@ -185,7 +185,7 @@ encode_bc2_block(Imaging im, ImagingCodecState state, UINT8 *dst) { } static void -encode_bc3_alpha(Imaging im, ImagingCodecState state, UINT8 *dst) { +encode_bc3_alpha(Imaging im, ImagingCodecState state, UINT8 *dst, int o) { int i, j; UINT8 alpha_min = 0, alpha_max = 0; UINT8 block[16], current_alpha; @@ -202,7 +202,7 @@ encode_bc3_alpha(Imaging im, ImagingCodecState state, UINT8 *dst) { continue; } - current_alpha = (UINT8)im->image[y][x + 3]; + current_alpha = (UINT8)im->image[y][x + o]; block[i + j * 4] = current_alpha; if (first || current_alpha < alpha_min) { @@ -226,12 +226,13 @@ encode_bc3_alpha(Imaging im, ImagingCodecState state, UINT8 *dst) { if (!current_alpha) { l |= 6 << (j * 3); continue; - } else if (current_alpha == 255 || denom == 0) { + } else if (current_alpha == 255) { l |= 7 << (j * 3); continue; } - float distance = abs(current_alpha - alpha_min) / denom * 10; + float distance = + denom == 0 ? 0 : abs(current_alpha - alpha_min) / denom * 10; if (distance < 3) { l |= 2 << (j * 3); // 4/5 * alpha_min + 1/5 * alpha_max } else if (distance < 5) { @@ -257,21 +258,28 @@ ImagingBcnEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { UINT8 *dst = buf; for (;;) { - if (n == 2 || n == 3) { - if (has_alpha_channel) { - if (n == 2) { - encode_bc2_block(im, state, dst); + if (n == 5) { + encode_bc3_alpha(im, state, dst, 0); + dst += 8; + + encode_bc3_alpha(im, state, dst, 1); + } else { + if (n == 2 || n == 3) { + if (has_alpha_channel) { + if (n == 2) { + encode_bc2_block(im, state, dst); + } else { + encode_bc3_alpha(im, state, dst, 3); + } + dst += 8; } else { - encode_bc3_alpha(im, state, dst); - } - dst += 8; - } else { - for (int i = 0; i < 8; i++) { - *dst++ = 0xff; + for (int i = 0; i < 8; i++) { + *dst++ = 0xff; + } } } + encode_bc1_color(im, state, dst, n == 1 && has_alpha_channel); } - encode_bc1_color(im, state, dst, n == 1 && has_alpha_channel); dst += 8; state->x += im->pixelsize * 4; From 841ba163fd9fc6e1cf04837515c6208d67032a9a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Tue, 18 Mar 2025 00:21:08 +1100 Subject: [PATCH 13/15] If every tile covers the image, only use the last offset --- src/PIL/TiffImagePlugin.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 2b471abac..39783f1f8 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -1608,6 +1608,10 @@ class TiffImageFile(ImageFile.ImageFile): raise ValueError(msg) w = tilewidth + if w == xsize and h == ysize and self._planar_configuration != 2: + # Every tile covers the image. Only use the last offset + offsets = offsets[-1:] + for offset in offsets: if x + w > xsize: stride = w * sum(bps_tuple) / 8 # bytes per line @@ -1630,11 +1634,11 @@ class TiffImageFile(ImageFile.ImageFile): args, ) ) - x = x + w + x += w if x >= xsize: x, y = 0, y + h if y >= ysize: - x = y = 0 + y = 0 layer += 1 else: logger.debug("- unsupported data organization") From ba2c4291ea009a91e52512abb0258289b72d74d3 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 19 Mar 2025 19:22:15 +1100 Subject: [PATCH 14/15] Updated comment --- src/PIL/WebPImagePlugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index c2dde4431..1716a18cc 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -238,7 +238,7 @@ def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None: cur_idx = im.tell() try: for ims in [im] + append_images: - # Get # of frames in this image + # Get number of frames in this image nfr = getattr(ims, "n_frames", 1) for idx in range(nfr): From 700d36f2d2b351074a136243d7d72682bd3113e0 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Thu, 20 Mar 2025 00:11:18 +1100 Subject: [PATCH 15/15] Added release notes for #8807 --- docs/handbook/image-file-formats.rst | 6 ++++++ docs/releasenotes/11.2.0.rst | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index b0e20fa84..97599ace5 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -93,6 +93,12 @@ DXT1 and DXT5 pixel formats can be read, only in ``RGBA`` mode. in ``P`` mode. +.. versionadded:: 11.2.0 + DXT1, DXT3, DXT5, BC2, BC3 and BC5 pixel formats can be saved:: + + im.save(out, pixel_format="DXT1") + + DIB ^^^ diff --git a/docs/releasenotes/11.2.0.rst b/docs/releasenotes/11.2.0.rst index f7e644cf3..3e977221e 100644 --- a/docs/releasenotes/11.2.0.rst +++ b/docs/releasenotes/11.2.0.rst @@ -66,6 +66,14 @@ libjpeg library, and what version of MozJPEG is being used:: features.check_feature("mozjpeg") # True or False features.version_feature("mozjpeg") # "4.1.1" for example, or None +Saving compressed DDS images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Compressed DDS images can now be saved using a ``pixel_format`` argument. DXT1, DXT3, +DXT5, BC2, BC3 and BC5 are supported:: + + im.save("out.dds", pixel_format="DXT1") + Other Changes =============