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:
1 parent
a6ee3e98c7
commit
8b60ee1a0e
2 files changed
+298
-17
No files matched your search
+131
-10
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user