From 4de4023c894cd60ed352316c924ce7537393a701 Mon Sep 17 00:00:00 2001 From: HuaTNA Date: Mon, 28 Sep 2026 16:30:11 -0400 Subject: [PATCH 1/2] fix: preserve center crop size with odd padding --- fastembed/image/transform/functional.py | 5 +- tests/test_image_transform.py | 66 ++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/fastembed/image/transform/functional.py b/fastembed/image/transform/functional.py index ed6f880a..e3b977db 100644 --- a/fastembed/image/transform/functional.py +++ b/fastembed/image/transform/functional.py @@ -42,9 +42,10 @@ def center_crop( new_shape = image.shape[:-2] + (new_height, new_width) new_image = np.zeros_like(image, shape=new_shape, dtype=np.float32) - top_pad = (new_height - orig_height) // 2 + # Round padding up to offset the floor-rounded crop origin when the difference is odd. + top_pad = (new_height - orig_height + 1) // 2 bottom_pad = top_pad + orig_height - left_pad = (new_width - orig_width) // 2 + left_pad = (new_width - orig_width + 1) // 2 right_pad = left_pad + orig_width new_image[..., top_pad:bottom_pad, left_pad:right_pad] = image diff --git a/tests/test_image_transform.py b/tests/test_image_transform.py index d152719c..66cf7f84 100644 --- a/tests/test_image_transform.py +++ b/tests/test_image_transform.py @@ -2,7 +2,71 @@ import pytest from PIL import Image -from fastembed.image.transform.functional import normalize, resize +from fastembed.image.transform.functional import center_crop, normalize, resize +from fastembed.image.transform.operators import Compose + + +@pytest.mark.parametrize("as_array", [False, True], ids=["pil", "numpy"]) +@pytest.mark.parametrize( + ("image_size", "crop_size"), + [ + ((3, 3), (4, 4)), # odd padding on both axes + ((3, 7), (4, 4)), # pad height, crop width + ((7, 3), (4, 4)), # crop height, pad width + ((1, 3), (6, 8)), # more than one padding pixel on each side + ((3, 6), (4, 6)), # pad height only + ((4, 5), (4, 6)), # pad width only + ((2, 4), (4, 6)), # even padding + ((6, 8), (4, 6)), # no padding needed + ((4, 6), (4, 6)), # already the requested size + ], +) +def test_center_crop_shape_and_pixels( + image_size: tuple[int, int], crop_size: tuple[int, int], as_array: bool +) -> None: + height, width = image_size + pixels = np.arange(1, height * width * 3 + 1, dtype=np.uint8).reshape(height, width, 3) + pil_image = Image.fromarray(pixels) + image = pixels.transpose(2, 0, 1) if as_array else pil_image + + crop_height, crop_width = crop_size + top, left = (height - crop_height) // 2, (width - crop_width) // 2 + # Pillow independently supplies zero padding outside the requested crop box. + expected = np.asarray( + pil_image.crop((left, top, left + crop_width, top + crop_height)) + ).transpose(2, 0, 1) + + result = center_crop(image, size=crop_size) + + assert result.shape == (3, crop_height, crop_width) + np.testing.assert_array_equal(result, expected) + + +@pytest.mark.parametrize("rectangular", [False, True]) +def test_center_crop_batches_different_image_sizes(rectangular: bool) -> None: + height, width = 4, 6 if rectangular else 4 + processor = Compose.from_config( + { + "do_resize": False, + "do_center_crop": True, + "crop_size": {"height": height, "width": width} if rectangular else height, + "do_rescale": True, + "rescale_factor": 1 / 255, + } + ) + images = [ + Image.new("RGB", (3, 3), color=(10, 20, 30)), + Image.new("RGB", (width, height), color=(10, 20, 30)), + Image.new("RGB", (9, 7), color=(10, 20, 30)), + ] + + # OnnxImageModel also constructs its input batch with np.array(processor(images)). + batch = np.array(processor(images)) + + assert batch.shape == (len(images), 3, height, width) + assert batch.dtype == np.float32 + np.testing.assert_array_equal(batch[0, :, 0, :], 0) + np.testing.assert_allclose(batch[0, :, height // 2, width // 2], np.array([10, 20, 30]) / 255) @pytest.mark.parametrize( From 199fbebdae5821b63b4f9328f1ba3f7bb3ede808 Mon Sep 17 00:00:00 2001 From: George Date: Tue, 29 Sep 2026 16:52:43 +0700 Subject: [PATCH 2/2] tests: refactor tests, leave just one --- tests/test_image_transform.py | 63 ++++++----------------------------- 1 file changed, 10 insertions(+), 53 deletions(-) diff --git a/tests/test_image_transform.py b/tests/test_image_transform.py index 66cf7f84..ceed9e35 100644 --- a/tests/test_image_transform.py +++ b/tests/test_image_transform.py @@ -2,71 +2,28 @@ import pytest from PIL import Image -from fastembed.image.transform.functional import center_crop, normalize, resize +from fastembed.image.transform.functional import normalize, resize from fastembed.image.transform.operators import Compose -@pytest.mark.parametrize("as_array", [False, True], ids=["pil", "numpy"]) -@pytest.mark.parametrize( - ("image_size", "crop_size"), - [ - ((3, 3), (4, 4)), # odd padding on both axes - ((3, 7), (4, 4)), # pad height, crop width - ((7, 3), (4, 4)), # crop height, pad width - ((1, 3), (6, 8)), # more than one padding pixel on each side - ((3, 6), (4, 6)), # pad height only - ((4, 5), (4, 6)), # pad width only - ((2, 4), (4, 6)), # even padding - ((6, 8), (4, 6)), # no padding needed - ((4, 6), (4, 6)), # already the requested size - ], -) -def test_center_crop_shape_and_pixels( - image_size: tuple[int, int], crop_size: tuple[int, int], as_array: bool -) -> None: - height, width = image_size - pixels = np.arange(1, height * width * 3 + 1, dtype=np.uint8).reshape(height, width, 3) - pil_image = Image.fromarray(pixels) - image = pixels.transpose(2, 0, 1) if as_array else pil_image - - crop_height, crop_width = crop_size - top, left = (height - crop_height) // 2, (width - crop_width) // 2 - # Pillow independently supplies zero padding outside the requested crop box. - expected = np.asarray( - pil_image.crop((left, top, left + crop_width, top + crop_height)) - ).transpose(2, 0, 1) - - result = center_crop(image, size=crop_size) - - assert result.shape == (3, crop_height, crop_width) - np.testing.assert_array_equal(result, expected) - - -@pytest.mark.parametrize("rectangular", [False, True]) -def test_center_crop_batches_different_image_sizes(rectangular: bool) -> None: - height, width = 4, 6 if rectangular else 4 +def test_center_crop_odd_padding_keeps_batch_shape_and_pixels() -> None: + pixels = np.arange(1, 28, dtype=np.uint8).reshape(3, 3, 3) processor = Compose.from_config( { "do_resize": False, "do_center_crop": True, - "crop_size": {"height": height, "width": width} if rectangular else height, - "do_rescale": True, - "rescale_factor": 1 / 255, + "crop_size": 4, + "do_rescale": False, } ) - images = [ - Image.new("RGB", (3, 3), color=(10, 20, 30)), - Image.new("RGB", (width, height), color=(10, 20, 30)), - Image.new("RGB", (9, 7), color=(10, 20, 30)), - ] + images = [Image.fromarray(pixels), Image.new("RGB", (4, 4))] - # OnnxImageModel also constructs its input batch with np.array(processor(images)). batch = np.array(processor(images)) + expected = np.zeros((3, 4, 4), dtype=np.float32) + expected[:, 1:, 1:] = pixels.transpose(2, 0, 1) - assert batch.shape == (len(images), 3, height, width) - assert batch.dtype == np.float32 - np.testing.assert_array_equal(batch[0, :, 0, :], 0) - np.testing.assert_allclose(batch[0, :, height // 2, width // 2], np.array([10, 20, 30]) / 255) + assert batch.shape == (2, 3, 4, 4) + np.testing.assert_array_equal(batch[0], expected) @pytest.mark.parametrize(