Table of Contents
  1. CoreML 이용방법
    1. CoreML Tools를 이용하여 mlmodel 만들기
    2. CoreML을 이용하여 iOS에 모델 올리기
  2. Model Input Image Preprocessing
    1. Core Image Framework
    2. Vision Framework
  3. Model Output Postprocessing
    1. Compute Bounding Boxes
    2. Bounding Box 그리기
  4. YOLO Model을 이용한 실시간 조명탐지 구현
    1. 조명의 3차원 좌표 획득
    2. 실시간 조명 설치 및 해제
  5. 마치며

CoreML을 이용하여 iOS 어플리케이션에 YOLO v3 모델을 탑재하는 방법에 대해 공유합니다.

1부. 딥러닝으로 더욱 현실감 있는 AR 앱 만들기
2부. ARKit을 이용하여 iOS AR앱 만들기
3부. Custom YOLO v3 모델 만들기
✔︎ 4부. CoreML을 이용하여 iOS에 YOLO v3 모델 탑재하기


CoreML 이용방법

CoreML은 애플의 머신러닝 프레임워크로, iOS 어플리케이션에 손쉽게 머신러닝 모델을 올릴 수 있도록 도와줍니다.

✔︎ 진행 순서

  • CoreML Tools를 이용하여 mlmodel 만들기
  • CoreML을 이용하여 iOS에 모델 올리기

CoreML Tools를 이용하여 mlmodel 만들기

CoreML을 이용하여 모델을 올리기 위해선 먼저 모델을 .mlmodel 형태로 변환해야하는데, CoreML Tools를 이용하면 손쉽게 변환 할 수 있습니다. CoreML Tools는 Darknet 포멧을 지원하지 않기 때문에 앞서 Darknet 모델을 Keras 모델로 변환하여 학습을 진행하였습니다.

CoreML Tools를 이용하여 keras 모델을 mlmodel로 변환
1
2
3
4
5
6
7
8
import coremltools
coreml_model = coremltools.converters.keras.convert('model_data/light_tiny_model.h5',
image_input_names='image',
input_names='image',
output_names=['13'],
image_scale=1/255.0)
coreml_model.output_description['13'] = '13 x 13 x 30'
coreml_model.save('model_data/light_tiny_model.mlmodel')

3부에서 학습하여 만든 Tiny YOLO 모델은 이미지를 13 x 13 Grid로 나누어 5개의 클래스에 대해 Grid 당 3개의 박스를 예측하기 때문에 13 x 13 x 30의 결과를 반환하기 때문에 위와같이 output을 설정해주었습니다.

N x N x (C + 5) x B)

  • N x N : Grid
  • C : 학습한 Object 수(Classes)
  • 5 : 탐지한 Object의 Bounding Box 정보(x, y, w, h, confidence)
  • B : Grid cell당 박스 수

CoreML을 이용하여 iOS에 모델 올리기

light_tiny_model.mlmodel 모델을 iOS 프로젝트에 추가하면, 다음과 같이 사용할 수 있습니다.

Load mlmodel
1
2
import CoreML
let model = light_tiny_model()

Model은 인자로 416 x 416 size인 CVPixelBuffer를 받는 prediction 함수를 가지고있습니다.

model prediction
1
model.prediction(image: CVPixelBuffer)

CVPixelBuffer는 ARSessionDeligate의 session(_:didUpdate:) 함수의 ARFrame.capturedImage를 이용하여 실시간 CVPixelBuffer를 얻을 수 있습니다.

매프레임마다 호출되는 함수
1
2
3
func session(_ session: ARSession, didUpdate frame: ARFrame) {
model.prediction(image: frame.capturedImage)
}

Model Input Image Preprocessing

프로젝트에 많은 참고가 된 TinyYOLO-CoreML에서는 ARSessionDelegate가 아닌 AVCaptureVideoDataOutputSampleBufferDelegate를 이용하고 있습니다.

동일한 아이폰에서 ARKit의 ARSessionDelegate과 AVFoundation의 AVCaptureVideoDataOutputSampleBufferDelegate는 서로 다른 이미지를 반환합니다.

