Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 124 additions & 4 deletions Tests/test_file_dds.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@

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,
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"
Expand Down Expand Up @@ -109,6 +115,32 @@ 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:
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",
(
Expand Down Expand Up @@ -370,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)


Expand All @@ -389,5 +421,93 @@ 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_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")
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, 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_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")
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)


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")
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def get_version() -> str:
"Reduce",
"Bands",
"BcnDecode",
"BcnEncode",
"BitDecode",
"Blend",
"Chops",
Expand Down
93 changes: 72 additions & 21 deletions src/PIL/DdsImagePlugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,14 @@ def _open(self) -> None:
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"
n = 3
elif dxgi_format in (DXGI_FORMAT.BC4_TYPELESS, DXGI_FORMAT.BC4_UNORM):
self._mode = "L"
self.pixel_format = "BC4"
Expand Down Expand Up @@ -518,30 +526,68 @@ 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]
flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT
bitcount = len(im.getbands()) * 8
pixel_format = im.encoderinfo.get("pixel_format")
args: tuple[int] | str
if pixel_format:
codec_name = "bcn"
flags |= DDSD.LINEARSIZE
pitch = (im.width + 3) * 4
rgba_mask = [0, 0, 0, 0]
pixel_flags = DDPF.FOURCC
if pixel_format == "DXT1":
fourcc = D3DFMT.DXT1
args = (1,)
elif pixel_format == "DXT3":
fourcc = D3DFMT.DXT3
args = (2,)
elif pixel_format == "DXT5":
fourcc = D3DFMT.DXT5
args = (3,)
else:
rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000]
fourcc = D3DFMT.DX10
if pixel_format == "BC2":
args = (2,)
dxgi_format = DXGI_FORMAT.BC2_TYPELESS
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:
pixel_flags = DDPF.RGB
rawmode = im.mode[::-1]
rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF]
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
args = im.mode
if alpha:
rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF]
else:
rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000]
else:
pixel_flags = DDPF.RGB
args = 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:
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
bitcount = len(im.getbands()) * 8
pitch = (im.width * bitcount + 7) // 8
pixel_flags |= DDPF.ALPHAPIXELS
rgba_mask.append(0xFF000000 if alpha else 0)

fourcc = D3DFMT.UNKNOWN
fp.write(
o32(DDS_MAGIC)
+ struct.pack(
Expand All @@ -556,11 +602,16 @@ 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)])
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)])


def _accept(prefix: bytes) -> bool:
Expand Down
3 changes: 3 additions & 0 deletions src/_imaging.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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},
Expand Down
26 changes: 26 additions & 0 deletions src/encode.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -350,6 +351,31 @@
return 0;
}

/* -------------------------------------------------------------------- */
/* BCN */
/* -------------------------------------------------------------------- */

PyObject *
PyImaging_BcnEncoderNew(PyObject *self, PyObject *args) {
ImagingEncoderObject *encoder;

char *mode;
int n;
if (!PyArg_ParseTuple(args, "si", &mode, &n)) {
return NULL;

Check warning on line 365 in src/encode.c

View check run for this annotation

Codecov / codecov/patch

src/encode.c#L365

Added line #L365 was not covered by tests
}

encoder = PyImaging_EncoderNew(0);
if (encoder == NULL) {
return NULL;

Check warning on line 370 in src/encode.c

View check run for this annotation

Codecov / codecov/patch

src/encode.c#L370

Added line #L370 was not covered by tests
}

encoder->encode = ImagingBcnEncode;
encoder->state.state = n;

return (PyObject *)encoder;
}

/* -------------------------------------------------------------------- */
/* EPS */
/* -------------------------------------------------------------------- */
Expand Down
Loading
Loading