Search code examples
pythonpython-3.xuser-interfacecropgif

How to crop GIFs with a script?


Is there a script for cropping gifs in python like this page: https://www.iloveimg.com/crop-image ?

Some time ago, I found Image Cropping using Python but the problem is you need to draw the rectangle with the cursor.

And I need a GUI like https://www.iloveimg.com/crop-image which have a rectangle that it can be moved wherever I want:

ILOVEIMG

See that https://www.iloveimg.com/crop-image crops the GIF into a new animated one. And Image Cropping using Python only crops the first frame of the GIF.

Some modules that I can use are:

  • Tkinter (preferably)
  • Pygame
  • Pillow / PIL
  • Other

Solution

  • Since this is still the top hit on google for cropping gifs in python, it is probably worth an update.

    If we generalize the approach from above like this, then the usage is a bit more familiar:

    import io
    from dataclasses import dataclass
    from pathlib import Path
    from typing import List, Tuple, Union
    
    import numpy as np
    from PIL import Image, ImageSequence
    
    Left, Upper, Right, Lower = int, int, int, int
    Box = Tuple[Left, Upper, Right, Lower]
    Frames = List[np.ndarray]
    ImageArray = List[Image.Image]
    File = Union[str, bytes, Path, io.BytesIO]
    
    
    @dataclass
    class MultiFrameImage:
        fp: File
    
        @property
        def im(self):
            return Image.open(self.fp)
    
        @property
        def frames(self) -> Frames:
            return [
                np.array(frame.copy().convert("RGB"))
                for frame in ImageSequence.Iterator(self.im)
            ]
    
        def crop_frames(self, box: Box) -> List[np.ndarray]:
            left, upper, right, lower = box
            return [frame[upper:lower, left:right] for frame in self.frames]
    
        def image_array_from_frames(self, frames: Frames) -> ImageArray:
            return [Image.fromarray(np.uint8(frame)) for frame in frames]
    
        def crop_to_buffer(self, box: Box, **kwargs) -> io.BytesIO:
            cropped_frames = self.crop_frames(box)
            cropped_images = self.image_array_from_frames(cropped_frames)
            buffer = io.BytesIO()
            cropped_images[0].save(
                buffer,
                save_all=True,
                format="GIF",
                append_images=cropped_images[1:],
                duration=16,
                loop=0,
                **kwargs
            )
            return buffer
    
        def crop(self, box: Box, **kwargs) -> Image.Image:
            return Image.open(self.crop_to_buffer(box, **kwargs))
    
    

    The crop method here will return a PIL image just like Image.crop does.

    Usage looks like this:

    image = MultiFrameImage(io.BytesIO(avatar_bytes))
    buffer = image.crop_to_buffer((left, upper, right, lower))
    
    # or if you need the image instead of the buffer
    cropped_image = image.crop((left, upper, right, lower))
    

    If you're in a hurry, ignore this part and copy the code above

    Another option (just for fun, the first one I present is probably cleaner) would be to monkey-patch the open function from PIL and recurse our crop method like this:

    import io
    from dataclasses import dataclass
    from pathlib import Path
    from typing import List, Tuple, Union, cast
    
    import numpy as np
    from PIL import Image, ImageSequence
    
    Left, Upper, Right, Lower = int, int, int, int
    Box = Tuple[Left, Upper, Right, Lower]
    Frames = List[np.ndarray]
    ImageArray = List[Image.Image]
    File = Union[str, bytes, Path, io.BytesIO]
    
    
    @dataclass
    class MultiFrameImage:
        fp: File
    
        @property
        def im(self):
            return Image.open(self.fp)
    
        @property
        def frames(self) -> Frames:
            return [
                np.array(frame.copy().convert("RGB"))
                for frame in ImageSequence.Iterator(self.im)
            ]
    
        def crop_frames(self, box: Box) -> List[np.ndarray]:
            left, upper, right, lower = box
            return [frame[upper:lower, left:right] for frame in self.frames]
    
        def image_array_from_frames(self, frames: Frames) -> ImageArray:
            return [Image.fromarray(np.uint8(frame)) for frame in frames]
    
        def crop_to_buffer(self, box: Box, **kwargs) -> io.BytesIO:
            cropped_frames = self.crop_frames(box)
            cropped_images = self.image_array_from_frames(cropped_frames)
            buffer = io.BytesIO()
            cropped_images[0].save(
                buffer,
                save_all=True,
                format="GIF",
                append_images=cropped_images[1:],
                duration=16,
                loop=0,
                **kwargs
            )
            return buffer
    
        def crop(self, box: Box, **kwargs) -> "MultiFrameImage":
            return open_multiframe_image(self.crop_to_buffer(box, **kwargs))
    
    
    class MonkeyPatchedMultiFrameImage(Image.Image, MultiFrameImage):
        pass
    
    
    def open_multiframe_image(fp):
        multi_frame_im = MultiFrameImage(fp)
        im = multi_frame_im.im
        setattr(im, "frames", multi_frame_im.frames)
        setattr(im, "crop_frames", multi_frame_im.crop_frames)
        setattr(im, "image_array_from_frames", multi_frame_im.image_array_from_frames)
        setattr(im, "crop_to_buffer", multi_frame_im.crop_to_buffer)
        setattr(im, "crop", multi_frame_im.crop)
        return cast(MonkeyPatchedMultiFrameImage, im)
    

    This gives the illusion that we are actually working with the PIL Image class.. this is dangerous unless you plan to override all the other Image methods as well. For most use cases, the first code block I gave should suffice