1
2
3
4
5
6
7
// AVCaptureVideoDataOutputSampleBufferDelegate
CVPixelBuffer Height : 1280
CVPixelBuffer Width : 720

// ARSessionDelegate
CVPixelBuffer Height : 1440
CVPixelBuffer Width : 1920

이번 프로젝트에서 사용하는 ARSessionDelegate는 왼쪽으로 회전된 이미지를 반환하므로 이미지를 오른쪽으로 회전시키고, Model의 input size인 416 x 416으로 변환하는 전처리가 필요합니다.

전처리 방법으로 Core ImageVision 두 프레임워크를 이용한 방법을 공유하겠습니다.

✔︎ 진행 순서

  • Core Image Framework
  • Vision Framework

이번 글에서는 다루지 않았지만 성능 향상을 위해 이미지 전처리 부분을 DispatchSemaphoreDispatchQueue 등을 이용하여 병렬로 처리하였습니다. 관련 내용은 TinyYOLO-CoreML를 참고해주세요.


Core Image Framework

Core Image은 iOS에서 지원하는 이미지 분석 및 처리 프레임워크입니다. Core Image를 이용하여 CVPixelBuffer를 YOLO Model의 input에 적합하도록 변환해보도록 하겠습니다.

Core Image Framework를 이용한 전처리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let ModelInputWidth = 416
let ModelInputHeight = 416

func getResizedPixelBufferByCoreImage(pixelBuffer: CVPixelBuffer) -> CVPixelBuffer {
var resizedPixelBuffer: CVPixelBuffer?
CVPixelBufferCreate(nil, ModelInputWidth, ModelInputHeight, kCVPixelFormatType_32BGRA, nil, &resizedPixelBuffer)

let ciImage = CIImage(cvPixelBuffer: pixelBuffer)

// Rotate
let rotatedImage = ciImage.oriented(forExifOrientation: Int32(CGImagePropertyOrientation.right.rawValue))

// Scale to YOLO input size
let sx = CGFloat(ModelInputWidth) / CGFloat(CVPixelBufferGetHeight(pixelBuffer))
let sy = CGFloat(ModelInputHeight) / CGFloat(CVPixelBufferGetWidth(pixelBuffer))
let scaleTransform = CGAffineTransform(scaleX: sx, y: sy)
let scaledImage = rotatedImage.transformed(by: scaleTransform)

// render : image into a pixel buffer.
ciContext.render(scaledImage, to: resizedPixelBuffer)

// Model Predict
predict(image: resizedPixelBuffer)
}

이렇게 가공된 이미지를 model.prediction 함수에 전달합니다. output._13은 CoreML Tools를 이용한 mlmodel 변환시 입력한 output_names=['13'] 입니다.

YOLO model predict
1
2
3
4
5
6
7
8
let model = light_tiny_model()
func predict(image: CVPixelBuffer) throws -> [Prediction]? {
if let output = try? model.prediction(image: image) {
return computeBoundingBoxes(features: output._13)
} else {
return nil
}
}

output.grid는 각 grid당 13 x 13 x 30 크기의 MLMultiArray 타입의 결과로 computeBoundingBoxes() 함수로 전달하여 결과에 대한 후처리를 진행합니다.


Vision Framework

앞서 이미지를 전처리를 Core Image Framework로 구현하였는데, Vision Framework를 이용하여 구현하는 다른 방법도 있습니다.

Vision Framework 세팅
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
let request: VNCoreMLRequest

// 초기 세팅
func setUpVision() {
guard let visionModel = try? VNCoreMLModel(for: yolo.model.model) else {
print("Error: could not create Vision model")
return
}

request = VNCoreMLRequest(model: visionModel, completionHandler: visionRequestDidComplete)
request.imageCropAndScaleOption = .scaleFill
}

