Skip to content

Commit b7e1224

Browse files
committed
support cropping animated images (gif+webp)
1 parent 3932745 commit b7e1224

File tree

3 files changed

+89
-13
lines changed

3 files changed

+89
-13
lines changed

src/bma_client_lib/bma_client.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
ThumbnailJob,
2626
ThumbnailSourceJob,
2727
)
28+
from .pillow_resize_and_crop import transform_image
2829

2930
logger = logging.getLogger("bma_client")
3031

@@ -248,7 +249,7 @@ def write_and_upload_result(self, job: Job, result: "JobResult", filename: str)
248249
metadata: dict[str, int | str] = {}
249250
if isinstance(job, ImageConversionJob | ThumbnailJob):
250251
image, exif = result
251-
if not isinstance(image, Image.Image) or not isinstance(exif, Image.Exif):
252+
if not isinstance(image[0], Image.Image) or not isinstance(exif, Image.Exif):
252253
raise TypeError("Fuck")
253254
# apply format specific encoding options
254255
kwargs = {}
@@ -258,18 +259,30 @@ def write_and_upload_result(self, job: Job, result: "JobResult", filename: str)
258259
logger.debug(f"Format {job.mimetype} has custom encoding settings, kwargs is now: {kwargs}")
259260
else:
260261
logger.debug(f"No custom settings for format {job.mimetype}")
261-
image.save(buf, format=job.filetype, exif=exif, **kwargs)
262+
# sequence?
263+
if len(image) > 1:
264+
kwargs["append_images"] = image[1:]
265+
kwargs["save_all"] = True
266+
image[0].save(buf, format=job.filetype, exif=exif, **kwargs)
262267

263268
elif isinstance(job, ImageExifExtractionJob):
264269
logger.debug(f"Got exif data {result}")
265270
buf.write(json.dumps(result).encode())
266271

267272
elif isinstance(job, ThumbnailSourceJob):
268273
image, exif = result
269-
if not isinstance(image, Image.Image) or not isinstance(exif, Image.Exif):
274+
if not isinstance(image[0], Image.Image) or not isinstance(exif, Image.Exif):
270275
raise TypeError("Fuck")
271-
image.save(buf, format="WEBP", lossless=True, quality=1)
272-
metadata = {"width": 500, "height": image.height, "mimetype": "image/webp"}
276+
kwargs = {}
277+
# thumbnailsources are always WEBP
278+
if "image/webp" in self.settings["encoding"]["images"]:
279+
kwargs.update(self.settings["encoding"]["images"]["image/webp"])
280+
# sequence?
281+
if len(image) > 1:
282+
kwargs["append_images"] = image[1:]
283+
kwargs["save_all"] = True
284+
image[0].save(buf, format="WEBP", **kwargs)
285+
metadata = {"width": 500, "height": image[0].height, "mimetype": "image/webp"}
273286

