yolov8实战之torchserve服务化:使用yolov8x来预打标

前言

最近在做一个目标检测的任务,部署在边缘侧,对于模型的速度要求比较严格(yolov8n这种),所以模型的大小不能弄太大,所以原模型的性能受限,更多的重点放在增加数据上。实测yolov8x在数据集上的效果比小模型要好不少,所以想法是用yolov8x来预打标,然后选择一些置信度高的样本加到训练集来训练yolov8n,减少标注的成本。原始数据是在ceph上,比较直观的方式就是一张张读,然后一张张推理。这样效率不高,毕竟GPU适合组batch去推理,所以为了效率就需要自己去组成batch然后推理,然后再把batch的结果再分开对应到单张图上,虽然并不难,还是挺繁琐的,这也是我以前的做法。其实可以不需要这么麻烦,这种batching操作很多的推理服务都会帮我们做掉,我们只需要并发去请求就好了,和做单个没什么区别,要加其他的模型进行组合逻辑也是非常方便。GroundingDINO(一种开集目标检测算法)服务化,根据文本生成检测框_CodingInCV的博客-CSDN博客这个里面我们使用torchserve来实现了算法的服务化,这里我们依旧还是使用torchserve。基础就不做介绍了,可以读上面这篇。与GroundingDINO不同的是,这里我们会启用batch操作,而GroudingDINO里没有支持batch。

导出onnx模型

为了方便起见,我们使用onnx模型,避免去处理yolov8的pytorch环境问题,官网提供了导出的方式:Detect - Ultralytics YOLOv8 Docs
为了支持动态的batch, 我们导出时要以dynamic的方式导出,我这里对导出做了一点修改,只让batch为动态,而输入尺寸固定, 修改engine/exporter.py:
yolov8实战之torchserve服务化:使用yolov8x来预打标_第1张图片
为了支持我们新增的dynamic_batch参数,我们还需要再default.yaml中增加这个参数,具体可以参考:yolov8训练进阶:新增配置参数_CodingInCV的博客-CSDN博客
yolov8实战之torchserve服务化:使用yolov8x来预打标_第2张图片
然后自行写脚本转换:

from ultralytics import YOLO

model = YOLO('yolov8x6404/weights/last.pt')  # initialize
model.export(format = "onnx", opset = 11, simplify = True,
             dynamic_batch=True, imgsz=640)  # export to onnx

导出的模型将和输入的模型在同一个路径。

自定义handler

handler的写法

在GroundingDINO(一种开集目标检测算法)服务化,根据文本生成检测框_CodingInCV的博客-CSDN博客我们没有提到怎么写自己的模型handler,所谓模型handler就是告诉torchserve我们的模型如何载入、前处理和后处理。官方教程:Custom Service — PyTorch/Serve master documentation
torchserve自身带了一些handler:
BaseHandler: handler的基类,我们可以继承这个,也可以不继承,如果不继承则至少要实现initializehandle方法。
yolov8实战之torchserve服务化:使用yolov8x来预打标_第3张图片

我们可以继承他们来实现自己的,也可以不继承,这里以不继承来实现,通用性比较强,不管什么模型都可以搞定,主要就是实现一个类,这个类至少要实现initializehandle方法:
initialize 就是初始化模型,这个方法必须有一个输入参数context(serve/ts/context.py at master · pytorch/serve (github.com)), 从这个参数我们可以拿到比如模型的路径、显卡号等信息。
handle 是接收输入请求和返回处理结果的接口,具有2个参数,第一个参数是输入请求,第二个参数也是context。
对于每个模型我们可以将推理过程拆分为三个过程(方法):preprocess、inference、postprocess,即前处理、推理、后处理,我们的handler只要实现这三个方法,然后依次在handle中调用即可,最后把输出按要求组合起来,handle的返回值必须是list of list,也就是数组的数组,外层list的长度等于输入的batch数(torchserve可以自动组batch),内层的list是单个请求的输出,里面的元素可以是dict,完整代码如下:

import logging
import os,sys
import onnxruntime as ort
import base64
import numpy as np
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
try:
    from common.common import resize_image
except:
    from common import resize_image
import cv2

logger = logging.getLogger(__name__)
console_logger = logging.StreamHandler(sys.stdout)
console_logger.setLevel(logging.DEBUG)
console_logger.setFormatter(logging.Formatter("%(asctime)s %(name)s [%(levelname)s] %(message)s"))
logger.addHandler(console_logger)

