From 8b60ee1a0e9b8b3383244b30f985b8b5fb21d451 Mon Sep 17 00:00:00 2001 From: Mourtaza Ali <63417797+aza-ali@users.noreply.github.com> Date: Mon, 30 Mar 2026 06:56:52 -0700 Subject: [PATCH] feat: add support for hexadecimal RGBA color format (#2004) This commit adds support for 4-digit and 8-digit hexadecimal color codes with alpha channel (e.g., #FF00FF80 for 50% transparent magenta). Changes: - Extended Color dataclass with optional alpha field (default 255) - Updated _validate_color_hex to accept lengths 3, 4, 6, 8 - Updated from_hex to parse 4-digit (#RGBA) and 8-digit (#RRGGBBAA) hex codes - Modified as_hex to return #RRGGBBAA when alpha != 255 - Added as_rgba() and as_bgra() methods - Added from_rgba_tuple() and from_bgra_tuple() class methods - Updated __eq__ and __hash__ to include alpha channel - Added comprehensive unit tests for all RGBA functionality - Updated docstrings with examples of new alpha channel support --------- Co-authored-by: jirka <6035284+Borda@users.noreply.github.com> Co-authored-by: Claude Code Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/supervision/draw/color.py | 141 +++++++++++++++++++++++++-- tests/draw/test_color.py | 174 ++++++++++++++++++++++++++++++++-- 2 files changed, 298 insertions(+), 17 deletions(-) diff --git a/src/supervision/draw/color.py b/src/supervision/draw/color.py index 04526c54..b8a70498 100644 --- a/src/supervision/draw/color.py +++ b/src/supervision/draw/color.py @@ -58,22 +58,24 @@ def _validate_color_hex(color_hex: str) -> None: color_hex = color_hex.lstrip("#") if not all(c in "0123456789abcdefABCDEF" for c in color_hex): raise ValueError("Invalid characters in color hash") - if len(color_hex) not in (3, 6): + if len(color_hex) not in (3, 4, 6, 8): raise ValueError("Invalid length of color hash") @dataclass class Color: """ - Represents a color in RGB format. + Represents a color in RGBA format. This class provides methods to work with colors, including creating colors from hex - codes, converting colors to hex strings, RGB tuples, and BGR tuples. + codes, converting colors to hex strings, RGB tuples, BGR tuples, RGBA tuples, + and BGRA tuples. Attributes: r: Red channel value (0-255). g: Green channel value (0-255). b: Blue channel value (0-255). + a: Alpha channel value (0-255). Default is 255 (fully opaque). Example: ```pycon @@ -98,6 +100,7 @@ class Color: r: int g: int b: int + a: int = 255 @classmethod def from_hex(cls, color_hex: str) -> Color: @@ -106,9 +109,9 @@ class Color: Args: color_hex: The hex string representing the color. This string can - start with '#' followed by either 3 or 6 hexadecimal characters. In - case of 3 characters, each character is repeated to form the full - 6-character hex code. + start with '#' followed by 3, 4, 6, or 8 hexadecimal characters. + For 3- or 4-character codes, each character is doubled to form the + full hex code. Returns: An instance representing the color. @@ -121,14 +124,26 @@ class Color: >>> sv.Color.from_hex('#f0f') Color(r=255, g=0, b=255) + >>> sv.Color.from_hex('#ff00ff80') + Color(r=255, g=0, b=255, a=128) + >>> sv.Color.from_hex('#f0f8') + Color(r=255, g=0, b=255, a=136) + ``` """ _validate_color_hex(color_hex) color_hex = color_hex.lstrip("#") if len(color_hex) == 3: color_hex = "".join(c * 2 for c in color_hex) - r, g, b = (int(color_hex[i : i + 2], 16) for i in range(0, 6, 2)) - return cls(r, g, b) + elif len(color_hex) == 4: + color_hex = "".join(c * 2 for c in color_hex) + + if len(color_hex) == 6: + r, g, b = (int(color_hex[i : i + 2], 16) for i in range(0, 6, 2)) + return cls(r, g, b) + else: # len(color_hex) == 8 + r, g, b, a = (int(color_hex[i : i + 2], 16) for i in range(0, 8, 2)) + return cls(r, g, b, a) @classmethod def from_rgb_tuple(cls, color_tuple: tuple[int, int, int]) -> Color: @@ -186,12 +201,73 @@ class Color: raise ValueError(f"BGR values must be in range 0-255, got ({b}, {g}, {r})") return cls(r=r, g=g, b=b) + @classmethod + def from_rgba_tuple(cls, color_tuple: tuple[int, int, int, int]) -> Color: + """ + Create a Color instance from an RGBA tuple. + + Args: + color_tuple: A tuple representing the color in RGBA format, where each + element is an integer in the range 0-255. + + Returns: + An instance representing the color. + + Raises: + ValueError: If any RGBA value is outside the range 0-255. + + Example: + ```pycon + >>> import supervision as sv + >>> sv.Color.from_rgba_tuple((255, 255, 0, 128)) + Color(r=255, g=255, b=0, a=128) + + ``` + """ + r, g, b, a = color_tuple + if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255 and 0 <= a <= 255): + raise ValueError( + f"RGBA values must be in range 0-255, got ({r}, {g}, {b}, {a})" + ) + return cls(r=r, g=g, b=b, a=a) + + @classmethod + def from_bgra_tuple(cls, color_tuple: tuple[int, int, int, int]) -> Color: + """ + Create a Color instance from a BGRA tuple. + + Args: + color_tuple: A tuple representing the color in BGRA format, where each + element is an integer in the range 0-255. + + Returns: + An instance representing the color. + + Raises: + ValueError: If any BGRA value is outside the range 0-255. + + Example: + ```pycon + >>> import supervision as sv + >>> sv.Color.from_bgra_tuple((0, 255, 255, 128)) + Color(r=255, g=255, b=0, a=128) + + ``` + """ + b, g, r, a = color_tuple + if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255 and 0 <= a <= 255): + raise ValueError( + f"BGRA values must be in range 0-255, got ({b}, {g}, {r}, {a})" + ) + return cls(r=r, g=g, b=b, a=a) + def as_hex(self) -> str: """ Converts the Color instance to a hex string. Returns: - The hexadecimal color string. + The hexadecimal color string. Returns `#RRGGBBAA` if alpha is not 255, + otherwise returns `#RRGGBB`. Example: ```pycon @@ -199,8 +275,13 @@ class Color: >>> sv.Color(r=255, g=255, b=0).as_hex() '#ffff00' + >>> sv.Color(r=255, g=0, b=255, a=128).as_hex() + '#ff00ff80' + ``` """ + if self.a != 255: + return f"#{self.r:02x}{self.g:02x}{self.b:02x}{self.a:02x}" return f"#{self.r:02x}{self.g:02x}{self.b:02x}" def as_rgb(self) -> tuple[int, int, int]: @@ -237,6 +318,40 @@ class Color: """ return self.b, self.g, self.r + def as_rgba(self) -> tuple[int, int, int, int]: + """ + Returns the color as an RGBA tuple. + + Returns: + RGBA tuple. + + Example: + ```pycon + >>> import supervision as sv + >>> sv.Color(r=255, g=255, b=0, a=128).as_rgba() + (255, 255, 0, 128) + + ``` + """ + return self.r, self.g, self.b, self.a + + def as_bgra(self) -> tuple[int, int, int, int]: + """ + Returns the color as a BGRA tuple. + + Returns: + BGRA tuple. + + Example: + ```pycon + >>> import supervision as sv + >>> sv.Color(r=255, g=255, b=0, a=128).as_bgra() + (0, 255, 255, 128) + + ``` + """ + return self.b, self.g, self.r, self.a + @classproperty def WHITE(cls) -> Color: return Color.from_hex("#FFFFFF") @@ -270,7 +385,12 @@ class Color: return Color.from_hex("#A351FB") def __hash__(self) -> int: - return hash((self.r, self.g, self.b)) + return hash((self.r, self.g, self.b, self.a)) + + def __repr__(self) -> str: + if self.a == 255: + return f"Color(r={self.r}, g={self.g}, b={self.b})" + return f"Color(r={self.r}, g={self.g}, b={self.b}, a={self.a})" def __eq__(self, other: Any) -> bool: return ( @@ -278,6 +398,7 @@ class Color: and self.r == other.r and self.g == other.g and self.b == other.b + and self.a == other.a ) diff --git a/tests/draw/test_color.py b/tests/draw/test_color.py index 2e5d4799..e7baca72 100644 --- a/tests/draw/test_color.py +++ b/tests/draw/test_color.py @@ -18,15 +18,41 @@ from supervision.draw.color import Color ("0f0", Color.GREEN, DoesNotRaise()), ("00f", Color.BLUE, DoesNotRaise()), ("#808000", Color(r=128, g=128, b=0), DoesNotRaise()), + # RGBA hex codes (4 digits) + ("f0f8", Color(r=255, g=0, b=255, a=136), DoesNotRaise()), + ("#f0f8", Color(r=255, g=0, b=255, a=136), DoesNotRaise()), + ("ffff", Color(r=255, g=255, b=255, a=255), DoesNotRaise()), + ("f008", Color(r=255, g=0, b=0, a=136), DoesNotRaise()), + # RGBA hex codes (8 digits) + ("ff00ff80", Color(r=255, g=0, b=255, a=128), DoesNotRaise()), + ("#ff00ff80", Color(r=255, g=0, b=255, a=128), DoesNotRaise()), + ("ffffff00", Color(r=255, g=255, b=255, a=0), DoesNotRaise()), + ("00ff00ff", Color(r=0, g=255, b=0, a=255), DoesNotRaise()), + # Invalid hex codes ("", None, pytest.raises(ValueError, match="Invalid length of color hash")), ("00", None, pytest.raises(ValueError, match="Invalid length of color hash")), - ("0000", None, pytest.raises(ValueError, match="Invalid length of color hash")), + ( + "00000", + None, + pytest.raises(ValueError, match="Invalid length of color hash"), + ), ( "0000000", None, pytest.raises(ValueError, match="Invalid length of color hash"), ), + ( + "000000000", + None, + pytest.raises(ValueError, match="Invalid length of color hash"), + ), ("ffg", None, pytest.raises(ValueError, match="Invalid characters in color")), + ("fffg", None, pytest.raises(ValueError, match="Invalid characters in color")), + ( + "ff00ffgg", + None, + pytest.raises(ValueError, match="Invalid characters in color"), + ), ], ) def test_color_from_hex( @@ -35,10 +61,11 @@ def test_color_from_hex( """ Verify that Color.from_hex correctly parses various hex string formats. - Scenario: Creating a `Color` object from various hex string formats (3-digit, - 6-digit, with/without # prefix). - Expected: Correct RGB values are parsed, and invalid hex strings raise `ValueError`. - This allows users to define colors using familiar web formats. + Scenario: Creating a `Color` object from various hex string formats (3-, 4-, 6-, + and 8-digit, with/without # prefix). + Expected: Correct RGBA values are parsed, and invalid hex strings raise + `ValueError`. This allows users to define colors using familiar web formats + including alpha. """ with exception: result = Color.from_hex(color_hex=color_hex) @@ -54,6 +81,11 @@ def test_color_from_hex( (Color.GREEN, "#00ff00", DoesNotRaise()), (Color.BLUE, "#0000ff", DoesNotRaise()), (Color(r=128, g=128, b=0), "#808000", DoesNotRaise()), + # With alpha channel + (Color(r=255, g=0, b=255, a=128), "#ff00ff80", DoesNotRaise()), + (Color(r=255, g=255, b=255, a=255), "#ffffff", DoesNotRaise()), + (Color(r=0, g=255, b=0, a=0), "#00ff0000", DoesNotRaise()), + (Color(r=128, g=128, b=0, a=200), "#808000c8", DoesNotRaise()), ], ) def test_color_as_hex( @@ -63,8 +95,8 @@ def test_color_as_hex( Verify that Color.as_hex correctly converts Color objects to hex strings. Scenario: Converting a `Color` object back to a hex string. - Expected: Correct 6-digit hex string with # prefix is returned, ensuring - round-trip consistency for color definitions. + Expected: 6-digit hex (#RRGGBB) when alpha is 255, 8-digit hex (#RRGGBBAA) + otherwise, ensuring round-trip consistency for color definitions. """ with exception: result = color.as_hex() @@ -155,3 +187,131 @@ def test_color_from_bgr_tuple( with exception: result = Color.from_bgr_tuple(color_tuple=color_tuple) assert result == expected_result + + +@pytest.mark.parametrize( + ("color_tuple", "expected_result", "exception"), + [ + ((255, 255, 0, 128), Color(r=255, g=255, b=0, a=128), DoesNotRaise()), + ((0, 255, 255, 255), Color(r=0, g=255, b=255, a=255), DoesNotRaise()), + ((128, 0, 128, 0), Color(r=128, g=0, b=128, a=0), DoesNotRaise()), + ( + (300, 0, 0, 128), + None, + pytest.raises( + ValueError, match=r"RGBA values must be in range.*300, 0, 0, 128" + ), + ), + ( + (0, 0, 0, 300), + None, + pytest.raises( + ValueError, match=r"RGBA values must be in range.*0, 0, 0, 300" + ), + ), + ], +) +def test_color_from_rgba_tuple( + color_tuple: tuple[int, int, int, int], + expected_result: Color | None, + exception: Exception, +) -> None: + with exception: + result = Color.from_rgba_tuple(color_tuple=color_tuple) + assert result == expected_result + + +@pytest.mark.parametrize( + ("color_tuple", "expected_result", "exception"), + [ + ((0, 255, 255, 128), Color(r=255, g=255, b=0, a=128), DoesNotRaise()), + ((255, 255, 0, 255), Color(r=0, g=255, b=255, a=255), DoesNotRaise()), + ((128, 0, 128, 0), Color(r=128, g=0, b=128, a=0), DoesNotRaise()), + ( + (300, 0, 0, 128), + None, + pytest.raises( + ValueError, match=r"BGRA values must be in range.*300, 0, 0, 128" + ), + ), + ( + (0, 0, 0, 300), + None, + pytest.raises( + ValueError, match=r"BGRA values must be in range.*0, 0, 0, 300" + ), + ), + ], +) +def test_color_from_bgra_tuple( + color_tuple: tuple[int, int, int, int], + expected_result: Color | None, + exception: Exception, +) -> None: + with exception: + result = Color.from_bgra_tuple(color_tuple=color_tuple) + assert result == expected_result + + +@pytest.mark.parametrize( + ("color", "expected_result", "exception"), + [ + (Color(r=255, g=255, b=0, a=128), (255, 255, 0, 128), DoesNotRaise()), + (Color(r=0, g=255, b=255, a=255), (0, 255, 255, 255), DoesNotRaise()), + (Color(r=128, g=0, b=128, a=0), (128, 0, 128, 0), DoesNotRaise()), + ], +) +def test_color_as_rgba( + color: Color, + expected_result: tuple[int, int, int, int] | None, + exception: Exception, +) -> None: + with exception: + result = color.as_rgba() + assert result == expected_result + + +@pytest.mark.parametrize( + ("color", "expected_result", "exception"), + [ + (Color(r=255, g=255, b=0, a=128), (0, 255, 255, 128), DoesNotRaise()), + (Color(r=0, g=255, b=255, a=255), (255, 255, 0, 255), DoesNotRaise()), + (Color(r=128, g=0, b=128, a=0), (128, 0, 128, 0), DoesNotRaise()), + ], +) +def test_color_as_bgra( + color: Color, + expected_result: tuple[int, int, int, int] | None, + exception: Exception, +) -> None: + with exception: + result = color.as_bgra() + assert result == expected_result + + +@pytest.mark.parametrize( + ("color", "expected_repr"), + [ + (Color(r=255, g=255, b=255), "Color(r=255, g=255, b=255)"), + (Color(r=0, g=0, b=0), "Color(r=0, g=0, b=0)"), + (Color(r=255, g=0, b=255, a=255), "Color(r=255, g=0, b=255)"), + (Color(r=255, g=0, b=255, a=128), "Color(r=255, g=0, b=255, a=128)"), + (Color(r=255, g=0, b=255, a=0), "Color(r=255, g=0, b=255, a=0)"), + ], +) +def test_color_repr(color: Color, expected_repr: str) -> None: + assert repr(color) == expected_repr + + +@pytest.mark.parametrize( + ("color_a", "color_b", "expect_equal_hash"), + [ + (Color(r=255, g=0, b=0), Color(r=255, g=0, b=0), True), + (Color(r=255, g=0, b=0, a=255), Color(r=255, g=0, b=0), True), + (Color(r=255, g=0, b=0, a=128), Color(r=255, g=0, b=0, a=64), False), + (Color(r=255, g=0, b=0, a=0), Color(r=255, g=0, b=0, a=255), False), + ], +) +def test_color_hash(color_a: Color, color_b: Color, expect_equal_hash: bool) -> None: + assert (hash(color_a) == hash(color_b)) == expect_equal_hash + assert (color_a == color_b) == expect_equal_hash