Skip to content

Commit 53bab88

Browse files
fix: detect and preserve image format instead of hardcoding RAW_UINT8
Previously, all images were sent with IMAGE_FORMAT_RAW_UINT8 regardless of their actual format (JPEG, PNG, etc.), causing incorrect metadata to be sent to the API when images weren't resized. Changes: - Add image format detection via magic number signatures for PNG, JPEG, GIF, BMP, WebP, and TIFF formats - Update ImageData to automatically detect format during initialization - Preserve detected format throughout transformation pipeline - Convert UNSPECIFIED format to RAW_UINT8 before sending to ensure API never receives UNSPECIFIED - Update resize transformer to set format to RAW_UINT8 when converting to raw pixel data This ensures: 1. Native image formats (PNG, JPEG, etc.) are correctly identified and preserved when sent without resizing 2. Resized images are correctly marked as RAW_UINT8 3. IMAGE_FORMAT_UNSPECIFIED is never sent over the API (defaults to RAW_UINT8) Tests: Added 46 new tests covering format detection, API validation, and format preservation across both streaming and single classification methods. All 166 tests passing.
1 parent 616b3a1 commit 53bab88

10 files changed

Lines changed: 435 additions & 3 deletions

File tree

src/resolver_athena_client/client/athena_client.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,12 +239,18 @@ async def classify_single(
239239
else RequestEncoding.REQUEST_ENCODING_UNCOMPRESSED
240240
)
241241

