From f8f62891de20f5a4ac2e773141c15526877e9413 Mon Sep 17 00:00:00 2001 From: Soham Walam Date: Fri, 20 Feb 2026 14:28:15 +0530 Subject: [PATCH] =?UTF-8?q?Added=20HEX=E2=86=94RGBA=20color=20utilities=20?= =?UTF-8?q?and=20tests=20(Hacktoberfest=202025)=20(#1988)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Added color utility functions: hex_to_rgba, rgba_to_hex, is_valid_hex with tests * Parametrize hex and RGBA utility tests in `test_utils.py` for improved clarity and coverage * Enhance hex and RGBA utilities: improve test coverage, validations, and docstrings; refactor shared logic. * Refactor color input handling with `_normalize_color_input` utility; add hex string support across annotators and enhance test coverage --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com> --- src/supervision/__init__.py | 10 ++- src/supervision/annotators/core.py | 115 +++++++++++++++++----------- src/supervision/annotators/utils.py | 69 ++++++++++++++++- tests/annotators/test_core.py | 48 ++++++++++++ tests/annotators/test_utils.py | 76 +++++++++++++++++- 5 files changed, 267 insertions(+), 51 deletions(-) diff --git a/src/supervision/__init__.py b/src/supervision/__init__.py index 00820076..1bda2816 100644 --- a/src/supervision/__init__.py +++ b/src/supervision/__init__.py @@ -31,7 +31,12 @@ from supervision.annotators.core import ( TraceAnnotator, TriangleAnnotator, ) -from supervision.annotators.utils import ColorLookup +from supervision.annotators.utils import ( + ColorLookup, + hex_to_rgba, + is_valid_hex, + rgba_to_hex, +) from supervision.classification.core import Classifications from supervision.dataset.core import ( BaseDataset, @@ -228,6 +233,8 @@ __all__ = [ "get_polygon_center", "get_video_frames_generator", "grayscale_image", + "hex_to_rgba", + "is_valid_hex", "letterbox_image", "list_files_with_extensions", "mask_iou_batch", @@ -248,6 +255,7 @@ __all__ = [ "polygon_to_xyxy", "process_video", "resize_image", + "rgba_to_hex", "rle_to_mask", "scale_boxes", "scale_image", diff --git a/src/supervision/annotators/core.py b/src/supervision/annotators/core.py index af844099..57522b66 100644 --- a/src/supervision/annotators/core.py +++ b/src/supervision/annotators/core.py @@ -2,7 +2,7 @@ from __future__ import annotations from functools import lru_cache from math import sqrt -from typing import Any +from typing import Any, overload import cv2 import numpy as np @@ -16,6 +16,7 @@ from supervision.annotators.utils import ( ColorLookup, Trace, get_labels_text, + hex_to_rgba, resolve_color, resolve_text_background_xyxy, snap_boxes, @@ -45,6 +46,30 @@ from supervision.utils.image import ( scale_image, ) + +@overload +def _normalize_color_input(color: Color | str) -> Color: ... + + +@overload +def _normalize_color_input( + color: Color | ColorPalette | str, +) -> Color | ColorPalette: ... + + +def _normalize_color_input(color: Color | ColorPalette | str) -> Color | ColorPalette: + """Normalize accepted color inputs to internal color objects. + + Accepts `Color`, `ColorPalette`, or hex string input. Hex strings are parsed via + `hex_to_rgba` and converted to `Color` (alpha channel is ignored because annotator + drawing uses RGB/BGR colors). + """ + if isinstance(color, str): + r, g, b, _ = hex_to_rgba(color) + return Color.from_rgb_tuple((r, g, b)) + return color + + CV2_FONT = cv2.FONT_HERSHEY_SIMPLEX @@ -70,9 +95,9 @@ class _BaseLabelAnnotator(BaseAnnotator): def __init__( self, - color: Color | ColorPalette = ColorPalette.DEFAULT, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, color_lookup: ColorLookup = ColorLookup.CLASS, - text_color: Color | ColorPalette = Color.WHITE, + text_color: Color | ColorPalette | str = Color.WHITE, text_padding: int = 10, text_position: Position = Position.TOP_LEFT, text_offset: tuple[int, int] = (0, 0), @@ -102,9 +127,9 @@ class _BaseLabelAnnotator(BaseAnnotator): max_line_length: Maximum number of characters per line before wrapping the text. None means no wrapping. """ - self.color: Color | ColorPalette = color + self.color: Color | ColorPalette = _normalize_color_input(color) self.color_lookup: ColorLookup = color_lookup - self.text_color: Color | ColorPalette = text_color + self.text_color: Color | ColorPalette = _normalize_color_input(text_color) self.text_padding: int = text_padding self.text_anchor: Position = text_position self.text_offset: tuple[int, int] = text_offset @@ -162,7 +187,7 @@ class BoxAnnotator(BaseAnnotator): def __init__( self, - color: Color | ColorPalette = ColorPalette.DEFAULT, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, thickness: int = 2, color_lookup: ColorLookup = ColorLookup.CLASS, ): @@ -174,7 +199,7 @@ class BoxAnnotator(BaseAnnotator): color_lookup: Strategy for mapping colors to annotations. Options are `INDEX`, `CLASS`, `TRACK`. """ - self.color: Color | ColorPalette = color + self.color: Color | ColorPalette = _normalize_color_input(color) self.thickness: int = thickness self.color_lookup: ColorLookup = color_lookup @@ -249,7 +274,7 @@ class OrientedBoxAnnotator(BaseAnnotator): def __init__( self, - color: Color | ColorPalette = ColorPalette.DEFAULT, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, thickness: int = 2, color_lookup: ColorLookup = ColorLookup.CLASS, ): @@ -261,7 +286,7 @@ class OrientedBoxAnnotator(BaseAnnotator): color_lookup: Strategy for mapping colors to annotations. Options are `INDEX`, `CLASS`, `TRACK`. """ - self.color: Color | ColorPalette = color + self.color: Color | ColorPalette = _normalize_color_input(color) self.thickness: int = thickness self.color_lookup: ColorLookup = color_lookup @@ -340,7 +365,7 @@ class MaskAnnotator(BaseAnnotator): def __init__( self, - color: Color | ColorPalette = ColorPalette.DEFAULT, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, opacity: float = 0.5, color_lookup: ColorLookup = ColorLookup.CLASS, ): @@ -352,7 +377,7 @@ class MaskAnnotator(BaseAnnotator): color_lookup: Strategy for mapping colors to annotations. Options are `INDEX`, `CLASS`, `TRACK`. """ - self.color: Color | ColorPalette = color + self.color: Color | ColorPalette = _normalize_color_input(color) self.opacity = opacity self.color_lookup: ColorLookup = color_lookup @@ -435,7 +460,7 @@ class PolygonAnnotator(BaseAnnotator): def __init__( self, - color: Color | ColorPalette = ColorPalette.DEFAULT, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, thickness: int = 2, color_lookup: ColorLookup = ColorLookup.CLASS, ): @@ -447,7 +472,7 @@ class PolygonAnnotator(BaseAnnotator): color_lookup: Strategy for mapping colors to annotations. Options are `INDEX`, `CLASS`, `TRACK`. """ - self.color: Color | ColorPalette = color + self.color: Color | ColorPalette = _normalize_color_input(color) self.thickness: int = thickness self.color_lookup: ColorLookup = color_lookup @@ -526,7 +551,7 @@ class ColorAnnotator(BaseAnnotator): def __init__( self, - color: Color | ColorPalette = ColorPalette.DEFAULT, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, opacity: float = 0.5, color_lookup: ColorLookup = ColorLookup.CLASS, ): @@ -538,7 +563,7 @@ class ColorAnnotator(BaseAnnotator): color_lookup: Strategy for mapping colors to annotations. Options are `INDEX`, `CLASS`, `TRACK`. """ - self.color: Color | ColorPalette = color + self.color: Color | ColorPalette = _normalize_color_input(color) self.color_lookup: ColorLookup = color_lookup self.opacity = opacity @@ -622,7 +647,7 @@ class HaloAnnotator(BaseAnnotator): def __init__( self, - color: Color | ColorPalette = ColorPalette.DEFAULT, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, opacity: float = 0.8, kernel_size: int = 40, color_lookup: ColorLookup = ColorLookup.CLASS, @@ -637,7 +662,7 @@ class HaloAnnotator(BaseAnnotator): color_lookup: Strategy for mapping colors to annotations. Options are `INDEX`, `CLASS`, `TRACK`. """ - self.color: Color | ColorPalette = color + self.color: Color | ColorPalette = _normalize_color_input(color) self.opacity = opacity self.color_lookup: ColorLookup = color_lookup self.kernel_size: int = kernel_size @@ -724,7 +749,7 @@ class EllipseAnnotator(BaseAnnotator): def __init__( self, - color: Color | ColorPalette = ColorPalette.DEFAULT, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, thickness: int = 2, start_angle: int = -45, end_angle: int = 235, @@ -740,7 +765,7 @@ class EllipseAnnotator(BaseAnnotator): color_lookup: Strategy for mapping colors to annotations. Options are `INDEX`, `CLASS`, `TRACK`. """ - self.color: Color | ColorPalette = color + self.color: Color | ColorPalette = _normalize_color_input(color) self.thickness: int = thickness self.start_angle: int = start_angle self.end_angle: int = end_angle @@ -823,7 +848,7 @@ class BoxCornerAnnotator(BaseAnnotator): def __init__( self, - color: Color | ColorPalette = ColorPalette.DEFAULT, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, thickness: int = 4, corner_length: int = 15, color_lookup: ColorLookup = ColorLookup.CLASS, @@ -837,7 +862,7 @@ class BoxCornerAnnotator(BaseAnnotator): color_lookup: Strategy for mapping colors to annotations. Options are `INDEX`, `CLASS`, `TRACK`. """ - self.color: Color | ColorPalette = color + self.color: Color | ColorPalette = _normalize_color_input(color) self.thickness: int = thickness self.corner_length: int = corner_length self.color_lookup: ColorLookup = color_lookup @@ -918,7 +943,7 @@ class CircleAnnotator(BaseAnnotator): def __init__( self, - color: Color | ColorPalette = ColorPalette.DEFAULT, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, thickness: int = 2, color_lookup: ColorLookup = ColorLookup.CLASS, ): @@ -931,7 +956,7 @@ class CircleAnnotator(BaseAnnotator): Options are `INDEX`, `CLASS`, `TRACK`. """ - self.color: Color | ColorPalette = color + self.color: Color | ColorPalette = _normalize_color_input(color) self.thickness: int = thickness self.color_lookup: ColorLookup = color_lookup @@ -1011,12 +1036,12 @@ class DotAnnotator(BaseAnnotator): def __init__( self, - color: Color | ColorPalette = ColorPalette.DEFAULT, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, radius: int = 4, position: Position = Position.CENTER, color_lookup: ColorLookup = ColorLookup.CLASS, outline_thickness: int = 0, - outline_color: Color | ColorPalette = Color.BLACK, + outline_color: Color | ColorPalette | str = Color.BLACK, ): """ Args: @@ -1031,12 +1056,12 @@ class DotAnnotator(BaseAnnotator): use for outline. It is activated by setting outline_thickness to a value greater than 0. """ - self.color: Color | ColorPalette = color + self.color: Color | ColorPalette = _normalize_color_input(color) self.radius: int = radius self.position: Position = position self.color_lookup: ColorLookup = color_lookup self.outline_thickness = outline_thickness - self.outline_color: Color | ColorPalette = outline_color + self.outline_color: Color | ColorPalette = _normalize_color_input(outline_color) @ensure_cv2_image_for_class_method def annotate( @@ -1121,9 +1146,9 @@ class LabelAnnotator(_BaseLabelAnnotator): def __init__( self, - color: Color | ColorPalette = ColorPalette.DEFAULT, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, color_lookup: ColorLookup = ColorLookup.CLASS, - text_color: Color | ColorPalette = Color.WHITE, + text_color: Color | ColorPalette | str = Color.WHITE, text_scale: float = 0.5, text_thickness: int = 1, text_padding: int = 10, @@ -1437,9 +1462,9 @@ class RichLabelAnnotator(_BaseLabelAnnotator): def __init__( self, - color: Color | ColorPalette = ColorPalette.DEFAULT, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, color_lookup: ColorLookup = ColorLookup.CLASS, - text_color: Color | ColorPalette = Color.WHITE, + text_color: Color | ColorPalette | str = Color.WHITE, font_path: str | None = None, font_size: int = 10, text_padding: int = 10, @@ -1885,7 +1910,7 @@ class TraceAnnotator(BaseAnnotator): def __init__( self, - color: Color | ColorPalette = ColorPalette.DEFAULT, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, position: Position = Position.CENTER, trace_length: int = 30, thickness: int = 2, @@ -1905,7 +1930,7 @@ class TraceAnnotator(BaseAnnotator): color_lookup: Strategy for mapping colors to annotations. Options are `INDEX`, `CLASS`, `TRACK`. """ - self.color: Color | ColorPalette = color + self.color: Color | ColorPalette = _normalize_color_input(color) self.trace = Trace(max_size=trace_length, anchor=position) self.thickness = thickness self.smooth = smooth @@ -2190,13 +2215,13 @@ class TriangleAnnotator(BaseAnnotator): def __init__( self, - color: Color | ColorPalette = ColorPalette.DEFAULT, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, base: int = 10, height: int = 10, position: Position = Position.TOP_CENTER, color_lookup: ColorLookup = ColorLookup.CLASS, outline_thickness: int = 0, - outline_color: Color | ColorPalette = Color.BLACK, + outline_color: Color | ColorPalette | str = Color.BLACK, ): """ Args: @@ -2212,13 +2237,13 @@ class TriangleAnnotator(BaseAnnotator): use for outline. It is activated by setting outline_thickness to a value greater than 0. """ - self.color: Color | ColorPalette = color + self.color: Color | ColorPalette = _normalize_color_input(color) self.base: int = base self.height: int = height self.position: Position = position self.color_lookup: ColorLookup = color_lookup self.outline_thickness: int = outline_thickness - self.outline_color: Color | ColorPalette = outline_color + self.outline_color: Color | ColorPalette = _normalize_color_input(outline_color) @ensure_cv2_image_for_class_method def annotate( @@ -2312,7 +2337,7 @@ class RoundBoxAnnotator(BaseAnnotator): def __init__( self, - color: Color | ColorPalette = ColorPalette.DEFAULT, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, thickness: int = 2, color_lookup: ColorLookup = ColorLookup.CLASS, roundness: float = 0.6, @@ -2329,7 +2354,7 @@ class RoundBoxAnnotator(BaseAnnotator): By default roundness percent is calculated based on smaller side length (width or height). """ - self.color: Color | ColorPalette = color + self.color: Color | ColorPalette = _normalize_color_input(color) self.thickness: int = thickness self.color_lookup: ColorLookup = color_lookup if not 0 < roundness <= 1.0: @@ -2449,8 +2474,8 @@ class PercentageBarAnnotator(BaseAnnotator): self, height: int = 16, width: int = 80, - color: Color | ColorPalette = ColorPalette.DEFAULT, - border_color: Color = Color.BLACK, + color: Color | ColorPalette | str = ColorPalette.DEFAULT, + border_color: Color | str = Color.BLACK, position: Position = Position.TOP_CENTER, color_lookup: ColorLookup = ColorLookup.CLASS, border_thickness: int | None = None, @@ -2469,8 +2494,8 @@ class PercentageBarAnnotator(BaseAnnotator): """ self.height: int = height self.width: int = width - self.color: Color | ColorPalette = color - self.border_color: Color = border_color + self.color: Color | ColorPalette = _normalize_color_input(color) + self.border_color: Color = _normalize_color_input(border_color) self.position: Position = position self.color_lookup: ColorLookup = color_lookup @@ -2644,7 +2669,7 @@ class CropAnnotator(BaseAnnotator): self, position: Position = Position.TOP_CENTER, scale_factor: float = 2.0, - border_color: Color | ColorPalette = ColorPalette.DEFAULT, + border_color: Color | ColorPalette | str = ColorPalette.DEFAULT, border_thickness: int = 2, border_color_lookup: ColorLookup = ColorLookup.CLASS, ): @@ -2663,7 +2688,7 @@ class CropAnnotator(BaseAnnotator): """ self.position: Position = position self.scale_factor: float = scale_factor - self.border_color: Color | ColorPalette = border_color + self.border_color: Color | ColorPalette = _normalize_color_input(border_color) self.border_thickness: int = border_thickness self.border_color_lookup: ColorLookup = border_color_lookup diff --git a/src/supervision/annotators/utils.py b/src/supervision/annotators/utils.py index 6bb6417b..652f176d 100644 --- a/src/supervision/annotators/utils.py +++ b/src/supervision/annotators/utils.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re import textwrap from enum import Enum from typing import Any @@ -364,7 +365,67 @@ class Trace: self.current_frame_id += 1 def get(self, tracker_id: int) -> np.ndarray[Any, np.dtype[np.float32]]: - result: np.ndarray[Any, np.dtype[np.float32]] = self.xy[ - self.tracker_id == tracker_id - ].copy() - return result + filtered: np.ndarray[Any, np.dtype[np.float32]] = ( + self.xy[self.tracker_id == tracker_id].copy().astype(np.float32, copy=False) + ) + return filtered + + +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 as exc: + raise ValueError(f"Invalid hex digits in {hex_color}") from exc + 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. + + Args: + rgba: RGBA values in range 0-255. + + Returns: + Hex color string in the format "#RRGGBBAA". + + Raises: + ValueError: If `rgba` is not a 4-tuple or contains values outside 0-255. + """ + 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. + + Args: + hex_color: A hex color string with an optional leading "#". Supports + 6-digit (RGB) or 8-digit (RGBA) formats. + + Returns: + True if the string is a valid 6- or 8-digit hex color, otherwise False. + """ + return bool(re.fullmatch(r"#?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?", hex_color.strip())) diff --git a/tests/annotators/test_core.py b/tests/annotators/test_core.py index 778418e8..c0a511f4 100644 --- a/tests/annotators/test_core.py +++ b/tests/annotators/test_core.py @@ -25,6 +25,7 @@ from supervision.annotators.core import ( PolygonAnnotator, RichLabelAnnotator, RoundBoxAnnotator, + TraceAnnotator, TriangleAnnotator, ) from supervision.annotators.utils import ColorLookup @@ -58,6 +59,53 @@ def gradient_image() -> np.ndarray: return image +@pytest.mark.parametrize( + ("factory", "expected_colors"), + [ + (lambda: BoxAnnotator(color="#010203"), {"color": (1, 2, 3)}), + (lambda: OrientedBoxAnnotator(color="#010203"), {"color": (1, 2, 3)}), + (lambda: MaskAnnotator(color="#010203"), {"color": (1, 2, 3)}), + (lambda: PolygonAnnotator(color="#010203"), {"color": (1, 2, 3)}), + (lambda: ColorAnnotator(color="#010203"), {"color": (1, 2, 3)}), + (lambda: HaloAnnotator(color="#010203"), {"color": (1, 2, 3)}), + (lambda: EllipseAnnotator(color="#010203"), {"color": (1, 2, 3)}), + (lambda: BoxCornerAnnotator(color="#010203"), {"color": (1, 2, 3)}), + (lambda: CircleAnnotator(color="#010203"), {"color": (1, 2, 3)}), + ( + lambda: DotAnnotator(color="#010203", outline_color="#040506"), + {"color": (1, 2, 3), "outline_color": (4, 5, 6)}, + ), + ( + lambda: LabelAnnotator(color="#010203", text_color="#040506"), + {"color": (1, 2, 3), "text_color": (4, 5, 6)}, + ), + ( + lambda: RichLabelAnnotator(color="#010203", text_color="#040506"), + {"color": (1, 2, 3), "text_color": (4, 5, 6)}, + ), + (lambda: TraceAnnotator(color="#010203"), {"color": (1, 2, 3)}), + ( + lambda: TriangleAnnotator(color="#010203", outline_color="#040506"), + {"color": (1, 2, 3), "outline_color": (4, 5, 6)}, + ), + (lambda: RoundBoxAnnotator(color="#010203"), {"color": (1, 2, 3)}), + ( + lambda: PercentageBarAnnotator(color="#010203", border_color="#040506"), + {"color": (1, 2, 3), "border_color": (4, 5, 6)}, + ), + (lambda: CropAnnotator(border_color="#010203"), {"border_color": (1, 2, 3)}), + ], +) +def test_hex_color_support_across_annotators( + factory, expected_colors: dict[str, tuple[int, int, int]] +) -> None: + annotator = factory() + for attribute_name, expected_rgb in expected_colors.items(): + color = getattr(annotator, attribute_name) + assert isinstance(color, Color) + assert color.as_rgb() == expected_rgb + + class TestBoxAnnotator: """ Verify that BoxAnnotator correctly draws bounding boxes on an image. diff --git a/tests/annotators/test_utils.py b/tests/annotators/test_utils.py index 49de4a21..51642cfe 100644 --- a/tests/annotators/test_utils.py +++ b/tests/annotators/test_utils.py @@ -5,7 +5,14 @@ from contextlib import ExitStack as DoesNotRaise import numpy as np import pytest -from supervision.annotators.utils import ColorLookup, resolve_color_idx, wrap_text +from supervision.annotators.utils import ( + ColorLookup, + hex_to_rgba, + is_valid_hex, + resolve_color_idx, + rgba_to_hex, + wrap_text, +) from supervision.detection.core import Detections from tests.helpers import _create_detections @@ -172,3 +179,70 @@ def test_wrap_text( with exception: result = wrap_text(text=text, max_line_length=max_line_length) assert result == expected_result + + +@pytest.mark.parametrize( + ("hex_color", "expected_rgba"), + [ + ("#FF00FF", (255, 0, 255, 255)), + ("FF00FF", (255, 0, 255, 255)), + ("#FF00FF80", (255, 0, 255, 128)), + ("00FF0080", (0, 255, 0, 128)), + (" #ff00ff80 ", (255, 0, 255, 128)), + ("abcdef", (171, 205, 239, 255)), + ], +) +def test_hex_to_rgba_valid( + hex_color: str, expected_rgba: tuple[int, int, int, int] +) -> None: + assert hex_to_rgba(hex_color) == expected_rgba + + +@pytest.mark.parametrize("hex_color", ["#FF00F", "#GGHHII", "#FFF", "1234567"]) +def test_hex_to_rgba_invalid(hex_color: str) -> None: + with pytest.raises(ValueError, match="Invalid hex"): + hex_to_rgba(hex_color) + + +@pytest.mark.parametrize( + ("rgba", "expected_hex"), + [ + ((0, 0, 0, 0), "#00000000"), + ((255, 0, 255, 255), "#FF00FFFF"), + ((0, 255, 0, 128), "#00FF0080"), + ((255, 255, 255, 255), "#FFFFFFFF"), + ], +) +def test_rgba_to_hex(rgba: tuple[int, int, int, int], expected_hex: str) -> None: + assert rgba_to_hex(rgba) == expected_hex + + +@pytest.mark.parametrize( + "rgba", + [ + (255, 0, 0), + (256, 0, 0, 255), + (-1, 0, 0, 255), + (255, 0, 0, -1), + ], +) +def test_rgba_to_hex_invalid(rgba: tuple[int, ...]) -> None: + with pytest.raises(ValueError, match="RGBA must be a 4-tuple"): + rgba_to_hex(rgba) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + ("hex_color", "expected_result"), + [ + ("#FF00FF", True), + ("ff00ff", True), + ("00FF0080", True), + (" 00ff0080 ", True), + ("#XYZ123", False), + ("FF00F", False), + ("#FFF", False), + ("#1234567", False), + ], +) +def test_is_valid_hex(hex_color: str, expected_result: bool) -> None: + assert is_valid_hex(hex_color) is expected_result