diff --git a/supervision/annotators/color_utils.py b/supervision/annotators/color_utils.py new file mode 100644 index 000000000..249e90147 --- /dev/null +++ b/supervision/annotators/color_utils.py @@ -0,0 +1,27 @@ +from typing import Tuple + + +def hex_to_rgba(hex_color: str, opacity: float = 1.0) -> tuple[int, int, int, int]: + """Convert a HEX color string to RGBA tuple.""" + hex_color = hex_color.lstrip("#") + if len(hex_color) not in (6, 8): + raise ValueError("Invalid HEX color format") + r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16) + a = int(opacity * 255) if len(hex_color) == 6 else int(hex_color[6:8], 16) + return (r, g, b, a) + + +def rgba_to_hex(rgba: tuple[int, int, int, int]) -> str: + """Convert an RGBA tuple to HEX color string.""" + r, g, b, a = rgba + return f"#{r:02X}{g:02X}{b:02X}{a:02X}" + + +def validate_color(value: str) -> bool: + """Check if a given string is a valid HEX color.""" + if not value.startswith("#"): + return False + hex_digits = value.lstrip("#") + return len(hex_digits) in (6, 8) and all( + c in "0123456789ABCDEFabcdef" for c in hex_digits + ) diff --git a/supervision/annotators/core.py b/supervision/annotators/core.py index 900d823b2..672cddd4a 100644 --- a/supervision/annotators/core.py +++ b/supervision/annotators/core.py @@ -44,6 +44,28 @@ scale_image, ) + +def hex_to_rgba(hex_color: str): + """ + Convert a hexadecimal color string (#RRGGBB or #RRGGBBAA) to an RGBA tuple. + + Args: + hex_color (str): The hex string (e.g. '#FF00FF' or '#FF00FF80'). + + Returns: + Tuple[int, int, int, int]: Corresponding (R, G, B, A) tuple. + """ + hex_color = hex_color.lstrip("#") + if len(hex_color) == 6: + r, g, b = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) + a = 255 + elif len(hex_color) == 8: + r, g, b, a = tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4, 6)) + else: + raise ValueError(f"Invalid hex color format: {hex_color}") + return (r, g, b, a) + + CV2_FONT = cv2.FONT_HERSHEY_SIMPLEX @@ -101,6 +123,8 @@ def __init__( max_line_length (Optional[int], optional): Maximum number of characters per line before wrapping the text. None means no wrapping. """ + if isinstance(color, str) and color.startswith("#"): + color = hex_to_rgba(color) self.color: Color | ColorPalette = color self.color_lookup: ColorLookup = color_lookup self.text_color: Color | ColorPalette = text_color diff --git a/supervision/annotators/test_hex_color.py b/supervision/annotators/test_hex_color.py new file mode 100644 index 000000000..cbc98f28d --- /dev/null +++ b/supervision/annotators/test_hex_color.py @@ -0,0 +1,4 @@ +from supervision.annotators.core import hex_to_rgba + +print(hex_to_rgba("#FF00FF")) # (255, 0, 255, 255) +print(hex_to_rgba("#FF00FF80")) # (255, 0, 255, 128) diff --git a/supervision/annotators/utils.py b/supervision/annotators/utils.py index 42a436c9b..6ee1410c4 100644 --- a/supervision/annotators/utils.py +++ b/supervision/annotators/utils.py @@ -354,3 +354,49 @@ def put(self, detections: Detections) -> None: def get(self, tracker_id: int) -> np.ndarray: return self.xy[self.tracker_id == tracker_id] + + +def hex_to_rgba(hex_color: str) -> tuple[int, int, int, int]: + """ + Converts a hex color string (e.g. "#FF00FF" or "#FF00FF80") to an RGBA tuple. + + Args: + hex_color (str): A hex color string. + + Returns: + tuple[int, int, int, int]: RGBA values in range 0–255. + + Raises: + ValueError: If the format is invalid. + """ + hex_color = hex_color.strip().lstrip("#") + if len(hex_color) == 6: + hex_color += "FF" # default full opacity + if len(hex_color) != 8: + raise ValueError(f"Invalid hex color format: {hex_color}") + try: + r = int(hex_color[0:2], 16) + g = int(hex_color[2:4], 16) + b = int(hex_color[4:6], 16) + a = int(hex_color[6:8], 16) + except ValueError: + raise ValueError(f"Invalid hex digits in {hex_color}") + return (r, g, b, a) + + +def rgba_to_hex(rgba: tuple[int, int, int, int]) -> str: + """ + Converts an RGBA tuple (0–255 each) to a hex color string. + """ + if len(rgba) != 4 or not all(0 <= c <= 255 for c in rgba): + raise ValueError("RGBA must be a 4-tuple with values between 0–255.") + return "#{:02X}{:02X}{:02X}{:02X}".format(*rgba) + + +def is_valid_hex(hex_color: str) -> bool: + """ + Checks if a given string is a valid hex color. + """ + import re + + return bool(re.fullmatch(r"#?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?", hex_color.strip())) diff --git a/test/annotators/test_utils.py b/test/annotators/test_utils.py index 3ab0f9b90..cc9db7451 100644 --- a/test/annotators/test_utils.py +++ b/test/annotators/test_utils.py @@ -172,3 +172,35 @@ def test_wrap_text( with exception: result = wrap_text(text=text, max_line_length=max_line_length) assert result == expected_result + + +def test_hex_to_rgba_valid(): + from supervision.annotators.utils import hex_to_rgba + + assert hex_to_rgba("#FF00FF") == (255, 0, 255, 255) + assert hex_to_rgba("#FF00FF80") == (255, 0, 255, 128) + assert hex_to_rgba("00FF0080") == (0, 255, 0, 128) + + +def test_hex_to_rgba_invalid(): + import pytest + + from supervision.annotators.utils import hex_to_rgba + + with pytest.raises(ValueError): + hex_to_rgba("#FF00F") # wrong length + + with pytest.raises(ValueError): + hex_to_rgba("#GGHHII") # invalid chars + + +def test_rgba_to_hex_and_is_valid_hex(): + from supervision.annotators.utils import is_valid_hex, rgba_to_hex + + assert rgba_to_hex((255, 0, 255, 255)) == "#FF00FFFF" + assert rgba_to_hex((0, 255, 0, 128)) == "#00FF0080" + + assert is_valid_hex("#FF00FF") + assert is_valid_hex("00FF0080") + assert not is_valid_hex("#XYZ123") + assert not is_valid_hex("FF00F")