274287
else:
275288
logger.error("Unsupported job type")
@@ -315,15 +328,11 @@ def handle_image_conversion_job(
315328

316329
logger.debug(f"Desired image size is {size}, aspect ratio: {ratio} ({orig_str}), converting image...")
317330
start = time.time()
318-
# custom AR or not?
319-
if job.custom_aspect_ratio:
320-
image = ImageOps.fit(image=image, size=size, method=Image.Resampling.LANCZOS, centering=crop_center) # type: ignore[assignment]
321-
else:
322-
image.thumbnail(size=size, resample=Image.Resampling.LANCZOS)
331+
images = transform_image(original_img=image, crop_w=size[0], crop_h=size[1])
323332
logger.debug(f"Converting image size and AR took {time.time() - start} seconds")
324333

325334
logger.debug("Done, returning result...")
326-
return image, exif
335+
return images, exif
327336

328337
def upload_job_result(
329338
self,

src/bma_client_lib/datastructures.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from dataclasses import dataclass
55
from typing import TypeAlias
66

7-
from PIL import Image
7+
from PIL import Image, ImageFile
88

99

1010
@dataclass
@@ -20,6 +20,7 @@ class BaseJob:
2020
finished: bool
2121
source_url: str
2222
source_filename: str
23+
source_mimetype: str
2324
schema_name: str
2425

2526

@@ -54,7 +55,7 @@ class ThumbnailJob(ImageConversionJob):
5455
"ThumbnailJob": ThumbnailJob,
5556
}
5657

57-
ImageConversionJobResult: TypeAlias = tuple[Image.Image, Image.Exif]
58+
ImageConversionJobResult: TypeAlias = tuple[list[Image.Image | ImageFile.ImageFile], Image.Exif]
5859
ThumbnailSourceJobResult: TypeAlias = ImageConversionJobResult
5960
ExifExtractionJobResult: TypeAlias = dict[str, dict[str, str]]
6061
JobResult: TypeAlias = ImageConversionJobResult | ExifExtractionJobResult | ThumbnailSourceJobResult
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Pillow cropping with sequence (gif, webp) support.
2+
3+
Borrowed from https://gist.github.com/muratgozel/ce1aa99f97fc1a99b3f3ec90cf77e5f5
4+
"""
5+
6+
from math import fabs, floor
7+
8+
from PIL import Image, ImageFile, ImageSequence
9+
10+
11+
def transform_image(original_img: Image.Image, crop_w: int, crop_h: int) -> list[Image.Image | ImageFile.ImageFile]:
12+
"""Resizes and crops the image to the specified crop_w and crop_h if necessary.
13+
14+
Works with multi frame gif and webp images.
15+
16+
Args:
17+
original_img(Image.Image): is the image instance created by pillow ( Image.open(filepath) )
18+
crop_w(int): is the width in pixels for the image that will be resized and cropped
19+
crop_h(int): is the height in pixels for the image that will be resized and cropped
20+
21+
returns:
22+
Instance of an Image or list of frames which they are instances of an Image individually
23+
"""
24+
img_w, img_h = (original_img.size[0], original_img.size[1])
25+
n_frames = getattr(original_img, "n_frames", 1)
26+
27+
def transform_frame(frame: Image.Image) -> Image.Image | ImageFile.ImageFile:
28+
"""Resizes and crops the individual frame in the image."""
29+
# resize the image to the specified height if crop_w is null in the recipe
30+
if crop_w is None:
31+
if crop_h == img_h:
32+
return frame
33+
new_w = floor(img_w * crop_h / img_h)
34+
new_h = crop_h
35+
return frame.resize((new_w, new_h), resample=Image.Resampling.LANCZOS)
36+
37+
# return the original image if crop size is equal to img size
38+
if crop_w == img_w and crop_h == img_h:
39+
return frame
40+
41+
# first resize to get most visible area of the image and then crop
42+
w_diff = fabs(crop_w - img_w)
43+
h_diff = fabs(crop_h - img_h)
44+
enlarge_image = bool(crop_w > img_w or crop_h > img_h)
45+
shrink_image = bool(crop_w < img_w or crop_h < img_h)
46+
47+
if enlarge_image is True:
48+
new_w = floor(crop_h * img_w / img_h) if h_diff > w_diff else crop_w
49+
new_h = floor(crop_w * img_h / img_w) if h_diff < w_diff else crop_h
50+
51+
if shrink_image is True:
52+
new_w = crop_w if h_diff > w_diff else floor(crop_h * img_w / img_h)
53+
new_h = crop_h if h_diff < w_diff else floor(crop_w * img_h / img_w)
54+
55+
left = (new_w - crop_w) // 2
56+
right = left + crop_w
57+
top = (new_h - crop_h) // 2
58+
bottom = top + crop_h
59+
60+
return frame.resize((new_w, new_h), resample=Image.Resampling.LANCZOS).crop((left, top, right, bottom))
61+
62+
# single frame image
63+
if n_frames == 1:
64+
return [transform_frame(original_img)]
65+
# in the case of a multiframe image
66+
return [transform_frame(frame) for frame in ImageSequence.Iterator(original_img)]

0 commit comments

Comments
 (0)