From d3c40f7223c66b738a0bac1b6b4574ceadb93fdb Mon Sep 17 00:00:00 2001 From: Ramzi Alashmali <186906047+RAMZI0TO99@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:11:47 +0300 Subject: [PATCH 1/3] fix(image): respect do_resize=False in ConvNeXT preprocessing --- fastembed/image/transform/operators.py | 7 ++ tests/test_convnext_do_resize.py | 140 +++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 tests/test_convnext_do_resize.py diff --git a/fastembed/image/transform/operators.py b/fastembed/image/transform/operators.py index c5155be4..aba1df5c 100644 --- a/fastembed/image/transform/operators.py +++ b/fastembed/image/transform/operators.py @@ -325,6 +325,11 @@ def _get_convert_to_rgb(transforms: list[Transform], config: dict[str, Any]) -> @classmethod def _get_resize(cls, transforms: list[Transform], config: dict[str, Any]) -> None: + """Append resize operations selected by the image processor configuration. + + ConvNeXT defaults to resizing when do_resize is omitted. Disabling it + skips both resize and its coupled crop while later preprocessing continues. + """ mode = config.get("image_processor_type", "CLIPImageProcessor") if mode in ("CLIPImageProcessor", "SiglipImageProcessor"): if config.get("do_resize", False): @@ -344,6 +349,8 @@ def _get_resize(cls, transforms: list[Transform], config: dict[str, Any]) -> Non ) ) elif mode == "ConvNextFeatureExtractor": + if not config.get("do_resize", True): + return if "size" in config and "shortest_edge" not in config["size"]: raise ValueError( f"Size dictionary must contain 'shortest_edge' key. Got {config['size'].keys()}" diff --git a/tests/test_convnext_do_resize.py b/tests/test_convnext_do_resize.py new file mode 100644 index 00000000..d254e34c --- /dev/null +++ b/tests/test_convnext_do_resize.py @@ -0,0 +1,140 @@ +"""Regression tests for ConvNext preprocessing with resizing enabled or disabled.""" + +import numpy as np +import pytest +from PIL import Image + +from fastembed.image.transform.operators import Compose + + +def _patterned_rgb(width: int, height: int) -> Image.Image: + """Create deterministic RGB pixels that reveal unintended resizing or cropping.""" + pixels = np.random.default_rng(0).integers(0, 256, (height, width, 3), dtype=np.uint8) + return Image.fromarray(pixels) + + +@pytest.mark.parametrize("shortest_edge", [224, 384, 512]) +@pytest.mark.parametrize("image_size", [(67, 43), (43, 67), (224, 224)]) +def test_convnext_disabled_resize_preserves_shape_and_pixels( + shortest_edge: int, image_size: tuple[int, int] +) -> None: + """Preserve portrait, landscape, and square pixels when resizing is disabled.""" + image = _patterned_rgb(*image_size) + processor = Compose.from_config( + { + "image_processor_type": "ConvNextFeatureExtractor", + "do_resize": False, + "size": {"shortest_edge": shortest_edge}, + "do_rescale": False, + } + ) + + output = processor([image])[0] + + expected = np.asarray(image).transpose(2, 0, 1) + assert output.shape == expected.shape + np.testing.assert_array_equal(output, expected) + + +def test_convnext_disabled_resize_does_not_require_geometry_settings() -> None: + """Allow disabled resizing without a size dictionary or other geometry settings.""" + image = _patterned_rgb(13, 7) + processor = Compose.from_config( + { + "image_processor_type": "ConvNextFeatureExtractor", + "do_resize": False, + "do_rescale": False, + } + ) + + np.testing.assert_array_equal(processor([image])[0], np.asarray(image).transpose(2, 0, 1)) + + +@pytest.mark.parametrize("do_resize", [True, None], ids=["enabled", "omitted"]) +@pytest.mark.parametrize( + ("shortest_edge", "resize_size", "crop_box"), + [ + (224, (384, 256), (80, 16, 304, 240)), + (384, (384, 384), None), + (512, (512, 512), None), + ], +) +def test_convnext_enabled_and_default_resize_preserve_existing_behavior( + do_resize: bool | None, + shortest_edge: int, + resize_size: tuple[int, int], + crop_box: tuple[int, int, int, int] | None, +) -> None: + """Match independent Pillow results for enabled and default ConvNext geometry.""" + image = _patterned_rgb(300, 200) + config = { + "image_processor_type": "ConvNextFeatureExtractor", + "size": {"shortest_edge": shortest_edge}, + "do_rescale": False, + } + if do_resize is not None: + config["do_resize"] = do_resize + processor = Compose.from_config(config) + expected = image.resize(resize_size, Image.Resampling.BICUBIC) + if crop_box is not None: + expected = expected.crop(crop_box) + + np.testing.assert_array_equal(processor([image])[0], np.asarray(expected).transpose(2, 0, 1)) + + +@pytest.mark.parametrize("do_resize", [True, None], ids=["enabled", "omitted"]) +def test_convnext_enabled_resize_still_rejects_invalid_size(do_resize: bool | None) -> None: + """Keep shortest-edge validation active when resizing is enabled or unspecified.""" + config = { + "image_processor_type": "ConvNextFeatureExtractor", + "size": {"height": 224, "width": 224}, + } + if do_resize is not None: + config["do_resize"] = do_resize + + with pytest.raises(ValueError, match="shortest_edge"): + Compose.from_config(config) + + +def test_convnext_disabled_resize_still_rescales_and_normalizes() -> None: + """Apply rescaling and normalization even when geometry transforms are disabled.""" + image = _patterned_rgb(13, 7) + processor = Compose.from_config( + { + "image_processor_type": "ConvNextFeatureExtractor", + "do_resize": False, + "size": {"shortest_edge": 224}, + "do_rescale": True, + "rescale_factor": 0.25, + "do_normalize": True, + "image_mean": [1.0, 2.0, 3.0], + "image_std": [2.0, 4.0, 8.0], + } + ) + pixels = np.asarray(image).transpose(2, 0, 1).astype(np.float32) + expected = (pixels * 0.25 - np.array([1.0, 2.0, 3.0])[:, None, None]) / np.array( + [2.0, 4.0, 8.0] + )[:, None, None] + + np.testing.assert_array_equal(processor([image])[0], expected) + + +@pytest.mark.parametrize("mode", ["CLIPImageProcessor", "SiglipImageProcessor"]) +@pytest.mark.parametrize("do_resize", [False, True, None], ids=["disabled", "enabled", "omitted"]) +def test_other_processors_keep_existing_resize_flag_behavior( + mode: str, do_resize: bool | None +) -> None: + """Keep CLIP and Siglip's existing resize flag defaults and explicit behavior.""" + image = _patterned_rgb(67, 43) + config = { + "image_processor_type": mode, + "size": {"height": 32, "width": 48}, + "do_rescale": False, + } + if do_resize is not None: + config["do_resize"] = do_resize + expected = image.resize((48, 32), Image.Resampling.BICUBIC) if do_resize else image + + processor = Compose.from_config(config) + + np.testing.assert_array_equal(processor([image])[0], np.asarray(expected).transpose(2, 0, 1)) From 02a1aad73178da5883968ecb10e85c7ff9ee42a6 Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Mon, 5 Oct 2026 15:10:01 +0700 Subject: [PATCH 2/3] test: drop tests --- tests/test_convnext_do_resize.py | 140 ------------------------------- 1 file changed, 140 deletions(-) delete mode 100644 tests/test_convnext_do_resize.py diff --git a/tests/test_convnext_do_resize.py b/tests/test_convnext_do_resize.py deleted file mode 100644 index d254e34c..00000000 --- a/tests/test_convnext_do_resize.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Regression tests for ConvNext preprocessing with resizing enabled or disabled.""" - -import numpy as np -import pytest -from PIL import Image - -from fastembed.image.transform.operators import Compose - - -def _patterned_rgb(width: int, height: int) -> Image.Image: - """Create deterministic RGB pixels that reveal unintended resizing or cropping.""" - pixels = np.random.default_rng(0).integers(0, 256, (height, width, 3), dtype=np.uint8) - return Image.fromarray(pixels) - - -@pytest.mark.parametrize("shortest_edge", [224, 384, 512]) -@pytest.mark.parametrize("image_size", [(67, 43), (43, 67), (224, 224)]) -def test_convnext_disabled_resize_preserves_shape_and_pixels( - shortest_edge: int, image_size: tuple[int, int] -) -> None: - """Preserve portrait, landscape, and square pixels when resizing is disabled.""" - image = _patterned_rgb(*image_size) - processor = Compose.from_config( - { - "image_processor_type": "ConvNextFeatureExtractor", - "do_resize": False, - "size": {"shortest_edge": shortest_edge}, - "do_rescale": False, - } - ) - - output = processor([image])[0] - - expected = np.asarray(image).transpose(2, 0, 1) - assert output.shape == expected.shape - np.testing.assert_array_equal(output, expected) - - -def test_convnext_disabled_resize_does_not_require_geometry_settings() -> None: - """Allow disabled resizing without a size dictionary or other geometry settings.""" - image = _patterned_rgb(13, 7) - processor = Compose.from_config( - { - "image_processor_type": "ConvNextFeatureExtractor", - "do_resize": False, - "do_rescale": False, - } - ) - - np.testing.assert_array_equal(processor([image])[0], np.asarray(image).transpose(2, 0, 1)) - - -@pytest.mark.parametrize("do_resize", [True, None], ids=["enabled", "omitted"]) -@pytest.mark.parametrize( - ("shortest_edge", "resize_size", "crop_box"), - [ - (224, (384, 256), (80, 16, 304, 240)), - (384, (384, 384), None), - (512, (512, 512), None), - ], -) -def test_convnext_enabled_and_default_resize_preserve_existing_behavior( - do_resize: bool | None, - shortest_edge: int, - resize_size: tuple[int, int], - crop_box: tuple[int, int, int, int] | None, -) -> None: - """Match independent Pillow results for enabled and default ConvNext geometry.""" - image = _patterned_rgb(300, 200) - config = { - "image_processor_type": "ConvNextFeatureExtractor", - "size": {"shortest_edge": shortest_edge}, - "do_rescale": False, - } - if do_resize is not None: - config["do_resize"] = do_resize - processor = Compose.from_config(config) - expected = image.resize(resize_size, Image.Resampling.BICUBIC) - if crop_box is not None: - expected = expected.crop(crop_box) - - np.testing.assert_array_equal(processor([image])[0], np.asarray(expected).transpose(2, 0, 1)) - - -@pytest.mark.parametrize("do_resize", [True, None], ids=["enabled", "omitted"]) -def test_convnext_enabled_resize_still_rejects_invalid_size(do_resize: bool | None) -> None: - """Keep shortest-edge validation active when resizing is enabled or unspecified.""" - config = { - "image_processor_type": "ConvNextFeatureExtractor", - "size": {"height": 224, "width": 224}, - } - if do_resize is not None: - config["do_resize"] = do_resize - - with pytest.raises(ValueError, match="shortest_edge"): - Compose.from_config(config) - - -def test_convnext_disabled_resize_still_rescales_and_normalizes() -> None: - """Apply rescaling and normalization even when geometry transforms are disabled.""" - image = _patterned_rgb(13, 7) - processor = Compose.from_config( - { - "image_processor_type": "ConvNextFeatureExtractor", - "do_resize": False, - "size": {"shortest_edge": 224}, - "do_rescale": True, - "rescale_factor": 0.25, - "do_normalize": True, - "image_mean": [1.0, 2.0, 3.0], - "image_std": [2.0, 4.0, 8.0], - } - ) - pixels = np.asarray(image).transpose(2, 0, 1).astype(np.float32) - expected = (pixels * 0.25 - np.array([1.0, 2.0, 3.0])[:, None, None]) / np.array( - [2.0, 4.0, 8.0] - )[:, None, None] - - np.testing.assert_array_equal(processor([image])[0], expected) - - -@pytest.mark.parametrize("mode", ["CLIPImageProcessor", "SiglipImageProcessor"]) -@pytest.mark.parametrize("do_resize", [False, True, None], ids=["disabled", "enabled", "omitted"]) -def test_other_processors_keep_existing_resize_flag_behavior( - mode: str, do_resize: bool | None -) -> None: - """Keep CLIP and Siglip's existing resize flag defaults and explicit behavior.""" - image = _patterned_rgb(67, 43) - config = { - "image_processor_type": mode, - "size": {"height": 32, "width": 48}, - "do_rescale": False, - } - if do_resize is not None: - config["do_resize"] = do_resize - expected = image.resize((48, 32), Image.Resampling.BICUBIC) if do_resize else image - - processor = Compose.from_config(config) - - np.testing.assert_array_equal(processor([image])[0], np.asarray(expected).transpose(2, 0, 1)) From 97e6734a9066f9ae9919145f70fe42743b62fbeb Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Mon, 5 Oct 2026 15:11:37 +0700 Subject: [PATCH 3/3] fix: move convnext comment --- fastembed/image/transform/operators.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/fastembed/image/transform/operators.py b/fastembed/image/transform/operators.py index aba1df5c..5c747b85 100644 --- a/fastembed/image/transform/operators.py +++ b/fastembed/image/transform/operators.py @@ -325,11 +325,6 @@ def _get_convert_to_rgb(transforms: list[Transform], config: dict[str, Any]) -> @classmethod def _get_resize(cls, transforms: list[Transform], config: dict[str, Any]) -> None: - """Append resize operations selected by the image processor configuration. - - ConvNeXT defaults to resizing when do_resize is omitted. Disabling it - skips both resize and its coupled crop while later preprocessing continues. - """ mode = config.get("image_processor_type", "CLIPImageProcessor") if mode in ("CLIPImageProcessor", "SiglipImageProcessor"): if config.get("do_resize", False): @@ -349,6 +344,7 @@ def _get_resize(cls, transforms: list[Transform], config: dict[str, Any]) -> Non ) ) elif mode == "ConvNextFeatureExtractor": + # HF defaults do_resize to True for ConvNeXT; it also gates the coupled crop if not config.get("do_resize", True): return if "size" in config and "shortest_edge" not in config["size"]: