diff --git a/supervision/draw/color.py b/supervision/draw/color.py index 705d5922c..1fc5811e1 100644 --- a/supervision/draw/color.py +++ b/supervision/draw/color.py @@ -57,29 +57,33 @@ def _validate_color_hex(color_hex: str): 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, and RGBA tuples. Attributes: r (int): Red channel value (0-255). g (int): Green channel value (0-255). b (int): Blue channel value (0-255). + a (int): Alpha channel value (0-255). Default is 255 (fully opaque). Example: ```python import supervision as sv sv.Color.WHITE - # Color(r=255, g=255, b=255) + # Color(r=255, g=255, b=255, a=255) + + sv.Color(r=255, g=0, b=255, a=128) + # Color(r=255, g=0, b=255, a=128) ``` | Constant | Hex Code | RGB | @@ -97,6 +101,7 @@ class Color: r: int g: int b: int + a: int = 255 @classmethod def from_hex(cls, color_hex: str) -> Color: @@ -105,9 +110,11 @@ def from_hex(cls, color_hex: str) -> Color: Args: color_hex (str): 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. + - 3 characters: RGB (e.g., '#f0f' -> #ff00ff) + - 4 characters: RGBA (e.g., '#f0f8' -> #ff00ff88) + - 6 characters: RRGGBB (e.g., '#ff00ff') + - 8 characters: RRGGBBAA (e.g., '#ff00ff80') Returns: Color: An instance representing the color. @@ -117,18 +124,31 @@ def from_hex(cls, color_hex: str) -> Color: import supervision as sv sv.Color.from_hex('#ff00ff') - # Color(r=255, g=0, b=255) + # Color(r=255, g=0, b=255, a=255) sv.Color.from_hex('#f0f') - # Color(r=255, g=0, b=255) + # Color(r=255, g=0, b=255, a=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: @@ -176,12 +196,59 @@ def from_bgr_tuple(cls, color_tuple: tuple[int, int, int]) -> Color: b, g, r = color_tuple 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 (Tuple[int, int, int, int]): A tuple representing the color + in RGBA format, where each element is an integer in the range 0-255. + + Returns: + Color: An instance representing the color. + + Example: + ```python + 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 + 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 (Tuple[int, int, int, int]): A tuple representing the color + in BGRA format, where each element is an integer in the range 0-255. + + Returns: + Color: An instance representing the color. + + Example: + ```python + 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 + return cls(r=r, g=g, b=b, a=a) + def as_hex(self) -> str: """ Converts the Color instance to a hex string. Returns: - str: The hexadecimal color string. + str: The hexadecimal color string. Returns #RRGGBBAA if alpha is not 255, + otherwise returns #RRGGBB. Example: ```python @@ -189,8 +256,13 @@ def as_hex(self) -> str: 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]: @@ -227,6 +299,40 @@ def as_bgr(self) -> tuple[int, int, int]: """ return self.b, self.g, self.r + def as_rgba(self) -> tuple[int, int, int, int]: + """ + Returns the color as an RGBA tuple. + + Returns: + Tuple[int, int, int, int]: RGBA tuple. + + Example: + ```python + 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: + Tuple[int, int, int, int]: BGRA tuple. + + Example: + ```python + 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") @@ -260,7 +366,7 @@ def ROBOFLOW(cls) -> Color: return Color.from_hex("#A351FB") def __hash__(self): - return hash((self.r, self.g, self.b)) + return hash((self.r, self.g, self.b, self.a)) def __eq__(self, other): return ( @@ -268,6 +374,7 @@ def __eq__(self, other): and self.r == other.r and self.g == other.g and self.b == other.b + and self.a == other.a ) diff --git a/test/draw/test_color.py b/test/draw/test_color.py index 05ad68627..0f0cf671f 100644 --- a/test/draw/test_color.py +++ b/test/draw/test_color.py @@ -18,11 +18,25 @@ ("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)), ("00", None, pytest.raises(ValueError)), - ("0000", None, pytest.raises(ValueError)), + ("00000", None, pytest.raises(ValueError)), ("0000000", None, pytest.raises(ValueError)), + ("000000000", None, pytest.raises(ValueError)), ("ffg", None, pytest.raises(ValueError)), + ("fffg", None, pytest.raises(ValueError)), + ("ff00ffgg", None, pytest.raises(ValueError)), ], ) def test_color_from_hex( @@ -42,6 +56,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( @@ -50,3 +69,75 @@ def test_color_as_hex( with exception: result = color.as_hex() 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()), + ], +) +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()), + ], +) +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 diff --git a/uv.lock b/uv.lock index 4dd081c41..40ce6c37d 100644 --- a/uv.lock +++ b/uv.lock @@ -3522,7 +3522,7 @@ wheels = [ [[package]] name = "supervision" -version = "0.26.0rc7" +version = "0.27.0rc1" source = { editable = "." } dependencies = [ { name = "defusedxml" },