1 はじめに
CX 事業本部 delivery部の平内(SIN)です。
「ぬいぐるみ」の物体検出は、結構、むつかしくて、色々試していたのですが、なかなか精度の高いものを作れていませんでした。 しかし、Segment Anythingを使用して、手返し良くたデータセットを作成することで、いい感じのモデルになったので、今回はこちらを、紹介させてください。
また、AWS IoT Greengrassは、ONNXRuntimeにも対応しているとのことで、こちらでも試してみたいので、今回は、ONNXへ変換して、RaspberryPIで使用してみました。
https://github.com/aws-samples/aws-iot-gg-onnx-runtime
2 YOLOv5
Segment Anythingでデータセットを作成するには、検出したい物体を撮影し、その動画からバウンディングボックで切り取られた透過画像を生成します。
そして、適当な背景を合成することでデータセットを作成しています。
「データセットを手返し良く」という意味で、モデルは逐次作成して確認しながら進めています。精度が納得できない場合、その場面の動画を撮影して、データを追加し、再度モデルを作成するという作業の繰り返しです。
今回、最終的に利用されたデータセットは、以下のようになりました。
- 30秒程度の動画
- TOMATO × 12本、 AHIRU × 12本
- SegmentAnythingで作成した、透過PNG
- TOMATO 1,222枚、 AHIRU × 1,228枚
- データセット
- 画像 3,000枚
- アノテーション(バウンディングボックス) 約32,000
YOLOv5のファインチューニングについては、下記をご参照ください。
3 ONNX
YOKOv5で提供されている export.py で、ONNX形式のモデルを簡単に作成できます。
$ python3 export.py --weights best.pt --include onnx
$ ls -al
-rw-r--r-- 1 root root 28509774 May 28 04:34 best.onnx
-rw-r--r-- 1 root root 14391485 May 28 04:33 best.pt
参考:https://docs.ultralytics.com/yolov5/tutorials/model_export/
入出力の形式は、以下のようになっていました。
confirm.py
import onnxruntime
sess = onnxruntime.InferenceSession("./best.onnx")
print(sess.get_inputs()[0])
print(sess.get_outputs()[0])
best.onnx
$ python3 confirm.py
NodeArg(name='images', type='tensor(float)', shape=[1, 3, 640, 640])
NodeArg(name='output0', type='tensor(float)', shape=[1, 25200, 7])
4 RaspberryPi
使用したのは、RaspberryPi 4B(4G) で、OSは、今年5月の最新版(2023-05-03) Raspberry Pi OS (64-bit) A port Debian Bullseye with the Respbeyy Pi Desktop (Compatible with Raspberry Pi 3/4/400)です。
$ cat /proc/cpuinfo | grep Revision
Revision : c03112
$ lsb_release -a
No LSB modules are available.
Distributor ID: Debian
Description: Debian GNU/Linux 11 (bullseye)
Release: 11
Codename: bullseye
$ uname -a
Linux raspberrypi 6.1.21-v8+ #1642 SMP PREEMPT Mon Apr 3 17:24:16 BST 2023 aarch64 GNU/Linux
$ getconf LONG_BIT
64
最新の機械学習関連のライブラリは、64bit版OSでは、pipで簡単にインストールできます。
$ pip install opencv-python
Successfully installed opencv-python-4.7.0.72
$ pip install onnxruntime
Successfully installed coloredlogs-15.0.1 flatbuffers-20181003210633 humanfriendly-10.0 mpmath-1.3.0 numpy-1.24.3 onnxruntime-1.15.0 packaging-23.1 protobuf-4.23.2 sympy-1.12
$ pip install torch
Successfully installed filelock-3.12.0 networkx-3.1 torch-2.0.1
$ pip install torchvision
Successfully installed torchvision-0.15.2
$ pip install onnx
Successfully installed onnx-1.14.0
RaspberryPIのインストーラで、デフォルトとなっている32bitOSを使用すると、上記の最新ライブラリは、pip installだけではインストールできませんでした。例えば、下記のように、少し前のバージョンのwheelsを使用したり、コンパイルすることになってしまうので、ちょっと面倒かもしれません。
$ wget https://github.com/nknytk/built-onnxruntime-for-raspberrypi-linux/raw/master/wheels/bullseye/onnxruntime-1.14.0-cp39-cp39-linux_armv7l.whl
$ sudo pip install onnxruntime-1.14.0-cp39-cp39-linux_armv7l.whl
5 動作確認
動作確認した様子です。
RaspberryPI上で、写真を推論しています。
コードです。https://github.com/ultralytics/yolov5/blob/master/detect.pyを参考にさせて頂きました。
detect.py
import numpy as np
import cv2
import math
import torch
import time
import torchvision
import onnxruntime
def box_label(
img, box, line_width, label="", color=(128, 128, 128), txt_color=(255, 255, 255)
):
p1, p2 = (int(box[0]), int(box[1])), (int(box[2]), int(box[3]))
cv2.rectangle(img, p1, p2, color, thickness=line_width, lineType=cv2.LINE_AA)
if label:
tf = max(3 - 1, 1)
w, h = cv2.getTextSize(label, 0, fontScale=3 / 3, thickness=tf)[0]
outside = p1[1] - h >= 3
p2 = p1[0] + w, p1[1] - h - 3 if outside else p1[1] + h + 3
cv2.rectangle(img, p1, p2, color, -1, cv2.LINE_AA) # filled
cv2.putText(
img,
label,
(p1[0], p1[1] - 2 if outside else p1[1] + h + 2),
0,
3 / 3,
txt_color,
thickness=tf,
lineType=cv2.LINE_AA,
)
return img
def xywh2xyxy(x):
y = x.clone()
y[..., 0] = x[..., 0] - x[..., 2] / 2 # top left x
y[..., 1] = x[..., 1] - x[..., 3] / 2 # top left y
y[..., 2] = x[..., 0] + x[..., 2] / 2 # bottom right x
y[..., 3] = x[..., 1] + x[..., 3] / 2 # bottom right y
return y
def non_max_suppression(
prediction,
conf_thres=0.25,
iou_thres=0.45,
agnostic=False,
labels=(),
max_det=300,
nm=0,
):
bs = prediction.shape[0] # batch size
nc = prediction.shape[2] - nm - 5 # number of classes
xc = prediction[..., 4] > conf_thres # candidates
max_wh = 7680 # (pixels) maximum box width and height
max_nms = 30000 # maximum number of boxes into torchvision.ops.nms()
time_limit = 0.5 + 0.05 * bs # seconds to quit after
t = time.time()
mi = 5 + nc
output = [torch.zeros((0, 6 + nm), device=prediction.device)] * bs
for xi, x in enumerate(prediction): # image index, image inference
x = x[xc[xi]] # confidence
if labels and len(labels[xi]):
lb = labels[xi]
v = torch.zeros((len(lb), nc + nm + 5), device=x.device)
v[:, :4] = lb[:, 1:5] # box
v[:, 4] = 1.0 # conf
v[range(len(lb)), lb[:, 0].long() + 5] = 1.0 # cls
x = torch.cat((x, v), 0)
if not x.shape[0]:
continue
x[:, 5:] *= x[:, 4:5]
box = xywh2xyxy(x[:, :4])
mask = x[:, mi:]
conf, j = x[:, 5:mi].max(1, keepdim=True)
x = torch.cat((box, conf, j.float(), mask), 1)[conf.view(-1) > conf_thres]
n = x.shape[0]
if not n:
continue
x = x[x[:, 4].argsort(descending=True)[:max_nms]]
c = x[:, 5:6] * (0 if agnostic else max_wh)
boxes, scores = x[:, :4] + c, x[:, 4]
i = torchvision.ops.nms(boxes, scores, iou_thres)
i = i[:max_det]
output[xi] = x[i]
if (time.time() - t) > time_limit:
break
return output
def letterbox(
im,
new_shape=(640, 640),
color=(114, 114, 114),
scaleup=True,
stride=32,
):
shape = im.shape[:2] # current shape [height, width]
r = min(new_shape[0] / shape[0], new_shape[1] / shape[1])
if not scaleup: # only scale down, do not scale up (for better val mAP)
r = min(r, 1.0)
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
dw /= 2
dh /= 2
if shape[::-1] != new_unpad: # resize
im = cv2.resize(im, new_unpad, interpolation=cv2.INTER_LINEAR)
top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
im = cv2.copyMakeBorder(
im, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color
)
return im, ratio, (dw, dh)
def clip_boxes(boxes, shape):
# Clip boxes (xyxy) to image shape (height, width)
boxes[..., 0].clamp_(0, shape[1]) # x1
boxes[..., 1].clamp_(0, shape[0]) # y1
boxes[..., 2].clamp_(0, shape[1]) # x2
boxes[..., 3].clamp_(0, shape[0]) # y2
def scale_boxes(img1_shape, boxes, img0_shape):
gain = min(
img1_shape[0] / img0_shape[0], img1_shape[1] / img0_shape[1]
) # gain = old / new
pad = (img1_shape[1] - img0_shape[1] * gain) / 2, (
img1_shape[0] - img0_shape[0] * gain
) / 2 # wh padding
boxes[..., [0, 2]] -= pad[0] # x padding
boxes[..., [1, 3]] -= pad[1] # y padding
boxes[..., :4] /= gain
clip_boxes(boxes, img0_shape)
return boxes
colors = [(0, 0, 200), (200, 200, 0)]
line_width = 6
def main():
# weights = "./yolov5s.onnx"
weights = "./best.onnx"
input_path = "test-images"
output_path = "output"
files = [
"001.jpg",
"002.jpg",
"003.jpg",
"004.jpg",
"005.jpg",
"006.jpg",
"007.jpg",
"008.jpg",
"009.jpg",
"010.jpg"
]
session = onnxruntime.InferenceSession(weights, providers=["CPUExecutionProvider"])
for file in files:
input_file = "{}/{}".format(input_path, file)
output_file = "{}/{}".format(output_path, file)
run(session, input_file, output_file)
def run(session, input_file, output_file):
print("input_file:{} output_file:{}".format(input_file, output_file))
max_det = 1000 # maximum detections per image
# conf_thres = 0.25 # confidence threshold
# iou_thres = 0.45 # NMS IOU threshold
conf_thres = 0.5 # confidence threshold
iou_thres = 0.45 # NMS IOU threshold
imgsz = (640, 640)
agnostic_nms = False
hide_conf = False
stride = 32
meta = session.get_modelmeta().custom_metadata_map
if "stride" in meta:
stride, names = int(meta["stride"]), eval(meta["names"])
fp16 = False
imgsz = list(imgsz)
imgsz = [max(math.ceil(x / int(stride)) * int(stride), 0) for x in imgsz]
output_names = [x.name for x in session.get_outputs()]
im0s = cv2.imread(input_file) # BGR
im = letterbox(im0s, [640, 640], stride=32)[0] # padded resize
im = im.transpose((2, 0, 1))[::-1] # HWC to CHW, BGR to RGB
im = np.ascontiguousarray(im) # contiguous
im = torch.from_numpy(im)
im = im.half() if fp16 else im.float() # uint8 to fp16/32
im /= 255 # 0 - 255 to 0.0 - 1.0
im = im[None] # [3, 640, 480] => [1, 3, 640, 480]
# inference
im = im.cpu().numpy() # torch to numpy
y = session.run(output_names, {session.get_inputs()[0].name: im})
pred = torch.from_numpy(y[0])
pred = non_max_suppression(
pred, conf_thres, iou_thres, agnostic_nms, max_det=max_det
)
for det in pred:
im0 = im0s.copy()
if len(det):
det[:, :4] = scale_boxes(im.shape[2:], det[:, :4], im0.shape).round()
for *xyxy, conf, cls in reversed(det):
c = int(cls)
label = names if hide_conf else f"{names} {conf:.2f}" #
im0 = box_label(
im0, xyxy, line_width, label, color=colors
)
cv2.imwrite(output_file, im0)
if __name__ == "__main__":
main()
6 最後に
改めて実感してますが、Segment Anythingを使用したデータセット作成は、強力です。
透過PNGの切り出しもGPU環境であれば、フレーム単位の処理時間は1秒程度なので、そこまで時間もかかりませんでした。
もう少し工夫すれば、「手返し」も更にに良くなるのではと、妄想しております。