ML Kit를 사용하여 포즈 인식 기능 개발하기

Hyeons
Uniquegood
Published in
19 min readJul 7, 2021

인사말

안녕하세요. 유니크굿컴퍼니의 안드로이드 개발자 고현석입니다.

지난 포스트 마지막 부분에 말씀드린 대로, 이번에는 기술 관련 글로 돌아왔습니다.

오늘 소개해드릴 기술은 ML Kit입니다 ㅎㅎ.

ML Kit란?

Google의 기기 내 머신 러닝 전문 지식을 Android 및 iOS 앱에 제공하는 모바일 SDK입니다. 강력하면서도 사용하기 쉬운 Vision 및 Natural Language API를 사용하여 앱의 일반적인 문제를 해결하거나 완전히 새로운 사용자 경험을 만드세요. 모두 Google의 동급 최고의 ML 모델을 기반으로하며 무료로 제공됩니다. (원문 링크)

ML Kit는 텍스트 인식, 얼굴 인식, 포즈 인식, 이미지 라벨링 등의 다양한 API를 제공합니다.

오늘은 그중에서도 포즈 인식 API(Pose Detection API)에 대해 소개해드리겠습니다.

포즈 인식 API

포즈 인식 API를 사용하여 사진 또는 비디오를 분석하고, 사람이 취하고 있는 포즈를 실시간으로 판별할 수 있습니다.

아무것도 없이 처음부터 포즈를 인식하는 기술을 개발하려면 엄청난 시간과 노력이 필요겠지만, ML Kit의 포즈 인식 API를 사용하면 코드 몇 줄만으로 포즈 인식을 구현할 수 있습니다. (구글 고마워!)

그래서 이 포즈 인식을 어디다 쓰냐면…

출처 : https://developers.google.com/ml-kit/vision/pose-detection

이런 멋진 것(?)을 만들 수 있습니다 ㅎㅎ..

이제 카메라를 사용하여 실시간으로 O/X 동작을 인식하는 아주 간단한 기능을 가진 앱을 만들어보겠습니다.

개발 시작

먼저 New Project → Empty Activity를 선택하여 아래와 같이 새로운 프로젝트를 생성합니다.

app단 build.gradle에 아래 의존성을 추가합니다.

//ML Kit Pose Detection
implementation 'com.google.mlkit:pose-detection:17.0.1-beta4'
//CameraX
implementation "androidx.camera:camera-core:1.1.0-alpha05"
implementation "androidx.camera:camera-camera2:1.1.0-alpha05"
implementation "androidx.camera:camera-lifecycle:1.1.0-alpha05"
implementation "androidx.camera:camera-view:1.0.0-alpha25"

버전은 gradle에서 자동으로 잡아주는 최신 버전을 사용하였습니다.

그리고 편의를 위해 viewBinding을 설정하였습니다만.. 이 부분은 선택사항입니다.

buildFeatures {
viewBinding = true
}

진짜 코드 작성

이제 진짜 코드를 작성해보겠습니다.

코드를 작성하기 전에 한 가지 알아두어야 할 것이 있습니다.

포즈를 분석하기 위해 관절 등 인체의 포인트가 필요한데, ML Kit는 이것을 Landmark라고 칭합니다.

지금부터 작성할 코드 전반에 Landmark라는 표현이 사용되기에 미리 설명해 드렸습니다 ^^.

먼저 아래 3가지 프로퍼티를 선언해줍니다.

private val options by lazy {
PoseDetectorOptions.Builder()
.setDetectorMode(PoseDetectorOptions.STREAM_MODE)
.build()
}
private val poseDetector by lazy {
PoseDetection.getClient(options)
}
private val onPoseDetected: (pose: Pose) -> Unit = { pose ->}

options : 포즈 인식 클라이언트에 적용되는 옵션입니다. DetectorMode에는 STREAM_MODE와 SINGLE_IMAGE_MODE가 있습니다. STREAM_MODE는 비디오 또는 실시간 영상 분석에, SINGLE_IMAGE_MODE는 정적 파일 분석에 사용됩니다.

poseDetector : 위에서 생성한 options로 생성한 PoseDetector Client 입니다.

onPoseDetected : CameraX 영상을 분석 후, 발견된 Landmark를 담고 있는 Pose 객체를 넘겨주는 콜백입니다.

다음으로 CameraX 라이브러리를 사용하여 카메라를 설정해줍니다.

