Skip to content

Commit 74fec91

Browse files
authored
Merge pull request #8807 from radarhere/dxt1
Support saving DDS images with pixel formats
2 parents 039ecac + cd11792 commit 74fec91

File tree

7 files changed

+526
-25
lines changed

7 files changed

+526
-25
lines changed

Tests/test_file_dds.py

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
99

1010
from PIL import DdsImagePlugin, Image
1111

12-
from .helper import assert_image_equal, assert_image_equal_tofile, hopper
12+
from .helper import (
13+
assert_image_equal,
14+
assert_image_equal_tofile,
15+
assert_image_similar,
16+
assert_image_similar_tofile,
17+
hopper,
18+
)
1319

1420
TEST_FILE_DXT1 = "Tests/images/dxt1-rgb-4bbp-noalpha_MipMaps-1.dds"
1521
TEST_FILE_DXT3 = "Tests/images/dxt3-argb-8bbp-explicitalpha_MipMaps-1.dds"
@@ -109,6 +115,32 @@ def test_sanity_ati1_bc4u(image_path: str) -> None:
109115
assert_image_equal_tofile(im, TEST_FILE_ATI1.replace(".dds", ".png"))
110116

111117

118+
def test_dx10_bc2(tmp_path: Path) -> None:
119+
out = str(tmp_path / "temp.dds")
120+
with Image.open(TEST_FILE_DXT3) as im:
121+
im.save(out, pixel_format="BC2")
122+
123+
with Image.open(out) as reloaded:
124+
assert reloaded.format == "DDS"
125+
assert reloaded.mode == "RGBA"
126+
assert reloaded.size == (256, 256)
127+
128+
assert_image_similar(im, reloaded, 3.81)
129+
130+
131+
def test_dx10_bc3(tmp_path: Path) -> None:
132+
out = str(tmp_path / "temp.dds")
133+
with Image.open(TEST_FILE_DXT5) as im:
134+
im.save(out, pixel_format="BC3")
135+
136+
with Image.open(out) as reloaded:
137+
assert reloaded.format == "DDS"
138+
assert reloaded.mode == "RGBA"
139+
assert reloaded.size == (256, 256)
140+
141+
assert_image_similar(im, reloaded, 3.69)
142+
143+
112144
@pytest.mark.parametrize(
113145
"image_path",
114146
(
@@ -370,7 +402,7 @@ def test_not_implemented(test_file: str) -> None:
370402
def test_save_unsupported_mode(tmp_path: Path) -> None:
371403
out = str(tmp_path / "temp.dds")
372404
im = hopper("HSV")
373-
with pytest.raises(OSError):
405+
with pytest.raises(OSError, match="cannot write mode HSV as DDS"):
374406
im.save(out)
375407

376408

@@ -389,5 +421,93 @@ def test_save(mode: str, test_file: str, tmp_path: Path) -> None:
389421
assert im.mode == mode
390422
im.save(out)
391423

392-
with Image.open(out) as reloaded:
393-
assert_image_equal(im, reloaded)
424+
assert_image_equal_tofile(im, out)
425+
426+
427+
def test_save_unsupported_pixel_format(tmp_path: Path) -> None:
428+
out = str(tmp_path / "temp.dds")
429+
im = hopper()
430+
with pytest.raises(OSError, match="cannot write pixel format UNKNOWN"):
431+
im.save(out, pixel_format="UNKNOWN")
432+
433+
434+
def test_save_dxt1(tmp_path: Path) -> None:
435+
# RGB
436+
out = str(tmp_path / "temp.dds")
437+
with Image.open(TEST_FILE_DXT1) as im:
438+
im.convert("RGB").save(out, pixel_format="DXT1")
439+
assert_image_similar_tofile(im, out, 1.84)
440+
441+
# RGBA
442+
im_alpha = im.copy()
443+
im_alpha.putpixel((0, 0), (0, 0, 0, 0))
444+
im_alpha.save(out, pixel_format="DXT1")
445+
with Image.open(out) as reloaded:
446+
assert reloaded.getpixel((0, 0)) == (0, 0, 0, 0)
447+
448+
# L
449+
im_l = im.convert("L")
450+
im_l.save(out, pixel_format="DXT1")
451+
assert_image_similar_tofile(im_l.convert("RGBA"), out, 6.07)
452+
453+
# LA
454+
im_alpha.convert("LA").save(out, pixel_format="DXT1")
455+
with Image.open(out) as reloaded:
456+
assert reloaded.getpixel((0, 0)) == (0, 0, 0, 0)
457+
458+
459+
def test_save_dxt3(tmp_path: Path) -> None:
460+
# RGB
461+
out = str(tmp_path / "temp.dds")
462+
with Image.open(TEST_FILE_DXT3) as im:
463+
im_rgb = im.convert("RGB")
464+
im_rgb.save(out, pixel_format="DXT3")
465+
assert_image_similar_tofile(im_rgb.convert("RGBA"), out, 1.26)
466+
467+
# RGBA
468+
im.save(out, pixel_format="DXT3")
469+
assert_image_similar_tofile(im, out, 3.81)
470+
471+
# L
472+
im_l = im.convert("L")
473+
im_l.save(out, pixel_format="DXT3")
474+
assert_image_similar_tofile(im_l.convert("RGBA"), out, 5.89)
475+
476+
# LA
477+
im_la = im.convert("LA")
478+
im_la.save(out, pixel_format="DXT3")
479+
assert_image_similar_tofile(im_la.convert("RGBA"), out, 8.44)
480+
481+
482+
def test_save_dxt5(tmp_path: Path) -> None:
483+
# RGB
484+
out = str(tmp_path / "temp.dds")
485+
with Image.open(TEST_FILE_DXT1) as im:
486+
im.convert("RGB").save(out, pixel_format="DXT5")
487+
assert_image_similar_tofile(im, out, 1.84)
488+
489+
# RGBA
490+
with Image.open(TEST_FILE_DXT5) as im_rgba:
491+
im_rgba.save(out, pixel_format="DXT5")
492+
assert_image_similar_tofile(im_rgba, out, 3.69)
493+
494+
# L
495+
im_l = im.convert("L")
496+
im_l.save(out, pixel_format="DXT5")
497+
assert_image_similar_tofile(im_l.convert("RGBA"), out, 6.07)
498+
499+
# LA
500+
im_la = im_rgba.convert("LA")
501+
im_la.save(out, pixel_format="DXT5")
502+
assert_image_similar_tofile(im_la.convert("RGBA"), out, 8.32)
503+
504+
505+
def test_save_dx10_bc5(tmp_path: Path) -> None:
506+
out = str(tmp_path / "temp.dds")
507+
with Image.open(TEST_FILE_DX10_BC5_TYPELESS) as im:
508+
im.save(out, pixel_format="BC5")
509+
assert_image_similar_tofile(im, out, 9.56)
510+
511+
im = hopper("L")
512+
with pytest.raises(OSError, match="only RGB mode can be written as BC5"):
513+
im.save(out, pixel_format="BC5")

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def get_version() -> str:
6868
"Reduce",
6969
"Bands",
7070
"BcnDecode",
71+
"BcnEncode",
7172
"BitDecode",
7273
"Blend",
7374
"Chops",

src/PIL/DdsImagePlugin.py

Lines changed: 72 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,14 @@ def _open(self) -> None:
419419
self._mode = "RGBA"
420420
self.pixel_format = "BC1"
421421
n = 1
422+
elif dxgi_format in (DXGI_FORMAT.BC2_TYPELESS, DXGI_FORMAT.BC2_UNORM):
423+
self._mode = "RGBA"
424+
self.pixel_format = "BC2"
425+
n = 2
426+
elif dxgi_format in (DXGI_FORMAT.BC3_TYPELESS, DXGI_FORMAT.BC3_UNORM):
427+
self._mode = "RGBA"
428+
self.pixel_format = "BC3"
429+
n = 3
422430
elif dxgi_format in (DXGI_FORMAT.BC4_TYPELESS, DXGI_FORMAT.BC4_UNORM):
423431
self._mode = "L"
424432
self.pixel_format = "BC4"
@@ -518,30 +526,68 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
518526
msg = f"cannot write mode {im.mode} as DDS"
519527
raise OSError(msg)
520528

521-
alpha = im.mode[-1] == "A"
522-
if im.mode[0] == "L":
523-
pixel_flags = DDPF.LUMINANCE
524-
rawmode = im.mode
525-
if alpha:
526-
rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF]
529+
flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT
530+
bitcount = len(im.getbands()) * 8
531+
pixel_format = im.encoderinfo.get("pixel_format")
532+
args: tuple[int] | str
533+
if pixel_format:
534+
codec_name = "bcn"
535+
flags |= DDSD.LINEARSIZE
536+
pitch = (im.width + 3) * 4
537+
rgba_mask = [0, 0, 0, 0]
538+
pixel_flags = DDPF.FOURCC
539+
if pixel_format == "DXT1":
540+
fourcc = D3DFMT.DXT1
541+
args = (1,)
542+
elif pixel_format == "DXT3":
543+
fourcc = D3DFMT.DXT3
544+
args = (2,)
545+
elif pixel_format == "DXT5":
546+
fourcc = D3DFMT.DXT5
547+
args = (3,)
527548
else:
528-
rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000]
549+
fourcc = D3DFMT.DX10
550+
if pixel_format == "BC2":
551+
args = (2,)
552+
dxgi_format = DXGI_FORMAT.BC2_TYPELESS
553+
elif pixel_format == "BC3":
554+
args = (3,)
555+
dxgi_format = DXGI_FORMAT.BC3_TYPELESS
556+
elif pixel_format == "BC5":
557+
args = (5,)
558+
dxgi_format = DXGI_FORMAT.BC5_TYPELESS
559+
if im.mode != "RGB":
560+
msg = "only RGB mode can be written as BC5"
561+
raise OSError(msg)
562+
else:
563+
msg = f"cannot write pixel format {pixel_format}"
564+
raise OSError(msg)
529565
else:
530-
pixel_flags = DDPF.RGB
531-
rawmode = im.mode[::-1]
532-
rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF]
566+
codec_name = "raw"
567+
flags |= DDSD.PITCH
568+
pitch = (im.width * bitcount + 7) // 8
569+
570+
alpha = im.mode[-1] == "A"
571+
if im.mode[0] == "L":
572+
pixel_flags = DDPF.LUMINANCE
573+
args = im.mode
574+
if alpha:
575+
rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF]
576+
else:
577+
rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000]
578+
else:
579+
pixel_flags = DDPF.RGB
580+
args = im.mode[::-1]
581+
rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF]
533582