// 완료시 호출 함수
func visionRequestDidComplete(request: VNRequest, error: Error?) {
guard let observations = request.results as? [VNCoreMLFeatureValueObservation] else { return }

// 13 x 13 x 30에 해당하는 결과 찾기
if observationsIndex == nil {
for (i, observation) in observations.enumerated() {
guard let feature = observation.featureValue.multiArrayValue else { continue }
if feature.count == featureCount {
self.observationsIndex = i
break
}
}
}
if observationsIndex != nil {
if let output = observations[observationsIndex!].featureValue.multiArrayValue {
computeBoundingBoxes(features: output)
}
}
}
Vision framework를 이용한 이미지 전처리
1
2
3
4
5
6
7
func getResizedPixelBufferByVision(pixelBuffer: CVPixelBuffer, request: VNCoreMLRequest) {
// orientation 옵션에 .right를 주어 오른쪽으로 회전
let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .right)

// 수행 완료시 visionRequestDidComplete()를 호출
handler.perform([request])
}

output은 각 grid당 13 x 13 x 30 크기의 MLMultiArray 타입의 결과로 computeBoundingBoxes() 함수로 전달하여 결과에 대한 후처리를 진행합니다.


Model Output Postprocessing

Model은 각 이미지당 13 x 13 x 30 텐서를 반환합니다.

N x N x (C + 5) x B)

  • N x N : Grid
  • C : 학습한 Object 수(Classes)
  • 5 : 탐지한 Object의 Bounding Box 정보(x, y, w, h, confidence)
  • B : Grid cell당 박스 수

전체 Bounding Box를 그대로 화면에 나타낸다면 대략 다음과 같은 결과가 나옵니다.

YOLO output을 나타낸 이미지<br>출처 - <a href=https://machinethink.net/blog/object-detection-with-yolo/>https://machinethink.net</a>YOLO output을 나타낸 이미지
출처 - https://machinethink.net

무수히 많은 박스 중에서 실제 물체의 위치에 해당하는 결과만 필터링하여 아래와 같이 깔끔한 Bounding Box를 그리기 위해선 output에 대한 후처리가 필요합니다.

YOLO output을 필터링하여 나타낸 이미지<br>출처 - <a href=https://machinethink.net/blog/object-detection-with-yolo/>https://machinethink.net</a>YOLO output을 필터링하여 나타낸 이미지
출처 - https://machinethink.net

✔︎ 진행 순서

  • Compute Bounding Boxes
  • Bounding Box 그리기

Compute Bounding Boxes

[분석] YOLO 포스팅에서는 YOLO Model의 ouput을 다음과 같이 설명합니다.

  • Input image를 N X N grid로 나눕니다.
  • 각각의 grid cell은 B개의 Bounding Box와 각 Bounding Box에 대한 confidence score를 갖습니다.
    (만약 cell에 object가 존재하지 않는다면 confidence score는 0)
  • 각각의 grid cell은 C개의 conditional class probability를 갖습니다.
  • 각각의 Bounding Box는 x, y, w, h, confidence로 구성됩니다.
    (x, y): Bounding Box의 중심점을 의미하며, grid cell의 범위에 대한 상대값이 입력
    (w, h): 전체 이미지의 width, height에 대한 상대값이 입력
  • 예1: 만약 x가 grid cell의 가장 왼쪽에 있다면 x = 0, y가 grid cell의 중간에 있다면 y = 0.5
  • 예2: bbox의 width가 이미지 width의 절반이라면 w = 0.5

위와 같은 output을 아래 computeBoundingBoxes() 함수를 이용하여 정리해줍니다.

Bounding Box 필터링
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
let anchors: [Float] = [1.08, 1.19, 3.42, 4.41, 6.63, 11.38, 9.42, 5.11, 16.62, 10.52]

