From c8f5c741fd1da66eda796c4746aaa12f87876bb6 Mon Sep 17 00:00:00 2001 From: SkalskiP Date: Fri, 14 Nov 2025 17:55:07 +0100 Subject: [PATCH] reimplement `crop_image` to skip type casting when Pillow Image given `get_image_resolution_wh` added --- docs/utils/image.md | 6 ++++ supervision/__init__.py | 2 ++ supervision/utils/image.py | 70 ++++++++++++++++++++++++++++++++++++-- test/utils/test_image.py | 68 +++++++++++++++++++++++++++++++++++- 4 files changed, 143 insertions(+), 3 deletions(-) diff --git a/docs/utils/image.md b/docs/utils/image.md index 17d94eac..9d1c1895 100644 --- a/docs/utils/image.md +++ b/docs/utils/image.md @@ -41,6 +41,12 @@ status: new :::supervision.utils.image.grayscale_image +
+

get_image_resolution_wh

+
+ +:::supervision.utils.image.get_image_resolution_wh +

ImageSink

diff --git a/supervision/__init__.py b/supervision/__init__.py index ccd27293..1b6ac80b 100644 --- a/supervision/__init__.py +++ b/supervision/__init__.py @@ -129,6 +129,7 @@ from supervision.utils.image import ( resize_image, scale_image, tint_image, + get_image_resolution_wh, ) from supervision.utils.notebook import plot_image, plot_images_grid from supervision.utils.video import ( @@ -163,6 +164,7 @@ __all__ = [ "DetectionsSmoother", "DotAnnotator", "EdgeAnnotator", + "get_image_resolution_wh", "EllipseAnnotator", "FPSMonitor", "HaloAnnotator", diff --git a/supervision/utils/image.py b/supervision/utils/image.py index e8931f21..dad20277 100644 --- a/supervision/utils/image.py +++ b/supervision/utils/image.py @@ -6,6 +6,7 @@ import shutil import cv2 import numpy as np import numpy.typing as npt +from PIL import Image from supervision.annotators.base import ImageType from supervision.draw.color import Color, unify_to_bgr @@ -15,7 +16,6 @@ from supervision.utils.conversion import ( from supervision.utils.internal import deprecated -@ensure_cv2_image_for_standalone_function def crop_image( image: ImageType, xyxy: npt.NDArray[int] | list[int] | tuple[int, int, int, int], @@ -65,9 +65,20 @@ def crop_image( """ # noqa E501 // docs if isinstance(xyxy, (list, tuple)): xyxy = np.array(xyxy) + xyxy = np.round(xyxy).astype(int) x_min, y_min, x_max, y_max = xyxy.flatten() - return image[y_min:y_max, x_min:x_max] + + if isinstance(image, np.ndarray): + return image[y_min:y_max, x_min:x_max] + + if isinstance(image, Image.Image): + return image.crop((x_min, y_min, x_max, y_max)) + + raise TypeError( + "`image` must be a numpy.ndarray or PIL.Image.Image. " + f"Received {type(image)}" + ) @ensure_cv2_image_for_standalone_function @@ -460,6 +471,61 @@ def grayscale_image(image: ImageType) -> ImageType: return cv2.cvtColor(grayscaled, cv2.COLOR_GRAY2BGR) +def get_image_resolution_wh(image: ImageType) -> tuple[int, int]: + """ + Get image width and height as a tuple `(width, height)` for various image formats. + + Supports both `numpy.ndarray` images (with shape `(H, W, ...)`) and + `PIL.Image.Image` inputs. + + Args: + image (`numpy.ndarray` or `PIL.Image.Image`): Input image. + + Returns: + (`tuple[int, int]`): Image resolution as `(width, height)`. + + Raises: + ValueError: If a `numpy.ndarray` image has fewer than 2 dimensions. + TypeError: If `image` is not a supported type (`numpy.ndarray` or + `PIL.Image.Image`). + + Examples: + ```python + import cv2 + import supervision as sv + + image = cv2.imread("example.png") + sv.get_image_resolution_wh(image) + # (1920, 1080) + ``` + + ```python + from PIL import Image + import supervision as sv + + image = Image.open("example.png") + sv.get_image_resolution_wh(image) + # (1920, 1080) + ``` + """ + if isinstance(image, np.ndarray): + if image.ndim < 2: + raise ValueError( + "NumPy image must have at least 2 dimensions (H, W, ...). " + f"Received shape: {image.shape}" + ) + height, width = image.shape[:2] + return int(width), int(height) + + if isinstance(image, Image.Image): + width, height = image.size + return int(width), int(height) + + raise TypeError( + "`image` must be a numpy.ndarray or PIL.Image.Image. " + f"Received type: {type(image)}" + ) + class ImageSink: def __init__( self, diff --git a/test/utils/test_image.py b/test/utils/test_image.py index 6ae9567b..8dbd5b59 100644 --- a/test/utils/test_image.py +++ b/test/utils/test_image.py @@ -1,7 +1,9 @@ import numpy as np +import pytest from PIL import Image, ImageChops -from supervision.utils.image import letterbox_image, resize_image +from supervision.utils.image import letterbox_image, resize_image, crop_image, \ + get_image_resolution_wh def test_resize_image_for_opencv_image() -> None: @@ -94,3 +96,67 @@ def test_letterbox_image_for_pillow_image() -> None: assert difference.getbbox() is None, ( "Expected padding to be added top and bottom with padding added top and bottom" ) + + +@pytest.mark.parametrize( + "image, xyxy, expected_size", + [ + # NumPy RGB + ( + np.zeros((4, 6, 3), dtype=np.uint8), + (2, 1, 5, 3), + (3, 2), # width = 5-2, height = 3-1 + ), + + # NumPy grayscale + ( + np.zeros((5, 5), dtype=np.uint8), + (1, 1, 4, 4), + (3, 3), + ), + + # Pillow RGB + ( + Image.new("RGB", (6, 4), color=0), + (2, 1, 5, 3), + (3, 2), + ), + + # Pillow grayscale + ( + Image.new("L", (5, 5), color=0), + (1, 1, 4, 4), + (3, 3), + ), + ], +) +def test_crop_image(image, xyxy, expected_size): + cropped = crop_image(image=image, xyxy=xyxy) + if isinstance(image, np.ndarray): + assert isinstance(cropped, np.ndarray) + assert cropped.shape[1] == expected_size[0] # width + assert cropped.shape[0] == expected_size[1] # height + else: + assert isinstance(cropped, Image.Image) + assert cropped.size == expected_size + + +@pytest.mark.parametrize( + "image, expected", + [ + # NumPy RGB + (np.zeros((4, 6, 3), dtype=np.uint8), (6, 4)), + + # NumPy grayscale + (np.zeros((10, 20), dtype=np.uint8), (20, 10)), + + # Pillow RGB + (Image.new("RGB", (6, 4), color=0), (6, 4)), + + # Pillow grayscale + (Image.new("L", (20, 10), color=0), (20, 10)), + ], +) +def test_get_image_resolution_wh(image, expected): + resolution = get_image_resolution_wh(image) + assert resolution == expected \ No newline at end of file