583+
if alpha:
584+
r, g, b, a = im.split()
585+
im = Image.merge("RGBA", (a, r, g, b))
534586
if alpha:
535-
r, g, b, a = im.split()
536-
im = Image.merge("RGBA", (a, r, g, b))
537-
if alpha:
538-
pixel_flags |= DDPF.ALPHAPIXELS
539-
rgba_mask.append(0xFF000000 if alpha else 0)
540-
541-
flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PITCH | DDSD.PIXELFORMAT
542-
bitcount = len(im.getbands()) * 8
543-
pitch = (im.width * bitcount + 7) // 8
587+
pixel_flags |= DDPF.ALPHAPIXELS
588+
rgba_mask.append(0xFF000000 if alpha else 0)
544589

590+
fourcc = D3DFMT.UNKNOWN
545591
fp.write(
546592
o32(DDS_MAGIC)
547593
+ struct.pack(
@@ -556,11 +602,16 @@ def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
556602
)
557603
+ struct.pack("11I", *((0,) * 11)) # reserved
558604
# pfsize, pfflags, fourcc, bitcount
559-
+ struct.pack("<4I", 32, pixel_flags, 0, bitcount)
605+
+ struct.pack("<4I", 32, pixel_flags, fourcc, bitcount)
560606
+ struct.pack("<4I", *rgba_mask) # dwRGBABitMask
561607
+ struct.pack("<5I", DDSCAPS.TEXTURE, 0, 0, 0, 0)
562608
)
563-
ImageFile._save(im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, rawmode)])
609+
if fourcc == D3DFMT.DX10:
610+
fp.write(
611+
# dxgi_format, 2D resource, misc, array size, straight alpha
612+
struct.pack("<5I", dxgi_format, 3, 0, 0, 1)
613+
)
614+
ImageFile._save(im, fp, [ImageFile._Tile(codec_name, (0, 0) + im.size, 0, args)])
564615

565616

566617
def _accept(prefix: bytes) -> bool:

src/_imaging.c

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4041,6 +4041,8 @@ PyImaging_ZipDecoderNew(PyObject *self, PyObject *args);
40414041

40424042
/* Encoders (in encode.c) */
40434043
extern PyObject *
4044+
PyImaging_BcnEncoderNew(PyObject *self, PyObject *args);
4045+
extern PyObject *
40444046
PyImaging_EpsEncoderNew(PyObject *self, PyObject *args);
40454047
extern PyObject *
40464048
PyImaging_GifEncoderNew(PyObject *self, PyObject *args);
@@ -4109,6 +4111,7 @@ static PyMethodDef functions[] = {
41094111

41104112
/* Codecs */
41114113
{"bcn_decoder", (PyCFunction)PyImaging_BcnDecoderNew, METH_VARARGS},
4114+
{"bcn_encoder", (PyCFunction)PyImaging_BcnEncoderNew, METH_VARARGS},
41124115
{"bit_decoder", (PyCFunction)PyImaging_BitDecoderNew, METH_VARARGS},
41134116
{"eps_encoder", (PyCFunction)PyImaging_EpsEncoderNew, METH_VARARGS},
41144117
{"fli_decoder", (PyCFunction)PyImaging_FliDecoderNew, METH_VARARGS},

src/encode.c

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727

2828
#include "thirdparty/pythoncapi_compat.h"
2929
#include "libImaging/Imaging.h"
30+
#include "libImaging/Bcn.h"
3031
#include "libImaging/Gif.h"
3132

3233
#ifdef HAVE_UNISTD_H
@@ -350,6 +351,31 @@ get_packer(ImagingEncoderObject *encoder, const char *mode, const char *rawmode)
350351
return 0;
351352
}
352353

354+
/* -------------------------------------------------------------------- */
355+
/* BCN */
356+
/* -------------------------------------------------------------------- */
357+
358+
PyObject *
359+
PyImaging_BcnEncoderNew(PyObject *self, PyObject *args) {
360+
ImagingEncoderObject *encoder;
361+
362+
char *mode;
363+
int n;
364+
if (!PyArg_ParseTuple(args, "si", &mode, &n)) {
365+
return NULL;
366+
}
367+
368+
encoder = PyImaging_EncoderNew(0);
369+
if (encoder == NULL) {
370+
return NULL;
371+
}
372+
373+
encoder->encode = ImagingBcnEncode;
374+
encoder->state.state = n;
375+
376+
return (PyObject *)encoder;
377+
}
378+
353379
/* -------------------------------------------------------------------- */
354380
/* EPS */
355381
/* -------------------------------------------------------------------- */

0 commit comments

Comments
 (0)