|
1 | | -"""Image resizer that ensures all images match expected dimensions.""" |
| 1 | +"""Optimized image resizer that ensures all images match expected dimensions.""" |
2 | 2 |
|
3 | 3 | import asyncio |
4 | 4 | from collections.abc import AsyncIterator |
5 | 5 | from io import BytesIO |
6 | 6 |
|
7 | 7 | from PIL import Image |
8 | | -from PIL.Image import Image as PILImage |
9 | 8 |
|
10 | 9 | from athena_client.client.consts import EXPECTED_HEIGHT, EXPECTED_WIDTH |
11 | 10 | from athena_client.client.models import ImageData |
12 | 11 | from athena_client.client.transformers.async_transformer import ( |
13 | 12 | AsyncTransformer, |
14 | 13 | ) |
15 | 14 |
|
| 15 | +# Global optimization caches and constants |
| 16 | +_MAX_BUFFER_POOL_SIZE = 10 |
| 17 | +_buffer_pool = [] |
| 18 | +_target_size = (EXPECTED_WIDTH, EXPECTED_HEIGHT) |
| 19 | +_expected_raw_size = EXPECTED_WIDTH * EXPECTED_HEIGHT * 3 |
| 20 | + |
| 21 | + |
| 22 | +def _get_buffer() -> BytesIO: |
| 23 | + """Get a reusable BytesIO buffer from pool.""" |
| 24 | + if _buffer_pool: |
| 25 | + buffer = _buffer_pool.pop() |
| 26 | + buffer.seek(0) |
| 27 | + buffer.truncate(0) |
| 28 | + return buffer |
| 29 | + return BytesIO() |
| 30 | + |
| 31 | + |
| 32 | +def _return_buffer(buffer: BytesIO) -> None: |
| 33 | + """Return buffer to pool for reuse.""" |
| 34 | + if len(_buffer_pool) < _MAX_BUFFER_POOL_SIZE: |
| 35 | + _buffer_pool.append(buffer) |
| 36 | + |
| 37 | + |
| 38 | +def _is_raw_rgb(data: bytes) -> bool: |
| 39 | + """Detect if data is already a raw 448x448x3 RGB array.""" |
| 40 | + return ( |
| 41 | + len(data) == _expected_raw_size |
| 42 | + and not data.startswith(b"\x89PNG") |
| 43 | + and not data.startswith(b"\xff\xd8\xff") # JPEG |
| 44 | + and not data.startswith(b"GIF") |
| 45 | + and not data.startswith(b"BM") # BMP |
| 46 | + and not data.startswith(b"RIFF") # WebP |
| 47 | + ) |
| 48 | + |
16 | 49 |
|
17 | 50 | class ImageResizer(AsyncTransformer[ImageData, ImageData]): |
18 | | - """Transform ImageData to ensure expected dimensions.""" |
| 51 | + """Transform ImageData to ensure expected dimensions with optimization.""" |
19 | 52 |
|
20 | 53 | def __init__(self, source: AsyncIterator[ImageData]) -> None: |
21 | | - """Initialize with source iterator and optional size overrides. |
| 54 | + """Initialize with source iterator. |
22 | 55 |
|
23 | 56 | Args: |
24 | 57 | source: Iterator yielding ImageData objects |
25 | 58 |
|
26 | 59 | """ |
27 | 60 | super().__init__(source) |
28 | | - self.target_size = (EXPECTED_WIDTH, EXPECTED_HEIGHT) |
29 | | - |
30 | | - def _resize_image(self, image: PILImage) -> PILImage: |
31 | | - """Resize image to expected dimensions.""" |
32 | | - return image.resize(self.target_size) |
33 | 61 |
|
34 | 62 | async def transform(self, data: ImageData) -> ImageData: |
35 | 63 | """Transform ImageData by resizing to expected dimensions.""" |
36 | 64 |
|
37 | 65 | def process_image() -> bytes: |
38 | | - with Image.open(BytesIO(data.data)) as image: |
39 | | - image_to_resize = image |
| 66 | + # Fast path for raw RGB arrays |
| 67 | + if _is_raw_rgb(data.data): |
| 68 | + # Already correct size raw RGB, convert to PNG efficiently |
| 69 | + image = Image.frombytes("RGB", _target_size, data.data) |
| 70 | + output_buffer = _get_buffer() |
| 71 | + try: |
| 72 | + image.save(output_buffer, format="PNG", optimize=False) |
| 73 | + return output_buffer.getvalue() |
| 74 | + finally: |
| 75 | + _return_buffer(output_buffer) |
| 76 | + |
| 77 | + # Standard path for encoded images |
| 78 | + input_buffer = BytesIO(data.data) |
40 | 79 |
|
| 80 | + with Image.open(input_buffer) as image: |
| 81 | + # Early exit for already-correct images |
| 82 | + if image.size == _target_size and image.mode == "RGB": |
| 83 | + output_buffer = _get_buffer() |
| 84 | + try: |
| 85 | + image.save(output_buffer, format="PNG", optimize=False) |
| 86 | + return output_buffer.getvalue() |
| 87 | + finally: |
| 88 | + _return_buffer(output_buffer) |
| 89 | + |
| 90 | + # Convert to RGB if needed |
41 | 91 | if image.mode != "RGB": |
42 | | - image_to_resize = image.convert("RGB") |
| 92 | + rgb_image = image.convert("RGB") |
| 93 | + else: |
| 94 | + rgb_image = image |
43 | 95 |
|
44 | | - resized = self._resize_image(image_to_resize) |
| 96 | + # Resize if needed |
| 97 | + if rgb_image.size != _target_size: |
| 98 | + resized_image = rgb_image.resize( |
| 99 | + _target_size, Image.Resampling.LANCZOS |
| 100 | + ) |
| 101 | + else: |
| 102 | + resized_image = rgb_image |
45 | 103 |
|
46 | | - # Save as PNG to maintain quality and preserve format |
47 | | - output_buffer = BytesIO() |
48 | | - resized.save(output_buffer, format="PNG") |
49 | | - return output_buffer.getvalue() |
| 104 | + # Save with optimized settings |
| 105 | + output_buffer = _get_buffer() |
| 106 | + try: |
| 107 | + resized_image.save( |
| 108 | + output_buffer, format="PNG", optimize=False |
| 109 | + ) |
| 110 | + return output_buffer.getvalue() |
| 111 | + finally: |
| 112 | + _return_buffer(output_buffer) |
50 | 113 |
|
| 114 | + # Use thread pool for CPU-intensive processing |
51 | 115 | resized_bytes = await asyncio.to_thread(process_image) |
| 116 | + |
52 | 117 | # Modify existing ImageData with new bytes and add transformation hashes |
53 | 118 | data.data = resized_bytes |
54 | 119 | data.add_transformation_hashes() |
|
0 commit comments