Repository: python-pillow/Pillow

Issue:
GIF has transparency issues, not all fixed with PR #6176
<!--
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?

Copied an animated GIF:

```python
with Image.open('aniship.gif') as im:
    im.save(f'new.gif', save_all=True, optimize=True, interlace=0)
```

### What did you expect to happen?

Hoped the copy would be something like the original aniship.gif:

![aniship](https://user-images.githubusercontent.com/24783736/166325176-1379ec4c-67ef-495d-8672-f50e222ddac7.gif)

### What actually happened?

With PR #6176 applied, I get an artifact of (apropos of the nautical theme) aqua background on first frame:

![new](https://user-images.githubusercontent.com/24783736/166326996-86c6c625-745a-47d0-86f0-5f216488e184.gif)


### What are your OS, Python and Pillow versions?

* OS: Windows 10
* Python: 3.10.4
* Pillow: 9.1.0 with PR #6176 applied

<!--
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.
-->

#### Discussion:

Issues of transparency continue to vex GIF handling. Not sure if I have a complete handle on this yet but will try.

In `_write_multiple_frames()` there is a call to `_normalize_palette()`, which calls `.remap_palette()` which calls `convert("L")` which changes `.info["transparency"]`. This happens after the fix of PR #6176. Also, `.remap_palette()` changes the palette but does not make a corresponding change to the transparency index. So here is a proposal (not yet a PR because it conflicts with PR #6176 and because I don't know if it's a good fix):

Add a function:

```python
def _find_new_transparency(im, used_palette_colors):
    try:
        return used_palette_colors.index(im.info.get("transparency"))
    except ValueError:
        return None
```

Then in `_normalize_palette()` change

```python
        im = im.remap_palette(used_palette_colors)
    else:
        used_palette_colors = _get_optimize(im, info)
        if used_palette_colors is not None:
            return im.remap_palette(used_palette_colors, source_palette)
```

to:

```python
        transparency = _find_new_transparency(im, used_palette_colors)
        im = im.remap_palette(used_palette_colors)
        if transparency is not None:
            im.info["transparency"] = transparency
    else:
        used_palette_colors = _get_optimize(im, info)
        if used_palette_colors is not None:
            transparency = _find_new_transparency(im, used_palette_colors)
            im = im.remap_palette(used_palette_colors, source_palette)
            if transparency is not None:
                im.info["transparency"] = transparency
            return im
```

This changes `.info["transparency"]` to the remapped transparency index, if it was in the used palette to begin with. (More about this below... still a problem.)

Then in `_write_multiple_frames()` put the call to `_normalize_palette()` ahead of the set of `encoderinfo`:

```python
            im_frame = _normalize_palette(im_frame, palette, encoderinfo)
            if "transparency" in im_frame.info:
                encoderinfo.setdefault("transparency", im_frame.info["transparency"])
```

I don't know if changing the order of these statements from PR #6176 cause a problem or not.

Another problem is: what if there is a transparency index (i.e. the Transparent Color Flag is set) but the index is to an unused color? My "fix" above to `_normalize_palette()` will not change `im.info["transparency"]` and may leave it referring to a color that is used and should not be transparent. This happens with that Tazspin.gif I used in Issue #6259. Only affects about 3 pixels there so it is not visible but could be a problem for some GIFs. Maybe there's an easy way to force the index to an unused color in this case?

BTW aniship.gif has no GCT, only LCTs (and they're all the same). The current 9.1.0 either with or without PR #6176 will create a reduced GCT of 8 slots (of used colors) but leaves the transparent color index at 14, pointing to a non-existent entry.




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
@@ -573,10 +573,14 @@ def _write_multiple_frames(im, fp, palette):
             im_frame = _normalize_mode(im_frame.copy())
             if frame_count == 0:
                 for k, v in im_frame.info.items():
+                    if k == "transparency":
+                        continue
                     im.encoderinfo.setdefault(k, v)
-            im_frame = _normalize_palette(im_frame, palette, im.encoderinfo)
 
             encoderinfo = im.encoderinfo.copy()
+            im_frame = _normalize_palette(im_frame, palette, encoderinfo)
+            if "transparency" in im_frame.info:
+                encoderinfo.setdefault("transparency", im_frame.info["transparency"])
             if isinstance(duration, (list, tuple)):
                 encoderinfo["duration"] = duration[frame_count]
             if isinstance(disposal, (list, tuple)):
diff --git a/src/PIL/Image.py b/src/PIL/Image.py
--- a/src/PIL/Image.py
+++ b/src/PIL/Image.py
@@ -1934,6 +1934,13 @@ def remap_palette(self, dest_map, source_palette=None):
         m_im.putpalette(new_palette_bytes)
         m_im.palette = ImagePalette.ImagePalette("RGB", palette=palette_bytes)
 
+        if "transparency" in self.info:
+            try:
+                m_im.info["transparency"] = dest_map.index(self.info["transparency"])
+            except ValueError:
+                if "transparency" in m_im.info:
+                    del m_im.info["transparency"]
+
         return m_im
 
     def _get_safe_box(self, size, resample, box):


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
@@ -619,7 +619,8 @@ def test_dispose2_background(tmp_path):
         assert im.getpixel((0, 0)) == (255, 0, 0)
 
 
-def test_transparency_in_second_frame():
+def test_transparency_in_second_frame(tmp_path):
+    out = str(tmp_path / "temp.gif")
     with Image.open("Tests/images/different_transparency.gif") as im:
         assert im.info["transparency"] == 0
 
@@ -629,6 +630,14 @@ def test_transparency_in_second_frame():
 
         assert_image_equal_tofile(im, "Tests/images/different_transparency_merged.png")
 
+        im.save(out, save_all=True)
+
+    with Image.open(out) as reread:
+        reread.seek(reread.tell() + 1)
+        assert_image_equal_tofile(
+            reread, "Tests/images/different_transparency_merged.png"
+        )
+
 
 def test_no_transparency_in_second_frame():
     with Image.open("Tests/images/iss634.gif") as img:
@@ -640,6 +649,22 @@ def test_no_transparency_in_second_frame():
         assert img.histogram()[255] == 0
 
 
+def test_remapped_transparency(tmp_path):
+    out = str(tmp_path / "temp.gif")
+
+    im = Image.new("P", (1, 2))
+    im2 = im.copy()
+
+    # Add transparency at a higher index
+    # so that it will be optimized to a lower index
+    im.putpixel((0, 1), 5)
+    im.info["transparency"] = 5
+    im.save(out, save_all=True, append_images=[im2])
+
+    with Image.open(out) as reloaded:
+        assert reloaded.info["transparency"] == reloaded.getpixel((0, 1))
+
+
 def test_duration(tmp_path):
     duration = 1000
 
diff --git a/Tests/test_image.py b/Tests/test_image.py
--- a/Tests/test_image.py
+++ b/Tests/test_image.py
@@ -607,6 +607,20 @@ def test_remap_palette(self):
             with pytest.raises(ValueError):
                 im.remap_palette(None)
 
+    def test_remap_palette_transparency(self):
+        im = Image.new("P", (1, 2))
+        im.putpixel((0, 1), 1)
+        im.info["transparency"] = 0
+
+        im_remapped = im.remap_palette([1, 0])
+        assert im_remapped.info["transparency"] == 1
+
+        # Test unused transparency
+        im.info["transparency"] = 2
+
+        im_remapped = im.remap_palette([1, 0])
+        assert "transparency" not in im_remapped.info
+
     def test__new(self):
         im = hopper("RGB")
         im_p = hopper("P")