Repository: python-pillow/Pillow

Issue:
GIF: _get_optimize() missed opportunities
<!--
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 a GIF using a recent 9.2.0dev0 build:

```python
from PIL import Image
with Image.open('stripes.gif') as im:
    im.save('out.gif', save_all=True, optimize=True, disposal=1)
```
![stripes](https://user-images.githubusercontent.com/24783736/173413412-1807329f-0f5e-46da-ae22-fca06dc0ac22.gif)

### What did you expect to happen?

The output file would have optimized local color tables.

### What actually happened?

All the local color tables had 256 colors where 8 colors would suffice.

### What are your OS, Python and Pillow versions?

* OS: Windows 10 Pro
* Python: 3.10.4
* Pillow: 9.2.0dev0

<!--
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

`stripes.gif` is similar in every way that matters to the `aniship.gif` image I used in issue #6260. I created it so it could be freely distributed as desired. I realize it's unusual. It has no GCT and the LCT is the same on every frame. But `aniship.gif` was found "in the wild" so `stripes.gif` not an entirely artificial example.

In `GifImagePlugin.py`, `_get_optimize()` uses certain criteria to decide if the palette is worth trying to optimize (remapping to only the used colors). The optimization is done only if the image is smaller than 262144 pixels, there are no more than 128 used colors, and there are "holes" in the palette, the latter being the case if `max(used_palette_colors) > len(used_palette_colors)`.

I think the "holes" test should be `max(used_palette_colors) >= len(used_palette_colors)`, as e.g. [1, 2, 3, 4, 5] has a hole at the bottom and [0, 1, 2, 4, 5] has a hole in the middle, but would be seen now as having no hole.

But I'm not clear on the point regarding holes. I would think the condition for optimizing would be if the palette actually got smaller. The number of colors in the current palette would (I think) be a power of two, so you would want to check if the new (used colors) palette is not more than half the size of the current palette. I'm sure I must be missing something here, but how about getting the current number of colors and testing if the number of colors used is not more than half of that. For example:

```python
            num_palette_colors = len(im.palette.palette) // 4 if len(im.palette.palette) % 3 else len(im.palette.palette) // 3
            if optimise or (
                len(used_palette_colors) <= 128
                and (max(used_palette_colors) >= len(used_palette_colors)
                or len(used_palette_colors) <= num_palette_colors // 2)
            ):
```

This assumes that im.palette.palette exists, is not None and is actually a byte string or array of RGB or RGBA colors. If its length is not a multiple of 3 I assume it's RGBA because they would always be a multiple of 4 in size. Are these valid assumptions? If not, is there something we can do along these lines?

Would this also obviate the need for `len(used_palette_colors) <= 128`?

With this change to `_get_optimize()`, The `out.gif` is reduced from 36653 bytes to 22517 bytes.

If this change is worth pursuing and my logic is close to correct, I can create a PR for this. What have I missed?



Discussion:
`im.palette.mode` will tell you whether the palette is RGB or RGBA (`% 3` will not, because there may be 3 RGBA entries).

It would remove the need for `len(used_palette_colors) <= 128`, yes.

Your thinking sounds essentially correct to me, so feel free to create a PR.
>  there may be 3 RGBA entries

So the number of colors in a RGBA palette may be not a power of 2? Can the number of colors in a RGB palette be other than a power of 2? At least at the point where `_get_optimize()` is used? 
If so, I think I may have to rework this. Maybe round up the number of colors to a power of 2 then divide by 2 before comparing with `used_palette_colors`?
If nothing else, a palette might be modified by Python code. So yes, it can contain any number of colors.

Resolving patch:
diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py
--- a/src/PIL/GifImagePlugin.py
+++ b/src/PIL/GifImagePlugin.py
@@ -824,9 +824,18 @@ def _get_optimize(im, info):
                 if count:
                     used_palette_colors.append(i)
 
-            if optimise or (
-                len(used_palette_colors) <= 128
-                and max(used_palette_colors) > len(used_palette_colors)
+            if optimise or max(used_palette_colors) >= len(used_palette_colors):
+                return used_palette_colors
+
+            num_palette_colors = len(im.palette.palette) // Image.getmodebands(
+                im.palette.mode
+            )
+            current_palette_size = 1 << (num_palette_colors - 1).bit_length()
+            if (
+                # check that the palette would become smaller when saved
+                len(used_palette_colors) <= current_palette_size // 2
+                # check that the palette is not already the smallest possible size
+                and current_palette_size > 2
             ):
                 return used_palette_colors
 


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
@@ -158,6 +158,9 @@ def check(colors, size, expected_palette_length):
             assert_image_equal(im.convert("RGB"), reloaded.convert("RGB"))
 
     # These do optimize the palette
+    check(256, 511, 256)
+    check(255, 511, 255)
+    check(129, 511, 129)
     check(128, 511, 128)
     check(64, 511, 64)
     check(4, 511, 4)
@@ -167,11 +170,6 @@ def check(colors, size, expected_palette_length):
     check(64, 513, 256)
     check(4, 513, 256)
 
-    # Other limits that don't optimize the palette
-    check(129, 511, 256)
-    check(255, 511, 256)
-    check(256, 511, 256)
-
 
 def test_optimize_full_l():
     im = Image.frombytes("L", (16, 16), bytes(range(256)))
@@ -180,6 +178,19 @@ def test_optimize_full_l():
     assert im.mode == "L"
 
 
+def test_optimize_if_palette_can_be_reduced_by_half():
+    with Image.open("Tests/images/test.colors.gif") as im:
+        # Reduce dimensions because original is too big for _get_optimize()
+        im = im.resize((591, 443))
+    im_rgb = im.convert("RGB")
+
+    for (optimize, colors) in ((False, 256), (True, 8)):
+        out = BytesIO()
+        im_rgb.save(out, "GIF", optimize=optimize)
+        with Image.open(out) as reloaded:
+            assert len(reloaded.palette.palette) // 3 == colors
+
+
 def test_roundtrip(tmp_path):
     out = str(tmp_path / "temp.gif")
     im = hopper()
@@ -982,8 +993,8 @@ def im_generator(ims):
 def test_transparent_optimize(tmp_path):
     # From issue #2195, if the transparent color is incorrectly optimized out, GIF loses
     # transparency.
-    # Need a palette that isn't using the 0 color, and one that's > 128 items where the
-    # transparent color is actually the top palette entry to trigger the bug.
+    # Need a palette that isn't using the 0 color,
+    # where the transparent color is actually the top palette entry to trigger the bug.
 
     data = bytes(range(1, 254))
     palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3)
@@ -993,10 +1004,10 @@ def test_transparent_optimize(tmp_path):
     im.putpalette(palette)
 
     out = str(tmp_path / "temp.gif")
-    im.save(out, transparency=253)
-    with Image.open(out) as reloaded:
+    im.save(out, transparency=im.getpixel((252, 0)))
 
-        assert reloaded.info["transparency"] == 253
+    with Image.open(out) as reloaded:
+        assert reloaded.info["transparency"] == reloaded.getpixel((252, 0))
 
 
 def test_rgb_transparency(tmp_path):