Repository: python-pillow/Pillow

Issue:
PIL.UnidentifiedImageError: cannot identify image file (DDS file)
Hello! i try to make .DDS to .PNG converter but when i call code it raises PIL.UnidentifiedImageError: cannot identify image file
```python
# Imports
from PIL import Image
import os

# Define names
file = 'C:\DDS\Portrait_Britain_Alan_Brooke.dds'
l = len(os.listdir("SOV"))+1
name = f'SOV/{l}.png'

# Load image
with Image.open(file) as im: # in this line error
    im.save(name)
```
screenshot of console:
![error](https://github.com/python-pillow/Pillow/assets/109862343/924e80c8-98f0-47f0-b334-2c5034f37d02)
this file:
[Portrait_Britain_Alan_Brooke.zip](https://github.com/python-pillow/Pillow/files/13239297/Portrait_Britain_Alan_Brooke.zip)



Discussion:
Just as an explanation, the image contains uncompressed RGB data with a bitcount of 16 - it looks like it is compressing the three channels into the space normally allocated for two. Pillow doesn't yet support this configuration.

Is the image one that can be added to our test suite, and distributed under Pillow's license?
Yeah, I don't mind adding it, but this image was taken from the Hearts Of Iron 4 files

Resolving patch:
diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py
--- a/src/PIL/DdsImagePlugin.py
+++ b/src/PIL/DdsImagePlugin.py
@@ -17,6 +17,7 @@
 
 from . import Image, ImageFile, ImagePalette
 from ._binary import i32le as i32
+from ._binary import o8
 from ._binary import o32le as o32
 
 # Magic ("DDS ")
@@ -340,6 +341,7 @@ def _open(self):
 
         flags, height, width = struct.unpack("<3I", header.read(12))
         self._size = (width, height)
+        extents = (0, 0) + self.size
 
         pitch, depth, mipmaps = struct.unpack("<3I", header.read(12))
         struct.unpack("<11I", header.read(44))  # reserved
@@ -350,22 +352,16 @@ def _open(self):
         rawmode = None
         if pfflags & DDPF.RGB:
             # Texture contains uncompressed RGB data
-            masks = struct.unpack("<4I", header.read(16))
-            masks = {mask: ["R", "G", "B", "A"][i] for i, mask in enumerate(masks)}
-            if bitcount == 24:
-                self._mode = "RGB"
-                rawmode = masks[0x000000FF] + masks[0x0000FF00] + masks[0x00FF0000]
-            elif bitcount == 32 and pfflags & DDPF.ALPHAPIXELS:
+            if pfflags & DDPF.ALPHAPIXELS:
                 self._mode = "RGBA"
-                rawmode = (
-                    masks[0x000000FF]
-                    + masks[0x0000FF00]
-                    + masks[0x00FF0000]
-                    + masks[0xFF000000]
-                )
+                mask_count = 4
             else:
-                msg = f"Unsupported bitcount {bitcount} for {pfflags}"
-                raise OSError(msg)
+                self._mode = "RGB"
+                mask_count = 3
+
+            masks = struct.unpack(f"<{mask_count}I", header.read(mask_count * 4))
+            self.tile = [("dds_rgb", extents, 0, (bitcount, masks))]
+            return
         elif pfflags & DDPF.LUMINANCE:
             if bitcount == 8:
                 self._mode = "L"
@@ -463,7 +459,6 @@ def _open(self):
             msg = f"Unknown pixel format flags {pfflags}"
             raise NotImplementedError(msg)
 
-        extents = (0, 0) + self.size
         if n:
             self.tile = [
                 ImageFile._Tile("bcn", extents, offset, (n, self.pixel_format))
@@ -475,6 +470,39 @@ def load_seek(self, pos):
         pass
 
 
+class DdsRgbDecoder(ImageFile.PyDecoder):
+    _pulls_fd = True
+
+    def decode(self, buffer):
+        bitcount, masks = self.args
+
+        # Some masks will be padded with zeros, e.g. R 0b11 G 0b1100
+        # Calculate how many zeros each mask is padded with
+        mask_offsets = []
+        # And the maximum value of each channel without the padding
+        mask_totals = []
+        for mask in masks:
+            offset = 0
+            if mask != 0:
+                while mask >> (offset + 1) << (offset + 1) == mask:
+                    offset += 1
+            mask_offsets.append(offset)
+            mask_totals.append(mask >> offset)
+
+        data = bytearray()
+        bytecount = bitcount // 8
+        while len(data) < self.state.xsize * self.state.ysize * len(masks):
+            value = int.from_bytes(self.fd.read(bytecount), "little")
+            for i, mask in enumerate(masks):
+                masked_value = value & mask
+                # Remove the zero padding, and scale it to 8 bits
+                data += o8(
+                    int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255)
+                )
+        self.set_as_raw(bytes(data))
+        return -1, 0
+
+
 def _save(im, fp, filename):
     if im.mode not in ("RGB", "RGBA", "L", "LA"):
         msg = f"cannot write mode {im.mode} as DDS"
@@ -532,5 +560,6 @@ def _accept(prefix):
 
 
 Image.register_open(DdsImageFile.format, DdsImageFile, _accept)
+Image.register_decoder("dds_rgb", DdsRgbDecoder)
 Image.register_save(DdsImageFile.format, _save)
 Image.register_extension(DdsImageFile.format, ".dds")


Test patch:
diff --git a/Tests/images/bgr15.dds b/Tests/images/bgr15.dds
new file mode 100644
Binary files /dev/null and b/Tests/images/bgr15.dds differ
diff --git a/Tests/images/bgr15.png b/Tests/images/bgr15.png
new file mode 100644
Binary files /dev/null and b/Tests/images/bgr15.png differ
diff --git a/Tests/images/unsupported_bitcount_luminance.dds b/Tests/images/unsupported_bitcount.dds
similarity index 100%
rename from Tests/images/unsupported_bitcount_luminance.dds
rename to Tests/images/unsupported_bitcount.dds
diff --git a/Tests/images/unsupported_bitcount_rgb.dds b/Tests/images/unsupported_bitcount_rgb.dds
deleted file mode 100644
Binary files a/Tests/images/unsupported_bitcount_rgb.dds and /dev/null differ
diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py
--- a/Tests/test_file_dds.py
+++ b/Tests/test_file_dds.py
@@ -31,6 +31,7 @@
 TEST_FILE_UNCOMPRESSED_L = "Tests/images/uncompressed_l.dds"
 TEST_FILE_UNCOMPRESSED_L_WITH_ALPHA = "Tests/images/uncompressed_la.dds"
 TEST_FILE_UNCOMPRESSED_RGB = "Tests/images/hopper.dds"
+TEST_FILE_UNCOMPRESSED_BGR15 = "Tests/images/bgr15.dds"
 TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA = "Tests/images/uncompressed_rgb.dds"
 
 
@@ -248,6 +249,7 @@ def test_dx10_r8g8b8a8_unorm_srgb():
         ("L", (128, 128), TEST_FILE_UNCOMPRESSED_L),
         ("LA", (128, 128), TEST_FILE_UNCOMPRESSED_L_WITH_ALPHA),
         ("RGB", (128, 128), TEST_FILE_UNCOMPRESSED_RGB),
+        ("RGB", (128, 128), TEST_FILE_UNCOMPRESSED_BGR15),
         ("RGBA", (800, 600), TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA),
     ],
 )
@@ -340,16 +342,9 @@ def test_palette():
         assert_image_equal_tofile(im, "Tests/images/transparent.gif")
 
 
-@pytest.mark.parametrize(
-    "test_file",
-    (
-        "Tests/images/unsupported_bitcount_rgb.dds",
-        "Tests/images/unsupported_bitcount_luminance.dds",
-    ),
-)
-def test_unsupported_bitcount(test_file):
+def test_unsupported_bitcount():
     with pytest.raises(OSError):
-        with Image.open(test_file):
+        with Image.open("Tests/images/unsupported_bitcount.dds"):
             pass