Repository: python-pillow/Pillow

Issue:
Alpha channel is discarded when saving an animated PNG
### What did you do?

Created an animated PNG from RGBA images with transparent colors (non-zero alpha).

### What did you expect to happen?

The animated PNG should preserve the alpha channel of the individual images.

### What actually happened?

The alpha channel of all but the first image in the sequence is discarded. If the frames are of a static object fading in or out, then PIL then thinks that all of the frames are the same, and then discards them.

This bug also seems to occur with animated GIFs, but I'm not 100% sure the root cause is the same.

### What are your OS, Python and Pillow versions?

* OS: macOS 13.3.1
* Python: 3.11.3
* Pillow: 9.5.0

This script will create an APNG from 10 frames, with the alpha of the red square in the center increasing each frame: 
```python
#!/usr/bin/env python3

from PIL import Image, ImageDraw

images = []
for i in range(10):
    img = Image.new('RGBA', (300, 300), (255, 255, 255, 255))

    draw = ImageDraw.Draw(img)
    draw.rectangle((100, 100, 200, 200), fill=(255, 0, 0, i * 20))

    img.save(f'frame-{i}.png')
    images.append(img)

images[0].save(
    'test.png', save_all=True, append_images=images[1:], duration=100, loop=0
)
images[0].show()
```
Here is the PNG file with the transparency issue:
![test-incorrect](https://user-images.githubusercontent.com/1012677/235279655-a71bdda3-3d21-43d1-a736-bd71cdb9b561.png)

What happened here is that when the alpha channel was thrown away, all of the images in `append_images` are exactly the same and they get thrown away.

This is how I would expect the correct image to look:
![test-correct](https://user-images.githubusercontent.com/1012677/235279695-924e0b14-7125-4999-9b3a-9a116af12cf5.png)





Discussion:
I was able to fix this issue and generate the correct APNG file shown above by changing [this line](https://github.com/python-pillow/Pillow/blob/d1731b35fed4e69158d98bffbf5a415f3a4cec8d/src/PIL/PngImagePlugin.py#L1141) from:
```python
delta = ImageChops.subtract_modulo(
    im_frame.convert("RGB"), base_im.convert("RGB")
)
```
to:
```python
delta = ImageChops.subtract_modulo(
    im_frame.convert("RGBA"), base_im.convert("RGBA")
)
```

But I don't know if this is the correct fix, or whether it will break in some cases.

Resolving patch:
diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py
--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -569,9 +569,9 @@ def _getbbox(base_im, im_frame):
         delta = ImageChops.subtract_modulo(im_frame, base_im)
     else:
         delta = ImageChops.subtract_modulo(
-            im_frame.convert("RGB"), base_im.convert("RGB")
+            im_frame.convert("RGBA"), base_im.convert("RGBA")
         )
-    return delta.getbbox()
+    return delta.getbbox(alpha_only=False)
 
 
 def _write_multiple_frames(im, fp, palette):
diff --git a/src/PIL/Image.py b/src/PIL/Image.py
--- a/src/PIL/Image.py
+++ b/src/PIL/Image.py
@@ -1293,11 +1293,15 @@ def getbands(self):
         """
         return ImageMode.getmode(self.mode).bands
 
-    def getbbox(self):
+    def getbbox(self, *, alpha_only=True):
         """
         Calculates the bounding box of the non-zero regions in the
         image.
 
+        :param alpha_only: Optional flag, defaulting to ``True``.
+           If ``True`` and the image has an alpha channel, trim transparent pixels.
+           Otherwise, trim pixels when all channels are zero.
+           Keyword-only argument.
         :returns: The bounding box is returned as a 4-tuple defining the
            left, upper, right, and lower pixel coordinate. See
            :ref:`coordinate-system`. If the image is completely empty, this
@@ -1306,7 +1310,7 @@ def getbbox(self):
         """
 
         self.load()
-        return self.im.getbbox()
+        return self.im.getbbox(alpha_only)
 
     def getcolors(self, maxcolors=256):
         """
diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py
--- a/src/PIL/PngImagePlugin.py
+++ b/src/PIL/PngImagePlugin.py
@@ -1138,9 +1138,9 @@ def _write_multiple_frames(im, fp, chunk, rawmode, default_image, append_images)
                 else:
                     base_im = previous["im"]
                 delta = ImageChops.subtract_modulo(
-                    im_frame.convert("RGB"), base_im.convert("RGB")
+                    im_frame.convert("RGBA"), base_im.convert("RGBA")
                 )
-                bbox = delta.getbbox()
+                bbox = delta.getbbox(alpha_only=False)
                 if (
                     not bbox
                     and prev_disposal == encoderinfo.get("disposal")


Test patch:
diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py
--- a/Tests/test_file_apng.py
+++ b/Tests/test_file_apng.py
@@ -374,6 +374,20 @@ def test_apng_save(tmp_path):
         assert im.getpixel((64, 32)) == (0, 255, 0, 255)
 
 
+def test_apng_save_alpha(tmp_path):
+    test_file = str(tmp_path / "temp.png")
+
+    im = Image.new("RGBA", (1, 1), (255, 0, 0, 255))
+    im2 = Image.new("RGBA", (1, 1), (255, 0, 0, 127))
+    im.save(test_file, save_all=True, append_images=[im2])
+
+    with Image.open(test_file) as reloaded:
+        assert reloaded.getpixel((0, 0)) == (255, 0, 0, 255)
+
+        reloaded.seek(1)
+        assert reloaded.getpixel((0, 0)) == (255, 0, 0, 127)
+
+
 def test_apng_save_split_fdat(tmp_path):
     # test to make sure we do not generate sequence errors when writing
     # frames with image data spanning multiple fdAT chunks (in this case
diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py
--- a/Tests/test_file_gif.py
+++ b/Tests/test_file_gif.py
@@ -1130,6 +1130,18 @@ def test_bbox(tmp_path):
         assert reread.n_frames == 2
 
 
+def test_bbox_alpha(tmp_path):
+    out = str(tmp_path / "temp.gif")
+
+    im = Image.new("RGBA", (1, 2), (255, 0, 0, 255))
+    im.putpixel((0, 1), (255, 0, 0, 0))
+    im2 = Image.new("RGBA", (1, 2), (255, 0, 0, 0))
+    im.save(out, save_all=True, append_images=[im2])
+
+    with Image.open(out) as reread:
+        assert reread.n_frames == 2
+
+
 def test_palette_save_L(tmp_path):
     # Generate an L mode image with a separate palette
 
diff --git a/Tests/test_image_getbbox.py b/Tests/test_image_getbbox.py
--- a/Tests/test_image_getbbox.py
+++ b/Tests/test_image_getbbox.py
@@ -1,3 +1,5 @@
+import pytest
+
 from PIL import Image
 
 from .helper import hopper
@@ -38,3 +40,16 @@ def check(im, fill_color):
         for color in ((0, 0), (127, 0), (255, 0)):
             im = Image.new(mode, (100, 100), color)
             check(im, (255, 255))
+
+
+@pytest.mark.parametrize("mode", ("RGBA", "RGBa", "La", "LA", "PA"))
+def test_bbox_alpha_only_false(mode):
+    im = Image.new(mode, (100, 100))
+    assert im.getbbox(alpha_only=False) is None
+
+    fill_color = [1] * Image.getmodebands(mode)
+    fill_color[-1] = 0
+    im.paste(tuple(fill_color), (25, 25, 75, 75))
+    assert im.getbbox(alpha_only=False) == (25, 25, 75, 75)
+
+    assert im.getbbox() is None