Skip to content

Commit cf5c312

Browse files
anna-singleton-resolveranna-singleton-resolverCopilot
authored
feat: openCV resizing (#95)
* feat: openCV resizing * fix: linting and enum for selecting openCV resampling algos * chore: remove irrelevant comment * refactor: imagemagick is no longer a dependency for the functional tests * feat: entirely remove PIL from this project * chore: unify image_generation files into once place * chore: remove defunct file * style: lint * style: standardise opencv2 imports to be import cv2 as cv * doc: remove reference to PIL in docs * chore: capitalisation Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * chore: grammar Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * refactor: split out image_generation file into separate module --------- Co-authored-by: anna-singleton-resolver <anna.singleton@resolver.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 412a52c commit cf5c312

21 files changed

Lines changed: 219 additions & 454 deletions

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,6 @@ You will need:
132132
- An Athena host URL.
133133
- An OAuth client ID and secret with access to the Athena environment.
134134
- An affiliate with Athena enabled.
135-
- `imagemagick` installed on your system and on your path at `magick`.
136135

137136

138137
#### Preparing your environment

common_utils/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Utility package for testing and examples.
2+
3+
This package contains helper functions that are not core to the client library,
4+
but are shared across the examples and the tests.
5+
"""
Lines changed: 38 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,31 @@
1-
"""Ultra-fast random image creation utilities for maximum throughput."""
1+
"""Ultra-fast random image creation utilities for maximum throughput.
2+
3+
This file is intended to be used for generating benign test images for the
4+
purposes of integration testing the client, as is provided as a convenience
5+
for API consumers.
6+
"""
27

38
import asyncio
4-
import io
59
import random
610
import time
711
from collections.abc import AsyncIterator
812

9-
from PIL import Image, ImageDraw
13+
import cv2 as cv
14+
import numpy as np
1015

1116
from resolver_athena_client.client.models import ImageData
1217

1318
# Global cache for reusable objects and constants
14-
_image_cache: dict[
15-
tuple[int, int], tuple[Image.Image, ImageDraw.ImageDraw]
16-
] = {}
19+
_image_cache: dict[tuple[int, int], np.ndarray] = {}
1720
_rng = random.Random() # noqa: S311 - Not used for cryptographic purposes
1821

1922

20-
def _get_cached_image(
21-
width: int, height: int
22-
) -> tuple[Image.Image, ImageDraw.ImageDraw]:
23-
"""Get cached image and draw objects, creating if needed."""
23+
def _get_cached_image(width: int, height: int) -> np.ndarray:
24+
"""Get cached image array, creating if needed."""
2425
key = (width, height)
2526
if key not in _image_cache:
26-
img = Image.new("RGB", (width, height), (0, 0, 0))
27-
draw = ImageDraw.Draw(img)
28-
_image_cache[key] = (img, draw)
27+
img = np.zeros((height, width, 3), dtype=np.uint8)
28+
_image_cache[key] = img
2929
return _image_cache[key]
3030

3131

@@ -45,8 +45,9 @@ def create_random_image(
4545
PNG image bytes
4646
4747
"""
48-
# Get cached image and draw objects
49-
image, draw = _get_cached_image(width, height)
48+
# Get cached image array
49+
image = _get_cached_image(width, height)
50+
img = image.copy()
5051

5152
# Random background color
5253
bg_r, bg_g, bg_b = (
@@ -56,21 +57,24 @@ def create_random_image(
5657
)
5758

5859
# Fill with background color
59-
draw.rectangle([0, 0, width, height], fill=(bg_r, bg_g, bg_b))
60+
img[:, :] = (bg_b, bg_g, bg_r) # OpenCV uses BGR
6061

6162
# Add single accent rectangle for visual variation
62-
accent_color = (255 - bg_r, 255 - bg_g, 255 - bg_b)
63+
accent_color = (255 - bg_b, 255 - bg_g, 255 - bg_r) # BGR
6364
x1, y1 = width // 4, height // 4
6465
x2, y2 = (width * 3) // 4, (height * 3) // 4
65-
draw.rectangle([x1, y1, x2, y2], fill=accent_color)
66+
img = cv.rectangle(img, (x1, y1), (x2, y2), accent_color, thickness=-1)
6667

6768
if img_format.upper() == "RAW_UINT8":
68-
return image.tobytes()
69+
return img.tobytes()
6970

70-
# Convert to PNG bytes
71-
buffer = io.BytesIO()
72-
image.save(buffer, format=img_format)
73-
return buffer.getvalue()
71+
# Convert to PNG/JPEG bytes
72+
ext = f".{img_format.lower()}"
73+
success, buf = cv.imencode(ext, img)
74+
if not success:
75+
err = f"Failed to encode image as {img_format}"
76+
raise RuntimeError(err)
77+
return buf.tobytes()
7478

7579

7680
def create_batch_images(
@@ -90,29 +94,32 @@ def create_batch_images(
9094
9195
"""
9296
images: list[bytes] = []
93-
image, draw = _get_cached_image(width, height)
97+
image = _get_cached_image(width, height)
9498

9599
# Pre-calculate accent rectangle coordinates
96100
x1, y1 = width // 4, height // 4
97101
x2, y2 = (width * 3) // 4, (height * 3) // 4
98102

99103
for _ in range(count):
104+
img = image.copy()
100105
# Random background
101106
bg_r, bg_g, bg_b = (
102107
_rng.randint(0, 255),
103108
_rng.randint(0, 255),
104109
_rng.randint(0, 255),
105110
)
106-
draw.rectangle([0, 0, width, height], fill=(bg_r, bg_g, bg_b))
111+
img[:, :] = (bg_b, bg_g, bg_r) # OpenCV uses BGR
107112

108113
# Complement accent color
109-
accent_color = (255 - bg_r, 255 - bg_g, 255 - bg_b)
110-
draw.rectangle([x1, y1, x2, y2], fill=accent_color)
114+
accent_color = (255 - bg_b, 255 - bg_g, 255 - bg_r) # BGR
115+
img = cv.rectangle(img, (x1, y1), (x2, y2), accent_color, thickness=-1)
111116

112117
# Convert to PNG bytes
113-
buffer = io.BytesIO()
114-
image.save(buffer, format="PNG")
115-
images.append(buffer.getvalue())
118+
success, buf = cv.imencode(".png", img)
119+
if not success:
120+
msg = "Failed to encode image as PNG"
121+
raise RuntimeError(msg)
122+
images.append(buf.tobytes())
116123

117124
return images
118125

@@ -196,18 +203,7 @@ async def rate_limited_image_iter(
196203
def create_random_image_generator(
197204
max_images: int, rate_limit_min_interval_ms: int | None = None
198205
) -> AsyncIterator[ImageData]:
199-
"""Generate a stream of random test images.
200-
201-
Args:
202-
----
203-
max_images: Maximum number of images to generate
204-
rate_limit_min_interval_ms: Minimum interval in ms between images
205-
206-
Yields:
207-
------
208-
ImageData objects containing random image bytes
209-
210-
"""
206+
"""Create an async generator for images with optional rate limiting."""
211207
if rate_limit_min_interval_ms is not None:
212208
return rate_limited_image_iter(rate_limit_min_interval_ms, max_images)
213209

docs/api/transformers.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ Transformers accept configuration through their constructors:
241241
**ImageResizer Configuration:**
242242

243243
* ``target_size``: Tuple of (width, height) for output dimensions
244-
* ``resampling``: PIL resampling algorithm (default: ``Image.LANCZOS``)
244+
* ``resampling``: OpenCV resampling algorithm (default: ``cv.INTER_LINEAR``)
245245
* ``maintain_aspect_ratio``: Whether to preserve aspect ratio (default: ``True``)
246246

247247
**BrotliCompressor Configuration:**

examples/classify_single_example.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from dotenv import load_dotenv
1212

13-
from examples.utils.image_generation import create_test_image
13+
from common_utils.image_generation import create_test_image
1414
from resolver_athena_client.client.athena_client import AthenaClient
1515
from resolver_athena_client.client.athena_options import AthenaOptions
1616
from resolver_athena_client.client.channel import (

examples/example.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from dotenv import load_dotenv
1212

13-
from examples.utils.image_generation import iter_images
13+
from common_utils.image_generation import iter_images
1414
from examples.utils.streaming_classify_utils import count_and_yield
1515
from resolver_athena_client.client.athena_client import AthenaClient
1616
from resolver_athena_client.client.athena_options import AthenaOptions

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ dependencies = [
2727
"grpcio-tools>=1.74.0",
2828
"httpx>=0.25.0",
2929
"numpy>=2.2.6",
30-
"pillow>=11.3.0",
30+
"opencv-python-headless>=4.13.0.92"
3131
]
3232

3333
[project.optional-dependencies]

src/resolver_athena_client/client/athena_options.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
from dataclasses import dataclass
44

5-
from PIL.Image import Resampling
6-
75
from resolver_athena_client.client.correlation import (
86
CorrelationProvider,
97
HashCorrelationProvider,
108
)
9+
from resolver_athena_client.client.transformers.core import (
10+
OpenCVResamplingAlgorithm,
11+
)
1112

1213

1314
@dataclass
@@ -69,4 +70,6 @@ class AthenaOptions:
6970
timeout: float | None = 120.0
7071
keepalive_interval: float | None = None
7172
compression_quality: int = 11 # Brotli quality level (0-11)
72-
resampling_algorithm: Resampling = Resampling.LANCZOS
73+
resampling_algorithm: OpenCVResamplingAlgorithm = (
74+
OpenCVResamplingAlgorithm.BILINEAR
75+
)

src/resolver_athena_client/client/transformers/core.py

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66
"""
77

88
import asyncio
9-
from io import BytesIO
9+
import enum
1010

1111
import brotli
12-
from PIL import Image
12+
import cv2 as cv
13+
import numpy as np
1314

1415
from resolver_athena_client.client.consts import EXPECTED_HEIGHT, EXPECTED_WIDTH
1516
from resolver_athena_client.client.models import ImageData
@@ -20,14 +21,29 @@
2021
_expected_raw_size = EXPECTED_WIDTH * EXPECTED_HEIGHT * 3
2122

2223

24+
class OpenCVResamplingAlgorithm(enum.Enum):
25+
"""Open CV Resampling Configuration.
26+
27+
Enum for ease of configuration and type-safety when selecting OpenCV
28+
resampling algorithms.
29+
"""
30+
31+
NEAREST = cv.INTER_NEAREST
32+
BOX = cv.INTER_AREA
33+
BILINEAR = cv.INTER_LINEAR
34+
LANCZOS = cv.INTER_LANCZOS4
35+
36+
2337
def _is_raw_bgr_expected_size(data: bytes) -> bool:
2438
"""Detect if data is already a raw BGR array of expected size."""
2539
return len(data) == _expected_raw_size
2640

2741

2842
async def resize_image(
2943
image_data: ImageData,
30-
sampling_algorithm: Image.Resampling = Image.Resampling.LANCZOS,
44+
sampling_algorithm: OpenCVResamplingAlgorithm = (
45+
OpenCVResamplingAlgorithm.BILINEAR
46+
),
3147
) -> ImageData:
3248
"""Resize an image to expected dimensions.
3349
@@ -49,31 +65,23 @@ def process_image() -> tuple[bytes, bool]:
4965
return image_data.data, False # No transformation needed
5066

5167
# Try to load the image data directly
52-
input_buffer = BytesIO(image_data.data)
53-
54-
with Image.open(input_buffer) as image:
55-
# Convert to RGB if needed
56-
rgb_image = image.convert("RGB") if image.mode != "RGB" else image
57-
58-
# Resize if needed
59-
if rgb_image.size != _target_size:
60-
resized_image = rgb_image.resize(
61-
_target_size, sampling_algorithm
62-
)
63-
else:
64-
resized_image = rgb_image
65-
66-
rgb_bytes = resized_image.tobytes()
67-
68-
# Convert RGB to BGR by swapping channels
69-
bgr_bytes = bytearray(len(rgb_bytes))
70-
71-
for i in range(0, len(rgb_bytes), 3):
72-
bgr_bytes[i] = rgb_bytes[i + 2]
73-
bgr_bytes[i + 1] = rgb_bytes[i + 1]
74-
bgr_bytes[i + 2] = rgb_bytes[i]
75-
76-
return bytes(bgr_bytes), True # Data was transformed
68+
img_data_buf = np.frombuffer(image_data.data, dtype=np.uint8)
69+
img = cv.imdecode(img_data_buf, cv.IMREAD_COLOR)
70+
71+
if img is None:
72+
err = "Failed to decode image data for resizing"
73+
raise ValueError(err)
74+
75+
if img.shape[0] == EXPECTED_HEIGHT and img.shape[1] == EXPECTED_WIDTH:
76+
resized_img = img
77+
else:
78+
resized_img = cv.resize(
79+
img, _target_size, interpolation=sampling_algorithm.value
80+
)
81+
82+
# OpenCV loads in BGR format by default, so we can directly convert to
83+
# bytes
84+
return resized_img.tobytes(), True # Data was transformed
7785

7886
# Use thread pool for CPU-intensive processing
7987
resized_bytes, was_transformed = await asyncio.to_thread(process_image)

tests/client/transformers/test_core.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
"""Test core transformation functions."""
22

3-
from io import BytesIO
4-
3+
import cv2 as cv
4+
import numpy as np
55
import pytest
6-
from PIL import Image
76

87
from resolver_athena_client.client.consts import (
98
EXPECTED_HEIGHT,
@@ -19,11 +18,24 @@
1918
def create_test_image(
2019
width: int = 100, height: int = 100, mode: str = "RGB"
2120
) -> bytes:
22-
"""Create a test image with specified dimensions."""
23-
img = Image.new(mode, (width, height), color="red")
24-
img_bytes = BytesIO()
25-
img.save(img_bytes, format="PNG")
26-
return img_bytes.getvalue()
21+
"""Create a test image with specified dimensions using OpenCV."""
22+
# Map mode to OpenCV color shape
23+
if mode == "RGB":
24+
color = (255, 0, 0) # Red in RGB
25+
img = np.full((height, width, 3), color, dtype=np.uint8)
26+
elif mode == "L":
27+
color = 76 # Red in grayscale
28+
img = np.full((height, width), color, dtype=np.uint8)
29+
else:
30+
err = f"Unsupported mode: {mode}"
31+
raise ValueError(err)
32+
33+
success, buf = cv.imencode(".png", img)
34+
if not success:
35+
err = "Failed to encode image to PNG"
36+
raise RuntimeError(err)
37+
38+
return buf.tobytes()
2739

2840

2941
@pytest.mark.asyncio

0 commit comments

Comments
 (0)