class YOLOV8Handler(object):
    def __init__(self):
        self.context = None
        self.initialized = False
        self.model = None
        self.input_name = None
        self.input_shape = None
        self.conf_thres = 0.45
        self.iou_thres = 0.45
        self.class2label = {
            0: "body",
            1: "head",
        }
        self.device = None

    def initialize(self, context):
        #  load the model
        logger.info("initialize grounding dino handler")
        self.context = context
        self.manifest = context.manifest
        properties = context.system_properties
        model_dir = properties.get("model_dir")

        # Read model serialize/pt file
        serialized_file = self.manifest['model']['serializedFile']
        model_pt_path = os.path.join(model_dir, serialized_file)
        if not os.path.isfile(model_pt_path):
            raise RuntimeError("Missing the model file")
        
        # get device

        available_providers =  ort.get_available_providers()
        provide_options = {}
        if "CUDAExecutionProvider" in available_providers:
            self.device = str(properties.get("gpu_id"))
            provide_options["device_id"] = self.device
            privider = "CUDAExecutionProvider"
            logger.info("using gpu {}".format(self.device))
        else:
            privider = "CPUExecutionProvider"
        
        self.model = ort.InferenceSession(model_pt_path, providers=[privider], provider_options=[provide_options])
        self.initialized = True
        # get input shape
        self.input_name = self.model.get_inputs()[0].name
        self.input_shape = self.model.get_inputs()[0].shape
        logger.info("model loaded successfully")

    def preprocess(self, data):
        logger.info("preprocess data")
        preprocessed_data = []
        preprocessed_params = []
        network_input_height = self.input_shape[2]
        network_input_width = self.input_shape[3]
        for row in data:
            input = row.get("data") or row.get("body")
            if isinstance(input, dict) and "image" in input:
                image = input["image"]
            else:
                logger.error("No image found in the request")
                assert False, "No  image found in the request"
            if isinstance(image, str):
                # if the image is a string of bytesarray.
                image = base64.b64decode(image)
            # If the image is sent as bytesarray
            if isinstance(image, (bytearray, bytes)):
                image = cv2.imdecode(np.frombuffer(image, dtype=np.uint8), cv2.IMREAD_ANYCOLOR)
            else:
                logger.error("No caption or image found in the request")
                assert False, "No caption or image found in the request"
            
            image_h, image_w, _ = image.shape
            image, newh, neww, top, left  = resize_image(image, keep_ratio=True, dst_width=network_input_width, dst_height=network_input_height)
            image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
            preprocessed_data.append(image)
            preprocessed_params.append((newh, neww, top, left, image_h, image_w))
        logger.info("preprocess data done")
        preprocessed_data = np.array(preprocessed_data).astype(np.float32)
        preprocessed_data /= 255.0
        preprocessed_data = np.transpose(preprocessed_data, (0, 3, 1, 2))
        return preprocessed_data, preprocessed_params
    
    def inference(self, data, *args, **kwargs):
        logger.info("inference data")
        outputs = self.model.run(None, {self.input_name: data})
        return outputs[0]
    
    def postprocess_one(self, output, request_param):
        newh, neww, top, left, image_h, image_w = request_param
        x_factor = image_w / neww
        y_factor = image_h / newh
        outputs = output
        outputs = np.transpose(np.squeeze(outputs))
        boxes = {}
        for row in outputs:
            classes_scores = row[4:]
            max_score = np.max(classes_scores)
            if max_score < self.conf_thres:
                continue
            class_id = np.argmax(classes_scores)
            x, y, w, h = row[0], row[1], row[2], row[3]

            # Calculate the scaled coordinates of the bounding box
            x1 = x - w / 2
            y1 = y - h / 2
            x1 = x1-left
            y1 = y1-top
            x2 = x1 + w
            y2 = y1 + h

            # Scale the coordinates according to the original image
            x1 = x1 * x_factor
            y1 = y1 * y_factor
            x2 = x2 * x_factor
            y2 = y2 * y_factor
            if class_id not in boxes:
                boxes[class_id] = [[],[]]
            boxes[class_id][0].append([x1, y1, x2, y2])
            boxes[class_id][1].append(float(max_score))
        
        # NMS
        nms_boxes = []
        for class_id in boxes:
            candidate_boxes, scores = boxes[class_id]
            indices = cv2.dnn.NMSBoxes(candidate_boxes, scores, self.conf_thres, self.iou_thres)
            for index in indices:
                nms_boxes.append((candidate_boxes[index], scores[index], self.class2label[class_id]))
        return nms_boxes

    def postprocess(self, data):
        outputs, request_params = data
        boxes = []
        for i in range(len(outputs)):
            output = outputs[i]
            request_param = request_params[i]
            nms_boxes = self.postprocess_one(output, request_param)
            boxes.append(nms_boxes)

        return boxes
    
    def handle(self, data, context):
        self.context = context
        image, request_params = self.preprocess(data)
        outputs = self.inference(image)
        boxes_batch = self.postprocess((outputs, request_params))
        results = []
        for boxes in boxes_batch:
            ret = []
            for box, score, label in boxes:
                ret.append({"box": box, "score": score, "label": label})
            results.append(ret)
        return results

