Skip to content

Commit 943a764

Browse files
author
anna-singleton-resolver
committed
feat: entirely remove PIL from this project
1 parent 8a1f73c commit 943a764

8 files changed

Lines changed: 146 additions & 220 deletions

File tree

examples/utils/image_generation.py

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,26 @@
11
"""Ultra-fast random image creation utilities for maximum throughput."""
22

33
import asyncio
4-
import io
54
import random
65
import time
76
from collections.abc import AsyncIterator
87

9-
from PIL import Image, ImageDraw
8+
import cv2 as cv
9+
import numpy as np
1010

1111
from resolver_athena_client.client.models import ImageData
1212

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

1917

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."""
18+
def _get_cached_image(width: int, height: int) -> np.ndarray:
19+
"""Get cached image array, creating if needed."""
2420
key = (width, height)
2521
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)
22+
img = np.zeros((height, width, 3), dtype=np.uint8)
23+
_image_cache[key] = img
2924
return _image_cache[key]
3025

3126

@@ -45,8 +40,9 @@ def create_random_image(
4540
PNG image bytes
4641
4742
"""
48-
# Get cached image and draw objects
49-
image, draw = _get_cached_image(width, height)
43+
# Get cached image array
44+
image = _get_cached_image(width, height)
45+
img = image.copy()
5046

5147
# Random background color
5248
bg_r, bg_g, bg_b = (
@@ -56,21 +52,24 @@ def create_random_image(
5652
)
5753

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

6157
# Add single accent rectangle for visual variation
62-
accent_color = (255 - bg_r, 255 - bg_g, 255 - bg_b)
58+
accent_color = (255 - bg_b, 255 - bg_g, 255 - bg_r) # BGR
6359
x1, y1 = width // 4, height // 4
6460
x2, y2 = (width * 3) // 4, (height * 3) // 4
65-
draw.rectangle([x1, y1, x2, y2], fill=accent_color)
61+
img = cv.rectangle(img, (x1, y1), (x2, y2), accent_color, thickness=-1)
6662

6763
if img_format.upper() == "RAW_UINT8":
68-
return image.tobytes()
64+
return img.tobytes()
6965

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

7574

7675
def create_batch_images(
@@ -90,29 +89,32 @@ def create_batch_images(
9089
9190
"""
9291
images: list[bytes] = []
93-
image, draw = _get_cached_image(width, height)
92+
image = _get_cached_image(width, height)
9493

9594
# Pre-calculate accent rectangle coordinates
9695
x1, y1 = width // 4, height // 4
9796
x2, y2 = (width * 3) // 4, (height * 3) // 4
9897

9998
for _ in range(count):
99+
img = image.copy()
100100
# Random background
101101
bg_r, bg_g, bg_b = (
102102
_rng.randint(0, 255),
103103
_rng.randint(0, 255),
104104
_rng.randint(0, 255),
105105
)
106-
draw.rectangle([0, 0, width, height], fill=(bg_r, bg_g, bg_b))
106+
img[:, :] = (bg_b, bg_g, bg_r) # OpenCV uses BGR
107107

108108
# 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)
109+
accent_color = (255 - bg_b, 255 - bg_g, 255 - bg_r) # BGR
110+
img = cv.rectangle(img, (x1, y1), (x2, y2), accent_color, thickness=-1)
111111

112112
# Convert to PNG bytes
113-
buffer = io.BytesIO()
114-
image.save(buffer, format="PNG")
115-
images.append(buffer.getvalue())
113+
success, buf = cv.imencode(".png", img)
114+
if not success:
115+
msg = "Failed to encode image as PNG"
116+
raise RuntimeError(msg)
117+
images.append(buf.tobytes())
116118

117119
return images
118120

pyproject.toml

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

3433
[project.optional-dependencies]

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

tests/client/transformers/test_hash_pipeline.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""Tests for hash list behavior throughout the transformation pipeline."""
22

33
import hashlib
4-
from io import BytesIO
54

5+
import cv2
6+
import numpy as np
67
import pytest
7-
from PIL import Image
88

99
from resolver_athena_client.client.consts import EXPECTED_HEIGHT, EXPECTED_WIDTH
1010
from resolver_athena_client.client.models import ImageData
@@ -21,10 +21,17 @@
2121

2222
def create_test_png_image(width: int = 200, height: int = 200) -> bytes:
2323
"""Create a test PNG image with specified dimensions."""
24-
img = Image.new("RGB", (width, height), color=(255, 0, 0))
25-
buffer = BytesIO()
26-
img.save(buffer, format="PNG")
27-
return buffer.getvalue()
24+
25+
# Create a red RGB image using numpy
26+
img = np.zeros((height, width, 3), dtype=np.uint8)
27+
img[:] = (255, 0, 0) # Red color
28+
29+
# Encode image as PNG to memory
30+
success, buffer = cv2.imencode(".png", img)
31+
if not success:
32+
err = "Failed to encode image as PNG"
33+
raise RuntimeError(err)
34+
return buffer.tobytes()
2835

2936

3037
@pytest.mark.asyncio

tests/functional/conftest.py

Lines changed: 19 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,7 @@ def _create_base_test_image_opencv(width: int, height: int) -> np.ndarray:
3636
# Add an accent rectangle for visual variation
3737
x1, y1 = width // 4, height // 4
3838
x2, y2 = (width * 3) // 4, (height * 3) // 4
39-
cv2.rectangle(img_bgr, (x1, y1), (x2, y2), (200, 100, 50), -1)
40-
41-
return img_bgr
39+
return cv2.rectangle(img_bgr, (x1, y1), (x2, y2), (200, 100, 50), -1)
4240

4341

4442
SUPPORTED_TEST_FORMATS = [
@@ -56,7 +54,6 @@ def _create_base_test_image_opencv(width: int, height: int) -> np.ndarray:
5654
"tiff",
5755
"pic",
5856
"raw_uint8",
59-
6057
# pxm - OpenCV2 has issues with this format, the docs state its
6158
# supported, but pxm is also used to mean PBM/PGM/PPM which are supported,
6259
# so its unclear if this format is truly supported.
@@ -138,26 +135,21 @@ def valid_formatted_image(
138135
return f.read()
139136

140137
# Convert format using OpenCV2 and cache to disk
141-
try:
142-
# Encode image in the target format
143-
if image_format in ["pgm", "pbm"]:
144-
# PGM and PBM are grayscale, so convert the image to grayscale
145-
gray_image = cv2.cvtColor(base_image, cv2.COLOR_BGR2GRAY)
146-
success, encoded = cv2.imencode(f".{image_format}", gray_image)
147-
else:
148-
success, encoded = cv2.imencode(f".{image_format}", base_image)
149-
150-
if not success:
151-
pytest.fail(
152-
f"OpenCV failed to encode image in {image_format} format"
153-
)
154-
155-
image_bytes = encoded.tobytes()
156-
157-
# Cache the image to disk
158-
with image_path.open("wb") as f:
159-
_ = f.write(image_bytes)
160-
161-
return image_bytes
162-
except Exception as e:
163-
pytest.fail(f"Failed to create {image_format} image with OpenCV: {e}")
138+
# Encode image in the target format
139+
if image_format in ["pgm", "pbm"]:
140+
# PGM and PBM are grayscale, so convert the image to grayscale
141+
gray_image = cv2.cvtColor(base_image, cv2.COLOR_BGR2GRAY)
142+
success, encoded = cv2.imencode(f".{image_format}", gray_image)
143+
else:
144+
success, encoded = cv2.imencode(f".{image_format}", base_image)
145+
146+
if not success:
147+
pytest.fail(f"OpenCV failed to encode image in {image_format} format")
148+
149+
image_bytes = encoded.tobytes()
150+
151+
# Cache the image to disk
152+
with image_path.open("wb") as f:
153+
_ = f.write(image_bytes)
154+
155+
return image_bytes

tests/test_classify_single.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"""Tests for the classify_single method in AthenaClient."""
22

3-
import io
43
import uuid
54
from unittest.mock import AsyncMock, Mock
65

6+
import cv2 as cv
77
import grpc.aio
8+
import numpy as np
89
import pytest
9-
from PIL import Image
1010

1111
from resolver_athena_client.client.athena_client import AthenaClient
1212
from resolver_athena_client.client.athena_options import AthenaOptions
@@ -196,10 +196,11 @@ async def test_classify_single_error_handling(
196196
# Create a simple valid image for testing
197197

198198
# Create a simple 1x1 pixel image
199-
img = Image.new("RGB", (1, 1), color="red")
200-
img_bytes = io.BytesIO()
201-
img.save(img_bytes, format="PNG")
202-
valid_image_data = ImageData(img_bytes.getvalue())
199+
img_arr = np.ndarray((1, 1, 3), dtype=np.uint8)
200+
img_arr.fill(255)
201+
success, img = cv.imencode(".png", img_arr, [])
202+
assert success, "Failed to encode test image"
203+
valid_image_data = ImageData(img.tobytes())
203204

204205
# Enable resizing
205206
athena_client.options.resize_images = True

0 commit comments

Comments
 (0)