On-Device Ai 란?
- 인터넷이 되지않는 환경임에도 불구하고 ai 기능을 통해 삶에 편리함을 제공하는 방법 ( ex. Circle to Search 등.)
구현을 해보자 ( by YOLO.pt -> YOLO.tflite)
- YOLO 모델을 잘 구현해놓은 ultralytics 에서 가져와서 훈련을 진행합니다!
- 개인이 정한 parameter를 통해 학습을 진행하고, best.pt 모델을 도출해 냅니다.
- best.pt 모델을 device에 넣기 위해 tflite 형식으로 export 해요!
- export 할때, 기능 만큼 중요한 것이 경량화이므로, 경량화를 위해 float32를 float16으로 변환시켜봅니다! (상황마다 다르지만, int8로 변환시켜도 됩니다. ) -> 이 과정을 "양자화"(quantization) 라고 합니다.
- 경량화 한 모델을 안드로이드에서 적용시켜 보아요!!
안드로이드 스튜디오에서 작업을 진행해보자! (Kotlin)
- yolo 모델을 쓰기 위해서는 detector, , letterboxInfo 등과 같은 데이터 및 클래스가 필요합니다.
companion object {
private const val MODEL_INPUT_WIDTH = 384
private const val MODEL_INPUT_HEIGHT = 640
// 양자화 파라미터
private const val OUTPUT_SCALE = 2.8370347f
private const val OUTPUT_ZERO_POINT = -115
// 감정 검출 임계값 및 NMS 파라미터
private const val EXPRESSION_THRESHOLD = 0.5f
private const val NMS_THRESHOLD = 0.3f
// 후보 개수 (모델에 따라 조정)
private const val CANDIDATE_COUNT = 5040
}
- Detector 에서 자주 사용하게 되는 변수를 companion object로 생성한 코드입니다.
private var inputBufferSize = if (isQuantized) {
1 * MODEL_INPUT_WIDTH * MODEL_INPUT_HEIGHT * 3
} else {
1 * MODEL_INPUT_WIDTH * MODEL_INPUT_HEIGHT * 3 * 4
}
private var inputBuffer: ByteBuffer = ByteBuffer.allocateDirect(inputBufferSize).apply {
order(ByteOrder.nativeOrder())
}
private val pixels = IntArray(MODEL_INPUT_WIDTH * MODEL_INPUT_HEIGHT)
// 색상 RGB
private val letterboxBitmap: Bitmap =
Bitmap.createBitmap(MODEL_INPUT_WIDTH, MODEL_INPUT_HEIGHT, Bitmap.Config.ARGB_8888)
private val letterboxCanvas = Canvas(letterboxBitmap)
- 색상을 가져오기 위해서 inputBufferSize를 정해야하는데요. 이는, ai 모델이 원하는 tensor의 형태를 맞춰주기 위함이에요!!
- 색상에 따라 object Detection이 되는가 안되는가, 결정될 수 있기 때문에, RGB도 잘 작성해 놓습니다!
val fileDescriptor = assetManager.openFd(modelPath)
val inputStream = fileDescriptor.createInputStream()
val fileChannel = inputStream.channel
val mappedByteBuffer = fileChannel.map(
java.nio.channels.FileChannel.MapMode.READ_ONLY,
fileDescriptor.startOffset,
fileDescriptor.declaredLength
)
- val fileDescriptor = assetManager.openFd(modelPath)
- AssetManager를 통해서 modelPath 경로에 있는 파일 ( tflite 파일!!)을 열어요.
- AssetFileDescriptor : 반환값이에요. 시작 위치 (offset), 길이 (length), 파일 디스크립터(file descriptor) 포함!
- val inputStream = fileDescriptor.createInputStream()
- fileDescriptor.createInputStream()는 AssetFileDescriptor(반환값) 친구로 부터 InputStream을 생성하는데, 이 과정은 "파일을 읽을 수 있도록 하는 입력 스트림을 생성하는 과정"입니다!
- val fileChannel = inputStream.channel
- inputStream.channel : InputStream으로 부터 FileChannel을 가져와요. FileChannel은 파일을 읽거나, 쓰는데 사용되고, 메모리 맵핑이 가능해요!
- val mappedByteBuffer = fileChannel.map ( ~~~)
- fileChannel.map(~~~) : 파일을 메모리에 매핑해서 MappedByteBuffer 생성하기!
- java.nio.channels.FileChannel.MapMode.READ_ONLY : 읽기 전용으로 파일 메모리에 매핑하기!
- fileDescriptor.startOffset : 파일내 데이터가 시작하는 바이트 단위의 오프셋 (시작위치)
- fileDescriptor.declaredLength : 파일의 길이!
fun detect(bitmap: Bitmap, viewMatrix: Matrix?): FaceExpressionResult {
// 1) 전처리: 입력 Bitmap을 모델 입력 크기로 letterbox 리사이즈 후 픽셀 추출
val lbInfo = preprocessBitmapToBuffer(bitmap)
// 2) 모델 추론
val floatOutput: Array<Array<FloatArray>> = if (isQuantized) {
val outputSize = 1 * 7 * CANDIDATE_COUNT
val outputByteBuffer = ByteBuffer.allocateDirect(outputSize).apply {
order(ByteOrder.nativeOrder())
}
interpreter.run(inputBuffer, outputByteBuffer)
outputByteBuffer.rewind()
// quantized 값을 float 배열로 변환
val outArray = Array(1) { Array(7) { FloatArray(CANDIDATE_COUNT) } }
for (i in 0 until 1) {
for (j in 0 until 7) {
for (k in 0 until CANDIDATE_COUNT) {
val quantized = outputByteBuffer.get().toInt() and 0xFF
outArray[i][j][k] = (quantized - OUTPUT_ZERO_POINT) * OUTPUT_SCALE
}
}
}
outArray
} else {
val outputShape = arrayOf(1, 7, 5040)
val outBuffer =
Array(outputShape[0]) {
Array(outputShape[1]) { FloatArray(outputShape[2]) }
}
interpreter.run(inputBuffer, outBuffer)
outBuffer
}
// 3) 후처리: bbox 좌표 변환 및 소프트맥스 적용 후 NMS 처리
val detections = postProcess(
output = floatOutput,
lbInfo = lbInfo,
originalWidth = bitmap.width,
originalHeight = bitmap.height,
viewMatrix = viewMatrix
)
val nmsDetections = nonMaximumSuppression(detections, NMS_THRESHOLD)
return FaceExpressionResult(nmsDetections)
}
- 전처리
- preprocessBitmapToBuffer(추후에 등장합니다!)를 통해, 입력 Bitmap을 tflite 모델의 입력 크기로 letterbox를 resize를 한 뒤, 픽셀을 추출해요!
- 모델 추론
- output의 형식에 따라(양자화를 했는가, 하지 않았는가?)에 따라 Zero Point, Scale를 추가 계산 해주는가 안해주는가를 따집니다! 양자화를 했으면 Zero Point, Scale를 계산해줘야 하며, 그게 아니면 바로 interpreter에 집어넣어서 추론을 시작해요!
- 후처리
- 다중 분류인가, 이중분류인가에 따라 좌표 변환을 해주셔야 해요. 또한 Non-MaximumSuppression(비-최대 억제)을 통해서 정확한 box를 선택하도록 하는 방법이에요!
- 비-최대 억제는 IoU(Intersection over Union) 이라는 개념을 알아야 하는데, IoU란, object Detector의 정확도를 측정하는데 이용되는 평가 지표에요! 쉽게 말해, (예측 박스 와 실제 박스의 교집합 / (예측 박스와 실제 박스의 합집합) 이랍니다! IoU가 크면 클수록 좋은 모델이란 소리겠죠?
- 바로 아래에서 nonMaximumSuppression, IoU 코드를 써놓을게요!
private fun nonMaximumSuppression(
detections: List<FaceExpressionDetection>,
iouThreshold: Float
): List<FaceExpressionDetection> {
if (detections.isEmpty()) return emptyList()
val sorted = detections.sortedByDescending { it.score }.toMutableList()
val finalDetections = mutableListOf<FaceExpressionDetection>()
while (sorted.isNotEmpty()) {
val best = sorted.removeAt(0)
finalDetections.add(best)
val iterator = sorted.iterator()
while (iterator.hasNext()) {
val other = iterator.next()
if (computeIoU(best.box, other.box) > iouThreshold) {
iterator.remove()
}
}
}
return finalDetections
}
- 앞서 말씀드린 것처럼, 여러 박스 중, 가장 높은 확률을 가진 라벨을 가질거기 때문에, 그 부분을 처리하는 코드에요!
- 물론, 기본적인 iouThreshold 확률을 이기지 못한다면... 탈락이겠죠?
- 다음은 ioU 계산 코드!
private fun computeIoU(a: RectF, b: RectF): Float {
val areaA = a.width() * a.height()
val areaB = b.width() * b.height()
if (areaA <= 0f || areaB <= 0f) return 0f
val interLeft = maxOf(a.left, b.left)
val interTop = maxOf(a.top, b.top)
val interRight = minOf(a.right, b.right)
val interBottom = minOf(a.bottom, b.bottom)
val intersection = maxOf(0f, interRight - interLeft) * maxOf(0f, interBottom - interTop)
return intersection / (areaA + areaB - intersection)
}
- 실제 구역, 예측 박스 구역에 대한 ioU 계산 구하는 방법이에요! 차근차근 확인해보시면 이해하기 쉬우실거에요!
private fun postProcess(
output: Array<Array<FloatArray>>,
lbInfo: LetterboxInfo,
originalWidth: Int,
originalHeight: Int,
viewMatrix: Matrix? = null
): List<FaceExpressionDetection> {
val detections = mutableListOf<FaceExpressionDetection>()
val candidateCount = output[0][0].size
for (i in 0 until candidateCount) {
// 모델 출력의 앞 4개 값: center x, center y, box width, box height
val cx = output[0][0][i] * MODEL_INPUT_WIDTH
val cy = output[0][1][i] * MODEL_INPUT_HEIGHT
val bw = output[0][2][i] * MODEL_INPUT_WIDTH
val bh = output[0][3][i] * MODEL_INPUT_HEIGHT
val x1 = cx - bw / 2f
val y1 = cy - bh / 2f
val x2 = x1 + bw
val y2 = y1 + bh
val letterboxRect = RectF(x1, y1, x2, y2)
// letterbox 좌표를 뷰 좌표로 변환
var originalRect = letterboxToOriginalCoords(letterboxRect, lbInfo, viewMatrix)
// 감정 로짓 3개 (예: Negative, Positive, Neutral)
val exprLogits = floatArrayOf(
output[0][4][i],
output[0][5][i],
output[0][6][i]
)
// softmax 확률 계산
val exprScores = softmax(exprLogits)
val maxScore = exprScores.maxOrNull() ?: 0f
val maxIndex = exprScores.indexOfFirst { it == maxScore }
// 임계값 이상인 경우에만 detection 추가
if (maxScore > EXPRESSION_THRESHOLD) {
detections.add(
FaceExpressionDetection(
expression = expressions[maxIndex],
score = maxScore,
box = originalRect
)
)
}
}
return detections
}
- 후보군들을 쭉 뽑아서, 여러 박스별로 비교를 합니다!
- yolo.tflite 모델의 경우, [a,b,c] 형식으로 나타나요. b 위치에서 0~3 index 까지는 x, y, width, height 좌표를 나타내고, 그 이후의 index는 label이에요~
- letterbox 좌표의 경우, 실제 좌표가 아니기 때문에, View 좌표로 변환해줘야 해요! (letterboxToOriginalCoords)
- exprLogits에서 output 라벨을 floatArray로 쭉 받아요! 현재 코드에서 3개만 있는 이유는 라벨이 3개 이기 때문입니다!
- 3개 중 1개의 값을 가지기 위해서 softmax 확률을 계산해서 라벨 중 가장 높은 확률을 가지는 label을 bbox는 가져가는겁니다!
- 모든 박스가 다 체크되면 안되기 떄문에 THRESHOLD를 통해서 신뢰도가 높은 박스만 살려놓고, detections에 담아 놓는 거에요!
private fun preprocessBitmapToBuffer(original: Bitmap): LetterboxInfo {
// 모델 입력 크기로 letterbox 리사이즈 (targetWidth, targetHeight)
val lbInfo = letterboxResize(original, MODEL_INPUT_WIDTH, MODEL_INPUT_HEIGHT)
inputBuffer.rewind()
lbInfo.bitmap.getPixels(
pixels,
0,
MODEL_INPUT_WIDTH, //384
0,
0,
MODEL_INPUT_WIDTH, // 384
MODEL_INPUT_HEIGHT // 640
)
if (isQuantized) {
// 각 픽셀의 R, G, B 값을 1바이트씩 저장
for (pixel in pixels) {
val r = ((pixel shr 16) and 0xFF).toByte()
val g = ((pixel shr 8) and 0xFF).toByte()
val b = (pixel and 0xFF).toByte()
inputBuffer.put(r)
inputBuffer.put(g)
inputBuffer.put(b)
}
} else {
// float 모델의 경우 [0,1]로 정규화하여 저장
val floatBuffer = inputBuffer.asFloatBuffer()
for (pixel in pixels) {
val r = ((pixel shr 16) and 0xFF).toFloat() / 255f
val g = ((pixel shr 8) and 0xFF).toFloat() / 255f
val b = (pixel and 0xFF).toFloat() / 255f
floatBuffer.put(r)
floatBuffer.put(g)
floatBuffer.put(b)
}
}
inputBuffer.rewind()
return lbInfo
}
- 이 코드가 바로 Bitmap을 Buffer로 바꾸는 코드에요!
- bitmap의 pixels을 데이터의 width, height를 통해 맞춰줍니다.
- 양자화를 했을 경우, toBtye를 통해 R,G,B 값을 1바이트씩 저장해줘요! 앞서 양자화의 inputBufferSize가 더 작다는걸 확인하셔야 해요! -> 그래서 빠른거에요 ㅎㅎ
- 양자화를 안했을 경우, float 모델을 [0,1]로 정규화 해서 저장을 해요. 그렇기 때문에, 255로 나누어서 [0,1]로 범위를 좁혀주는거에요~!
private fun letterboxResize(src: Bitmap, targetWidth: Int, targetHeight: Int): LetterboxInfo {
val srcWidth = src.width
val srcHeight = src.height
val scale = min(targetWidth.toFloat() / srcWidth, targetHeight.toFloat() / srcHeight)
val newWidth = (srcWidth * scale).toInt()
val newHeight = (srcHeight * scale).toInt()
val padLeft = (targetWidth - newWidth) / 2f
val padTop = (targetHeight - newHeight) / 2f
letterboxCanvas.drawColor(Color.BLACK)
val dstRect = RectF(padLeft, padTop, padLeft + newWidth, padTop + newHeight)
letterboxCanvas.drawBitmap(src, null, dstRect, null)
return LetterboxInfo(letterboxBitmap, scale, padLeft, padTop)
}
- bitmap을 이제 target의 높이와 너비크기로 변경해서, 비율을 유지를 해줘요!
private fun letterboxToOriginalCoords(
box: RectF,
lbInfo: LetterboxInfo,
viewMatrix: Matrix? = null
): RectF {
val x1 = (box.left - lbInfo.padLeft) / lbInfo.scale
val y1 = (box.top - lbInfo.padTop) / lbInfo.scale
val x2 = (box.right - lbInfo.padLeft) / lbInfo.scale
val y2 = (box.bottom - lbInfo.padTop) / lbInfo.scale
val origRect = RectF(x1, y1, x2, y2)
if (viewMatrix != null) {
val pts = floatArrayOf(origRect.left, origRect.top, origRect.right, origRect.bottom)
viewMatrix.mapPoints(pts)
return RectF(pts[0], pts[1], pts[2], pts[3])
}
return origRect
}
- letterbox를 이제 실제 원본 좌표로 변환하는 방법이에요!
이렇게, 오늘은 YOLO 모델을 사용하기 위해 Detector를 알아봤습니다.
다음시간에는 이것을 어떻게 활용할것인가? 에 대해서 돌아오도록 하겠습니다.
읽어주셔서 감사합니다.