Repository: python-pillow/Pillow

Issue:
Setting disposal=2 on gif export can cause issues with transparency
<!--
Thank you for reporting an issue.

Follow these guidelines to ensure your issue is handled properly.

If you have a ...

1.  General question: consider asking the question on Stack Overflow
    with the python-imaging-library tag:

    * https://stackoverflow.com/questions/tagged/python-imaging-library

    Do not ask a question in both places.

    If you think you have found a bug or have an unexplained exception
    then file a bug report here.

2.  Bug report: include a self-contained, copy-pastable example that
    generates the issue if possible. Be concise with code posted.
    Guidelines on how to provide a good bug report:

    * https://stackoverflow.com/help/mcve

    Bug reports which follow these guidelines are easier to diagnose,
    and are often handled much more quickly.

3.  Feature request: do a quick search of existing issues
    to make sure this has not been asked before.

We know asking good questions takes effort, and we appreciate your time.
Thank you.
-->

### What did you do?
Created a sequence of images with jittering text and saved them as a gif, specifying `disposal=2`

### What did you expect to happen?
The gif to correctly display the images, with a consistent background

### What actually happened?
The background flashes between opaque and transparent, with which frames are which depending on how exactly the text is jittering
![testout](https://github.com/python-pillow/Pillow/assets/66188216/019e3217-2fb1-411e-8b47-a5c5c0ba4c88)

### What are your OS, Python and Pillow versions?

* OS: Linux (Arch)
* Python: 3.11.6
* Pillow: 10.2.0

<!--
Please include **code** that reproduces the issue and whenever possible, an **image** that demonstrates the issue. Please upload images to GitHub, not to third-party file hosting sites. If necessary, add the image to a zip or tar archive.

The best reproductions are self-contained scripts with minimal dependencies. If you are using a framework such as Plone, Django, or Buildout, try to replicate the issue just using Pillow.
-->

This code with these images was used to generate the above gif:
```python
from PIL import Image

baseimage = Image.open("test0.png")
images = [
    Image.open("test1.png")
]
baseimage.save("testout.gif", save_all=True, append_images=images, loop=0, duration=1000, disposal=2)
```
test0.png:
![test0](https://github.com/python-pillow/Pillow/assets/66188216/a5c8af77-7b5d-4b90-8ea3-cd786eedd8fe)
test1.png:
![test1](https://github.com/python-pillow/Pillow/assets/66188216/4ba2f56a-f699-476d-a960-c053a74554e6)

As far as I can tell, this is occurring because of the optimization in #7568 depending on a delta image which, when `disposal` is 2, can have been generated with a background image that is filled with a color with a palette index that does not exist in the frame's palette, presumably creating a nonsense delta.
Moving [these lines of code](https://github.com/python-pillow/Pillow/blob/main/src/PIL/GifImagePlugin.py#L634C1-L637C70) outside the if statement seems to fix the issue, presumably due to `_get_background` having a side effect of placing the appropriate color into the frame's palette with the same index as in the background.
This does not occur when `disposal` is set to any other value or when `optimize` is set to `False` due to skipping one or both of the codepaths involved in the bug.


Discussion:

Resolving patch:
diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py
--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -629,7 +629,7 @@ def _write_multiple_frames(im, fp, palette):
                             "duration"
                         ]
                     continue
-                if encoderinfo.get("disposal") == 2:
+                if im_frames[-1]["encoderinfo"].get("disposal") == 2:
                     if background_im is None:
                         color = im.encoderinfo.get(
                             "transparency", im.info.get("transparency", (0, 0, 0))
@@ -637,8 +637,8 @@ def _write_multiple_frames(im, fp, palette):
                         background = _get_background(im_frame, color)
                         background_im = Image.new("P", im_frame.size, background)
                         background_im.putpalette(im_frames[0]["im"].palette)
-                    delta, bbox = _getbbox(background_im, im_frame)
-                if encoderinfo.get("optimize") and im_frame.mode != "1":
+                    bbox = _getbbox(background_im, im_frame)[1]
+                elif encoderinfo.get("optimize") and im_frame.mode != "1":
                     if "transparency" not in encoderinfo:
                         try:
                             encoderinfo["transparency"] = (


Test patch:
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
@@ -647,6 +647,9 @@ def test_dispose2_palette(tmp_path: Path) -> None:
             # Center remains red every frame
             assert rgb_img.getpixel((50, 50)) == circle
 
+            # Check that frame transparency wasn't added unnecessarily
+            assert img._frame_transparency is None
+
 
 def test_dispose2_diff(tmp_path: Path) -> None:
     out = str(tmp_path / "temp.gif")
@@ -734,6 +737,25 @@ def test_dispose2_background_frame(tmp_path: Path) -> None:
         assert im.n_frames == 3
 
 
+def test_dispose2_previous_frame(tmp_path: Path) -> None:
+    out = str(tmp_path / "temp.gif")
+
+    im = Image.new("P", (100, 100))
+    im.info["transparency"] = 0
+    d = ImageDraw.Draw(im)
+    d.rectangle([(0, 0), (100, 50)], 1)
+    im.putpalette((0, 0, 0, 255, 0, 0))
+
+    im2 = Image.new("P", (100, 100))
+    im2.putpalette((0, 0, 0))
+
+    im.save(out, save_all=True, append_images=[im2], disposal=[0, 2])
+
+    with Image.open(out) as im:
+        im.seek(1)
+        assert im.getpixel((0, 0)) == (0, 0, 0, 255)
+
+
 def test_transparency_in_second_frame(tmp_path: Path) -> None:
     out = str(tmp_path / "temp.gif")
     with Image.open("Tests/images/different_transparency.gif") as im: