Pillow - 调整 GIF 大小

16

我有一个gif图像,希望用pillow调整大小以减小它的尺寸。当前gif的大小为2MB。

我正在尝试:

  1. 调整大小使其高度/宽度更小

  2. 降低其质量

使用JPEG格式,通常以下代码片段就足够让大图片的大小大幅减小。

from PIL import Image

im = Image.open("my_picture.jpg")
im = im.resize((im.size[0] // 2, im.size[1] // 2), Image.ANTIALIAS)  # decreases width and height of the image
im.save("out.jpg", optimize=True, quality=85)  # decreases its quality

然而,使用GIF格式好像不起作用。下面这段代码甚至会使out.gif比初始的gif文件更大:

im = Image.open("my_gif.gif")
im.seek(im.tell() + 1)  # loads all frames
im.save("out.gif", save_all=True, optimize=True, quality=10)  # should decrease its quality

print(os.stat("my_gif.gif").st_size)  # 2096558 bytes / roughly 2MB
print(os.stat("out.gif").st_size)  # 7536404 bytes / roughly 7.5MB
如果我添加以下行,则只保存 GIF 的第一帧,而不是所有帧。
im = im.resize((im.size[0] // 2, im.size[1] // 2), Image.ANTIALIAS)  # should decrease its size

我一直在考虑在im.seek()im.tell()上调用resize()方法,但这些方法都不返回Image对象,因此我不能在它们的输出上调用resize()方法。

您知道如何使用Pillow来缩小GIF的大小而保留所有帧吗?

[编辑]部分解决方案:

按照Old Bear的回答,我做了以下更改:

  • 我正在使用BigglesZX的脚本来提取所有帧。值得注意的是,这是一个Python 2脚本,我的项目是用Python 3编写的(我最初提到了这个细节,但被Stack Overflow社区删除了)。运行2to3 -w gifextract.py使该脚本与Python 3兼容。

  • 我一直在单独调整每个帧的大小:frame.resize((frame.size[0] // 2, frame.size[1] // 2), Image.ANTIALIAS)

  • 我一直在保存所有帧:img.save("out.gif", save_all=True, optimize=True)

新的gif已保存并可用,但存在2个主要问题:

  • 我不确定调整大小的方法是否有效,因为out.gif仍然是7.5MB。最初的gif是2MB。

  • GIF速度增加了,GIF不循环。它在第一次运行后停止。

示例:

原始gif my_gif.gif:

Original gif

处理后的gif(out.gif) https://i.imgur.com/zDO4cE4.mp4(我无法将其添加到Stack Overflow)。Imgur使其变慢(并将其转换为mp4)。当我从电脑上打开gif文件时,整个gif持续约1.5秒。


你能上传你想要调整大小的GIF文件吗? - Jeru Luke
@JeruLuke,我已经添加了GIF文件。 - Pauline
5个回答

13
使用BigglesZX的脚本,我创建了一个新的脚本,使用Pillow调整GIF的大小。
原始GIF(2.1 MB):

Original gif

调整大小后的输出GIF(1.7 MB):

Output gif

我已经把脚本保存在这里,它使用Pillow的thumbnail方法而不是resize方法,因为我发现resize方法无法正常工作。

它还有一些问题需要解决,欢迎进行改进。以下是一些未解决的问题:

  • 虽然GIF在imgur上托管时显示得非常好,但当我从我的计算机打开它时,速度会很慢,整个GIF只需要1.5秒。
  • 同样,在imgur似乎弥补了速度问题之后,当我尝试将GIF上传到stack.imgur时,它无法正确显示。只显示第一帧(您可以在此处查看)。

完整代码(如果上面的代码被删除):

def resize_gif(path, save_as=None, resize_to=None):
    """
    Resizes the GIF to a given length:

    Args:
        path: the path to the GIF file
        save_as (optional): Path of the resized gif. If not set, the original gif will be overwritten.
        resize_to (optional): new size of the gif. Format: (int, int). If not set, the original GIF will be resized to
                              half of its size.
    """
    all_frames = extract_and_resize_frames(path, resize_to)

    if not save_as:
        save_as = path

    if len(all_frames) == 1:
        print("Warning: only 1 frame found")
        all_frames[0].save(save_as, optimize=True)
    else:
        all_frames[0].save(save_as, optimize=True, save_all=True, append_images=all_frames[1:], loop=1000)


def analyseImage(path):
    """
    Pre-process pass over the image to determine the mode (full or additive).
    Necessary as assessing single frames isn't reliable. Need to know the mode
    before processing all frames.
    """
    im = Image.open(path)
    results = {
        'size': im.size,
        'mode': 'full',
    }
    try:
        while True:
            if im.tile:
                tile = im.tile[0]
                update_region = tile[1]
                update_region_dimensions = update_region[2:]
                if update_region_dimensions != im.size:
                    results['mode'] = 'partial'
                    break
            im.seek(im.tell() + 1)
    except EOFError:
        pass
    return results


def extract_and_resize_frames(path, resize_to=None):
    """
    Iterate the GIF, extracting each frame and resizing them

    Returns:
        An array of all frames
    """
    mode = analyseImage(path)['mode']

    im = Image.open(path)

    if not resize_to:
        resize_to = (im.size[0] // 2, im.size[1] // 2)

    i = 0
    p = im.getpalette()
    last_frame = im.convert('RGBA')

    all_frames = []

    try:
        while True:
            # print("saving %s (%s) frame %d, %s %s" % (path, mode, i, im.size, im.tile))

            '''
            If the GIF uses local colour tables, each frame will have its own palette.
            If not, we need to apply the global palette to the new frame.
            '''
            if not im.getpalette():
                im.putpalette(p)

            new_frame = Image.new('RGBA', im.size)

            '''
            Is this file a "partial"-mode GIF where frames update a region of a different size to the entire image?
            If so, we need to construct the new frame by pasting it on top of the preceding frames.
            '''
            if mode == 'partial':
                new_frame.paste(last_frame)

            new_frame.paste(im, (0, 0), im.convert('RGBA'))

            new_frame.thumbnail(resize_to, Image.ANTIALIAS)
            all_frames.append(new_frame)

            i += 1
            last_frame = new_frame
            im.seek(im.tell() + 1)
    except EOFError:
        pass

    return all_frames

对于 putpalette,我添加了 try ... / except ValueError pass 来避免 _非法图像模式_。自 Pillow 6 以来已经有了一些变化,不确定这是否是最佳方式。 - luigifab

5
根据Pillow 4.0x,Image.resize函数仅适用于单个图像/帧。
为了实现您想要的效果,我认为您首先需要从.gif文件中提取每一帧,逐一调整每个帧的大小,然后重新组合它们。
在执行第一步时,似乎有一些需要注意的细节。例如,每个gif帧是否使用本地调色板或应用于所有帧的全局调色板,以及gif是否使用完整或部分帧替换每个图像。 BigglesZX已经开发了一个脚本来解决这些问题并从gif文件中提取每一帧,因此可以利用它。
接下来,您需要编写脚本来调整提取的每个帧的大小,并使用PIL.Image.resize()和PIL.Image.save()将它们全部组合成新的.gif文件。
我注意到你写了“im.seek(im.tell() + 1) # load all frames”。我认为这是不正确的。它实际上是用于在.gif文件的帧之间进行递增。我注意到你在保存.gif文件时的保存函数中使用了quality=10。我在PIL文档中没有找到这个参数。您可以通过阅读链接来了解BiggleZX脚本中提到的tile属性。

你说得对,“quality”是对于“jpg”文件的一个有效参数,但不适用于“gif”文件。当我试图将我的代码从调整“gif”大小改为调整“jpg”大小时,我忘记删除它了。我已经编辑了我的帖子来回复你答案中的其他要素,因为我没有足够的空间在评论中回复。 - Pauline
@Pauline 如果你比较单独提取的帧的大小和等效调整大小的帧的大小,你是否发现调整大小的帧的大小更小?如果更小,我认为这意味着调整大小函数有效,并且文件大小的增加来自重新编译过程。对于你的循环问题,在Image.save命令中提到了一个“looping”选项,可以解决你的问题。至于速度,我认为“duration”选项控制它。你可以通过我之前回答中给出的链接查看它。 - Sun Bear
另一个想法。如果在减小文件大小时出现了尺寸增加的情况,那可能是由于你所使用的滤镜类型引起的。参考网址:http://pillow.readthedocs.io/en/4.0.x/handbook/concepts.html#filters。我注意到你正在使用Image.ANTIALIAS滤镜,但在调整大小的滤镜列表中没有找到它。Hamming滤镜看起来很吸引人。 - Sun Bear
我发现resize方法不起作用,因此我已经转而使用thumbnail方法,它可以正常工作。我不知道为什么resize无法正常工作。我现在正在使用循环属性,但最初我想要一个无限循环而不是固定次数的循环。虽然我没有找到实现这一点的方法,但我将循环次数设置为1000(这只会增加GIF的总大小几个字节)。我还没有找到一个不需要手动输入速度的解决方案。理想情况下,我希望能够从原始的GIF中读取速度,但我还没有找到实现这一点的方法。 - Pauline
Image.ANTIALIAS是遗留的(请参见此版本说明),但在较新版本的Pillow中仍然可以使用它,因为它与LANCZOS相关联。由于在旧版本的pillow中使用LANCZOS会引发异常,并且使用ANTIALIAS可以实现与使用LANCZOS类似的结果,所以我认为最好将其保留为ANTIALIAS,但我可能是错误的,如果有需要纠正的地方,我很乐意接受。感谢您的帮助,我已经找到了一种解决方法,并将其发布为答案。 - Pauline
1
@Pauline,很高兴你达成了目标,也很高兴能够参与你的旅程。关于ANTIALIAS已经注意到了。在阅读PIL文档时,发现了使用BOX滤镜进行缩放的建议,适用于某些比例的缩小。 - Sun Bear

1

我写了一段简单的代码,可以在保持相同速度和背景透明度的情况下调整Gif大小。我认为这可能会有所帮助。

"""
# Resize an animated GIF
Inspired from https://gist.github.com/skywodd/8b68bd9c7af048afcedcea3fb1807966
Useful links:
    * https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html#saving
    * https://dev59.com/G1gR5IYBdhLWcg3wHqTB#69850807
Example:
    ```
    python resize_gif.py input.gif output.gif 400,300
    ```
"""

import sys

from PIL import Image
from PIL import ImageSequence


def resize_gif(input_path, output_path, max_size):
    input_image = Image.open(input_path)
    frames = list(_thumbnail_frames(input_image))
    output_image = frames[0]
    output_image.save(
        output_path,
        save_all=True,
        append_images=frames[1:],
        disposal=input_image.disposal_method,
        **input_image.info,
    )


def _thumbnail_frames(image):
    for frame in ImageSequence.Iterator(image):
        new_frame = frame.copy()
        new_frame.thumbnail(max_size, Image.Resampling.LANCZOS)
        yield new_frame


if __name__ == "__main__":
    max_size = [int(px) for px in sys.argv[3].split(",")]  # "150,100" -> (150, 100)
    resize_gif(sys.argv[1], sys.argv[2], max_size)

1
我正在使用以下函数来调整大小和裁剪图片,包括动画图像(GIF、WEBP)。简单来说,我们需要迭代gif或webp中的每一帧。
from math import floor, fabs
from PIL import Image, ImageSequence

def transform_image(original_img, crop_w, crop_h):
  """
  Resizes and crops the image to the specified crop_w and crop_h if necessary.
  Works with multi frame gif and webp images also.

  args:
  original_img is the image instance created by pillow ( Image.open(filepath) )
  crop_w is the width in pixels for the image that will be resized and cropped
  crop_h is the height in pixels for the image that will be resized and cropped

  returns:
  Instance of an Image or list of frames which they are instances of an Image individually
  """
  img_w, img_h = (original_img.size[0], original_img.size[1])
  n_frames = getattr(original_img, 'n_frames', 1)

  def transform_frame(frame):
    """
    Resizes and crops the individual frame in the image.
    """
    # resize the image to the specified height if crop_w is null in the recipe
    if crop_w is None:
      if crop_h == img_h:
        return frame
      new_w = floor(img_w * crop_h / img_h)
      new_h = crop_h
      return frame.resize((new_w, new_h))

    # return the original image if crop size is equal to img size
    if crop_w == img_w and crop_h == img_h:
      return frame

    # first resize to get most visible area of the image and then crop
    w_diff = fabs(crop_w - img_w)
    h_diff = fabs(crop_h - img_h)
    enlarge_image = True if crop_w > img_w or crop_h > img_h else False
    shrink_image = True if crop_w < img_w or crop_h < img_h else False

    if enlarge_image is True:
      new_w = floor(crop_h * img_w / img_h) if h_diff > w_diff else crop_w
      new_h = floor(crop_w * img_h / img_w) if h_diff < w_diff else crop_h

    if shrink_image is True:
      new_w = crop_w if h_diff > w_diff else floor(crop_h * img_w / img_h)
      new_h = crop_h if h_diff < w_diff else floor(crop_w * img_h / img_w)

    left = (new_w - crop_w) // 2
    right = left + crop_w
    top = (new_h - crop_h) // 2
    bottom = top + crop_h

    return frame.resize((new_w, new_h)).crop((left, top, right, bottom))

  # single frame image
  if n_frames == 1:
    return transform_frame(original_img)
  # in the case of a multiframe image
  else:
    frames = []
    for frame in ImageSequence.Iterator(original_img):
      frames.append( transform_frame(frame) )
    return frames

0

我尝试使用所选择答案中给出的脚本,但正如Pauline评论所述,它存在一些问题,例如速度问题。

问题在于保存新GIF图像时未指定速度。为了解决这个问题,必须从原始GIF图像中获取速度,并在保存新图像时传递它。

以下是我的脚本:

from PIL import Image


def scale_gif(path, scale, new_path=None):
    gif = Image.open(path)
    if not new_path:
        new_path = path
    old_gif_information = {
        'loop': bool(gif.info.get('loop', 1)),
        'duration': gif.info.get('duration', 40),
        'background': gif.info.get('background', 223),
        'extension': gif.info.get('extension', (b'NETSCAPE2.0')),
        'transparency': gif.info.get('transparency', 223)
    }
    new_frames = get_new_frames(gif, scale)
    save_new_gif(new_frames, old_gif_information, new_path)

def get_new_frames(gif, scale):
    new_frames = []
    actual_frames = gif.n_frames
    for frame in range(actual_frames):
        gif.seek(frame)
        new_frame = Image.new('RGBA', gif.size)
        new_frame.paste(gif)
        new_frame.thumbnail(scale, Image.ANTIALIAS)
        new_frames.append(new_frame)
    return new_frames

def save_new_gif(new_frames, old_gif_information, new_path):
    new_frames[0].save(new_path,
                       save_all = True,
                       append_images = new_frames[1:],
                       duration = old_gif_information['duration'],
                       loop = old_gif_information['loop'],
                       background = old_gif_information['background'],
                       extension = old_gif_information['extension'] ,
                       transparency = old_gif_information['transparency'])

我还注意到,为了避免向 gif 添加黑色帧,必须使用 new_frames[0] 保存新的 gif,而不是创建一个新的 Image Pillow 对象。

如果您想在此脚本上使用 pytest 进行测试,可以查看 我的 GitHub 存储库


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接