import os from typing import Any, Dict, List, Optional, Tuple, Union import cv2 import numpy as np NEUTRAL_COLOR = (52, 235, 107) LEFT_ARM_COLOR = (216, 235, 52) LEFT_LEG_COLOR = (235, 107, 52) LEFT_SIDE_COLOR = (245, 188, 113) LEFT_FACE_COLOR = (235, 52, 107) RIGHT_ARM_COLOR = (52, 235, 216) RIGHT_LEG_COLOR = (52, 107, 235) RIGHT_SIDE_COLOR = (52, 171, 235) RIGHT_FACE_COLOR = (107, 52, 235) COCO_MARKERS = [ ["nose", cv2.MARKER_CROSS, NEUTRAL_COLOR], ["left_eye", cv2.MARKER_SQUARE, LEFT_FACE_COLOR], ["right_eye", cv2.MARKER_SQUARE, RIGHT_FACE_COLOR], ["left_ear", cv2.MARKER_CROSS, LEFT_FACE_COLOR], ["right_ear", cv2.MARKER_CROSS, RIGHT_FACE_COLOR], ["left_shoulder", cv2.MARKER_TRIANGLE_UP, LEFT_ARM_COLOR], ["right_shoulder", cv2.MARKER_TRIANGLE_UP, RIGHT_ARM_COLOR], ["left_elbow", cv2.MARKER_SQUARE, LEFT_ARM_COLOR], ["right_elbow", cv2.MARKER_SQUARE, RIGHT_ARM_COLOR], ["left_wrist", cv2.MARKER_CROSS, LEFT_ARM_COLOR], ["right_wrist", cv2.MARKER_CROSS, RIGHT_ARM_COLOR], ["left_hip", cv2.MARKER_TRIANGLE_UP, LEFT_LEG_COLOR], ["right_hip", cv2.MARKER_TRIANGLE_UP, RIGHT_LEG_COLOR], ["left_knee", cv2.MARKER_SQUARE, LEFT_LEG_COLOR], ["right_knee", cv2.MARKER_SQUARE, RIGHT_LEG_COLOR], ["left_ankle", cv2.MARKER_TILTED_CROSS, LEFT_LEG_COLOR], ["right_ankle", cv2.MARKER_TILTED_CROSS, RIGHT_LEG_COLOR], ] COCO_SKELETON = [ [[16, 14], LEFT_LEG_COLOR], # Left ankle - Left knee [[14, 12], LEFT_LEG_COLOR], # Left knee - Left hip [[17, 15], RIGHT_LEG_COLOR], # Right ankle - Right knee [[15, 13], RIGHT_LEG_COLOR], # Right knee - Right hip [[12, 13], NEUTRAL_COLOR], # Left hip - Right hip [[6, 12], LEFT_SIDE_COLOR], # Left hip - Left shoulder [[7, 13], RIGHT_SIDE_COLOR], # Right hip - Right shoulder [[6, 7], NEUTRAL_COLOR], # Left shoulder - Right shoulder [[6, 8], LEFT_ARM_COLOR], # Left shoulder - Left elbow [[7, 9], RIGHT_ARM_COLOR], # Right shoulder - Right elbow [[8, 10], LEFT_ARM_COLOR], # Left elbow - Left wrist [[9, 11], RIGHT_ARM_COLOR], # Right elbow - Right wrist [[2, 3], NEUTRAL_COLOR], # Left eye - Right eye [[1, 2], LEFT_FACE_COLOR], # Nose - Left eye [[1, 3], RIGHT_FACE_COLOR], # Nose - Right eye [[2, 4], LEFT_FACE_COLOR], # Left eye - Left ear [[3, 5], RIGHT_FACE_COLOR], # Right eye - Right ear [[4, 6], LEFT_FACE_COLOR], # Left ear - Left shoulder [[5, 7], RIGHT_FACE_COLOR], # Right ear - Right shoulder ] def _draw_line( img: np.ndarray, start: Tuple[float, float], stop: Tuple[float, float], color: Tuple[int, int, int], line_type: str, thickness: int = 1, ) -> np.ndarray: """ Draw a line segment on an image, supporting solid, dashed, or dotted styles. Args: img (np.ndarray): BGR image of shape (H, W, 3). start (tuple of float): (x, y) start coordinates. stop (tuple of float): (x, y) end coordinates. color (tuple of int): BGR color values. line_type (str): One of 'solid', 'dashed', or 'doted'. thickness (int): Line thickness in pixels. Returns: np.ndarray: Image with the line drawn. """ start = np.array(start)[:2] stop = np.array(stop)[:2] if line_type.lower() == "solid": img = cv2.line( img, (int(start[0]), int(start[1])), (int(stop[0]), int(stop[1])), color=(0, 0, 0), thickness=thickness+1, lineType=cv2.LINE_AA, ) img = cv2.line( img, (int(start[0]), int(start[1])), (int(stop[0]), int(stop[1])), color=color, thickness=thickness, lineType=cv2.LINE_AA, ) elif line_type.lower() == "dashed": delta = stop - start length = np.linalg.norm(delta) frac = np.linspace(0, 1, num=int(length / 5), endpoint=True) for i in range(0, len(frac) - 1, 2): s = start + frac[i] * delta e = start + frac[i + 1] * delta img = cv2.line( img, (int(s[0]), int(s[1])), (int(e[0]), int(e[1])), color=color, thickness=thickness, lineType=cv2.LINE_AA, ) elif line_type.lower() == "doted": delta = stop - start length = np.linalg.norm(delta) frac = np.linspace(0, 1, num=int(length / 5), endpoint=True) for i in range(0, len(frac)): s = start + frac[i] * delta img = cv2.circle( img, (int(s[0]), int(s[1])), radius=max(thickness // 2, 1), color=color, thickness=-1, lineType=cv2.LINE_AA, ) return img def pose_visualization( img: Union[str, np.ndarray], keypoints: Union[Dict[str, Any], np.ndarray], format: str = "COCO", greyness: float = 1.0, show_markers: bool = True, show_bones: bool = True, line_type: str = "solid", width_multiplier: float = 1.0, bbox_width_multiplier: float = 1.0, show_bbox: bool = False, differ_individuals: bool = False, confidence_thr: float = 0.3, errors: Optional[np.ndarray] = None, color: Optional[Tuple[int, int, int]] = None, keep_image_size: bool = False, return_padding: bool = False, ) -> Union[np.ndarray, Tuple[np.ndarray, List[int]]]: """ Overlay pose keypoints and skeleton on an image. Args: img (str or np.ndarray): Path to image file or BGR image array. keypoints (dict or np.ndarray): Either a dict with 'bbox' and 'keypoints' or an array of shape (17, 2 or 3) or multiple poses stacked. format (str): Keypoint format, currently only 'COCO'. greyness (float): Factor for bone/marker color intensity (0.0-1.0). show_markers (bool): Whether to draw keypoint markers. show_bones (bool): Whether to draw skeleton bones. line_type (str): One of 'solid', 'dashed', 'doted' for bone style. width_multiplier (float): Line width scaling factor for bones. bbox_width_multiplier (float): Line width scaling factor for bounding box. show_bbox (bool): Whether to draw bounding box around keypoints. differ_individuals (bool): Use distinct color per individual pose. confidence_thr (float): Confidence threshold for keypoint visibility. errors (np.ndarray or None): Optional array of per-kpt errors (17,1). color (tuple or None): Override color for markers and bones. keep_image_size (bool): Prevent image padding for out-of-bounds keypoints. return_padding (bool): If True, also return padding offsets [top,bottom,left,right]. Returns: np.ndarray or (np.ndarray, list of int): Annotated image, and optional padding offsets if `return_padding` is True. """ bbox = None if isinstance(keypoints, dict): try: bbox = np.array(keypoints["bbox"]).flatten() except KeyError: pass keypoints = np.array(keypoints["keypoints"]) # If keypoints is a list of poses, draw them all if len(keypoints) % 17 != 0 or keypoints.ndim == 3: if color is not None: if not isinstance(color, (list, tuple)): color = [color for keypoint in keypoints] else: color = [None for keypoint in keypoints] max_padding = [0, 0, 0, 0] for keypoint, clr in zip(keypoints, color): img = pose_visualization( img, keypoint, format=format, greyness=greyness, show_markers=show_markers, show_bones=show_bones, line_type=line_type, width_multiplier=width_multiplier, bbox_width_multiplier=bbox_width_multiplier, show_bbox=show_bbox, differ_individuals=differ_individuals, color=clr, confidence_thr=confidence_thr, keep_image_size=keep_image_size, return_padding=return_padding, ) if return_padding: img, padding = img max_padding = [max(max_padding[i], int(padding[i])) for i in range(4)] if return_padding: return img, max_padding else: return img keypoints = np.array(keypoints).reshape(17, -1) # If keypoint visibility is not provided, assume all keypoints are visible if keypoints.shape[1] == 2: keypoints = np.hstack([keypoints, np.ones((17, 1)) * 2]) assert keypoints.shape[1] == 3, "Keypoints should be in the format (x, y, visibility)" assert keypoints.shape[0] == 17, "Keypoints should be in the format (x, y, visibility)" if errors is not None: errors = np.array(errors).reshape(17, -1) assert errors.shape[1] == 1, "Errors should be in the format (K, r)" assert errors.shape[0] == 17, "Errors should be in the format (K, r)" else: errors = np.ones((17, 1)) * np.nan # If keypoint visibility is float between 0 and 1, it is detection # If conf < confidence_thr: conf = 1 # If conf >= confidence_thr: conf = 2 vis_is_float = np.any(np.logical_and(keypoints[:, -1] > 0, keypoints[:, -1] < 1)) if keypoints.shape[1] == 3 and vis_is_float: # print("before", keypoints[:, -1]) lower_idx = keypoints[:, -1] < confidence_thr keypoints[lower_idx, -1] = 1 keypoints[~lower_idx, -1] = 2 # print("after", keypoints[:, -1]) # print("-"*20) # All visibility values should be ints keypoints[:, -1] = keypoints[:, -1].astype(int) if isinstance(img, str): img = cv2.imread(img) if img is None: if return_padding: return None, [0, 0, 0, 0] else: return None if not (keypoints[:, 2] > 0).any(): if return_padding: return img, [0, 0, 0, 0] else: return img valid_kpts = (keypoints[:, 0] > 0) & (keypoints[:, 1] > 0) num_valid_kpts = np.sum(valid_kpts) if num_valid_kpts == 0: if return_padding: return img, [0, 0, 0, 0] else: return img min_x_kpts = np.min(keypoints[keypoints[:, 2] > 0, 0]) min_y_kpts = np.min(keypoints[keypoints[:, 2] > 0, 1]) max_x_kpts = np.max(keypoints[keypoints[:, 2] > 0, 0]) max_y_kpts = np.max(keypoints[keypoints[:, 2] > 0, 1]) if bbox is None: min_x = min_x_kpts min_y = min_y_kpts max_x = max_x_kpts max_y = max_y_kpts else: min_x = bbox[0] min_y = bbox[1] max_x = bbox[2] max_y = bbox[3] max_area = (max_x - min_x) * (max_y - min_y) diagonal = np.sqrt((max_x - min_x) ** 2 + (max_y - min_y) ** 2) line_width = max(int(np.sqrt(max_area) / 500 * width_multiplier), 1) bbox_line_width = max(int(np.sqrt(max_area) / 500 * bbox_width_multiplier), 1) marker_size = max(int(np.sqrt(max_area) / 80), 1) invisible_marker_size = max(int(np.sqrt(max_area) / 100), 1) marker_thickness = max(int(np.sqrt(max_area) / 100), 1) if differ_individuals: if color is not None: instance_color = color else: instance_color = np.random.randint(0, 255, size=(3,)).tolist() instance_color = tuple(instance_color) # Pad image with dark gray if keypoints are outside the image if not keep_image_size: padding = [ max(0, -min_y_kpts), max(0, max_y_kpts - img.shape[0]), max(0, -min_x_kpts), max(0, max_x_kpts - img.shape[1]), ] padding = [int(p) for p in padding] img = cv2.copyMakeBorder( img, padding[0], padding[1], padding[2], padding[3], cv2.BORDER_CONSTANT, value=(80, 80, 80), ) # Add padding to bbox and kpts value_x_to_add = max(0, -min_x_kpts) value_y_to_add = max(0, -min_y_kpts) keypoints[keypoints[:, 2] > 0, 0] += value_x_to_add keypoints[keypoints[:, 2] > 0, 1] += value_y_to_add if bbox is not None: bbox[0] += value_x_to_add bbox[1] += value_y_to_add bbox[2] += value_x_to_add bbox[3] += value_y_to_add if show_bbox and not (bbox is None): pts = [ (bbox[0], bbox[1]), (bbox[0], bbox[3]), (bbox[2], bbox[3]), (bbox[2], bbox[1]), (bbox[0], bbox[1]), ] for i in range(len(pts) - 1): if differ_individuals: img = _draw_line(img, pts[i], pts[i + 1], instance_color, "doted", thickness=bbox_line_width) else: img = _draw_line(img, pts[i], pts[i + 1], (0, 255, 0), line_type, thickness=bbox_line_width) if show_markers: for kpt, marker_info, err in zip(keypoints, COCO_MARKERS, errors): if kpt[0] == 0 and kpt[1] == 0: continue if kpt[2] != 2: color = (140, 140, 140) elif differ_individuals: color = instance_color else: color = marker_info[2] if kpt[2] == 1: img_overlay = img.copy() img_overlay = cv2.drawMarker( img_overlay, (int(kpt[0]), int(kpt[1])), color=color, markerType=marker_info[1], markerSize=marker_size, thickness=marker_thickness, ) img = cv2.addWeighted(img_overlay, 0.4, img, 0.6, 0) else: img = cv2.drawMarker( img, (int(kpt[0]), int(kpt[1])), color=color, markerType=marker_info[1], markerSize=invisible_marker_size if kpt[2] == 1 else marker_size, thickness=marker_thickness, ) if not np.isnan(err).any(): radius = err * diagonal clr = (0, 0, 255) if "solid" in line_type else (0, 255, 0) plus = 1 if "solid" in line_type else -1 img = cv2.circle( img, (int(kpt[0]), int(kpt[1])), radius=int(radius), color=clr, thickness=1, lineType=cv2.LINE_AA, ) dx = np.sqrt(radius**2 / 2) img = cv2.line( img, (int(kpt[0]), int(kpt[1])), (int(kpt[0] + plus * dx), int(kpt[1] - dx)), color=clr, thickness=1, lineType=cv2.LINE_AA, ) if show_bones: for bone_info in COCO_SKELETON: kp1 = keypoints[bone_info[0][0] - 1, :] kp2 = keypoints[bone_info[0][1] - 1, :] if (kp1[0] == 0 and kp1[1] == 0) or (kp2[0] == 0 and kp2[1] == 0): continue dashed = kp1[2] == 1 or kp2[2] == 1 if differ_individuals: color = np.array(instance_color) else: color = np.array(bone_info[1]) color = (color * greyness).astype(int).tolist() if dashed: img_overlay = img.copy() img_overlay = _draw_line(img_overlay, kp1, kp2, color, line_type, thickness=line_width) img = cv2.addWeighted(img_overlay, 0.4, img, 0.6, 0) else: img = _draw_line(img, kp1, kp2, color, line_type, thickness=line_width) if return_padding: return img, padding else: return img if __name__ == "__main__": kpts = np.array( [ 344, 222, 2, 356, 211, 2, 330, 211, 2, 372, 220, 2, 309, 224, 2, 413, 279, 2, 274, 300, 2, 444, 372, 2, 261, 396, 2, 398, 359, 2, 316, 372, 2, 407, 489, 2, 185, 580, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ] ) kpts = kpts.reshape(-1, 3) kpts[:, -1] = np.random.randint(1, 3, size=(17,)) img = pose_visualization("demo/posevis_test.jpg", kpts, show_markers=True, line_type="solid") kpts2 = kpts.copy() kpts2[kpts2[:, 1] > 0, :2] += 10 img = pose_visualization(img, kpts2, show_markers=False, line_type="doted") os.makedirs("demo/outputs", exist_ok=True) cv2.imwrite("demo/outputs/posevis_test_out.jpg", img)