Yolov8 Keypoint Detection
Yolov8 Keypoint Detection
Note: This notebook has been moved to a new branch named "latest". Click here to get the most updated version of the
notebook. This branch is deprecated.
Keypoint detection/Pose is a task that involves detecting specific points in an image or video frame. These points are referred to as keypoints
and are used to track movement or pose estimation. YOLOv8 can detect keypoints in an image or video frame with high accuracy and speed.
This tutorial demonstrates step-by-step instructions on how to run and optimize PyTorch YOLOv8 Pose model with OpenVINO. We consider
the steps required for keypoint detection scenario.
Table of contents:
Prerequisites
Instantiate model
In this case, the creators of the model provide an API that enables converting the YOLOv8 model to ONNX and then to OpenVINO IR.
Therefore, we do not need to do these steps manually.
keyboard_arrow_down Prerequisites
back to top ⬆️
Install necessary packages.
Import required utility functions. The lower cell will download the notebook_utils Python module from GitHub.
def plot_one_box(box:np.ndarray, img:np.ndarray, color:Tuple[int, int, int] = None, keypoints:np.ndarray = None, label:str = None
"""
Helper function for drawing single bounding box on image
Parameters:
box (np.ndarray): bounding box coordinates in format [x1, y1, x2, y2]
img (no.ndarray): input image
color (Tuple[int, int, int], *optional*, None): color in BGR format for drawing box, if not specified will be selected ra
keypoints (np.ndarray, *optional*, None): keypoints in format [x1, y1, s], x1, y1 - keypoint coordinates, s - the confide
if not provided, only box will be drawn
label (str, *optonal*, None): box label string, if not provided will not be provided as drowing result
line_thickness (int, *optional*, 5): thickness for box drawing lines
"""
# Plots one bounding box on image img
tl = line_thickness or round(0.002 * (img.shape[0] + img.shape[1]) / 2) + 1 # line/font thickness
color = color or [random.randint(0, 255) for _ in range(3)]
c1, c2 = (int(box[0]), int(box[1])), (int(box[2]), int(box[3]))
cv2.rectangle(img, c1, c2, color, thickness=tl, lineType=cv2.LINE_AA)
if label:
tf = max(tl - 1, 1) # font thickness
t_size = cv2.getTextSize(label, 0, fontScale=tl / 3, thickness=tf)[0]
c2 = c1[0] + t_size[0], c1[1] - t_size[1] - 3
cv2.rectangle(img, c1, c2, color, -1, cv2.LINE_AA) # filled
cv2.putText(img, label, (c1[0], c1[1] - 2), 0, tl / 3, [225, 255, 255], thickness=tf, lineType=cv2.LINE_AA)
if keypoints is not None:
kpt_color = colors.pose_palette[[16, 16, 16, 16, 16, 0, 0, 0, 0, 0, 0, 9, 9, 9, 9, 9, 9]]
skeleton = [[16, 14], [14, 12], [17, 15], [15, 13], [12, 13], [6, 12], [7, 13], [6, 7], [6, 8],
[7, 9], [8, 10], [9, 11], [2, 3], [1, 2], [1, 3], [2, 4], [3, 5], [4, 6], [5, 7]]
limb_color = colors.pose_palette[[9, 9, 9, 9, 7, 7, 7, 0, 0, 0, 0, 0, 16, 16, 16, 16, 16, 16, 16]]
shape = img.shape[:2]
for i, k in enumerate(keypoints):
color_k = [int(x) for x in kpt_color[i]]
x_coord, y_coord = k[0], k[1]
if x_coord % shape[1] != 0 and y_coord % shape[0] != 0:
if len(k) == 3:
if k[2] < 0.5:
continue
cv2.circle(img, (int(x_coord), int(y_coord)), 5, color_k, -1, lineType=cv2.LINE_AA)
ndim = keypoints.shape[-1]
for i, sk in enumerate(skeleton):
pos1 = (int(keypoints[(sk[0] - 1), 0]), int(keypoints[(sk[0] - 1), 1]))
pos2 = (int(keypoints[(sk[1] - 1), 0]), int(keypoints[(sk[1] - 1), 1]))
if ndim == 3:
conf1 = keypoints[(sk[0] - 1), 2]
conf2 = keypoints[(sk[1] - 1), 2]
if conf1 < 0.5 or conf2 < 0.5:
continue
if pos1[0] % shape[1] == 0 or pos1[1] % shape[0] == 0 or pos1[0] < 0 or pos1[1] < 0:
continue
if pos2[0] % shape[1] == 0 or pos2[1] % shape[0] == 0 or pos2[0] < 0 or pos2[1] < 0:
continue
cv2.line(img, pos1, pos2, [int(x) for x in limb_color[i]], thickness=2, lineType=cv2.LINE_AA)
return img
Making prediction, the model accepts a path to input image and returns list with Results class object. Results contains boxes and key points.
Also it contains utilities for processing results, for example, plot() method for drawing.
models_dir = Path('./models')
models_dir.mkdir(exist_ok=True)
POSE_MODEL_NAME = "yolov8n-pose"
res = pose_model(IMAGE_PATH)
Image.fromarray(res[0].plot()[:, :, ::-1])
image 1/1 /home/ea/work/openvino_notebooks/notebooks/230-yolov8-optimization/data/intel_rnb.jpg: 480x640 1 person, 52.6ms
Speed: 2.1ms preprocess, 52.6ms inference, 1.3ms postprocess per image at shape (1, 3, 480, 640)
back to top ⬆️
YOLOv8 provides API for convenient model exporting to different formats including OpenVINO IR. model.export is responsible for model
conversion. We need to specify the format, and additionally, we can preserve dynamic shapes in the model.
# object detection model
pose_model_path = models_dir / f"{POSE_MODEL_NAME}_openvino_model/{POSE_MODEL_NAME}.xml"
if not pose_model_path.exists():
pose_model.export(format="openvino", dynamic=True, half=False)
back to top ⬆️
To test model work, we create inference pipeline similar to model.predict method. The pipeline consists of preprocessing step, inference of
OpenVINO model and results post-processing to get results.
keyboard_arrow_down Preprocessing
back to top ⬆️
Model input is a tensor with the [-1, 3, -1, -1] shape in the N, C, H, W format, where
The model expects images in RGB channels format and normalized in [0, 1] range. Although the model supports dynamic input shape with
preserving input divisibility to 32, it is recommended to use static shapes, for example, 640x640 for better efficiency. To resize images to fit
model size letterbox , resize approach is used, where the aspect ratio of width and height is preserved.
def letterbox(img: np.ndarray, new_shape:Tuple[int, int] = (640, 640), color:Tuple[int, int, int] = (114, 114, 114), auto:bool =
"""
Resize image and padding for detection. Takes image as input,
resizes image to fit into new shape with saving original aspect ratio and pads it to meet stride-multiple constraints
Parameters:
img (np.ndarray): image for preprocessing
new_shape (Tuple(int, int)): image size after preprocessing in format [height, width]
color (Tuple(int, int, int)): color for filling padded area
auto (bool): use dynamic input size, only padding for stride constrins applied
scale_fill (bool): scale image to fill new_shape
scaleup (bool): allow scale image if it is lower then desired input size, can affect model accuracy
stride (int): input padding stride
Returns:
img (np.ndarray): image after preprocessing
ratio (Tuple(float, float)): hight and width scaling ratio
padding_size (Tuple(int, int)): height and width padding size
"""
# Resize and pad image while meeting stride-multiple constraints
shape = img.shape[:2] # current shape [height, width]
if isinstance(new_shape, int):
new_shape = (new_shape, new_shape)
# Compute padding
ratio = r, r # width, height ratios
new_unpad = int(round(shape[1] * r)), int(round(shape[0] * r))
dw, dh = new_shape[1] - new_unpad[0], new_shape[0] - new_unpad[1] # wh padding
if auto: # minimum rectangle
dw, dh = np.mod(dw, stride), np.mod(dh, stride) # wh padding
elif scale_fill: # stretch
dw, dh = 0.0, 0.0
new_unpad = (new_shape[1], new_shape[0])
ratio = new_shape[1] / shape[1], new_shape[0] / shape[0] # width, height ratios
Parameters:
img0 (np.ndarray): image for preprocessing
Returns:
img (np.ndarray): image after preprocessing
"""
# resize
img = letterbox(img0)[0]
def image_to_tensor(image:np.ndarray):
"""
Preprocess image according to YOLOv8 input requirements.
Takes image in np.array format, resizes it to specific size using letterbox resize and changes data layout from HWC to CHW.
Parameters:
img (np.ndarray): image for preprocessing
Returns:
input_tensor (np.ndarray): input tensor in NCHW format with float32 values in [0, 1] range
"""
input_tensor = image.astype(np.float32) # uint8 to fp32
input_tensor /= 255.0 # 0 - 255 to 0.0 - 1.0
keyboard_arrow_down Postprocessing
back to top ⬆️
The model output contains detection boxes candidates, it is a tensor with the [-1,56,-1] shape in the B,56,N format, where:
B - batch size
N - number of detection boxes
For getting the final prediction, we need to apply a non-maximum suppression algorithm and rescale box coordinates to the original image
size.
After prediction detection box has the [ x , y , h , w , detection_precision , class_id , keypoint_1_x , keypoint_1_y , keypoint_1_score ,
..., keypoint_17_x , keypoint_17_y , keypoint_17_score ] format, where:
def postprocess(
pred_boxes:np.ndarray,
input_hw:Tuple[int, int],
orig_img:np.ndarray,
min_conf_threshold:float = 0.25,
nms_iou_threshold:float = 0.45,
agnosting_nms:bool = False,
max_detections:int = 80,
):
"""
YOLOv8 model postprocessing function. Applied non maximum supression algorithm to detections and rescale boxes to original im
Parameters:
pred_boxes (np.ndarray): model output prediction boxes
input_hw (np.ndarray): preprocessed image
orig_image (np.ndarray): image before preprocessing
min_conf_threshold (float, *optional*, 0.25): minimal accepted confidence for object filtering
nms_iou_threshold (float, *optional*, 0.45): minimal overlap score for removing objects duplicates in NMS
agnostic_nms (bool, *optiona*, False): apply class agnostinc NMS approach or not
max_detections (int, *optional*, 300): maximum detections after NMS
Returns:
pred (List[Dict[str, np.ndarray]]): list of dictionary with det - detected boxes in format [x1, y1, x2, y2, score, label]
kpt - 17 keypoints in format [x1, y1, score1]
"""
nms_kwargs = {"agnostic": agnosting_nms, "max_det":max_detections}
preds = ops.non_max_suppression(
torch.from_numpy(pred_boxes),
min_conf_threshold,
nms_iou_threshold,
nc=1,
**nms_kwargs
)
results = []
kpt_shape = [17, 3]
for i, pred in enumerate(preds):
shape = orig_img[i].shape if isinstance(orig_img, list) else orig_img.shape
pred[:, :4] = ops.scale_boxes(input_hw, pred[:, :4], shape).round()
pred_kpts = pred[:, 6:].view(len(pred), *kpt_shape) if len(pred) else pred[:, 6:]
pred_kpts = ops.scale_coords(input_hw, pred_kpts, shape)
results.append({"box": pred[:, :6].numpy(), 'kpt': pred_kpts.numpy()})
return results
back to top ⬆️
Select device from dropdown list for running inference using OpenVINO
import ipywidgets as widgets
import openvino as ov
core = ov.Core()
device = widgets.Dropdown(
options=core.available_devices + ["AUTO"],
value='AUTO',
description='Device:',
disabled=False,
)
device
back to top ⬆️
Now, once we have defined preprocessing and postprocessing steps, we are ready to check model prediction.
core = ov.Core()
pose_ov_model = core.read_model(pose_model_path)
if device.value != "CPU":
pose_ov_model.reshape({0: [1, 3, 640, 640]})
ov_config = {}
if "GPU" in device.value or ("AUTO" in device.value and "GPU" in core.available_devices):
ov_config = {"GPU_DISABLE_WINOGRAD_CONVOLUTION": "YES"}
pose_compiled_model = core.compile_model(pose_ov_model, device.value, ov_config)
input_image = np.array(Image.open(IMAGE_PATH))
detections = detect(input_image, pose_compiled_model)[0]
image_with_boxes = draw_results(detections, input_image, label_map)
Image.fromarray(image_with_boxes)
Great! The result is the same, as produced by original models.
back to top ⬆️
YOLOv8 is pre-trained on the COCO dataset, so to evaluate the model accuracy we need to download it. According to the instructions
provided in the YOLOv8 repo, we also need to download annotations in the format used by the author of the model, for use with the original
model evaluation function.
Note: The initial dataset download may take a few minutes to complete. The download speed will vary depending on the quality
of your internet connection.
DATA_URL = "http://images.cocodataset.org/zips/val2017.zip"
LABELS_URL = "https://github.com/ultralytics/yolov5/releases/download/v1.0/coco2017labels-segments.zip"
CFG_URL = "https://raw.githubusercontent.com/ultralytics/ultralytics/8ebe94d1e928687feaa1fee6d5668987df5e43be/ultralytics/dataset
OUT_DIR = Path('./datasets')
back to top ⬆️
The original model repository uses a Validator wrapper, which represents the accuracy validation pipeline. It creates dataloader and
evaluation metrics and updates metrics on each data batch produced by the dataloader. Besides that, it is responsible for data preprocessing
and results postprocessing. For class initialization, the configuration should be provided. We will use the default setup, but it can be replaced
with some parameters overriding to test on custom data. The model has connected the ValidatorClass method, which creates a validator
class instance.
from ultralytics.utils import DEFAULT_CFG
from ultralytics.cfg import get_cfg
from ultralytics.data.utils import check_det_dataset
args = get_cfg(cfg=DEFAULT_CFG)
args.data = 'coco8-pose.yaml'
args.model = 'yolov8n-pose.pt'
pose_validator = PoseValidator(args=args)
pose_validator.data = check_det_dataset(args.data)
pose_data_loader = pose_validator.get_dataloader("datasets/coco8-pose", 1)
val: Scanning datasets/coco8-pose/labels/train.cache... 8 images, 0 backgrounds, 0 corrupt: 100%|██████████| 8/8 [00:00<?, ?it
pose_validator.is_coco = True
pose_validator.names = pose_model.model.names
pose_validator.metrics.names = pose_validator.names
pose_validator.nc = pose_model.model.model[-1].nc
pose_validator.sigma = OKS_SIGMA
After definition test function and validator creation, we are ready for getting accuracy metrics
Note: Model evaluation is time consuming process and can take several minutes, depending on the hardware. For reducing
calculation time, we define num_samples parameter with evaluation subset size, but in this case, accuracy can be
noncomparable with originally reported by the authors of the model, due to validation subset difference. To validate the models
on the full dataset set NUM_TEST_SAMPLES = None .
NUM_TEST_SAMPLES = 300
Boxes:
Class Images Labels Precision Recall [email protected] [email protected]:.95
all 8 21 1 0.9 0.955 0.736
Precision is the degree of exactness of the model in identifying only relevant objects.
Recall measures the ability of the model to detect all ground truths objects.
mAP@t - mean average precision, represented as area under the Precision-Recall curve aggregated over all classes in the dataset, where
t is the Intersection Over Union (IOU) threshold, degree of overlapping between ground truth and predicted objects. Therefore, [email protected]
indicates that mean average precision is calculated at 0.5 IOU threshold, [email protected]:.95 - is calculated on range IOU thresholds from 0.5
to 0.95 with step 0.05.
Reuse validation dataloader in accuracy testing for quantization. For that, it should be wrapped into the nncf.Dataset object and define a
transformation function for getting only input tensors.
import nncf # noqa: F811
from typing import Dict
def transform_fn(data_item:Dict):
"""
Quantization transform function. Extracts and preprocess input data from dataloader item for quantization.
Parameters:
data_item: Dict with data item produced by DataLoader during iteration
Returns:
input_tensor: Input data for quantization
"""
input_tensor = pose_validator.preprocess(data_item)['img'].numpy()
return input_tensor
INFO:nncf:NNCF initialized successfully. Supported frameworks detected: torch, tensorflow, onnx, openvino
The nncf.quantize function provides an interface for model quantization. It requires an instance of the OpenVINO Model and quantization
dataset. Optionally, some additional parameters for the configuration quantization process (number of samples for quantization, preset,
ignored scope, etc.) can be provided. YOLOv8 model contains non-ReLU activation functions, which require asymmetric quantization of
activations. To achieve a better result, we will use a mixed quantization preset. It provides symmetric quantization of weights and
asymmetric quantization of activations. For more accurate results, we should keep the operation in the postprocessing subgraph in floating
point precision, using the ignored_scope parameter.
Note: Model post-training quantization is time-consuming process. Be patient, it can take several minutes depending on your
hardware.
ignored_scope = nncf.IgnoredScope(
types=["Multiply", "Subtract", "Sigmoid"], # ignore operations
names=[
"/model.22/dfl/conv/Conv", # in the post-processing subgraph
"/model.22/Add",
"/model.22/Add_1",
"/model.22/Add_2",
"/model.22/Add_3",
"/model.22/Add_4",
"/model.22/Add_5",
"/model.22/Add_6",
"/model.22/Add_7",
"/model.22/Add_8",
"/model.22/Add_9",
"/model.22/Add_10"
]
)
# Detection model
quantized_pose_model = nncf.quantize(
pose_ov_model,
quantization_dataset,
preset=nncf.QuantizationPreset.MIXED,
ignored_scope=ignored_scope
)
back to top ⬆️
nncf.quantize returns the OpenVINO Model class instance, which is suitable for loading on a device for making predictions. INT8 model
input data and output result formats have no difference from the floating point model representation. Therefore, we can reuse the same
detect function defined above for getting the INT8 model result on the image.
device
if device.value != "CPU":
quantized_pose_model.reshape({0: [1, 3, 640, 640]})
ov_config = {}
if "GPU" in device.value or ("AUTO" in device.value and "GPU" in core.available_devices):
ov_config = {"GPU_DISABLE_WINOGRAD_CONVOLUTION": "YES"}
quantized_pose_compiled_model = core.compile_model(quantized_pose_model, device.value, ov_config)
input_image = np.array(Image.open(IMAGE_PATH))
detections = detect(input_image, quantized_pose_compiled_model)[0]
image_with_boxes = draw_results(detections, input_image, label_map)
Image.fromarray(image_with_boxes)
keyboard_arrow_down Compare the Original and Quantized Models
back to top ⬆️
back to top ⬆️ Finally, use the OpenVINO Benchmark Tool to measure the inference performance of the FP32 and INT8 models.
Note: For more accurate performance, it is recommended to run benchmark_app in a terminal/command prompt after closing
other applications. Run benchmark_app -m <model_path> -d CPU -shape "<input_shape>" to benchmark async inference
on CPU on specific input data shape for one minute. Change CPU to GPU to benchmark on GPU. Run benchmark_app --help
to see an overview of all command-line options.
device
back to top ⬆️
As we can see, there is no significant difference between INT8 and float model result in a single image test. To understand how quantization
influences model prediction precision, we can compare model accuracy on a dataset.
Great! Looks like accuracy was changed, but not significantly and it meets passing criteria.
back to top ⬆️
The performance could be also improved by another OpenVINO method such as async inference pipeline or preprocessing API.
Async Inference pipeline help to utilize the device more optimal. The key advantage of the Async API is that when a device is busy with
inference, the application can perform other tasks in parallel (for example, populating inputs or scheduling other requests) rather than wait
for the current inference to complete first. To understand how to perform async inference using openvino, refer to Async API tutorial
Preprocessing API enables making preprocessing a part of the model reducing application code and dependency on additional image
processing libraries. The main advantage of Preprocessing API is that preprocessing steps will be integrated into the execution graph and will
be performed on a selected device (CPU/GPU etc.) rather than always being executed on CPU as part of an application. This will also improve
selected device utilization. For more information, refer to the overview of Preprocessing API tutorial. To see, how it could be used with
YOLOV8 object detection model , please, see Convert and Optimize YOLOv8 real-time object detection with OpenVINO tutorial
import collections
import time
from IPython import display
processing_times = collections.deque()
while True:
# Grab the frame.
frame = player.next()
if frame is None:
print("Source ended")
break
# If the frame is larger than full HD, reduce size to improve the performance.
scale = 1280 / max(frame.shape)
if scale < 1:
frame = cv2.resize(
src=frame,
dsize=None,
fx=scale,
fy=scale,
interpolation=cv2.INTER_AREA,
)
# Get the results.
input_image = np.array(frame)
start_time = time.time()
# model expects RGB image, while video capturing in BGR
detections = detect(input_image[:, :, ::-1], compiled_model)[0]
stop_time = time.time()
processing_times.append(stop_time - start_time)
# Use processing times from last 200 frames.
if len(processing_times) > 200:
processing_times.popleft()
_, f_width = frame.shape[:2]
# Mean processing time [ms].
processing_time = np.mean(processing_times) * 1000
fps = 1000 / processing_time
cv2.putText(
img=frame,
text=f"Inference time: {processing_time:.1f}ms ({fps:.1f} FPS)",
org=(20, 40),
fontFace=cv2.FONT_HERSHEY_COMPLEX,
fontScale=f_width / 1000,
color=(0, 0, 255),
thickness=1,
lineType=cv2.LINE_AA,
)
# Use this workaround if there is flickering.
if use_popup:
cv2.imshow(winname=title, mat=frame)
key = cv2.waitKey(1)
# escape = 27
if key == 27:
break
else:
# Encode numpy array to jpg.
_, encoded_img = cv2.imencode(
ext=".jpg", img=frame, params=[cv2.IMWRITE_JPEG_QUALITY, 100]
)
# Create an IPython image.
i = display.Image(data=encoded_img)
# Display the image in this notebook.
display.clear_output(wait=True)
display.display(i)
# ctrl-c
except KeyboardInterrupt:
print("Interrupted")
# any different error
except RuntimeError as e:
print(e)
finally:
if player is not None:
# Stop capturing.
player.stop()
if use_popup: