"""
Drawing utilities - annotate RGB numpy arrays with detection boxes and text.
All functions take an RGB uint8 array (H, W, 3) and return a NEW array;
the input is never modified. cv2 accelerates rendering when installed,
otherwise Pillow (a hard SDK dependency) is used.
render_overlay_rgba is the hardware companion: it renders the same
annotation as a minimal straight-alpha RGBA canvas (plus its frame
offset) for DspClient.blend_hw instead of rasterizing onto the pixels.
Example:
frame = client.get_frame("main") # Frame
rgb = draw_detections(frame.to_rgb(), result)
"""
from __future__ import annotations
from typing import Iterable, Sequence
import numpy as np
from .dsp_wire import _MAX_BATCH
# Distinct palette used by draw_detections when color is None (indexed by class_id)
PALETTE: tuple[tuple[int, int, int], ...] = (
(0, 255, 0), # green
(255, 80, 80), # red
(80, 160, 255), # blue
(255, 220, 0), # yellow
(255, 0, 255), # magenta
(0, 255, 255), # cyan
(180, 120, 255), # purple
(255, 140, 0), # orange
)
def _to_xyxy(box) -> tuple[float, float, float, float]:
"""Coerce a box into (x1, y1, x2, y2).
Accepts 4-sequences, BoundingBox-like objects (with to_xyxy()),
or objects carrying .bbox.
"""
if hasattr(box, "bbox") and not hasattr(box, "to_xyxy"):
box = box.bbox
if hasattr(box, "to_xyxy"):
return box.to_xyxy()
x1, y1, x2, y2 = box
return x1, y1, x2, y2
def _format_label(label: str | None, score) -> str | None:
if label is None:
return f"{score:.2f}" if score is not None else None
if score is not None:
return f"{label} {score:.2f}"
return label
def _as_points(pts) -> np.ndarray:
"""Coerce polygon/track points into an (N, 2) int32 array."""
arr = np.asarray(pts, np.int32)
if arr.ndim != 2 or arr.shape[1] != 2 or arr.shape[0] < 2:
raise ValueError(
f"polygon/track points must be (N, 2) with N >= 2, got shape {arr.shape}"
)
return arr
[docs]
def draw_boxes(
image: np.ndarray,
boxes: Iterable,
labels: Sequence[str | None] | None = None,
scores: Sequence[float] | None = None,
color: tuple[int, int, int] = (0, 255, 0),
thickness: int = 2,
) -> np.ndarray:
"""Draw bounding boxes (pixel coordinates) on an RGB array copy.
Args:
image: RGB uint8 array (H, W, 3).
boxes: iterable of (x1, y1, x2, y2) or BoundingBox-like objects.
labels: optional per-box text (combined with scores when given).
scores: optional per-box confidence.
color: RGB box color.
thickness: line thickness in pixels.
Returns: new RGB array; the input array is not modified.
"""
out = image.copy()
thickness = max(1, int(thickness))
items = [_to_xyxy(b) for b in boxes]
try:
import cv2
for i, (x1, y1, x2, y2) in enumerate(items):
xi1, yi1, xi2, yi2 = int(x1), int(y1), int(x2), int(y2)
cv2.rectangle(out, (xi1, yi1), (xi2, yi2), color, thickness)
text = _format_label(
labels[i] if labels and i < len(labels) else None,
scores[i] if scores and i < len(scores) else None,
)
if text:
cv2.putText(
out,
text,
(xi1, max(12, yi1 - 6)),
cv2.FONT_HERSHEY_SIMPLEX,
0.5,
color,
1,
cv2.LINE_AA,
)
return out
except ImportError:
from PIL import Image, ImageDraw
pil = Image.fromarray(out)
draw = ImageDraw.Draw(pil)
for i, (x1, y1, x2, y2) in enumerate(items):
draw.rectangle([int(x1), int(y1), int(x2), int(y2)], outline=color, width=thickness)
text = _format_label(
labels[i] if labels and i < len(labels) else None,
scores[i] if scores and i < len(scores) else None,
)
if text:
draw.text((int(x1) + 2, max(0, int(y1) - 12)), text, fill=color)
return np.array(pil)
[docs]
def draw_text(
image: np.ndarray,
text: str,
xy: tuple[int, int],
color: tuple[int, int, int] = (255, 255, 255),
font_scale: float = 0.5,
thickness: int = 1,
) -> np.ndarray:
"""Draw a text string at pixel position xy on an RGB array copy."""
out = image.copy()
try:
import cv2
cv2.putText(
out,
str(text),
(int(xy[0]), int(xy[1])),
cv2.FONT_HERSHEY_SIMPLEX,
font_scale,
color,
max(1, int(thickness)),
cv2.LINE_AA,
)
return out
except ImportError:
from PIL import Image, ImageDraw
pil = Image.fromarray(out)
ImageDraw.Draw(pil).text((int(xy[0]), int(xy[1])), str(text), fill=color)
return np.array(pil)
[docs]
def draw_detections(
image: np.ndarray,
result_or_objects,
color: tuple[int, int, int] | None = None,
) -> np.ndarray:
"""Draw an InferenceResult (or a list of DetectedObject) on an RGB copy.
Each object gets a box plus a "label score" caption. When color is
None, a per-class color from PALETTE is chosen via class_id.
Routing: NV12 2D arrays ride the accel router (DSP blend when the
daemon is reachable, the CPU mirror otherwise); RGB arrays go
straight to the software raster (no doomed hardware attempt, no
fake degradation row); keep-fd frames raise — the zero-copy blend
chain that would serve them is gated (state-dependent field wedge).
"""
if getattr(image, "ndim", 0) == 2:
from .accel import get_default_router # noqa: PLC0415 — accel imports draw
return get_default_router().run(
"draw_detections", image, result_or_objects, color
)
if hasattr(image, "handle") or hasattr(image, "fds"):
from .accel import HardwareUnavailable # noqa: PLC0415
raise HardwareUnavailable(
"draw_detections takes NV12 arrays, not keep-fd frames: the "
"zero-copy frame blend chain has wedged the DSP device-wide "
"in the field (state-dependent; DspClient.blend_hw refuses "
"it). Call frame.to_array() and route the array."
)
return _draw_detections_impl(image, result_or_objects, color)
def _draw_detections_impl(
image: np.ndarray,
result_or_objects,
color: tuple[int, int, int] | None = None,
) -> np.ndarray:
"""CPU raster leg of ``draw_detections`` (boxes + captions on an RGB copy)."""
if hasattr(result_or_objects, "objects"):
objects = list(result_or_objects.objects)
else:
objects = list(result_or_objects)
if not objects:
return image.copy()
boxes: list[tuple[float, float, float, float]] = []
labels: list[str | None] = []
scores: list[float | None] = []
colors: list[tuple[int, int, int]] = []
for obj in objects:
boxes.append(_to_xyxy(obj.bbox if hasattr(obj, "bbox") else obj))
labels.append(getattr(obj, "label", None))
scores.append(getattr(obj, "score", None))
if color is not None:
colors.append(color)
else:
class_id = getattr(obj, "class_id", 0) or 0
colors.append(PALETTE[int(class_id) % len(PALETTE)])
out = image.copy()
for box, label, score, col in zip(boxes, labels, scores, colors):
out = draw_boxes(out, [box], labels=[label], scores=[score], color=col, thickness=2)
return out
# px reserved above a box for its caption (cv2 scale-0.5 ascent + baseline
# offset 6 + margin) — same strip the software draw_boxes text occupies
_LABEL_STRIP = 20
# daemon DSP floor (dsp_wire._MIN_DIM): blend_hw pads transparently, but a
# canvas born legal avoids a copy at paste time
_MIN_OVERLAY_DIM = 16
def _text_metrics() -> tuple[int, int, int]:
"""Caption metrics for the active rasterizer:
``(anchor_offset, canvas_row_floor, rows_below_anchor)`` — cv2
anchors text at the baseline (6 px above the box top, floored to
canvas row 12, descender ~4 px below), Pillow at the glyph top
(12 px above the box top, no floor, ~11 px tall)."""
try:
import cv2 # noqa: F401
return 6, 12, 6
except ImportError:
return 12, 0, 12
def _text_width(text: str) -> int:
"""Painted caption width measured from the box's left edge.
Backend-matched to :func:`_draw_masks` (cv2 draws at ``xi1`` with
LINE_AA spill, Pillow at ``xi1 + 2``) plus a 2 px pad, so a caption
strip tightened to this width never clips mid-glyph — the rasterizer
itself never clips a caption at the box extent.
"""
try:
import cv2
return int(cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)[0][0]) + 2
except ImportError:
from PIL import Image, ImageDraw
canvas = ImageDraw.Draw(Image.new("L", (1, 1)))
return int(canvas.textlength(text)) + 4
def _text_row(yi1: int, y0: int, ch: int, offset: int, floor: int,
fragment: bool) -> int | None:
"""Canvas row for a box caption; ``None`` = not this canvas's pixels.
The single-canvas path (``fragment=False``) keeps the legacy clamps.
A fragment canvas anchors the caption to the same frame row instead
of clamping it into view — the clamp is what used to spray captions
onto bottom/side strips that the caption was never meant for. Only
the canvas covering the caption strip gets the text; the ``y0 == 0``
floor keeps the near-top-edge degenerate case at parity with the
union canvas.
"""
row = yi1 - offset - y0
if not fragment:
return min(max(row, floor), ch - 2)
if y0 == 0:
row = max(row, floor)
return row if 0 <= row < ch else None
def _draw_masks(
ch: int, cw: int, x0: int, y0: int, t: int, rects, line_shapes,
fragment: bool = False,
) -> list[np.ndarray]:
"""Coverage masks for ``rects``/``line_shapes`` on a ``(ch, cw)``
canvas whose frame-space origin is ``(x0, y0)`` — the shared
rasterizer behind :func:`render_overlay_rgba` and
:func:`render_overlay_fragments`."""
masks: list[np.ndarray] = []
try:
import cv2
for xi1, yi1, xi2, yi2, text in rects:
m = np.zeros((ch, cw), np.uint8)
# rectangle default LINE_8, matching draw_boxes' hard edges
cv2.rectangle(m, (xi1 - x0, yi1 - y0), (xi2 - x0, yi2 - y0), 255, t)
if text:
ty = _text_row(yi1, y0, ch, 6, 12, fragment)
if ty is not None:
cv2.putText(
m, text, (xi1 - x0, ty),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, 255, 1, cv2.LINE_AA,
)
masks.append(m)
for pts, _col, closed in line_shapes:
m = np.zeros((ch, cw), np.uint8)
shifted = (pts - np.array([x0, y0], np.int32)).reshape(-1, 1, 2)
cv2.polylines(m, [shifted], closed, 255, t)
masks.append(m)
except ImportError:
from PIL import Image, ImageDraw
for xi1, yi1, xi2, yi2, text in rects:
img = Image.new("L", (cw, ch), 0)
d = ImageDraw.Draw(img)
d.rectangle(
[xi1 - x0, yi1 - y0, xi2 - x0, yi2 - y0], outline=255, width=t
)
if text:
ty = _text_row(yi1, y0, ch, 12, 0, fragment)
if ty is not None:
d.text((xi1 - x0 + 2, ty), text, fill=255)
masks.append(np.array(img))
for pts, _col, closed in line_shapes:
img = Image.new("L", (cw, ch), 0)
d = ImageDraw.Draw(img)
xy = [(int(px) - x0, int(py) - y0) for px, py in pts]
if closed:
xy.append(xy[0])
d.line(xy, fill=255, width=t, joint="curve")
masks.append(np.array(img))
return masks
def _colorize(masks, cols, ch: int, cw: int) -> np.ndarray:
"""Straight-alpha RGBA from coverage masks: color per draw order,
alpha the mask union."""
rgb = np.zeros((ch, cw, 3), np.uint8)
alpha = np.zeros((ch, cw), np.uint8)
for m, col in zip(masks, cols):
hit = m > 0
rgb[hit] = col # draw order: later shapes overwrite inside overlaps
np.maximum(alpha, m, out=alpha)
return np.dstack([rgb, alpha])
[docs]
def render_overlay_rgba(
frame_w: int,
frame_h: int,
boxes: Iterable = (),
labels: Sequence[str | None] | None = None,
scores: Sequence[float | None] | None = None,
colors: Sequence[tuple[int, int, int]] | None = None,
thickness: int = 2,
polygons: Sequence = (),
tracks: Sequence = (),
) -> tuple[np.ndarray, int, int]:
"""Render boxes + captions + polygons + tracks as a minimal
straight-alpha RGBA overlay.
Returns ``(rgba, x0, y0)``: an ``(h, w, 4)`` uint8 canvas holding
every shape and its top-left position on the frame. Hand it to
:meth:`DspClient.blend_hw` for the hardware composite — the canvas
is the union bbox of all shapes (clamped to the frame, floored at
16x16), so untouched pixels never enter the blend and the quota
footprint stays small.
``polygons`` is a sequence of ``(points, color)`` pairs drawn as
closed outlines (zone shapes); ``tracks`` the same shape drawn open
(trajectories). ``points`` is any (N, 2) sequence of frame pixel
coordinates; ``color=None`` falls back to green like boxes.
Straight alpha by construction: each shape is first drawn
white-on-black as a coverage mask (cv2 LINE_AA captions give edge
coverage t; rectangles stay hard-edged like :func:`draw_boxes`),
then shapes are colored in draw order and alpha is the mask union —
so compositing with ``t*C + (1-t)*base`` reproduces the software
output. Colors default to green; pick from :data:`PALETTE` by
class_id to match :func:`draw_detections`.
"""
t = max(1, int(thickness))
items = [_to_xyxy(b) for b in boxes]
lines = [(p, c, True) for p, c in polygons] + [(p, c, False) for p, c in tracks]
if not items and not lines:
raise ValueError("render_overlay_rgba needs at least one shape")
texts = [
_format_label(
labels[i] if labels and i < len(labels) else None,
scores[i] if scores and i < len(scores) else None,
)
for i in range(len(items))
]
# colors in draw order: boxes first, then polygons, then tracks — the
# same order masks get appended below, so zip() stays aligned
cols = [
tuple(colors[i]) if colors and i < len(colors) else (0, 255, 0)
for i in range(len(items))
]
cols += [
tuple(c) if c is not None else (0, 255, 0) for _p, c, _closed in lines
]
line_shapes = [
(_as_points(p), c, closed) for p, c, closed in lines
]
# canvas = union bbox of strokes + caption strips, clamped to the frame
x_min, y_min, x_max, y_max = int(frame_w), int(frame_h), 0, 0
rects = []
for (x1, y1, x2, y2), text in zip(items, texts):
xi1, yi1, xi2, yi2 = int(x1), int(y1), int(x2), int(y2)
rects.append((xi1, yi1, xi2, yi2, text))
x_min = min(x_min, xi1 - t - 1)
y_min = min(y_min, yi1 - t - 1 - (_LABEL_STRIP if text else 0))
x_max = max(x_max, xi2 + t + 1)
y_max = max(y_max, yi2 + t + 1)
for pts, _col, _closed in line_shapes:
x_min = min(x_min, int(pts[:, 0].min()) - t - 1)
y_min = min(y_min, int(pts[:, 1].min()) - t - 1)
x_max = max(x_max, int(pts[:, 0].max()) + t + 1)
y_max = max(y_max, int(pts[:, 1].max()) + t + 1)
x0 = max(0, x_min)
y0 = max(0, y_min)
x_end = min(int(frame_w), x_max)
y_end = min(int(frame_h), y_max)
if x_end <= x0 or y_end <= y0:
raise ValueError("all shapes lie outside the frame")
cw = max(_MIN_OVERLAY_DIM, x_end - x0)
ch = max(_MIN_OVERLAY_DIM, y_end - y0)
masks = _draw_masks(ch, cw, x0, y0, t, rects, line_shapes)
return _colorize(masks, cols, ch, cw), x0, y0
def _overlaps(a, b) -> bool:
"""Whether two ``(x0, y0, x1, y1)`` half-open rects intersect."""
return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]
[docs]
def render_overlay_fragments(
frame_w: int,
frame_h: int,
boxes: Iterable = (),
labels: Sequence[str | None] | None = None,
scores: Sequence[float | None] | None = None,
colors: Sequence[tuple[int, int, int]] | None = None,
thickness: int = 2,
polygons: Sequence = (),
tracks: Sequence = (),
) -> list[tuple[np.ndarray, int, int]]:
"""Render boxes as tight stroke fragments instead of one bbox canvas.
Same inputs and straight-alpha contract as :func:`render_overlay_rgba`,
but returns a *list* of ``(rgba, x, y)`` fragments to spread over one
:meth:`DspClient.blend_hw` call (the daemon composites the whole
overlay list in a single job, so extra fragments cost only their
area). Each box becomes up to four thin canvases — caption+top edge,
bottom edge, left edge, right edge — so a box pays for its
``2 * thickness`` stroke perimeter plus its caption strip, not its
area: a full-frame 1280x720 box drops from a 0.9 MP canvas (render,
RGBA->ARGB repack, wire bytes and blend are all area-bound; ~3.7 MB
per frame over the socket) to ~0.06 MP of 16 px strips. Polygons and
tracks stay one canvas per shape (that shape's own bbox, not the
union of everything).
Pixels are identical to :func:`render_overlay_rgba` for
non-overlapping shapes: every fragment is rasterized by the same
backend at the same frame coordinates on a smaller canvas, and the
fragments tile the shape extents without gaps or overlap. Two
caveats: overlapping shapes composite marginally differently (an AA
edge landing on another shape's stroke blends against it instead of
taking the union mask), and in the per-shape fallback below a
caption wider than its shape clips at that shape's extent (the
fragment path itself widens a caption's strip to its measured text
extent instead). Call once per detection for exact single-canvas
parity.
Fragment count is bounded for the caller: ``blend_hw`` caps one job
at 64 overlays and each box costs up to four fragments, so past
~15 boxes this falls back to one canvas per shape, and past 64
shapes to the one shared union canvas — the return always fits one
blend job.
"""
t = max(1, int(thickness))
items = [_to_xyxy(b) for b in boxes]
lines = [(p, c, True) for p, c in polygons] + [(p, c, False) for p, c in tracks]
if not items and not lines:
raise ValueError("render_overlay_fragments needs at least one shape")
texts = [
_format_label(
labels[i] if labels and i < len(labels) else None,
scores[i] if scores and i < len(scores) else None,
)
for i in range(len(items))
]
cols = [
tuple(colors[i]) if colors and i < len(colors) else (0, 255, 0)
for i in range(len(items))
]
cols += [
tuple(c) if c is not None else (0, 255, 0) for _p, c, _closed in lines
]
line_shapes = [
(_as_points(p), c, closed) for p, c, closed in lines
]
# per-shape stroke extents, identical formulas to the union canvas —
# fragment pixels then equal canvas pixels region for region
x_min, y_min, x_max, y_max = int(frame_w), int(frame_h), 0, 0
rects = []
rect_bboxes = []
for (x1, y1, x2, y2), text in zip(items, texts):
xi1, yi1, xi2, yi2 = int(x1), int(y1), int(x2), int(y2)
rects.append((xi1, yi1, xi2, yi2, text))
strip = _LABEL_STRIP if text else 0
rect_bboxes.append((xi1 - t - 1, yi1 - t - 1 - strip,
xi2 + t + 1, yi2 + t + 1))
x_min = min(x_min, xi1 - t - 1)
y_min = min(y_min, yi1 - t - 1 - strip)
x_max = max(x_max, xi2 + t + 1)
y_max = max(y_max, yi2 + t + 1)
line_bboxes = []
for pts, _col, _closed in line_shapes:
bx0 = int(pts[:, 0].min()) - t - 1
by0 = int(pts[:, 1].min()) - t - 1
bx1 = int(pts[:, 0].max()) + t + 1
by1 = int(pts[:, 1].max()) + t + 1
line_bboxes.append((bx0, by0, bx1, by1))
x_min = min(x_min, bx0)
y_min = min(y_min, by0)
x_max = max(x_max, bx1)
y_max = max(y_max, by1)
ux0, uy0 = max(0, x_min), max(0, y_min)
ux1, uy1 = min(int(frame_w), x_max), min(int(frame_h), y_max)
if ux1 <= ux0 or uy1 <= uy0:
raise ValueError("all shapes lie outside the frame")
# fragment rects, tight to what each must carry: horizontal strips
# to the box stroke extent (a captioned box's top strip widens to
# the measured text extent — the rasterizer never clips a caption
# at the box, so its canvas must cover where the text paints),
# vertical strips to the stroke columns only. Coverage is unchanged:
# every shape pixel lands in its own fragments, and a shape whose
# bbox overlaps another fragment's window still rasterizes there
# too (opaque double-paints are idempotent; AA edges keep the
# union-canvas caveat). Union-wide strips were tried first and cost
# 64% of the frame for two boxes on opposite edges.
frags: list[tuple[int, int, int, int]] = []
for (xi1, yi1, xi2, yi2, text), _bb in zip(rects, rect_bboxes):
strip = _LABEL_STRIP if text else 0
fx0 = max(ux0, xi1 - t - 1)
fx1 = min(ux1, xi2 + t + 1)
if text:
fx1 = min(ux1, max(fx1, xi1 + _text_width(text)))
top0 = max(uy0, yi1 - t - 1 - strip)
top1 = min(uy1, yi1 + t + 1)
bot0 = max(uy0, yi2 - t)
bot1 = min(uy1, yi2 + t + 1)
lx1 = min(ux1, xi1 + t + 1)
rx0 = max(ux0, xi2 - t)
if top1 > top0:
frags.append((fx0, top0, fx1, top1))
if bot1 > bot0:
frags.append((fx0, bot0, fx1, bot1))
if bot0 > top1: # side edges between the horizontal strips
if lx1 > fx0:
frags.append((fx0, top1, lx1, bot0))
if fx1 > rx0:
frags.append((rx0, top1, fx1, bot0))
for bb in line_bboxes:
fx0, fy0 = max(ux0, bb[0]), max(uy0, bb[1])
fx1, fy1 = min(ux1, bb[2]), min(uy1, bb[3])
if fx1 > fx0 and fy1 > fy0:
frags.append((fx0, fy0, fx1, fy1))
# blend_hw caps one job at _MAX_BATCH overlays and a box costs up to
# four fragments: past that, fall back to one canvas per shape (a
# caption wider than its shape then clips at that shape's extent);
# past _MAX_BATCH shapes, one shared union canvas. Either way the
# return always fits a single blend job.
if len(frags) > _MAX_BATCH:
frags = [
(max(ux0, bb[0]), max(uy0, bb[1]), min(ux1, bb[2]), min(uy1, bb[3]))
for bb in rect_bboxes + line_bboxes
if min(ux1, bb[2]) > max(ux0, bb[0])
and min(uy1, bb[3]) > max(uy0, bb[1])
]
if len(frags) > _MAX_BATCH:
frags = [(ux0, uy0, ux1, uy1)]
m = _text_metrics()
out: list[tuple[np.ndarray, int, int]] = []
for fx, fy, fx1, fy1 in frags:
cw = max(_MIN_OVERLAY_DIM, fx1 - fx)
ch = max(_MIN_OVERLAY_DIM, fy1 - fy)
# a caption landing in this fragment dictates its height: the
# 16 px floor must not clip the descender (the union canvas is
# always tall enough; row is stable under the later slide)
for xi1c, yi1c, _xi2c, _yi2c, text in rects:
if not text:
continue
row = _text_row(yi1c, fy, ch, m[0], m[1], True)
if row is not None:
ch = max(ch, row + m[2])
# even height before the slide: blend_hw pads an odd overlay
# height with one transparent bottom row and enforces bounds on
# the padded rect, so an odd ch flush to the frame bottom would
# overshoot the base by that pad row. Growing here instead adds
# the same transparent row while the slide can still keep the
# canvas in-bounds.
ch += ch & 1
# the 16 px floor can push the canvas past the frame's right or
# bottom edge — slide the origin back (content offsets follow the
# origin, so pixels keep their frame position)
fx = max(0, min(fx, int(frame_w) - cw))
fy = max(0, min(fy, int(frame_h) - ch))
window = (fx, fy, fx + cw, fy + ch)
sel_rects = [r for r, bb in zip(rects, rect_bboxes) if _overlaps(bb, window)]
sel_lines = [ls for ls, bb in zip(line_shapes, line_bboxes)
if _overlaps(bb, window)]
if not sel_rects and not sel_lines:
continue # no visible pixels in this fragment
sel_cols = [col for col, bb in zip(cols[:len(rects)], rect_bboxes)
if _overlaps(bb, window)]
sel_cols += [col for col, bb in zip(cols[len(rects):], line_bboxes)
if _overlaps(bb, window)]
masks = _draw_masks(ch, cw, fx, fy, t, sel_rects, sel_lines,
fragment=True)
out.append((_colorize(masks, sel_cols, ch, cw), fx, fy))
return out
[docs]
def draw_polygons(
image: np.ndarray,
shapes: Iterable,
thickness: int = 2,
closed: bool = True,
) -> np.ndarray:
"""Draw polygon outlines (or open tracks) on an RGB array copy.
The software counterpart of :func:`render_overlay_rgba`'s
polygons/tracks legs, mirroring :func:`draw_boxes`' copy-in
copy-out conventions — reach for it when the frame stays client-side
and no blend is needed.
Args:
image: RGB uint8 array (H, W, 3).
shapes: iterable of ``(points, color)`` pairs, ``points`` being
any (N, 2) sequence of pixel coordinates (same schema as
:func:`render_overlay_rgba`); ``color=None`` means green.
thickness: line thickness in pixels.
closed: close each outline (True for zones, False for tracks).
Returns: new RGB array; the input array is not modified.
"""
t = max(1, int(thickness))
parsed = [(_as_points(p), tuple(c) if c is not None else (0, 255, 0))
for p, c in shapes]
out = image.copy()
if not parsed:
return out
try:
import cv2
for pts, col in parsed:
cv2.polylines(out, [pts.reshape(-1, 1, 2)], closed, col, t)
return out
except ImportError:
from PIL import Image, ImageDraw
pil = Image.fromarray(out)
draw = ImageDraw.Draw(pil)
for pts, col in parsed:
xy = [(int(px), int(py)) for px, py in pts]
if closed:
xy.append(xy[0])
draw.line(xy, fill=col, width=t, joint="curve")
return np.array(pil)