7.14. YOLOv8 둘러보기¶
YOLOv8 후처리기는 입력 텐서에서 반환 리스트까지 단계별로 살펴볼 수 있을 만큼 작습니다. 이를 한 번 읽어보면 카탈로그의 다른 모든 후처리기가 무엇을 하는지 알 수 있습니다. 양자화된 점수를 저렴하게 임계값 처리하고, 살아남은 것을 취하고, 역양자화하고, 기하 정보를 디코딩하고, NMS로 보내고, 클래스별 리스트를 반환합니다.
7.14.1. 시작 텐서¶
YOLOv8 모델은 형태가 (1, C, A)인 단일 출력 텐서를 출력합니다. 즉 하나의 프레임, C개의 채널, A개의 앵커 예측입니다. 처음 네 채널은 상자 기하 정보(cx, cy, w, h)로 신경망 입력 차원의 [0, 1]로 정규화되어 있습니다. 나머지 C - 4개의 채널은 클래스별 점수이며, 학습된 모델의 경우 이미 [0, 1] 범위에 있습니다. 각 앵커는 채널을 따라 내려가는 하나의 열입니다.
하나의 YOLOv8 앵커는 채널을 따라 내려가는 하나의 열입니다. 네 개의 상자 숫자와 N개의 클래스 점수입니다.¶
기본 제공되는 yolov8n_192.tflite는 단일 클래스 사람 검출기이므로 C = 5이고 A는 수천 개에 이릅니다. 전체 80개 클래스 COCO 세트로 학습된 사용자 정의 모델은 동일한 A에 대해 C = 84를 가집니다. 아래의 디코드는 어떤 클래스 개수에 대해서도 유효합니다.
7.14.2. 역양자화 전 임계값 처리¶
먼저 저렴한 단계입니다. 출력 텐서는 모델의 양자화된 정수 dtype으로 되어 있으며, 모든 값을 역양자화하면 수천 개의 항목을 가진 텐서의 모든 요소를 건드리게 됩니다. 그 대부분은 점수 임계값 아래에 있어 결국 버려집니다. 대신 후처리기는 임계값을 한 번 양자화하고 원시 양자화 공간에서 비교합니다:
from ml.utils import quantize, threshold, dequantize, NMS
from ulab import numpy as np
oh, ow, oc = model.output_shape[0]
scale = model.output_scale[0]
t = quantize(model, self.threshold)
column_outputs = outputs[0].reshape((oh * ow, oc))
score_block = column_outputs[4:, :]
score_indices = threshold(score_block, t, scale,
find_max=True,
find_max_axis=0)
if not len(score_indices):
return ()
column_outputs는 채널이 행이고 앵커가 열이 되도록 (C, A)로 재구성된 텐서입니다. score_block은 클래스 점수 하위 텐서로, 행 4부터 아래의 모든 것입니다. ml.utils.threshold()는 그 블록을 축 0을 따라 축약하여(find_max=True, find_max_axis=0) 앵커별 최대 점수를 구한 다음, 최댓값이 양자화된 임계값을 통과하는 앵커의 색인을 반환합니다. 전체 텐서는 결코 역양자화되지 않았으며, 양자화된 정수 공간에서의 열별 최대 축약만 이루어졌습니다.
통과하는 앵커가 없으면 후처리기는 predict()가 검출 없음으로 해석하는 빈 튜플을 반환합니다.
이 코드의 두 가지 결정이 실행 가능한 후처리기와 사용할 수 없을 만큼 느린 후처리기의 차이를 만듭니다. 첫 번째는 연산을 numpy를 통해 처리하는 것입니다. 출력 텐서는 수천 개의 요소를 가지고 있으며, 이를 순수 Python에서 반복하면 추론마다 수 초가 걸리는 반면, numpy로 벡터화된 동일한 연산은 밀리초 단위로 실행됩니다. 두 번째는 임계값 필터 이전이 아니라 이후에 역양자화하는 것입니다. 먼저 역양자화하면 양자화된 텐서의 네 배 크기인 부동소수점 텐서를 할당하고 거의 모든 요소를 버리기 전에 그 모든 요소를 순회하게 됩니다. 살아남은 열만 역양자화하면 기껏해야 소수의 값만 건드리며, 전체 변환이 소모했을 시간과 RAM을 모두 절약합니다.
7.14.3. 살아남은 것들 역양자화하기¶
살아남은 앵커만 그 기하 정보를 디코딩하면 됩니다. numpy.take()가 그 열들을 뽑아내고 단일 ml.utils.dequantize() 호출이 이를 부동소수점으로 변환합니다:
bb = dequantize(model,
np.take(column_outputs, score_indices, axis=1))
이제 bb는 (C, K)이며, 여기서 K는 살아남은 앵커의 개수로, A가 수천 개였더라도 보통 소수에 불과합니다.
7.14.4. 기하 정보 읽기¶
네 개의 상자 채널과 클래스별 점수는 직접 뽑아냅니다:
bb_scores = np.max(bb[4:, :], axis=0)
bb_classes = np.argmax(bb[4:, :], axis=0)
x_center = bb[0, :]
y_center = bb[1, :]
w_half = bb[2, :] * 0.5
h_half = bb[3, :] * 0.5
bb_scores는 살아남은 앵커별 최고 클래스 점수이고, bb_classes는 그 점수를 제공한 클래스 색인입니다. 상자 기하 정보는 여전히 신경망 입력 차원의 정규화된 [0, 1] 범위에 있으므로, 다음 단계에서 이를 픽셀로 스케일링합니다:
ib, ih, iw, ic = model.input_shape[0]
xmin = (x_center - w_half) * iw
ymin = (y_center - h_half) * ih
xmax = (x_center + w_half) * iw
ymax = (y_center + h_half) * ih
이후 상자들은 신경망 입력 픽셀 공간에 있게 됩니다. 이는 NMS가 입력으로 기대하는 좌표 공간입니다.
7.14.5. 비최대 억제¶
살아남은 것들은 NMS를 거쳐 클래스별 리스트로 반환됩니다:
nms = NMS(iw, ih, inputs[0].roi)
for i in range(bb.shape[1]):
nms.add_bounding_box(xmin[i], ymin[i],
xmax[i], ymax[i],
bb_scores[i], bb_classes[i])
return nms.get_bounding_boxes(threshold=self.nms_threshold,
sigma=self.nms_sigma)
NMS는 inputs[0].roi를 읽어 반환된 상자가 신경망이 아닌 원본 이미지의 좌표 공간에 있도록 합니다. 애플리케이션은 추가 재매핑 없이 캡처된 프레임에 직접 그것들을 그립니다.
7.14.6. 스크립트가 돌려받는 것¶
반환 값은 클래스로 색인된 클래스별 리스트의 리스트입니다. 세 개 클래스 예시는 다음과 같이 보일 수 있습니다:
[
[((23, 41, 95, 142), 0.92), ((180, 60, 88, 130), 0.71)],
[],
[((310, 95, 55, 70), 0.85)],
]
각 항목은 ((x, y, w, h), score) 튜플입니다. (x, y)는 원본 이미지의 픽셀 좌표에서 경계 상자의 좌상단 모서리이고, w와 h는 픽셀 단위의 너비와 높이이며, score는 신경망이 그 검출에 부여한 신뢰도입니다. 따라서 ((180, 60, 88, 130), 0.71)은 좌상단 모서리가 픽셀 (180, 60)에 있고 오른쪽으로 88픽셀, 아래로 130픽셀 뻗어 있으며 신뢰도 0.71로 보고된 상자로 읽힙니다.
외부 리스트는 클래스 0에 대해 살아남은 두 개의 상자, 클래스 1에 대해 아무것도 없음, 클래스 2에 대해 하나를 보여줍니다. 클래스 1의 빈 리스트는 외부 색인이 항상 클래스 색인과 일치하도록 그 자리에 유지됩니다. 기본 제공 사람 검출기의 경우 외부 리스트는 하나의 요소를 가지며 그 내부 리스트에는 살아남은 사람 상자가 담깁니다. 80개 클래스 모델의 경우 80개의 내부 리스트를 가지며, 임의의 주어진 프레임에서 대부분은 비어 있고 비어 있지 않은 항목은 반응한 클래스의 상자를 담습니다. 애플리케이션은 enumerate(boxes)로 결과를 읽어 클래스 색인을 상자 리스트와 함께 순회합니다. 이는 카탈로그 전반에서 검출 후처리기가 목표로 하는 것과 동일한 형태입니다.