from __future__ import annotations

from typing import Any, Literal, cast

import numpy as np
import numpy.typing as npt

from supervision import _cv2 as cv2

MIN_POLYGON_POINT_COUNT = 3
CoordinateConvention = Literal["inclusive", "exclusive"]


def xyxy_to_polygons(box: npt.NDArray[np.number]) -> npt.NDArray[np.number]:
    """
    Convert an array of boxes to an array of polygons.
    Retains the input datatype.

    Args:
        box: An array of boxes (N, 4), where each box is represented as a
            list of four coordinates in the format `(x_min, y_min, x_max, y_max)`.

    Returns:
        An array of polygons (N, 4, 2), where each polygon is
            represented as a list of four coordinates in the format `(x, y)`.

    Examples:
        ```pycon
        >>> import numpy as np
        >>> import supervision as sv
        >>> box = np.array([[10, 20, 30, 40]])
        >>> sv.xyxy_to_polygons(box)
        array([[[10, 20],
                [30, 20],
                [30, 40],
                [10, 40]]])

        ```
    """
    polygon = np.zeros((box.shape[0], 4, 2), dtype=box.dtype)
    polygon[:, :, 0] = box[:, [0, 2, 2, 0]]
    polygon[:, :, 1] = box[:, [1, 1, 3, 3]]
    return polygon


def polygon_to_mask(
    polygon: npt.NDArray[np.number],
    resolution_wh: tuple[int, int],
) -> npt.NDArray[np.uint8]:
    """Generate a mask from a polygon.

    Args:
        polygon: The polygon for which the mask should be generated,
            given as a list of vertices.
        resolution_wh: The width and height of the desired resolution.

    Returns:
        The generated 2D mask, where the polygon is marked with
            `1`s and the rest is filled with `0`s.

    Examples:
        ```pycon
        >>> import numpy as np
        >>> import supervision as sv
        >>> polygon = np.array([[2, 2], [6, 2], [6, 6], [2, 6]])
        >>> mask = sv.polygon_to_mask(polygon, resolution_wh=(10, 10))
        >>> int(mask.sum())
        25

        ```
    """
    width, height = map(int, resolution_wh)
    mask = np.zeros((height, width), dtype=np.uint8)
    cv2.fillPoly(mask, [polygon.astype(np.int32)], color=(1,))
    return mask


def xywh_to_xyxy(xywh: npt.NDArray[np.number]) -> npt.NDArray[np.number]:
    """
    Converts bounding box coordinates from `(x, y, width, height)`
    format to `(x_min, y_min, x_max, y_max)` format.

    Args:
        xywh: A numpy array of shape `(N, 4)` where each row
            corresponds to a bounding box in the format `(x, y, width, height)`.

    Returns:
        A numpy array of shape `(N, 4)` where each row corresponds
            to a bounding box in the format `(x_min, y_min, x_max, y_max)`.

    Examples:
        ```pycon
        >>> import numpy as np
        >>> import supervision as sv
        >>> xywh = np.array([
        ...     [10, 20, 30, 40],
        ...     [15, 25, 35, 45]
        ... ])
        >>> sv.xywh_to_xyxy(xywh=xywh)
        array([[10, 20, 40, 60],
               [15, 25, 50, 70]])

        ```
    """
    xyxy = xywh.copy()
    xyxy[:, 2] = xywh[:, 0] + xywh[:, 2]
    xyxy[:, 3] = xywh[:, 1] + xywh[:, 3]
    return cast(npt.NDArray[np.number], np.asarray(xyxy))


def xyxy_to_xywh(xyxy: npt.NDArray[np.number]) -> npt.NDArray[np.number]:
    """
    Converts bounding box coordinates from `(x_min, y_min, x_max, y_max)`
    format to `(x, y, width, height)` format.

    Args:
        xyxy: A numpy array of shape `(N, 4)` where each row
            corresponds to a bounding box in the format `(x_min, y_min, x_max,
            y_max)`.

    Returns:
        A numpy array of shape `(N, 4)` where each row corresponds
            to a bounding box in the format `(x, y, width, height)`.

    Examples:
        ```pycon
        >>> import numpy as np
        >>> import supervision as sv
        >>> xyxy = np.array([
        ...     [10, 20, 40, 60],
        ...     [15, 25, 50, 70]
        ... ])
        >>> sv.xyxy_to_xywh(xyxy=xyxy)
        array([[10, 20, 30, 40],
               [15, 25, 35, 45]])

        ```
    """
    xywh = xyxy.copy()
    xywh[:, 2] = xyxy[:, 2] - xyxy[:, 0]
    xywh[:, 3] = xyxy[:, 3] - xyxy[:, 1]
    return cast(npt.NDArray[np.number], np.asarray(xywh))


def xcycwh_to_xyxy(xcycwh: npt.NDArray[np.number]) -> npt.NDArray[np.number]:
    """
    Converts bounding box coordinates from `(center_x, center_y, width, height)`
    format to `(x_min, y_min, x_max, y_max)` format.

    Args:
        xcycwh: A numpy array of shape `(N, 4)` where each row
            corresponds to a bounding box in the format `(center_x, center_y, width,
            height)`.

    Returns:
        A numpy array of shape `(N, 4)` where each row corresponds
            to a bounding box in the format `(x_min, y_min, x_max, y_max)`.

    Examples:
        ```pycon
        >>> import numpy as np
        >>> import supervision as sv
        >>> xcycwh = np.array([
        ...     [50.0, 50.0, 20.0, 30.0],
        ...     [30.0, 40.0, 10.0, 15.0]
        ... ])
        >>> sv.xcycwh_to_xyxy(xcycwh=xcycwh)
        array([[40. , 35. , 60. , 65. ],
               [25. , 32.5, 35. , 47.5]])

        ```
    """
    # Build the result from the half-extent arithmetic instead of writing into a
    # copy of the input. Half of an odd width or height is fractional, so an
    # integer input array would silently truncate those coordinates; letting the
    # division set the output dtype keeps them exact.
    center_x = xcycwh[:, 0]
    center_y = xcycwh[:, 1]
    half_width = xcycwh[:, 2] / 2
    half_height = xcycwh[:, 3] / 2
    xyxy = np.column_stack(
        (
            center_x - half_width,
            center_y - half_height,
            center_x + half_width,
            center_y + half_height,
        )
    )
    return cast(npt.NDArray[np.number], xyxy)


def xyxy_to_xcycarh(xyxy: npt.NDArray[np.number]) -> npt.NDArray[np.floating]:
    """
    Converts bounding box coordinates from `(x_min, y_min, x_max, y_max)`
    into measurement space to format `(center x, center y, aspect ratio, height)`,
    where the aspect ratio is `width / height`.

    Args:
        xyxy: Bounding box in format `(x1, y1, x2, y2)`.
            Expected shape is `(N, 4)`.
    Returns:
        Bounding box in format
            `(center x, center y, aspect ratio, height)`. Shape `(N, 4)`.

    Examples:
        ```pycon
        >>> import numpy as np
        >>> import supervision as sv
        >>> xyxy = np.array([
        ...     [10, 20, 40, 60],
        ...     [15, 25, 50, 70]
        ... ])
        >>> sv.xyxy_to_xcycarh(xyxy=xyxy)  # doctest: +ELLIPSIS
        array([[25.        , 40.        ,  0.75      , 40.        ],
               [32.5       , 47.5       ,  0.77..., 45.        ]])

        ```

    """
    if xyxy.size == 0:
        return np.empty((0, 4), dtype=float)

    x1, y1, x2, y2 = xyxy.T
    width = x2 - x1
    height = y2 - y1
    center_x = x1 + width / 2
    center_y = y1 + height / 2

    aspect_ratio = np.divide(
        width,
        height,
        out=np.zeros_like(width, dtype=float),
        where=height != 0,
    )
    result = np.column_stack((center_x, center_y, aspect_ratio, height))
    return result.astype(float)


def mask_to_xyxy(
    masks: npt.NDArray[np.bool_],
    coordinate_convention: CoordinateConvention = "inclusive",
) -> npt.NDArray[np.int_]:
    """
    Converts a 3D `np.array` of 2D bool masks into a 2D `np.array` of bounding boxes.

    Args:
        masks: A 3D `np.array` of shape `(N, H, W)` containing 2D bool masks.
        coordinate_convention: How to interpret the returned max corner.
            Use `"inclusive"` to preserve the legacy Supervision behavior,
            where `x_max` and `y_max` are the last covered pixel. Use
            `"exclusive"` for half-open boxes that match area and IoU arithmetic.

    Returns:
        A 2D `np.array` of shape `(N, 4)` containing the bounding boxes
            `(x_min, y_min, x_max, y_max)` for each mask.

    Examples:
        ```pycon
        >>> import numpy as np
        >>> import supervision as sv
        >>> masks = np.array([
        ...     [[False, False, False],
        ...      [False,  True,  True],
        ...      [False,  True,  True]],
        ... ])
        >>> sv.mask_to_xyxy(masks)
        array([[1, 1, 2, 2]])

        ```
    """
    n, height, width = masks.shape
    if masks.size == 0:
        return np.zeros((n, 4), dtype=int)

    # Reduce the mask stack to per-row / per-column occupancy, then read the
    # tight bounds straight off those 1D profiles instead of scanning every
    # pixel of every mask with `np.where`.
    rows_any = cast(npt.NDArray[np.bool_], masks.any(axis=2))  # (N, H)
    cols_any = cast(npt.NDArray[np.bool_], masks.any(axis=1))  # (N, W)

    x_min = cols_any.argmax(axis=1)
    y_min = rows_any.argmax(axis=1)

    if coordinate_convention == "inclusive":
        x_max = width - 1 - cols_any[:, ::-1].argmax(axis=1)
        y_max = height - 1 - rows_any[:, ::-1].argmax(axis=1)
    elif coordinate_convention == "exclusive":
        x_max = width - cols_any[:, ::-1].argmax(axis=1)
        y_max = height - rows_any[:, ::-1].argmax(axis=1)
    else:
        raise ValueError(
            "coordinate_convention must be 'inclusive' or 'exclusive', "
            f"got {coordinate_convention!r}."
        )

    xyxy = np.stack((x_min, y_min, x_max, y_max), axis=1).astype(int)
    # Empty masks have no bounds; keep the original all-zeros box for them.
    xyxy[~rows_any.any(axis=1)] = 0
    return xyxy


def xyxy_to_mask(
    boxes: npt.NDArray[np.number],
    resolution_wh: tuple[int, int],
    coordinate_convention: CoordinateConvention = "inclusive",
) -> npt.NDArray[np.bool_]:
    """
    Converts a 2D `np.ndarray` of bounding boxes into a 3D `np.ndarray` of bool masks.

    Args:
        boxes: A 2D `np.ndarray` of shape `(N, 4)` containing bounding boxes
            `(x_min, y_min, x_max, y_max)`.
        resolution_wh: A tuple `(width, height)` specifying the resolution of
            the output masks.
        coordinate_convention: How to interpret `x_max` and `y_max`.
            Use `"inclusive"` for closed boxes with the legacy Supervision
            convention. Use `"exclusive"` for half-open boxes that align with
            box area and IoU calculations.

    Returns:
        A 3D `np.ndarray` of shape `(N, height, width)` containing 2D bool masks
            for each bounding box.

    Examples:
        ```pycon
        >>> import numpy as np
        >>> import supervision as sv
        >>> boxes = np.array([[0, 0, 2, 2]])
        >>> sv.xyxy_to_mask(boxes, (5, 5))
        array([[[ True,  True,  True, False, False],
                [ True,  True,  True, False, False],
                [ True,  True,  True, False, False],
                [False, False, False, False, False],
                [False, False, False, False, False]]])
        >>> boxes = np.array([[0, 0, 1, 1], [3, 3, 4, 4]])
        >>> sv.xyxy_to_mask(boxes, (5, 5))
        array([[[ True,  True, False, False, False],
                [ True,  True, False, False, False],
                [False, False, False, False, False],
                [False, False, False, False, False],
                [False, False, False, False, False]],
        <BLANKLINE>
               [[False, False, False, False, False],
                [False, False, False, False, False],
                [False, False, False, False, False],
                [False, False, False,  True,  True],
                [False, False, False,  True,  True]]])

        ```
    """
    width, height = resolution_wh
    n = boxes.shape[0]
    masks = np.zeros((n, height, width), dtype=bool)

    for i, (x_min, y_min, x_max, y_max) in enumerate(boxes):
        x_min = max(0, int(x_min))
        y_min = max(0, int(y_min))
        if coordinate_convention == "inclusive":
            x_max = min(width - 1, int(x_max))
            y_max = min(height - 1, int(y_max))
            if x_max >= x_min and y_max >= y_min:
                masks[i, y_min : y_max + 1, x_min : x_max + 1] = True
        elif coordinate_convention == "exclusive":
            x_max = min(width, int(x_max))
            y_max = min(height, int(y_max))
            if x_max > x_min and y_max > y_min:
                masks[i, y_min:y_max, x_min:x_max] = True
        else:
            raise ValueError(
                "coordinate_convention must be 'inclusive' or 'exclusive', "
                f"got {coordinate_convention!r}."
            )

    return masks


def mask_to_polygons(mask: npt.NDArray[np.bool_]) -> list[npt.NDArray[np.int32]]:
    """
    Converts a binary mask to a list of polygons.

    Args:
        mask: A binary mask represented as a 2D NumPy array of shape `(H, W)`,
            where H and W are the height and width of the mask, respectively.

    Returns:
        A list of polygons, where each polygon is represented by a NumPy array
            of shape `(N, 2)`, containing the `x`, `y` coordinates of the
            points. Polygons with fewer points than `MIN_POLYGON_POINT_COUNT = 3`
            are excluded from the output.

    Examples:
        ```pycon
        >>> import numpy as np
        >>> import supervision as sv
        >>> mask = np.zeros((10, 10), dtype=bool)
        >>> mask[2:6, 2:6] = True
        >>> sv.mask_to_polygons(mask)
        [array([[2, 2],
               [2, 5],
               [5, 5],
               [5, 2]], dtype=int32)]

        ```
    """

    contours = cv2.find_contours(mask.astype(np.uint8))
    return [
        np.squeeze(contour, axis=1)
        for contour in contours
        if contour.shape[0] >= MIN_POLYGON_POINT_COUNT
    ]


def _base48_decode(s: str) -> list[int]:
    """Decode a COCO base-48 string to raw (delta-encoded) integers.

    Implements the variable-length base-48 codec from the COCO API
    (pycocotools). Each integer is encoded across one or more 6-bit
    characters: bits 0-4 carry data; bit 5 signals continuation; bit 4
    of the final character signals a negative value.

    This is the pure codec layer — call :func:`_delta_decode` on the
    result to obtain absolute run-length counts.

    Args:
        s: COCO compressed RLE string.

    Returns:
        Raw delta-encoded integers, one per run.

    Raises:
        ValueError: If the string is truncated mid-integer.

    Examples:
        ```pycon
        >>> from supervision.detection.utils.converters import _base48_decode
        >>> _base48_decode("52203")
        [5, 2, 2, 0, 3]

        ```
    """
    values: list[int] = []
    i = 0
    while i < len(s):
        x = 0
        k = 0
        more = True
        while more:
            if i >= len(s):
                raise ValueError(
                    f"Malformed compressed RLE string: unexpected end at position {i}"
                )
            c = ord(s[i]) - 48
            x |= (c & 0x1F) << (5 * k)
            more = bool(c & 0x20)
            i += 1
            k += 1
            if not more and (c & 0x10):
                x |= ~0 << (5 * k)
        values.append(x)
    return values


def _base48_encode(values: list[int]) -> str:
    """Encode raw (delta-encoded) integers to a COCO base-48 string.

    The inverse of :func:`_base48_decode`. Applies the same variable-length
    base-48 codec used by pycocotools.

    Apply :func:`_delta_encode` to absolute run-length counts before calling
    this function to produce a valid COCO compressed RLE string.

    Args:
        values: Raw (delta-encoded) integers to encode.

    Returns:
        COCO base-48 encoded string.

    Examples:
        ```pycon
        >>> from supervision.detection.utils.converters import _base48_encode
        >>> _base48_encode([5, 2, 2, 0, 3])
        '52203'

        ```
    """
    chars: list[str] = []
    for x in values:
        more = True
        while more:
            c = x & 0x1F
            x >>= 5
            more = (x != -1) if (c & 0x10) else (x != 0)
            if more:
                c |= 0x20
            chars.append(chr(c + 48))
    return "".join(chars)


def _delta_decode(values: list[int]) -> list[int]:
    """Undo COCO delta encoding: ``counts[i] += counts[i - 2]`` for ``i > 2``.

    The COCO compressed RLE format stores run lengths as deltas relative to
    the count two positions earlier (starting at index 3). This function
    converts those relative values back to absolute run lengths.

    Args:
        values: Raw delta-encoded integers from :func:`_base48_decode`.

    Returns:
        Absolute run-length counts (alternating background / foreground).

    Examples:
        ```pycon
        >>> from supervision.detection.utils.converters import _delta_decode
        >>> _delta_decode([5, 2, 2, 0, 3])
        [5, 2, 2, 2, 5]

        ```
    """
    # The recurrence ``counts[i] += counts[i - 2]`` for ``i >= 3`` decomposes
    # into two independent stride-2 chains: the odd indices (1, 3, 5, ...) and
    # the even indices from 2 onward (2, 4, 6, ...). A cumulative sum over each
    # chain recovers the absolute counts. Index 0 is absolute and untouched.
    # int64 accumulation avoids overflow; ``tolist`` returns native Python ints.
    counts = np.asarray(values, dtype=np.int64)
    counts[1::2] = np.cumsum(counts[1::2])
    counts[2::2] = np.cumsum(counts[2::2])
    return cast("list[int]", counts.tolist())


def _delta_encode(counts: list[int]) -> list[int]:
    """Apply COCO delta encoding: ``d[i] = counts[i] - counts[i - 2]`` for ``i > 2``.

    The inverse of :func:`_delta_decode`. Converts absolute run lengths to
    the relative representation required by the COCO compressed RLE format.

    Args:
        counts: Absolute run-length counts (alternating background / foreground).

    Returns:
        Delta-encoded integers ready for :func:`_base48_encode`.

    Examples:
        ```pycon
        >>> from supervision.detection.utils.converters import _delta_encode
        >>> _delta_encode([5, 2, 2, 2, 5])
        [5, 2, 2, 0, 3]

        ```
    """
    deltas = list(counts)
    for i in range(3, len(deltas)):
        deltas[i] = counts[i] - counts[i - 2]
    return deltas


def is_compressed_rle(rle: object) -> bool:
    """Return ``True`` if ``rle`` is a COCO compressed RLE (``str`` or ``bytes``).

    Use this to branch between the compressed-string pipeline
    (:func:`_base48_decode` → :func:`_delta_decode`) and the uncompressed
    integer-list / array pipeline before calling :func:`rle_to_mask`.

    Args:
        rle: Candidate RLE value to inspect.

    Returns:
        ``True`` for ``str`` or ``bytes`` inputs; ``False`` otherwise.

    Examples:
        ```pycon
        >>> from supervision.detection.utils.converters import is_compressed_rle
        >>> is_compressed_rle("52203")
        True
        >>> is_compressed_rle([5, 2, 2, 2, 5])
        False

        ```
    """
    return isinstance(rle, (str, bytes))


def _mask_to_rle_counts(mask_2d: npt.NDArray[Any]) -> npt.NDArray[np.int32]:
    """Encode a 2D boolean mask as COCO F-order run lengths (int32 array).

    Pixels are scanned column-by-column (Fortran order), matching the COCO /
    pycocotools RLE convention. The first value is always the count of leading
    ``False`` pixels (may be 0 if the mask starts with ``True``).

    This is the shared low-level encoder used by both :func:`mask_to_rle` and
    :class:`~supervision.detection.compact_mask.CompactMask`.

    Args:
        mask_2d: 2D boolean array of shape ``(H, W)``.

    Returns:
        int32 array of run lengths starting with the False count.

    Examples:
        ```pycon
        >>> import numpy as np
        >>> from supervision.detection.utils.converters import _mask_to_rle_counts
        >>> mask = np.array([[False, True], [True, False]])
        >>> _mask_to_rle_counts(mask).tolist()
        [1, 2, 1]

        ```
    """
    flat = np.asarray(mask_2d, dtype=np.bool_).ravel(order="F")
    if len(flat) == 0:
        return np.array([0], dtype=np.int32)

    changes = np.diff(flat.view(np.uint8))
    boundaries = np.where(changes != 0)[0] + 1
    positions = np.concatenate(([0], boundaries, [len(flat)]))
    run_lengths = np.diff(positions).astype(np.int32)

    if flat[0]:
        run_lengths = np.concatenate(([np.int32(0)], run_lengths))

    return run_lengths


def _rle_counts_to_mask(
    rle: npt.NDArray[np.int32], height: int, width: int
) -> npt.NDArray[np.bool_]:
    """Decode COCO F-order run lengths back to a 2D boolean mask.

    This is the shared low-level decoder used by both :func:`rle_to_mask` and
    :class:`~supervision.detection.compact_mask.CompactMask`.

    Args:
        rle: int32 array of run lengths as produced by :func:`_mask_to_rle_counts`.
        height: Height of the output mask.
        width: Width of the output mask.

    Returns:
        2D boolean array of shape ``(height, width)``.

    Examples:
        ```pycon
        >>> import numpy as np
        >>> from supervision.detection.utils.converters import _rle_counts_to_mask
        >>> rle = np.array([0, 1, 2, 1], dtype=np.int32)
        >>> _rle_counts_to_mask(rle, 2, 2)
        array([[ True, False],
               [False,  True]])

        ```
    """
    is_true = np.arange(len(rle)) % 2 == 1
    flat: npt.NDArray[np.bool_] = np.repeat(is_true, rle)
    num_pixels = height * width
    if len(flat) < num_pixels:
        flat = np.pad(flat, (0, num_pixels - len(flat)))
    return cast(
        npt.NDArray[np.bool_], flat[:num_pixels].reshape(height, width, order="F")
    )


def rle_to_mask(
    rle: npt.NDArray[np.integer[Any]] | list[int] | str | bytes,
    resolution_wh: tuple[int, int],
) -> npt.NDArray[np.bool_]:
    """
    Converts a COCO run-length encoding (RLE) to a binary mask.

    Implements the COCO RLE format used by ``pycocotools``: pixels are counted
    in **column-major (Fortran) order** — top-to-bottom within each column,
    left-to-right across columns. This is the opposite of the row-major order
    used by NumPy's default ``'C'`` layout. Passing RLE data produced by a
    different row-major convention will yield an incorrect mask.

    Args:
        rle: The COCO RLE data in one of the following formats:

            - A 1D array or list of integers (uncompressed COCO RLE, where
              values at even indices are background run-lengths and values at
              odd indices are foreground run-lengths, both counted column-major).
            - A compressed COCO RLE string or bytes, as produced by
              ``pycocotools.mask.encode``.
        resolution_wh: The width (w) and height (h)
            of the desired binary mask.

    Returns:
        The generated 2D Boolean mask of shape `(h, w)`, where the foreground object is
            marked with `True`'s and the rest is filled with `False`'s.

    Raises:
        ValueError: If the sum of pixels encoded in RLE differs from the
            number of pixels in the expected mask (computed based on resolution_wh).

    Examples:
        ```pycon
        >>> import numpy as np
        >>> import supervision as sv
        >>> mask = sv.rle_to_mask([5, 2, 2, 2, 5], (4, 4))
        >>> mask  # doctest: +NORMALIZE_WHITESPACE
        array([[False, False, False, False],
               [False,  True,  True, False],
               [False,  True,  True, False],
               [False, False, False, False]])

        >>> mask = sv.rle_to_mask("52203", (4, 4))
        >>> mask  # doctest: +NORMALIZE_WHITESPACE
        array([[False, False, False, False],
               [False,  True,  True, False],
               [False,  True,  True, False],
               [False, False, False, False]])

        ```
    """
    if isinstance(rle, bytes):
        rle = rle.decode("utf-8")
    if isinstance(rle, str):
        counts: npt.NDArray[np.int32] = np.array(
            _delta_decode(_base48_decode(rle)), dtype=np.int32
        )
    elif isinstance(rle, list):
        counts = np.array(rle, dtype=np.int32)
    else:
        counts = np.asarray(rle, dtype=np.int32)

    width, height = resolution_wh

    if width * height != np.sum(counts):
        raise ValueError(
            "the sum of the number of pixels in the RLE must be the same "
            "as the number of pixels in the expected mask"
        )

    return _rle_counts_to_mask(counts, height, width)


def mask_to_rle(
    mask: npt.NDArray[np.bool_], compressed: bool = False
) -> list[int] | str:
    """
    Converts a binary mask into a COCO run-length encoding (RLE).

    Produces RLE in the COCO format used by ``pycocotools``: pixels are counted
    in **column-major (Fortran) order** — top-to-bottom within each column,
    left-to-right across columns. The output is directly compatible with
    ``pycocotools.mask.decode`` and COCO annotation JSON files.

    Args:
        mask: 2D binary mask where `True` indicates foreground
            object and `False` indicates background.
        compressed: If ``True``, return a compressed COCO RLE string
            compatible with ``pycocotools``. If ``False`` (default),
            return a list of integers.

    Returns:
        The COCO run-length encoded mask. When ``compressed`` is ``False``,
            values of a list with even indices represent the number of pixels
            assigned as background (`False`), values of a list with odd indices
            represent the number of pixels assigned as foreground object (`True`),
            both counted in column-major order.
            When ``compressed`` is ``True``, a COCO compressed RLE string.

    Raises:
        ValueError: If input mask is not 2D or is empty.

    Examples:
        ```pycon
        >>> import numpy as np
        >>> import supervision as sv
        >>> mask = np.array([
        ...     [True, True, True, True],
        ...     [True, True, True, True],
        ...     [True, True, True, True],
        ...     [True, True, True, True],
        ... ])
        >>> rle = sv.mask_to_rle(mask)
        >>> [int(x) for x in rle]
        [0, 16]

        ```

        ```pycon
        >>> import numpy as np
        >>> import supervision as sv
        >>> mask = np.array([
        ...     [False, False, False, False],
        ...     [False, True,  True,  False],
        ...     [False, True,  True,  False],
        ...     [False, False, False, False],
        ... ])
        >>> rle = sv.mask_to_rle(mask)
        >>> [int(x) for x in rle]
        [5, 2, 2, 2, 5]

        >>> sv.mask_to_rle(mask, compressed=True)
        '52203'

        ```

    ![mask_to_rle](https://media.roboflow.com/supervision-docs/
    mask-to-rle.png){ align=center width="800" }
    """
    if mask.ndim != 2:
        raise ValueError("Input mask must be 2D")
    if mask.size == 0:
        raise ValueError("Input mask cannot be empty")

    counts: list[int] = cast(list[int], _mask_to_rle_counts(mask).tolist())
    if compressed:
        return _base48_encode(_delta_encode(counts))
    return counts


def polygon_to_xyxy(polygon: npt.NDArray[np.number]) -> npt.NDArray[np.number]:
    """
    Converts a polygon represented by a NumPy array into a bounding box.

    Args:
        polygon: A polygon represented by a NumPy array of shape `(N, 2)`,
            containing the `x`, `y` coordinates of the points.

    Returns:
        A 1D NumPy array containing the bounding box
            `(x_min, y_min, x_max, y_max)` of the input polygon.

    Examples:
        ```pycon
        >>> import numpy as np
        >>> import supervision as sv
        >>> polygon = np.array([[10, 20], [30, 20], [30, 40], [10, 40]])
        >>> sv.polygon_to_xyxy(polygon)
        array([10, 20, 30, 40])

        ```
    """
    x_min, y_min = np.min(polygon, axis=0)
    x_max, y_max = np.max(polygon, axis=0)
    return np.array([x_min, y_min, x_max, y_max])
