Repository: python-pillow/Pillow

Issue:
GIF's n_frames is (about 60x) slower than it should be 
While idling around on SO and answering a question I noticed that `n_frames` is surprisingly slow for GIF:

```python
# setup code based on ImageIO 
# because it's short and we can avoid measuring disk IO
import imageio.v3 as iio
from PIL import Image
import io

img = iio.imread("imageio:newtonscradle.gif", index=None)
img_bytes = iio.imwrite("<bytes>", img, format="GIF")
```

The desired way to do things:

```python
%%timeit

with Image.open(io.BytesIO(img_bytes)) as file:
    n_frames = file.n_frames
```

Timing for this approach:

```
13.2 ms ± 94.3 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
```

This is much more time than it should take to check a series of block headers. So I checked, and - indeed - `n_frames` parses pixel data, which (in pillow9) involves a conversion to RGB(A) which is what makes this slow. Here is an alternative approach that only reads headers and is - more or less - a drop-in replacement:

```python
%%timeit
# based on SO: https://stackoverflow.com/a/7506880/6753182

with Image.open(io.BytesIO(img_bytes)) as file:
    def skip_color_table(flags):
        if flags & 0x80: 
            file.fp.seek(3 << ((flags & 7) + 1), 1)

    current_fp = file.fp.tell()
    total_frames = file.tell()  # start counting from the current frame

    # seek to beginning of next block
    buffer_start = file.tile[0][2]
    file.fp.seek(buffer_start)
    while True:
        size = file.fp.read(1)
        if size and size[0]:
            file.fp.seek(size[0], 1)
        else:
            break
    
    # count the remaining blocks
    while True:
        block = file.fp.read(1)
        if block == b';': 
            break
        if block == b'!': 
            file.fp.seek(1, 1)
        elif block == b',':
            total_frames += 1
            file.fp.seek(8, 1)
            skip_color_table(ord(file.fp.read(1)))
            file.fp.seek(1, 1)
        else: raise RuntimeError("unknown block type")
        
        # skip to next block instead of loading pixels
        while True:
            l = ord(file.fp.read(1))
            if not l: break
            file.fp.seek(l, 1)

    file.fp.seek(current_fp)
```

The timings:

```
211 µs ± 529 ns per loop (mean ± std. dev. of 7 runs, 1000 loops each) 
```

So if we only check block headers we can be about 60x faster than we are now. If desired, and there is somebody willing to review, I can submit a PR that adds this some time next week :)

(tests were done on Windows 11 with a AMD Ryzen 7 5800X 8-Core Processor and 2x Kingston KHX3200C16D4/32GX 32GB)


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
@@ -97,7 +97,7 @@ def n_frames(self):
             current = self.tell()
             try:
                 while True:
-                    self.seek(self.tell() + 1)
+                    self._seek(self.tell() + 1, False)
             except EOFError:
                 self._n_frames = self.tell() + 1
             self.seek(current)
@@ -110,14 +110,16 @@ def is_animated(self):
                 self._is_animated = self._n_frames != 1
             else:
                 current = self.tell()
-
-                try:
-                    self.seek(1)
+                if current:
                     self._is_animated = True
-                except EOFError:
-                    self._is_animated = False
+                else:
+                    try:
+                        self._seek(1, False)
+                        self._is_animated = True
+                    except EOFError:
+                        self._is_animated = False
 
-                self.seek(current)
+                    self.seek(current)
         return self._is_animated
 
     def seek(self, frame):
@@ -135,7 +137,7 @@ def seek(self, frame):
                 self.seek(last_frame)
                 raise EOFError("no more images in GIF file") from e
 
-    def _seek(self, frame):
+    def _seek(self, frame, update_image=True):
 
         if frame == 0:
             # rewind
@@ -147,14 +149,11 @@ def _seek(self, frame):
             self.disposal_method = 0
         else:
             # ensure that the previous frame was loaded
-            if self.tile:
+            if self.tile and update_image:
                 self.load()
 
         if frame != self.__frame + 1:
             raise ValueError(f"cannot seek to frame {frame}")
-        self.__frame = frame
-
-        self.tile = []
 
         self.fp = self.__fp
         if self.__offset:
@@ -164,25 +163,34 @@ def _seek(self, frame):
                 pass
             self.__offset = 0
 
-        if self.__frame == 1:
-            self.pyaccess = None
-            if "transparency" in self.info:
-                if self.mode == "P":
-                    self.im.putpalettealpha(self.info["transparency"], 0)
-                    self.im = self.im.convert("RGBA", Image.Dither.FLOYDSTEINBERG)
-                    self.mode = "RGBA"
-                else:
-                    self.im = self.im.convert_transparent(
-                        "LA", self.info["transparency"]
-                    )
-                    self.mode = "LA"
+        s = self.fp.read(1)
+        if not s or s == b";":
+            raise EOFError
 
-                del self.info["transparency"]
-            else:
-                self.mode = "RGB"
-                self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG)
-        if self.dispose:
-            self.im.paste(self.dispose, self.dispose_extent)
+        self.__frame = frame
+
+        self.tile = []
+
+        if update_image:
+            if self.__frame == 1:
+                self.pyaccess = None
+                if "transparency" in self.info:
+                    if self.mode == "P":
+                        self.im.putpalettealpha(self.info["transparency"], 0)
+                        self.im = self.im.convert("RGBA", Image.Dither.FLOYDSTEINBERG)
+                        self.mode = "RGBA"
+                    else:
+                        self.im = self.im.convert_transparent(
+                            "LA", self.info["transparency"]
+                        )
+                        self.mode = "LA"
+
+                    del self.info["transparency"]
+                else:
+                    self.mode = "RGB"
+                    self.im = self.im.convert("RGB", Image.Dither.FLOYDSTEINBERG)
+            if self.dispose:
+                self.im.paste(self.dispose, self.dispose_extent)
 
         palette = None
 
@@ -191,7 +199,8 @@ def _seek(self, frame):
         interlace = None
         while True:
 
-            s = self.fp.read(1)
+            if not s:
+                s = self.fp.read(1)
             if not s or s == b";":
                 break
 
@@ -229,6 +238,7 @@ def _seek(self, frame):
                         else:
                             info["comment"] = block
                         block = self.data()
+                    s = None
                     continue
                 elif s[0] == 255:
                     #
@@ -270,6 +280,13 @@ def _seek(self, frame):
             else:
                 pass
                 # raise OSError, "illegal GIF tag `%x`" % s[0]
+            s = None
+
+        if interlace is None:
+            # self.__fp = None
+            raise EOFError
+        if not update_image:
+            return
 
         frame_palette = palette or self.global_palette
 
@@ -329,9 +346,6 @@ def _rgb(color):
                     (bits, interlace),
                 )
             ]
-        else:
-            # self.__fp = None
-            raise EOFError
 
         for k in ["duration", "comment", "extension", "loop"]:
             if k in info:


Test patch: