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