Repository: python-pillow/Pillow

Issue:
Request DDSImagePlugin
I would like to request the .dds(direct draw surface) format be decoded and added as an image plugin. I guess this is the right place for requests...?

I found this as a start. It doesn't do all dxt formats but some basic ones.
http://nullege.com/codes/show/src%40p%40i%40pilgrim-HEAD%40pilgrim%40codecs%40__init__.py/9/dds.DDS/python



Discussion:
Sure, next step would be to send a pull request if you are able to

I haven't written anything. Just found some code that works with dxt1 floating around the net.
I'm basically requesting someone with some image decoding skills give it a try. More or less the folks already working on Pillow.
It's kinda a really big format for games. Millions of developers/modders use this format daily.

Niftools / blender plugin
http://niftools.sourceforge.net/wiki/NifTools
or
Pyffi
http://sourceforge.net/projects/pyffi/files/
should/might be of some help with this.

Nvidia tools might help also.
https://developer.nvidia.com/content/dds-utilities-83111271645

No worries, I only suggest you do it because that would increase the chances of it getting done. Otherwise we have to wait for @wiredfool or @d-schmidt to take an interest (which may or may not happen soon, or ever). In any event, thanks for making the request; we now have a record of it, at least

An interest huh... Haha
A Kickstarter would probably guaranteed generate $5000-10000 if not more for interested developers with the right type of interest.
Millions of people to sell to..., but the salesman, just speaks...
What most people want is an open source interest for the most part. Quality insurance. Per say.
Maybe I will get to it in the next 3 years, maybe not...
Anyway thanks you have a record of it now. :) Apparently got the right hub.

@Metallicow Tee hee hee!

@aclark4life, could you reopen this feature request?

Thanks. ;)

That dds code's mine actually, from an [experimental PIL fork before Pillow existed](https://github.com/jleclanche/pilgrim).

A dds plugin would be really nice, eg. right now I need to load DXT1 / DXT5 data into Pillow so I have to write my own decoder again =\

@jleclanche Great! So no issue incorporating your dds code into Pillow? I see you are right, pilgrim was born almost a full year before Pillow (2009-11-26 vs  2010-07-31). Interesting.

@aclark4life I'm writing an updated version right now which I'm testing against files from .unity3d packing. What license do you want it under?

@jleclanche Great & good question. We use the same license is PIL, in case that helps you decide: https://github.com/python-pillow/Pillow/blob/master/LICENSE

@aclark4life ok I'll re-release the decoders as CC0 to make things simpler.

Are you in the #pil channel? I might need some help with writing the plugin.

@jleclanche Yes but you may need @wiredfool or other members of the @python-pillow/pillow-team to weigh in on "real" programming questions. For the @python-pillow/pillow-team , here's some IRC discussion about this issue: http://chat-logs.dcpython.org/day/pil/2016-01-04

https://github.com/HearthSim/python-unitypack/blob/a44c30486246df4aafc0a41adf122b5697e5cdd6/unitypack/dds.py

Here's a dxt1 decoder, licensed CC0. It's pretty slow and will need to be rewritten in C but it's usable.
dxt3 / dxt5 are very straightforward to do from there (I'll be doing them soon-ish if no one else does)

DDS is a container format for s3tc and all the defines are in that file. [This](https://github.com/jleclanche/pilgrim/blob/master/pilgrim/codecs/dds.py) is a very old loader for dds (please don't use it), which should give an idea of how to write one.

I won't be filing a PR or anything, unfortunately I don't have time for that; I licensed it so that anyone may use it without having to worry about the license itself :)

By the way in that old project I also have an ftex (IW2 format) decoder lying around.. wouldn't mind making that CC0 as well if someone wants to push it to pillow.
https://github.com/jleclanche/pilgrim/blob/master/pilgrim/codecs/ftex.py

Updated link with a DXT5 decoder too:
https://github.com/HearthSim/python-unitypack/blob/a44c30486246df4aafc0a41adf122b5697e5cdd6/unitypack/dds.py 

OK this is actually crazy. I try to write a simple read-only DDS plugin, looks like the API has changed since PIL - fair enough.

I spend four hours trying to figure out the API. I still haven't figured it out as I write this. And none of this is properly documented.

I'm in `DDSImageFile.load()` and keep getting the very helpful `_imaging.c:208: bad argument to internal function` when trying to save my image as a png. It looks like this stems from a check on `self.im`. Can't tell exactly what's wrong with it because **every single image plugin is written differently** and half of them don't even work. I tried referencing the GbrImagePlugin which was one of the simpler ones but it turns out it uses functions which don't even exist anymore.
So I look at IcnsImagePlugin and it creates an image through self.icns.getimage, which creates it through channels.get(...).copy(), which creates it through dataforsize), which creates it from a bunch of custom readers **argh**.
Jpeg2KImagePlugin does it with a custom decoder. Of course none of these are listed anywhere I can find, I have to dig in decode.c and figure them out manually.
It would look like the proper way to implement this is to write the dxt\* decoder separate, but _I don't want to fork Pillow just to be able to read a custom image format_, that completely defeats the point.

Please _please_ find some time to work on proper documentation on how to write an image plugin for a custom format and to clean up the image plugins. 

@jleclanche I doubt much has changed since PIL, but if it has: you don't need to fork Pillow to fix it you can just fix it (send a PR). Rather than folks maintaining separate forks, we maintain one then release results to PyPI.

@aclark4life I fixed it eventually with @wiredfool's help. I'll send a PR.

Honestly there really needs to be a new plugin API, this was very painful.


Resolving patch:
diff --git a/PIL/DdsImagePlugin.py b/PIL/DdsImagePlugin.py
new file mode 100644
--- /dev/null
+++ b/PIL/DdsImagePlugin.py
@@ -0,0 +1,269 @@
+"""
+A Pillow loader for .dds files (S3TC-compressed aka DXTC)
+Jerome Leclanche <jerome@leclan.ch>
+
+Documentation:
+  http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt
+
+The contents of this file are hereby released in the public domain (CC0)
+Full text of the CC0 license:
+  https://creativecommons.org/publicdomain/zero/1.0/
+"""
+
+import struct
+from io import BytesIO
+from PIL import Image, ImageFile
+
+
+# Magic ("DDS ")
+DDS_MAGIC = 0x20534444
+
+# DDS flags
+DDSD_CAPS = 0x1
+DDSD_HEIGHT = 0x2
+DDSD_WIDTH = 0x4
+DDSD_PITCH = 0x8
+DDSD_PIXELFORMAT = 0x1000
+DDSD_MIPMAPCOUNT = 0x20000
+DDSD_LINEARSIZE = 0x80000
+DDSD_DEPTH = 0x800000
+
+# DDS caps
+DDSCAPS_COMPLEX = 0x8
+DDSCAPS_TEXTURE = 0x1000
+DDSCAPS_MIPMAP = 0x400000
+
+DDSCAPS2_CUBEMAP = 0x200
+DDSCAPS2_CUBEMAP_POSITIVEX = 0x400
+DDSCAPS2_CUBEMAP_NEGATIVEX = 0x800
+DDSCAPS2_CUBEMAP_POSITIVEY = 0x1000
+DDSCAPS2_CUBEMAP_NEGATIVEY = 0x2000
+DDSCAPS2_CUBEMAP_POSITIVEZ = 0x4000
+DDSCAPS2_CUBEMAP_NEGATIVEZ = 0x8000
+DDSCAPS2_VOLUME = 0x200000
+
+# Pixel Format
+DDPF_ALPHAPIXELS = 0x1
+DDPF_ALPHA = 0x2
+DDPF_FOURCC = 0x4
+DDPF_PALETTEINDEXED8 = 0x20
+DDPF_RGB = 0x40
+DDPF_LUMINANCE = 0x20000
+
+
+# dds.h
+
+DDS_FOURCC = DDPF_FOURCC
+DDS_RGB = DDPF_RGB
+DDS_RGBA = DDPF_RGB | DDPF_ALPHAPIXELS
+DDS_LUMINANCE = DDPF_LUMINANCE
+DDS_LUMINANCEA = DDPF_LUMINANCE | DDPF_ALPHAPIXELS
+DDS_ALPHA = DDPF_ALPHA
+DDS_PAL8 = DDPF_PALETTEINDEXED8
+
+DDS_HEADER_FLAGS_TEXTURE = (DDSD_CAPS | DDSD_HEIGHT | DDSD_WIDTH |
+                            DDSD_PIXELFORMAT)
+DDS_HEADER_FLAGS_MIPMAP = DDSD_MIPMAPCOUNT
+DDS_HEADER_FLAGS_VOLUME = DDSD_DEPTH
+DDS_HEADER_FLAGS_PITCH = DDSD_PITCH
+DDS_HEADER_FLAGS_LINEARSIZE = DDSD_LINEARSIZE
+
+DDS_HEIGHT = DDSD_HEIGHT
+DDS_WIDTH = DDSD_WIDTH
+
+DDS_SURFACE_FLAGS_TEXTURE = DDSCAPS_TEXTURE
+DDS_SURFACE_FLAGS_MIPMAP = DDSCAPS_COMPLEX | DDSCAPS_MIPMAP
+DDS_SURFACE_FLAGS_CUBEMAP = DDSCAPS_COMPLEX
+
+DDS_CUBEMAP_POSITIVEX = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_POSITIVEX
+DDS_CUBEMAP_NEGATIVEX = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_NEGATIVEX
+DDS_CUBEMAP_POSITIVEY = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_POSITIVEY
+DDS_CUBEMAP_NEGATIVEY = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_NEGATIVEY
+DDS_CUBEMAP_POSITIVEZ = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_POSITIVEZ
+DDS_CUBEMAP_NEGATIVEZ = DDSCAPS2_CUBEMAP | DDSCAPS2_CUBEMAP_NEGATIVEZ
+
+
+# DXT1
+DXT1_FOURCC = 0x31545844
+
+# DXT3
+DXT3_FOURCC = 0x33545844
+
+# DXT5
+DXT5_FOURCC = 0x35545844
+
+
+def _decode565(bits):
+    a = ((bits >> 11) & 0x1f) << 3
+    b = ((bits >> 5) & 0x3f) << 2
+    c = (bits & 0x1f) << 3
+    return a, b, c
+
+
+def _c2a(a, b):
+    return (2 * a + b) // 3
+
+
+def _c2b(a, b):
+    return (a + b) // 2
+
+
+def _c3(a, b):
+    return (2 * b + a) // 3
+
+
+def _dxt1(data, width, height):
+    # TODO implement this function as pixel format in decode.c
+    ret = bytearray(4 * width * height)
+
+    for y in range(0, height, 4):
+        for x in range(0, width, 4):
+            color0, color1, bits = struct.unpack("<HHI", data.read(8))
+
+            r0, g0, b0 = _decode565(color0)
+            r1, g1, b1 = _decode565(color1)
+
+            # Decode this block into 4x4 pixels
+            for j in range(4):
+                for i in range(4):
+                    # get next control op and generate a pixel
+                    control = bits & 3
+                    bits = bits >> 2
+                    if control == 0:
+                        r, g, b = r0, g0, b0
+                    elif control == 1:
+                        r, g, b = r1, g1, b1
+                    elif control == 2:
+                        if color0 > color1:
+                            r, g, b = _c2a(r0, r1), _c2a(g0, g1), _c2a(b0, b1)
+                        else:
+                            r, g, b = _c2b(r0, r1), _c2b(g0, g1), _c2b(b0, b1)
+                    elif control == 3:
+                        if color0 > color1:
+                            r, g, b = _c3(r0, r1), _c3(g0, g1), _c3(b0, b1)
+                        else:
+                            r, g, b = 0, 0, 0
+
+                    idx = 4 * ((y + j) * width + (x + i))
+                    ret[idx:idx+4] = struct.pack('4B', r, g, b, 255)
+
+    return bytes(ret)
+
+
+def _dxtc_alpha(a0, a1, ac0, ac1, ai):
+    if ai <= 12:
+        ac = (ac0 >> ai) & 7
+    elif ai == 15:
+        ac = (ac0 >> 15) | ((ac1 << 1) & 6)
+    else:
+        ac = (ac1 >> (ai - 16)) & 7
+
+    if ac == 0:
+        alpha = a0
+    elif ac == 1:
+        alpha = a1
+    elif a0 > a1:
+        alpha = ((8 - ac) * a0 + (ac - 1) * a1) // 7
+    elif ac == 6:
+        alpha = 0
+    elif ac == 7:
+        alpha = 0xff
+    else:
+        alpha = ((6 - ac) * a0 + (ac - 1) * a1) // 5
+
+    return alpha
+
+
+def _dxt5(data, width, height):
+    # TODO implement this function as pixel format in decode.c
+    ret = bytearray(4 * width * height)
+
+    for y in range(0, height, 4):
+        for x in range(0, width, 4):
+            a0, a1, ac0, ac1, c0, c1, code = struct.unpack("<2BHI2HI",
+                                                           data.read(16))
+
+            r0, g0, b0 = _decode565(c0)
+            r1, g1, b1 = _decode565(c1)
+
+            for j in range(4):
+                for i in range(4):
+                    ai = 3 * (4 * j + i)
+                    alpha = _dxtc_alpha(a0, a1, ac0, ac1, ai)
+
+                    cc = (code >> 2 * (4 * j + i)) & 3
+                    if cc == 0:
+                        r, g, b = r0, g0, b0
+                    elif cc == 1:
+                        r, g, b = r1, g1, b1
+                    elif cc == 2:
+                        r, g, b = _c2a(r0, r1), _c2a(g0, g1), _c2a(b0, b1)
+                    elif cc == 3:
+                        r, g, b = _c3(r0, r1), _c3(g0, g1), _c3(b0, b1)
+
+                    idx = 4 * ((y + j) * width + (x + i))
+                    ret[idx:idx+4] = struct.pack('4B', r, g, b, alpha)
+
+    return bytes(ret)
+
+
+class DdsImageFile(ImageFile.ImageFile):
+    format = "DDS"
+    format_description = "DirectDraw Surface"
+
+    def _open(self):
+        magic, header_size = struct.unpack("<II", self.fp.read(8))
+        if header_size != 124:
+            raise IOError("Unsupported header size %r" % (header_size))
+        header_bytes = self.fp.read(header_size - 4)
+        if len(header_bytes) != 120:
+            raise IOError("Incomplete header: %s bytes" % len(header_bytes))
+        header = BytesIO(header_bytes)
+        
+        flags, height, width = struct.unpack("<3I", header.read(12))
+        self.size = (width, height)
+        self.mode = "RGBA"
+
+        pitch, depth, mipmaps = struct.unpack("<3I", header.read(12))
+        reserved = struct.unpack("<11I", header.read(44))
+
+        # pixel format
+        pfsize, pfflags = struct.unpack("<2I", header.read(8))
+        fourcc = header.read(4)
+        bitcount, rmask, gmask, bmask, amask = struct.unpack("<5I",
+                                                             header.read(20))
+
+        self.tile = [
+            ("raw", (0, 0) + self.size, 0, (self.mode, 0, 1))
+        ]
+
+        if fourcc == b"DXT1":
+            self.pixel_format = "DXT1"
+            codec = _dxt1
+        elif fourcc == b"DXT5":
+            self.pixel_format = "DXT5"
+            codec = _dxt5
+        else:
+            raise NotImplementedError("Unimplemented pixel format %r" %
+                                      (fourcc))
+
+        try:
+            decoded_data = codec(self.fp, self.width, self.height)
+        except struct.error:
+            raise IOError("Truncated DDS file")
+        finally:
+            self.fp.close()
+            
+        self.fp = BytesIO(decoded_data)
+
+
+    def load_seek(self, pos):
+        pass
+
+
+def _validate(prefix):
+    return prefix[:4] == b"DDS "
+
+
+Image.register_open(DdsImageFile.format, DdsImageFile, _validate)
+Image.register_extension(DdsImageFile.format, ".dds")
diff --git a/PIL/__init__.py b/PIL/__init__.py
--- a/PIL/__init__.py
+++ b/PIL/__init__.py
@@ -18,6 +18,7 @@
             'BufrStubImagePlugin',
             'CurImagePlugin',
             'DcxImagePlugin',
+            'DdsImagePlugin',
             'EpsImagePlugin',
             'FitsStubImagePlugin',
             'FliImagePlugin',


Test patch:
diff --git a/Tests/images/dxt1-rgb-4bbp-noalpha_MipMaps-1.dds b/Tests/images/dxt1-rgb-4bbp-noalpha_MipMaps-1.dds
new file mode 100755
Binary files /dev/null and b/Tests/images/dxt1-rgb-4bbp-noalpha_MipMaps-1.dds differ
diff --git a/Tests/images/dxt1-rgb-4bbp-noalpha_MipMaps-1.png b/Tests/images/dxt1-rgb-4bbp-noalpha_MipMaps-1.png
new file mode 100644
Binary files /dev/null and b/Tests/images/dxt1-rgb-4bbp-noalpha_MipMaps-1.png differ
diff --git a/Tests/images/dxt3-argb-8bbp-explicitalpha_MipMaps-1.dds b/Tests/images/dxt3-argb-8bbp-explicitalpha_MipMaps-1.dds
new file mode 100755
Binary files /dev/null and b/Tests/images/dxt3-argb-8bbp-explicitalpha_MipMaps-1.dds differ
diff --git a/Tests/images/dxt5-argb-8bbp-interpolatedalpha_MipMaps-1.dds b/Tests/images/dxt5-argb-8bbp-interpolatedalpha_MipMaps-1.dds
new file mode 100755
Binary files /dev/null and b/Tests/images/dxt5-argb-8bbp-interpolatedalpha_MipMaps-1.dds differ
diff --git a/Tests/images/dxt5-argb-8bbp-interpolatedalpha_MipMaps-1.png b/Tests/images/dxt5-argb-8bbp-interpolatedalpha_MipMaps-1.png
new file mode 100644
Binary files /dev/null and b/Tests/images/dxt5-argb-8bbp-interpolatedalpha_MipMaps-1.png differ
diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py
new file mode 100644
--- /dev/null
+++ b/Tests/test_file_dds.py
@@ -0,0 +1,99 @@
+from io import BytesIO
+
+from helper import unittest, PillowTestCase
+from PIL import Image, DdsImagePlugin
+
+TEST_FILE_DXT1 = "Tests/images/dxt1-rgb-4bbp-noalpha_MipMaps-1.dds"
+TEST_FILE_DXT3 = "Tests/images/dxt3-argb-8bbp-explicitalpha_MipMaps-1.dds"
+TEST_FILE_DXT5 = "Tests/images/dxt5-argb-8bbp-interpolatedalpha_MipMaps-1.dds"
+
+
+class TestFileDds(PillowTestCase):
+    """Test DdsImagePlugin"""
+
+    def test_sanity_dxt1(self):
+        """Check DXT1 images can be opened"""
+        target = Image.open(TEST_FILE_DXT1.replace('.dds', '.png'))
+
+        im = Image.open(TEST_FILE_DXT1)
+        im.load()
+        
+        self.assertEqual(im.format, "DDS")
+        self.assertEqual(im.mode, "RGBA")
+        self.assertEqual(im.size, (256, 256))
+
+        # This target image is from the test set of images, and is exact. 
+        self.assert_image_equal(target.convert('RGBA'), im)
+
+    def test_sanity_dxt5(self):
+        """Check DXT5 images can be opened"""
+
+        target = Image.open(TEST_FILE_DXT5.replace('.dds', '.png'))
+
+        im = Image.open(TEST_FILE_DXT5)
+        im.load()
+
+        self.assertEqual(im.format, "DDS")
+        self.assertEqual(im.mode, "RGBA")
+        self.assertEqual(im.size, (256, 256))
+
+        # Imagemagick, which generated this target image from the .dds
+        # has a slightly different decoder than is standard. It looks
+        # a little brighter. The 0,0 pixel is (00,6c,f8,ff) by our code,
+        # and by the target image for the DXT1, and the imagemagick .png
+        # is giving (00, 6d, ff, ff).  So, assert similar, pretty tight
+        # I'm currently seeing about a 3 for the epsilon. 
+        self.assert_image_similar(target, im, 5)
+
+    def test_sanity_dxt3(self):
+        """Check DXT3 images are not supported"""
+        self.assertRaises(NotImplementedError,
+                          lambda: Image.open(TEST_FILE_DXT3))
+
+    def test__validate_true(self):
+        """Check valid prefix"""
+        # Arrange
+        prefix = b"DDS etc"
+
+        # Act
+        output = DdsImagePlugin._validate(prefix)
+
+        # Assert
+        self.assertTrue(output)
+
+    def test__validate_false(self):
+        """Check invalid prefix"""
+        # Arrange
+        prefix = b"something invalid"
+
+        # Act
+        output = DdsImagePlugin._validate(prefix)
+
+        # Assert
+        self.assertFalse(output)
+
+    def test_short_header(self):
+        """ Check a short header"""
+        with open(TEST_FILE_DXT5, 'rb') as f:
+            img_file = f.read()
+
+        def short_header():
+            im = Image.open(BytesIO(img_file[:119]))
+
+        self.assertRaises(IOError, short_header)
+
+    def test_short_file(self):
+        """ Check that the appropriate error is thrown for a short file"""
+        
+        with open(TEST_FILE_DXT5, 'rb') as f:
+            img_file = f.read()
+
+        def short_file():
+            im = Image.open(BytesIO(img_file[:-100]))
+
+        self.assertRaises(IOError, short_file)
+
+if __name__ == '__main__':
+    unittest.main()
+
+# End of file