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 <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
authored and jirka committed 2026-03-30 15:57:18 +02:00
1 parent a6ee3e98c7
commit 8b60ee1a0e
2 files changed
+298 -17

No files matched your search

+131 -10
View File
@@ -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
)
+167 -7
View File
@@ -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