공식 문서에 있는 내용이기도 하고, 많은 분이 이미 다 아실 것으로 생각하기에 따로 설명은 하지 않겠습니다.

한 가지 설명드릴 것은 아래와 같이 Analyzer를 추가해주는 것입니다.

val imageAnalyzer = ImageAnalysis.Builder()
.build()
.also {
it.setAnalyzer(cameraExecutor,CameraAnalyzer(poseDetector,
onPoseDetected)
}

CameraAnalyzer는 ImageAnalysis.Analyzer를 상속 받은 클래스로, poseDetector를 사용하여 발견된 Landmark를 담은 Pose 객체를 onPoseDetected에 넘겨줍니다.

private class CameraAnalyzer(
private val poseDetector: PoseDetector,
private val onPoseDetected: (pose: Pose) -> Unit
) : ImageAnalysis.Analyzer {
override fun analyze(imageProxy: ImageProxy) {
val mediaImage = imageProxy.image ?: return
val inputImage =
InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
poseDetector.process(inputImage)
.addOnSuccessListener { pose ->
onPoseDetected(pose)
}
.addOnFailureListener { e ->
//handel error
}
.addOnCompleteListener {
imageProxy.close()
mediaImage.close()
}
}
}

이 클래스의 역할은 아래와 같습니다.

  1. 카메라를 통해 전달받은 이미지로 포즈 인식에 필요한 InputImage 객체를 생성합니다.
  2. poseDetector.process(inputImage)로 분석을 시작합니다.
  3. 분석이 성공한다면 Pose 객체를 onPoseDetected에 전달합니다.
  4. 이 모든 과정이 끝난 후 다음 분석을 위해, 사용된 image들을 close() 호출합니다.

(책임을 좀 더 분리할 수 있을 것 같지만.. 이 글은 포즈 인식 구현에 중점을 두었기에 넘어가도록 하겠습니다 ^^…)

이제 전달받은 포즈를 분석하는 코드를 작성해보겠습니다.

포즈 분석

포즈 분석에는 아래와 같은 절차가 필요합니다.

예를 들어.. 왼쪽 무릎의 각도를 분석한다고 가정하면

  1. 현재 포즈에서 왼쪽 발목, 왼쪽 무릎, 왼쪽 골반을 찾습니다.
  2. 약간의 수학식을 통해 발목과 골반 사이에 있는 무릎의 각도를 계산합니다.
  3. 180도에 가깝다면 펴진 상태, 0도에 가깝다면 구부린 상태인 것을 확인할 수 있습니다.

각 Landmark에 이런 절차를 거친 후, 종합하여 현재 포즈를 알 수 있습니다.

출처 : https://developers.google.com/ml-kit/vision/pose-detection/classifying-poses

그리고 Landmark의 type(Int형)으로 해당 랜드마크가 인체의 어느 부분인지 알 수 있습니다.

각 Landmark와 type의 정보는 아래 문서에서 확인하실 수 있습니다.

https://developers.google.com/ml-kit/vision/pose-detection

이제 위 정보를 기반으로 코드를 작성해보겠습니다.

먼저 새로운 클래스를 작성합니다.

data class TargetShape(
val firstLandmarkType: Int,
val middleLandmarkType: Int,
val lastLandmarkType: Int,
val angle: Double
)
data class TargetPose(
val targets: List<TargetShape>
)

TargetShape는 하나의 관절과 그 관절의 타겟 각도에 관한 정보를 담고 있습니다.

이 TargetShape가 모여 하나의 포즈를 이루게 되는데, 이것은 TargetPose에 리스트 형식으로 담겨 있습니다.

무릎을 90도로 구부리는 포즈를 타겟으로 한다면 아래와 같이 작성할 수 있습니다.

private val targetPose: TargetPose = TargetPose(
listOf(
TargetShape(
PoseLandmark.LEFT_ANKLE, PoseLandmark.LEFT_KNEE, PoseLandmark.LEFT_HIP, 90.0
),
)
)

이제 타겟 포즈와 실제 포즈를 비교해보겠습니다.

비교는 아래 순서로 진행됩니다.

  1. Pose 객체로부터 TargetPose가 가진 Landmark의 타입과 일치하는 Landmark를 가져옵니다.
  2. 가져온 Landmark가 유효한지 확인합니다.
  3. Landmark를 종합하여 각도를 계산합니다.
  4. 오차값을 추가해 타겟 각도와 계산된 각도를 비교하여 결과를 반환합니다.

각 단게별로 코드와 함께 설명해보겠습니다.

Post 객체로부터 TargetPose가 가지고 있는 것과 일치하는 Landmark를 가져오는 코드입니다.

targetPose.targets.forEach { target ->
val (firstLandmark, middleLandmark, lastLandmark) = extractLandmark(pose, target)
...
}

extractLandmark 메소드는 아래와 같습니다.

private fun extractLandmark(
pose: Pose,
target: TargetShape
): Triple<PoseLandmark?, PoseLandmark?, PoseLandmark?> {
return Triple(
extractLandmarkFromType(pose, target.firstLandmarkType),
extractLandmarkFromType(pose, target.middleLandmarkType),
extractLandmarkFromType(pose, target.lastLandmarkType)
)
}
private fun extractLandmarkFromType(pose: Pose, landmarkType: Int): PoseLandmark? {
return pose.getPoseLandmark(landmarkType)
}

getPoseLandmark 메소드를 사용하여 pose 객체로부터 타겟 Landmark를 가져옵니다.

발견된 타겟 Landmark가 없다면 null을 반환합니다.

그리고 다음 코드로 null 체크를 한 후, 셋 중 하나라도 null이라면 false를 반환하여 포즈가 일치하지 않는다고 알려줍니다.

//Check landmark is null
if (landmarkNotFound(firstLandmark, middleLandmark, lastLandmark)) {
return false
}

이제 각 Landmark로 각도를 계산해보겠습니다.

private fun calculateAngle(
firstLandmark: PoseLandmark,
middleLandmark: PoseLandmark,
lastLandmark: PoseLandmark
): Double {
val angle = Math.toDegrees(
(atan2(
lastLandmark.position3D.y - middleLandmark.position3D.y,
lastLandmark.position3D.x - middleLandmark.position3D.x
) - atan2(
firstLandmark.position3D.y - middleLandmark.position3D.y,
firstLandmark.position3D.x - middleLandmark.position3D.x
)).toDouble()
)
var absoluteAngle = abs(angle)
if (absoluteAngle > 180) {
absoluteAngle = 360 - absoluteAngle
}
return absoluteAngle
}

저는 수포자..이므로 구글의 힘을 빌려 각도를 계산하였습니다 ^^…

그런데 테스트할 때 값이 90과 270에서 왔다 갔다 하는 현상이 발생하였습니다.

다른 해결책을 찾아보아야겠지만, 일단 임시방편으로 각도가 180도가 넘을 때 360에서 빼주었습니다.

이제 마지막입니다. 계산으로 나온 각도와 타겟 포즈의 각도를 비교합니다.

두 각도의 차가 offset으로 지정한 오차 범위보다 크다면 두 포즈가 일치하지 않는다고 판단하여 false를 반환합니다.

if (abs(angle - targetAngle) > offset) {
return false
}

전체 코드입니다. IDE로 볼 땐 몰랐는데, 이렇게 보니 간단한 작업임에도 코드 라인 수가 많은 것 같습니다.

class PoseMatcher {    fun match(pose: Pose, targetPose: TargetPose): Boolean {
return extractAndMatch(pose, targetPose)
}
private fun extractAndMatch(pose: Pose, targetPose: TargetPose) : Boolean {
targetPose.targets.forEach { target ->
val (firstLandmark, middleLandmark, lastLandmark) = extractLandmark(pose, target)
//Check landmark is null
if (landmarkNotFound(firstLandmark, middleLandmark, lastLandmark)) {
return false
}
val angle = calculateAngle(firstLandmark!!, middleLandmark!!, lastLandmark!!)
val targetAngle = target.angle
if (abs(angle - targetAngle) > offset) {
return false
}
}
return true
}
private fun extractLandmark(
pose: Pose,
target: TargetShape
): Triple<PoseLandmark?, PoseLandmark?, PoseLandmark?> {
return Triple(
extractLandmarkFromType(pose, target.firstLandmarkType),
extractLandmarkFromType(pose, target.middleLandmarkType),
extractLandmarkFromType(pose, target.lastLandmarkType)
)
}
private fun extractLandmarkFromType(pose: Pose, landmarkType: Int): PoseLandmark? {
return pose.getPoseLandmark(landmarkType)
}
private fun landmarkNotFound(
firstLandmark: PoseLandmark?,
middleLandmark: PoseLandmark?,
lastLandmark: PoseLandmark?
): Boolean {
return firstLandmark == null || middleLandmark == null || lastLandmark == null
}
private fun calculateAngle(
firstLandmark: PoseLandmark,
middleLandmark: PoseLandmark,
lastLandmark: PoseLandmark
): Double {
val angle = Math.toDegrees(
(atan2(
lastLandmark.position3D.y - middleLandmark.position3D.y,
lastLandmark.position3D.x - middleLandmark.position3D.x
) - atan2(
firstLandmark.position3D.y - middleLandmark.position3D.y,
firstLandmark.position3D.x - middleLandmark.position3D.x
)).toDouble()
)
absoluteAngle = abs(angle)
if (absoluteAngle > 180) {
absoluteAngle = 360 - absoluteAngle
}
return absoluteAngle
}
private fun anglesMatch(angle: Double, targetAngle: Double): Boolean {
return angle < targetAngle + offset && angle > targetAngle - offset
}
companion object {
private const val offset = 15.0
}
}

O/X 동작 인식

이제 포즈를 인식하기 위한 발판이 갖추어졌으니.. O/X 동작을 인식해보도록 하겠습니다 ^^

아주 간단합니다.

먼저 각 O/X 포즈를 취할 때 나오는 팔의 각도와 움직이는 관절을 생각해보겠습니다.

O 동작의 관절 : 왼쪽 어깨, 왼쪽 팔꿈치, 오른쪽 어깨, 오른쪽 팔꿈치

왼쪽 어깨의 각도 : 약 130도

왼쪽 팔꿈치의 각도 : 약 90도

오른쪽 어깨의 각도 : 약 130도

오른쪽 팔꿈치의 각도 : 약 90도

X 동작의 관절 : 왼쪽 팔꿈치, 오른쪽 팔꿈치

왼쪽 팔꿈치의 각도 : 약 45도

오른쪽 팔꿈치의 각도 : 약 45도

(저를 기준으로 한 것이기 때문에, 사람마다 측정되는 각도는 조금씩 상이합니다)

이제 위 정보로 TargetPose 객체를 제작합니다.

private val targetPoseOSign: TargetPose = TargetPose(
listOf(
TargetShape(
PoseLandmark.RIGHT_SHOULDER, PoseLandmark.LEFT_SHOULDER, PoseLandmark.LEFT_ELBOW, 130.0
),
TargetShape(
PoseLandmark.LEFT_SHOULDER, PoseLandmark.LEFT_ELBOW, PoseLandmark.LEFT_WRIST, 90.0
),
TargetShape(
PoseLandmark.LEFT_SHOULDER, PoseLandmark.RIGHT_SHOULDER, PoseLandmark.RIGHT_ELBOW, 130.0
),
TargetShape(
PoseLandmark.RIGHT_SHOULDER, PoseLandmark.RIGHT_ELBOW, PoseLandmark.RIGHT_WRIST, 90.0
),
)
)
private val targetPoseXSign: TargetPose = TargetPose(
listOf(
TargetShape(
PoseLandmark.LEFT_SHOULDER, PoseLandmark.LEFT_ELBOW, PoseLandmark.LEFT_WRIST, 50.0
),
TargetShape(
PoseLandmark.RIGHT_SHOULDER, PoseLandmark.RIGHT_ELBOW, PoseLandmark.RIGHT_WRIST, 50.0
),
)
)

그리고 onPoseDetected에서 이렇게 match()를 호출하면…

private val onPoseDetected: (pose: Pose) -> Unit = { pose ->
val isOSign = poseMatcher.match(pose, targetPoseOSign)
val isXSign = poseMatcher.match(pose, targetPoseXSign)
when {
isOSign -> {
//detect O Sign
}
isXSign -> {
//detect X Sign
}
}
}

약간의 코드를 추가하여 각 동작을 인식했을 때 O/X 모양의 텍스트가 보이게 하였습니다.

마무리

급하게 만들다 보니 인식률이나 안정성 면에서 부족한 부분이 많습니다.

하지만 차근차근 개선해나가다 보면 언젠가는 디비디비딥 게임도 할 수 있겠죠? ㅎㅎ

사실.. 이 글에는 포즈 인식 기능을 앱에 도입하고 싶은 제 마음이 담겨 있습니다 ^^..

앱에서 포즈 인식을 볼 날을 기다리며.. 다시 만나길 바라겠습니다!

긴 글 읽어주셔서 감사합니다.

--

--