242+
# Ensure we never send UNSPECIFIED format over the API
243+
# If format is still UNSPECIFIED, default to RAW_UINT8
244+
image_format = processed_image.image_format
245+
if image_format == ImageFormat.IMAGE_FORMAT_UNSPECIFIED:
246+
image_format = ImageFormat.IMAGE_FORMAT_RAW_UINT8
247+
242248
classification_input = ClassificationInput(
243249
affiliate=self.options.affiliate,
244250
correlation_id=correlation_id,
245251
encoding=request_encoding,
246252
data=processed_image.data,
247-
format=ImageFormat.IMAGE_FORMAT_RAW_UINT8,
253+
format=image_format,
248254
hashes=[
249255
ImageHash(
250256
value=hash_value,
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Utility for detecting image formats from raw bytes."""
2+
3+
from resolver_athena_client.generated.athena.models_pb2 import ImageFormat
4+
5+
6+
def detect_image_format(data: bytes) -> ImageFormat.ValueType:
7+
"""Detect image format from raw bytes using magic number signatures.
8+
9+
Args:
10+
----
11+
data: Raw image bytes to analyze
12+
13+
Returns:
14+
-------
15+
ImageFormat enum value representing the detected format
16+
17+
"""
18+
if not data:
19+
return ImageFormat.IMAGE_FORMAT_UNSPECIFIED
20+
21+
# Check magic numbers for common image formats
22+
# PNG: starts with \x89PNG (need at least 4 bytes)
23+
if len(data) >= 4 and data[:4] == b"\x89PNG":
24+
return ImageFormat.IMAGE_FORMAT_PNG
25+
26+
# JPEG: starts with \xFF\xD8\xFF (need at least 3 bytes)
27+
if len(data) >= 3 and data[:3] == b"\xFF\xD8\xFF":
28+
return ImageFormat.IMAGE_FORMAT_JPEG
29+
30+
# GIF: starts with GIF87a or GIF89a (need at least 6 bytes)
31+
if len(data) >= 6 and data[:6] in (b"GIF87a", b"GIF89a"):
32+
return ImageFormat.IMAGE_FORMAT_GIF
33+
34+
# BMP: starts with BM (need at least 2 bytes)
35+
if len(data) >= 2 and data[:2] == b"BM":
36+
return ImageFormat.IMAGE_FORMAT_BMP
37+
38+
# WebP: starts with RIFF....WEBP (need at least 12 bytes)
39+
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
40+
return ImageFormat.IMAGE_FORMAT_WEBP
41+
42+
# TIFF: starts with II* or MM* (need at least 4 bytes)
43+
if len(data) >= 4 and data[:4] in (b"II*\x00", b"MM\x00*"):
44+
return ImageFormat.IMAGE_FORMAT_TIFF
45+
46+
# If we can't detect the format, return UNSPECIFIED
47+
return ImageFormat.IMAGE_FORMAT_UNSPECIFIED

src/resolver_athena_client/client/models/input_model.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77

88
import hashlib
99

10+
from resolver_athena_client.client.image_format_detector import (
11+
detect_image_format,
12+
)
13+
from resolver_athena_client.generated.athena.models_pb2 import ImageFormat
14+
1015

1116
class ImageData:
1217
r"""Container for image bytes with calculated hashes.
@@ -24,6 +29,8 @@ class ImageData:
2429
Attributes:
2530
----------
2631
data: The raw bytes of the image (modified in-place by transformers).
32+
image_format: The format of the image data (e.g., JPEG, PNG, RAW_UINT8).
33+
Updated by transformers when they change the format.
2734
sha256_hashes: List of SHA256 hashes tracking image transformations.
2835
Index 0 is the original image, subsequent indices track
2936
transformations.
@@ -66,6 +73,9 @@ def __init__(self, image_bytes: bytes) -> None:
6673
6774
"""
6875
self.data: bytes = image_bytes
76+
self.image_format: ImageFormat.ValueType = detect_image_format(
77+
image_bytes
78+
)
6979
self.sha256_hashes: list[str] = [
7080
hashlib.sha256(image_bytes).hexdigest()
7181
]

src/resolver_athena_client/client/transformers/classification_input.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,20 @@ def __init__(
4848
def _create_classification_input(
4949
self, image_data: ImageData
5050
) -> ClassificationInput:
51-
# Get image format and data
51+
# Ensure we never send UNSPECIFIED format over the API
52+
# If format is still UNSPECIFIED, default to RAW_UINT8
53+
image_format = image_data.image_format
54+
if image_format == ImageFormat.IMAGE_FORMAT_UNSPECIFIED:
55+
image_format = ImageFormat.IMAGE_FORMAT_RAW_UINT8
56+
5257
return ClassificationInput(
5358
affiliate=self.affiliate,
5459
correlation_id=self.correlation_provider.get_correlation_id(
5560
image_data.data
5661
),
5762
data=image_data.data,
5863
encoding=self.request_encoding,
59-
format=ImageFormat.IMAGE_FORMAT_RAW_UINT8,
64+
format=image_format,
6065
)
6166

6267
@override

src/resolver_athena_client/client/transformers/core.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from resolver_athena_client.client.consts import EXPECTED_HEIGHT, EXPECTED_WIDTH
1515
from resolver_athena_client.client.models import ImageData
16+
from resolver_athena_client.generated.athena.models_pb2 import ImageFormat
1617

1718
# Global optimization constants
1819
_target_size = (EXPECTED_WIDTH, EXPECTED_HEIGHT)
@@ -73,6 +74,7 @@ def process_image() -> tuple[bytes, bool]:
7374
# Only modify data and add hashes if transformation occurred
7475
if was_transformed:
7576
image_data.data = resized_bytes
77+
image_data.image_format = ImageFormat.IMAGE_FORMAT_RAW_UINT8
7678
image_data.add_transformation_hashes()
7779

7880
return image_data

tests/client/models/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Tests for model classes."""
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Tests for ImageData model."""
2+
3+
import pytest
4+
5+
from resolver_athena_client.client.models import ImageData
6+
from resolver_athena_client.generated.athena.models_pb2 import ImageFormat
7+
8+
9+
def test_image_data_detects_png_format() -> None:
10+
"""Test that PNG format is detected during initialization."""
11+
png_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
12+
image_data = ImageData(png_data)
13+
14+
assert image_data.image_format == ImageFormat.IMAGE_FORMAT_PNG
15+
assert image_data.data == png_data
16+
assert len(image_data.sha256_hashes) == 1
17+
assert len(image_data.md5_hashes) == 1
18+
19+
20+
def test_image_data_detects_jpeg_format() -> None:
21+
"""Test that JPEG format is detected during initialization."""
22+
jpeg_data = b"\xFF\xD8\xFF\xE0" + b"\x00" * 100
23+
image_data = ImageData(jpeg_data)
24+
25+
assert image_data.image_format == ImageFormat.IMAGE_FORMAT_JPEG
26+
assert image_data.data == jpeg_data
27+
28+
29+
def test_image_data_detects_gif_format() -> None:
30+
"""Test that GIF format is detected during initialization."""
31+
gif_data = b"GIF89a" + b"\x00" * 100
32+
image_data = ImageData(gif_data)
33+
34+
assert image_data.image_format == ImageFormat.IMAGE_FORMAT_GIF
35+
36+
37+
def test_image_data_detects_bmp_format() -> None:
38+
"""Test that BMP format is detected during initialization."""
39+
bmp_data = b"BM" + b"\x00" * 100
40+
image_data = ImageData(bmp_data)
41+
42+
assert image_data.image_format == ImageFormat.IMAGE_FORMAT_BMP
43+
44+
45+
def test_image_data_detects_webp_format() -> None:
46+
"""Test that WebP format is detected during initialization."""
47+
webp_data = b"RIFF\x00\x00\x00\x00WEBP" + b"\x00" * 100
48+
image_data = ImageData(webp_data)
49+
50+
assert image_data.image_format == ImageFormat.IMAGE_FORMAT_WEBP
51+
52+
53+
def test_image_data_unspecified_for_unknown_format() -> None:
54+
"""Test that unknown data results in UNSPECIFIED format."""
55+
unknown_data = b"not_a_valid_image"
56+
image_data = ImageData(unknown_data)
57+
58+
assert image_data.image_format == ImageFormat.IMAGE_FORMAT_UNSPECIFIED
59+
60+
61+
def test_image_data_unspecified_for_empty_data() -> None:
62+
"""Test that empty data results in UNSPECIFIED format."""
63+
image_data = ImageData(b"")
64+
65+
assert image_data.image_format == ImageFormat.IMAGE_FORMAT_UNSPECIFIED
66+
67+
68+
def test_image_data_transformation_preserves_format() -> None:
69+
"""Test that format is preserved when transformation hashes are added."""
70+
png_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
71+
image_data = ImageData(png_data)
72+
73+
assert image_data.image_format == ImageFormat.IMAGE_FORMAT_PNG
74+
75+
# Simulate transformation
76+
image_data.data = b"transformed_data"
77+
image_data.add_transformation_hashes()
78+
79+
# Format should still be PNG (transformers will update it if needed)
80+
assert image_data.image_format == ImageFormat.IMAGE_FORMAT_PNG
81+
assert len(image_data.sha256_hashes) == 2
82+
assert len(image_data.md5_hashes) == 2
83+
84+
85+
@pytest.mark.parametrize(
86+
("data", "expected_format"),
87+
[
88+
(b"\x89PNG\r\n\x1a\n", ImageFormat.IMAGE_FORMAT_PNG),
89+
(b"\xFF\xD8\xFF", ImageFormat.IMAGE_FORMAT_JPEG),
90+
(b"GIF87a", ImageFormat.IMAGE_FORMAT_GIF),
91+
(b"GIF89a", ImageFormat.IMAGE_FORMAT_GIF),
92+
(b"BM", ImageFormat.IMAGE_FORMAT_BMP),
93+
(b"RIFF\x00\x00\x00\x00WEBP", ImageFormat.IMAGE_FORMAT_WEBP),
94+
(b"II*\x00", ImageFormat.IMAGE_FORMAT_TIFF),
95+
(b"MM\x00*", ImageFormat.IMAGE_FORMAT_TIFF),
96+
(b"unknown", ImageFormat.IMAGE_FORMAT_UNSPECIFIED),
97+
(b"", ImageFormat.IMAGE_FORMAT_UNSPECIFIED),
98+
],
99+
)
100+
def test_image_data_format_detection_parametrized(
101+
data: bytes, expected_format: ImageFormat.ValueType
102+
) -> None:
103+
"""Test format detection with various image data."""
104+
image_data = ImageData(data)
105+
assert image_data.image_format == expected_format
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Tests for image format detection."""
2+
3+
import pytest
4+
5+
from resolver_athena_client.client.image_format_detector import (
6+
detect_image_format,
7+
)
8+
from resolver_athena_client.generated.athena.models_pb2 import ImageFormat
9+
10+
11+
def test_detect_png_format() -> None:
12+
"""Test detection of PNG format."""
13+
png_header = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
14+
assert detect_image_format(png_header) == ImageFormat.IMAGE_FORMAT_PNG
15+
16+
17+
def test_detect_jpeg_format() -> None:
18+
"""Test detection of JPEG format."""
19+
jpeg_header = b"\xFF\xD8\xFF\xE0" + b"\x00" * 100
20+
assert detect_image_format(jpeg_header) == ImageFormat.IMAGE_FORMAT_JPEG
21+
22+
23+
def test_detect_gif87a_format() -> None:
24+
"""Test detection of GIF87a format."""
25+
gif_header = b"GIF87a" + b"\x00" * 100
26+
assert detect_image_format(gif_header) == ImageFormat.IMAGE_FORMAT_GIF
27+
28+
29+
def test_detect_gif89a_format() -> None:
30+
"""Test detection of GIF89a format."""
31+
gif_header = b"GIF89a" + b"\x00" * 100
32+
assert detect_image_format(gif_header) == ImageFormat.IMAGE_FORMAT_GIF
33+
34+
35+
def test_detect_bmp_format() -> None:
36+
"""Test detection of BMP format."""
37+
bmp_header = b"BM" + b"\x00" * 100
38+
assert detect_image_format(bmp_header) == ImageFormat.IMAGE_FORMAT_BMP
39+
40+
41+
def test_detect_webp_format() -> None:
42+
"""Test detection of WebP format."""
43+
webp_header = b"RIFF" + b"\x00\x00\x00\x00" + b"WEBP" + b"\x00" * 100
44+
assert detect_image_format(webp_header) == ImageFormat.IMAGE_FORMAT_WEBP
45+
46+
47+
def test_detect_tiff_little_endian_format() -> None:
48+
"""Test detection of TIFF format (little-endian)."""
49+
tiff_header = b"II*\x00" + b"\x00" * 100
50+
assert detect_image_format(tiff_header) == ImageFormat.IMAGE_FORMAT_TIFF
51+
52+
53+
def test_detect_tiff_big_endian_format() -> None:
54+
"""Test detection of TIFF format (big-endian)."""
55+
tiff_header = b"MM\x00*" + b"\x00" * 100
56+
assert detect_image_format(tiff_header) == ImageFormat.IMAGE_FORMAT_TIFF
57+
58+
59+
def test_detect_unspecified_for_empty_data() -> None:
60+
"""Test that empty data returns UNSPECIFIED."""
61+
assert (
62+
detect_image_format(b"") == ImageFormat.IMAGE_FORMAT_UNSPECIFIED
63+
)
64+
65+
66+
def test_detect_unspecified_for_short_data() -> None:
67+
"""Test that very short data returns UNSPECIFIED."""
68+
assert (
69+
detect_image_format(b"ab") == ImageFormat.IMAGE_FORMAT_UNSPECIFIED
70+
)
71+
72+
73+
def test_detect_unspecified_for_unknown_format() -> None:
74+
"""Test that unknown format returns UNSPECIFIED."""
75+
unknown_data = b"UNKNOWN_FORMAT" + b"\x00" * 100
76+
assert (
77+
detect_image_format(unknown_data)
78+
== ImageFormat.IMAGE_FORMAT_UNSPECIFIED
79+
)
80+
81+
82+
def test_detect_format_with_real_png_data() -> None:
83+
"""Test detection with minimal valid PNG data."""
84+
# Minimal PNG: signature + IHDR chunk
85+
png_data = (
86+
b"\x89PNG\r\n\x1a\n" # PNG signature
87+
b"\x00\x00\x00\x0dIHDR" # IHDR chunk
88+
b"\x00\x00\x00\x01" # width
89+
b"\x00\x00\x00\x01" # height
90+
b"\x08\x02\x00\x00\x00" # bit depth, color type, etc.
91+
)
92+
assert detect_image_format(png_data) == ImageFormat.IMAGE_FORMAT_PNG
93+
94+
95+
def test_detect_format_with_real_jpeg_data() -> None:
96+
"""Test detection with JPEG SOI marker."""
97+
# JPEG Start of Image (SOI) marker
98+
jpeg_data = b"\xFF\xD8\xFF\xDB" + b"\x00" * 100
99+
assert detect_image_format(jpeg_data) == ImageFormat.IMAGE_FORMAT_JPEG
100+
101+
102+
@pytest.mark.parametrize(
103+
("header", "expected"),
104+
[
105+
(b"\x89PNG\r\n\x1a\n", ImageFormat.IMAGE_FORMAT_PNG),
106+
(b"\xFF\xD8\xFF", ImageFormat.IMAGE_FORMAT_JPEG),
107+
(b"GIF87a", ImageFormat.IMAGE_FORMAT_GIF),
108+
(b"GIF89a", ImageFormat.IMAGE_FORMAT_GIF),
109+
(b"BM", ImageFormat.IMAGE_FORMAT_BMP),
110+
(b"RIFF\x00\x00\x00\x00WEBP", ImageFormat.IMAGE_FORMAT_WEBP),
111+
(b"II*\x00", ImageFormat.IMAGE_FORMAT_TIFF),
112+
(b"MM\x00*", ImageFormat.IMAGE_FORMAT_TIFF),
113+
(b"", ImageFormat.IMAGE_FORMAT_UNSPECIFIED),
114+
(b"xyz", ImageFormat.IMAGE_FORMAT_UNSPECIFIED),
115+
],
116+
)
117+
def test_detect_format_parametrized(
118+
header: bytes, expected: ImageFormat.ValueType
119+
) -> None:
120+
"""Test format detection with various headers."""
121+
assert detect_image_format(header) == expected

0 commit comments

Comments
 (0)