注意:为了实现batch操作,我们实现的接口都应该是对batch来的,而不是只对一张图。

调试handler

我们可以模仿context的内容来初始化handler, 然后调用handle方法来调试结果是否正常。

if __name__=="__main__":
    import addict
    context = addict.Dict()
    context.system_properties = {
        "gpu_id": 0,
        "model_dir": "./weights"

    }
    context.manifest = {
        "model": {
            "serializedFile": "yolov8x.onnx"
        }
        }
    handler = YOLOV8Handler()
    handler.initialize(context)
    image_path = "./body.png"
    with open(image_path, "rb") as f:
        image = f.read()

    data = [
        {
            "data": {
                "image": image
            }
        },
        {
            "data": {
                "image": image
            }
        }
    ]

    outputs = handler.handle(data, context)
    print(outputs)

镜像制作

在GroundingDINO(一种开集目标检测算法)服务化,根据文本生成检测框_CodingInCV的博客-CSDN博客中镜像的基础上安装onnxruntime-gpu, 或者在启动时安装

转换模型

这个操作和上一篇文章一样,只是权重文件和需要handler修改一下,不赘述:

docker run --rm -it -v $(pwd):/data -w /data torchserve:groundingdino bash -c "torch-model-archiver --model-name yolov8x --version 1.0 --serialized-file weights/yolov8x.onnx --handler yolov8/yolov8_handler.py --extra-files common/*.py"

启动服务

与上一篇服务化不同,我们启动时不载入所有模型,而是通过post接口去开启,方便设置模型的batch size, 其中端口号根据需要设置。

docker run -d --name groundingdino -v $(pwd)/model_store:/model_store -p 8080:8080 -p 8081:8081 -p 8082:8082 torchserve:groundingdino bash -c "pip install onnxruntime-gpu && torchserve --start --foreground --model-store /model_store

使用Management API载入模型

Management API — PyTorch/Serve master documentation
可以用curl也可以用postman, 如

curl -X POST "localhost:8081/models?url=yolov8x.mar&batch_size=8&max_batch_delay=50"

如果需要再修改batchsize, 要先调用卸载模型的接口写在,然后再调用上面的接口。
通过上面的操作,torchserve会帮我们组batch, 最大为8.

调用

import json
import base64
import requests
import threadpool

url = "http://localhost:8080/predictions/yolov8x"
headers = {"Content-Type": "application/json"}

def request_worker(arg):
    image_path = "./b03492798d5b44eeb70856b9253386df.jpeg"
    data = {
        "image": base64.b64encode(open(image_path, "rb").read()).decode("utf-8")
    }

    response = requests.post(url, headers=headers, json=data)
    print(response.text)

if __name__ == "__main__":
    pool = threadpool.ThreadPool(24)
    requests_task = threadpool.makeRequests(request_worker, range(100))
    [pool.putRequest(req) for req in requests_task]
    pool.wait()

这里,我们用多线程模仿了高并发的去调用模型,这样torchserve就可以自动的根据负载情况来组成batch了,提高模型的吞吐量。类似的,我们就可以方便的使用多线程去读取数据然后调用模型来得到预打标的结果,而不用去处理模型的依赖、组batch等逻辑,也可以很方便的提供给其他需要的同事来使用。

结语

本文简述了将yolov8服务化的过程,服务化后,我们可以方便的用模型来进行数据的预打标、分享模型给他人使用。
yolov8实战之torchserve服务化:使用yolov8x来预打标_第4张图片

你可能感兴趣的:(yolov7/8系列解读与实战,YOLO,pytorch,目标检测,深度学习,计算机视觉)