func computeBoundingBoxes(features: MLMultiArray, featureCount: Int) -> [Prediction] {
var predictions = [Prediction]()
let blockSize: Float = 32
let gridHeight = 13
let gridWidth = 13
let boxesPerCell = 3
let numClasses = labels.count

let featurePointer = UnsafeMutablePointer<Double>(OpaquePointer(features.dataPointer))
let channelStride = features.strides[0].intValue
let yStride = features.strides[1].intValue
let xStride = features.strides[2].intValue

@inline(__always) func offset(_ channel: Int, _ x: Int, _ y: Int) -> Int {
return channel*channelStride + y*yStride + x*xStride
}

for cy in 0..< gridHeight {
for cx in 0..< gridWidth {
for b in 0..< boxesPerCell {

let channel = b*(numClasses + 5)
// Grid 상의 x, y 위치
let tx = Float(featurePointer[offset(channel , cx, cy)])
let ty = Float(featurePointer[offset(channel + 1, cx, cy)])

let tw = Float(featurePointer[offset(channel + 2, cx, cy)])
let th = Float(featurePointer[offset(channel + 3, cx, cy)])
let tc = Float(featurePointer[offset(channel + 4, cx, cy)])

// 박스의 중심좌표 (x, y)
let x = (Float(cx) + sigmoid(tx)) * blockSize
let y = (Float(cy) + sigmoid(ty)) * blockSize

// 박스의 with, height
let w = exp(tw) * anchors[2*b ] * blockSize
let h = exp(th) * anchors[2*b + 1] * blockSize

// 박스안에 물체가 있을 확률
let confidence = sigmoid(tc)

var classes = [Float](repeating: 0, count: numClasses)
for c in 0..< numClasses {
classes[c] = Float(featurePointer[offset(channel + 5 + c, cx, cy)])
}
classes = softmax(classes)

// 박스안에 있는 class
let (detectedClass, bestClassScore) = classes.argmax()
let confidenceInClass = bestClassScore * confidence
if confidenceInClass > confidenceThreshold {
let rect = CGRect(x: CGFloat(x - w/2), y: CGFloat(y - h/2),
width: CGFloat(w), height: CGFloat(h))
let prediction = Prediction(classIndex: detectedClass,
score: confidenceInClass,
rect: rect)
predictions.append(prediction)
}
}
}
}

/* 중첩되는 Bounding Box 제거
* - boxes: Bounding Box와 score 배열
* - limit: 선택가능한 최대 박스 개수
* - threshold: 중첩 박스여부 판단 기준
*/
return nonMaxSuppression(boxes: predictions,
limit: 10,
threshold: 0.5)
}

computeBoundingBoxes에 대한 자세한 설명은 Real-time object detection with YOLO를 참고하시기 바랍니다.

computeBoundingBoxes() 함수는 불필요한 박스를 제외하고 신뢰도 있는 최종 Prediction 배열을 반환합니다.

Prediction에는 Bounding Box, 박스안에 존재하는 class 그리고 score가 들어있습니다.
1
2
3
4
5
struct Prediction {
let classIndex: Int
let score: Float
let rect: CGRect
}

TinyYOLO-CoreML를 참조하여 구현하였기에 전체 코드는 해당 프로젝트를 참고하시기 바랍니다.


Bounding Box 그리기

처음 ARSessionDelegate에서 얻은 이미지는 16:9가 아닌 16:12의 비율을 가지고 있고, 해당 이미지를 416 x 416으로 변환하여 모델에 입력하였습니다.

ARSessionDelegate에서 rotate right하여 얻은 이미지 사이즈
1
2
CVPixelBuffer Height : 1920
CVPixelBuffer Width : 1440

따라서 Model이 반환하는 Bounding Box를 실제 모바일 화면에 그리기 위해 CGRect 구조체의 x, y, width, height를 getScreenRect() 함수를 구현하여 스케일을 조정하였습니다.

CGRect 구조CGRect 구조
Bounding Box Rescale
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
let ModelInputWidth = 416
let ModelInputHeight = 416
func getScreenRect(prediction: Prediction, screenWidth: CGFloat, screenHeight: CGFloat) -> CGRect {
var rect = prediction.rect
let scaleX = screenWidth / CGFloat(ModelInputWidth)
let scaleY = screenHeight / CGFloat(ModelInputHeight)
let x1 = rect.origin.x
let x2 = rect.origin.x + rect.size.width
let xc = screenWidth/2
let wx1 = xc - x1
let wx2 = xc - x2
let swx1 = wx1 * 12 / 9
let swx2 = wx2 * 12 / 9

rect.origin.x = xc - swx1
rect.size.width = abs(swx1 - swx2)
rect.origin.x *= scaleX
rect.origin.y *= scaleY
rect.size.width *= scaleX
rect.size.height *= scaleY
return rect
}

이렇게 최종적으로 후처리된 결과를 UIBezierPath를 이용하여 화면에 실시간으로 나타내었습니다.

Draw Bounding Box
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func show(frame: CGRect, label: String, color: UIColor) {
CATransaction.setDisableActions(true)

let path = UIBezierPath(rect: frame)
shapeLayer.path = path.cgPath
shapeLayer.strokeColor = color.cgColor
shapeLayer.isHidden = false

textLayer.string = label
textLayer.backgroundColor = color.cgColor
textLayer.isHidden = false

let attributes = [
NSAttributedString.Key.font: textLayer.font as Any
]

let textRect = label.boundingRect(with: CGSize(width: 400, height: 100),
options: .truncatesLastVisibleLine,
attributes: attributes, context: nil)
let textSize = CGSize(width: textRect.width + 12, height: textRect.height)
let textOrigin = CGPoint(x: frame.origin.x - 2, y: frame.origin.y - textSize.height)
textLayer.frame = CGRect(origin: textOrigin, size: textSize)
}

YOLO Model을 이용한 실시간 조명탐지 구현

✔︎ 진행순서

  • 조명의 3차원 좌표 획득
  • 실시간 조명 설치 및 해제

조명의 3차원 좌표 획득

YOLO Model이 탐지한 Bounding Box의 중심 좌표와 ARKit의 hitTest()를 이용하면 오브젝트의 3차원 좌표를 획득할 수 있습니다.

hitTest() 함수를 이용하여 3D 좌표 획득
1
2
3
4
5
6
7
8
9
10
11
func getARAnchorPosition(point: CGPoint, ResultType type: ARHitTestResult.ResultType = .featurePoint) -> SCNVector3? {
let hitTestResults = hitTest(point, types: type)

guard let hitTestResult = hitTestResults.first else { return nil }
let translation = hitTestResult.worldTransform.translation // 3차원 좌표
let x = translation.x
let y = translation.y
let z = translation.z

return SCNVector3(x, y, z)
}

실시간 조명 설치 및 해제

이번 프로젝트의 YOLO Model은 총 5개의 클래스를 학습하였습니다. 그 중 램프와 전구는 ON/OFF 상태를 구분할 수 있도록 별도의 클래스로 구분하였기에 조명의 상태를 추적할 수 있습니다.

YOLO Model이 학습한 ClassYOLO Model이 학습한 Class

매 프레임마다 Model이 현재 프레임상에 존재하는 조명의 위치와 종류를 예측하는데, Bounding Box의 중심점이 다음 연속한 3프레임의 Bounding Box속에 존재하면서 동일한 클래스를 예측하는 경우, 해당 클래스에 해당하는 조명이 실제한다고 판단하여 행동을 취하도록 구현하였습니다. 예를들어 3회 연속 ON 상태의 조명을 인식하면 해당 위치에 SCNLight를 설치하고, 반대로 OFF 상태의 조명을 인식하면 해당 마지막 Bounding Box속에 존재하는 SCNLight를 삭제하였습니다.

YOLO 조명 on 상태 인식
YOLO 조명 off 상태 인식

화면상에 노란색 구슬은 SCNLight 설치여부를 볼 수 있도록 시각화한 가상 오브젝트입니다.


마치며

처음엔 CoreML을 이용하여 YOLO Model을 올리면 끝날 줄 알았는데, Model의 input과 output에 대한 처리에 대한 부분이 생각보다 힘들었습니다. 😭

이상으로 딥러닝을 이용하여 더욱 현실감 있는 AR 앱 만들기 4부작을 마치도록하겠습니다. 🎉
긴 글 읽어주셔서 감사합니다.

1부. 딥러닝으로 더욱 현실감 있는 AR 앱 만들기
2부. ARKit을 이용하여 iOS AR앱 만들기
3부. Custom YOLO v3 모델 만들기
✔︎ 4부. CoreML을 이용하여 iOS에 YOLO v3 모델 탑재하기