Added HEX↔RGBA color utilities and tests (Hacktoberfest 2025) (#1988)
* 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>
This commit is contained in:
parent
162a46cae1
commit
f8f62891de
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue