이 포스트는 MediapipeUnityPlugin을 사용하였습니다. 또한 이 코드의 일부는 학부 3학년이 짜서 좀 이상할 수 있습니다.
MediapipeUnityPlugin 출처:
https://github.com/homuler/MediaPipeUnityPlugin
GitHub - homuler/MediaPipeUnityPlugin: Unity plugin to run MediaPipe
Unity plugin to run MediaPipe. Contribute to homuler/MediaPipeUnityPlugin development by creating an account on GitHub.
github.com
0. 필요성
기본 Mediapipe에서는 Hand Gesture model을 제공해서 손 사진(dataset)을 model에 밀어넣으면 원하는 gesture를 구분 가능하게 만들 수 있다.
하지만 UnityPlugin은 Gesture Recognition model을 제공하지 않는다. 따라서, 제공되는 Hand Landmark Detection model을 사용하여 손수 Gesture Recognition model과 유사하게 만들어야 한다.
https://developeralice.tistory.com/12 이 블로그의 게시글을 참고하여 KNN 알고리즘을 활용하여 개발하기로 하였다.
1. KNN 선택 이유
Mediapipe Hand Landmark Detection은 기본적으로 21개의 Hand Landmark의 위치 정보를 제공한다.
이 landmark들을 이용하여 손의 gesture를 알아낼 수 있는 방법에는 여러 가지가 있다.
1. landmark 간의 상대적 위치 사용
예를 들어, 손바닥을 보였을 때 8번이 5번보다 위에 있을 때는 검지를 편 것이고, 아래에 있을 때는 접은 것이라고 정의할 수 있다. 이 방법은 직관적이라서 구현하기 쉽지만, 분류하려는 gesture의 개수가 많으면 복잡할 수 있다. 간단한 조작을 구현할 때 사용하기 좋다. 나는 32개의 수어를 인식하는 모델이 필요했기 때문에 32개 gesture의 상대적 위치를 일일히 정의하는 것이 힘들 것 같아 이 방법을 사용하지 않았다.
2. CNN, DNN과 같은 모델 사용
인공지능 모델을 사용하여 각 위치 혹은 각도 정보를 학습시킨 후 Unity에 집어넣을 수 있다. 이미 계산이 끝나있기 때문에 예측이 매우 빠르고 복잡한 패턴 인식이 가능하다. 게임 내에서 계속해서 존재해야 하는 몬스터의 AI 등을 구현하는 데에 사용하기 좋다. 하지만, 사전 학습 과정이 필요하고 데이터가 변경될 때마다 재학습시켜야 한다. 또한, Unity에 통합하는 과정이 다소 복잡하여 기술적인 어려움이 있었다.
3. KNN 알고리즘 사용
CNN, DNN 모델과 비교했을 때 비교적 구현이 간단하고 직관적이다. Unity 내에 단순한 계산 코드로서 존재하기 때문에 통합이 쉽다. 또한, 사전학습이 필요하지 않아서 데이터가 변경될 때마다 재학습할 필요가 없다. 하지만 예측하고자 하는 시점에 전체 데이터셋을 탐색해야 하므로, 데이터셋이 커질 수록 성능 저하가 발생할 수 있다. 나는 일정 내에 demo 개발을 빨리 끝내야 했으며, 게임 내에서 32개 gesture만 인식하면 되기 때문에 계산으로 인한 부하가 심하지 않을 것이라고 판단하여 이 방법을 사용했다. (실제로 인공지능 교수님이 이게 속도가 나와요? 하셨는데 켜보니까 충분히 빨라서 깜짝 놀라셨다 ㅎㅎ)
2. KNN 알고리즘 작동 방법
KNN은 지도학습의 방법 중 하나로, K-Nearest Neighbors의 약자다. 이름처럼 KNN 알고리즘은 데이터를 분류하거나 값을 예측할 때, 가장 가까운 K개의 이웃 데이터를 기준으로 결정을 내린다. 작동 방법은 다음과 같다.
1. 데이터셋을 먼저 준비하고, 각 데이터에 label을 달아놓는다.
2. 새로운 데이터(비교할 데이터)와 기존 데이터와의 거리를 측정한다.
이때 보통 유클리드 거리를 사용한다.
3. K개의 최근접 이웃을 선택한다.
4. 분류 문제의 경우, K개의 이웃의 레이블 중 가장 많이 등장하는 레이블로 새로운 데이터 포인트를 분류한다.
3. C#(+python)에서의 구현
나는 지문자(자음, 모음)의 의미 파악을 목적으로 구현하였다.
1. 데이터셋을 먼저 준비하고 , 각 데이터에 label을 달아놓는다.
label은 숫자로 다음과 같이 정의했다. KNN은 있는 dataset 중에서 가장 비슷한 레이블을 반환하므로, None을 만들어놓지 않는다면 내가 의도한 수어가 아닌 gesture를 했을 때에도 뭔가가 억지로 반환된다. 예시로, None이 없을 땐 손바닥을 펼쳤을 때 ㅎ이 반환된다. 이는 정확도에 큰 영향을 미치므로 빼먹지 말아야 한다.
0: 'ㄱ', 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: "None"
원하는 gesture의 각 landmark의 x,y,z 값을 먼저 csv 포맷으로 저장했다. 나는 landmark 별 각도를 사용하기 때문에 각도로 먼저 계산해서 저장하는 전처리 과정이 필요했는데, 한 번 하고 전처리하고 한 번 하고 전처리하고 이러는 게 싫어서 사진에는 없지만 ESC를 누르고 있으면 계속 저장되도록 했다.
if (runningMode == RunningMode.Sync)
{
var _ = graphRunner.TryGetNext(out palmDetections, out handRectsFromPalmDetections, out handLandmarks, out handWorldLandmarks, out handRectsFromLandmarks, out handedness, true);
string[] lmDataX = new string[21];
string[] lmDataY = new string[21];
string[] lmDataZ = new string[21];
if (handLandmarks != null && handLandmarks.Count > 0)
{
for (int i = 0; i < 21; i++)
{
foreach (var landmarks in handLandmarks)
{
var landmarkposition = landmarks.Landmark[i];
lmDataX[i] = landmarkposition.X.ToString();
lmDataY[i] = landmarkposition.Y.ToString();
lmDataZ[i] = landmarkposition.Z.ToString();
}
}
data.Add(lmDataX);
data.Add(lmDataY);
data.Add(lmDataZ);
}
SaveCSV("Dataset");
}
void SaveCSV(string fileName)
{
string newFileName = fileName;
string filePath = Path.Combine(Application.dataPath, newFileName + ".csv");
StreamWriter outStream = File.CreateText(filePath);
for (int i = 0; i < data.Count; i++)
{
string line = string.Join(",", data[i]);
outStream.WriteLine(line);
}
outStream.Close();
Debug.Log("CSV saved.");
}
그리고 python으로 csv를 읽어오고 그걸 한 번에 각도로 계산하고 label을 붙여 저장했다. 이 코드 상에서 하나의 csv 파일은 다 같은 gesture 번호로 저장되므로, 꼭 한 번에 하나의 gesture data만 넣도록 한다.
import csv
import numpy as np
import time
# 빈 리스트를 생성하여 데이터를 저장할 준비
rawdata = []
# CSV 파일 읽기. 파일 경로는 블로그용 임시 경로이므로 사용할 때 수정해야 한다.
with open(r'Dataset.csv', newline='') as csvfile:
reader = csv.reader(csvfile)
# 각 열의 값을 담을 리스트 초기화
column_data = [[] for _ in range(21)] # 3개의 열이 있다고 가정
# 한 번에 한 줄씩 읽어들이면서 처리
for idx, row in enumerate(reader):
# 각 열의 값을 가져와서 각 열의 리스트에 추가
for i in range(21):
column_data[i].append(float(row[i])) # 각 열의 값을 부동 소수점으로 변환하여 해당 열의 리스트에 추가
# 3개의 줄씩 처리할 때마다 각 열의 값을 data 리스트에 추가하고 각 열의 리스트 비우기
# 이 부분은 csv에 저장할 때 x끼리 y끼리 z끼리 저장하는 바람에 각 landmark 별로 분리하는 작업이다
if (idx + 1) % 3 == 0: # 3개의 줄씩 처리하고자 함
for i in range(21):
rawdata.append(column_data[i])
column_data[i] = []
# 리스트를 NumPy 배열로 변환
joint = np.array(rawdata)
print(joint)
print(rawdata)
# 벡터를 구하기 위해 생성하는 v1,v2 (v2에서 v1을 빼면 v백터가 된다)
v1 = joint[[0,1,2,3,0,5,6,7,0, 9,10,11, 0,13,14,15, 0,17,18,19],:]
v2 = joint[[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20],:]
# 정규화
v = v2-v1
v = v / np.linalg.norm(v,axis=1)[:,np.newaxis]
#각 벡터의 각도를 비교하기 위해 생성하는 compare벡터
compareV1 = v[[0,1,2,4,5,6,8,9,10,12,13,14,16,17,18],:]
compareV2 = v[[1,2,3,5,6,7,9,10,11,13,14,15,17,18,19],:]
# compare벡터를 사용하여 각도를 구함
angle = np.arccos(np.einsum('nt,nt->n',compareV1,compareV2))
angle = np.degrees(angle)
#dataset 저장할 파일 만들기
f = open(r'dataset.txt', 'a')
#파일에 dataset 집어넣기
for i in angle :
num = round(i, 6)
f.write(str(num))
f.write(',')
f.write("0.000000") #gesture 번호. 그때그때 수정하여 사용한다.
#각도가 소숫점 아래 6자리까지 저장되므로 label 또한 소숫점 아래 6자리를 지켜야 한다.
f.write("\n")
f.close();
rawdata = []
2. 새로운 데이터(비교할 데이터)와 기존 데이터와의 거리를 측정한다.
3. K개의 최근접 이웃을 선택한다.
이 코드에서는 2,3을 한 번에 구현하였다.
먼저 기존 데이터를 Unity에 올린다.
private void LoadTrainedModelFromResources(string modelName)
{
try
{
TextAsset modelFile = Resources.Load<TextAsset>(modelName);
if (modelFile == null)
{
Debug.LogError($"Failed to load model file: {modelName}");
return;
}
string temp = modelFile.text.Replace("\r", "");
string[] lines = temp.Split('\n');
int rowCount = lines.Length - 1;
// 데이터 배열 초기화
angles = new float[rowCount][];
labels = new int[rowCount];
// 모델 파일 데이터 파싱
for (int i = 0; i < rowCount; i++)
{
string[] values = lines[i].Split(',');
angles[i] = new float[values.Length - 1];
if (values.Length > 2)
{
for (int j = 0; j < values.Length - 2; j++)
{
if (values[j] == "") continue;
angles[i][j] = float.Parse(values[j]);
}
labels[i] = (int)float.Parse(values[values.Length - 1]);
}
}
}
catch (Exception e)
{
Debug.LogError($"Failed to load the trained model: {e}");
}
}
python에서 계산한 data를 dataset에 올릴 때 \n을 넣었으므로, Unity에서 읽을 때는 맨 마지막 \n을 빼야 한다.
기존 데이터를 각도로 계산했으므로, 새로운 데이터 또한 각도로 계산하는 과정이 필요하다.
public static float[] ProcessLandmarkData(string message)
{
message = message.Replace("\r", "");
List<float> landmarkDataList = message.Split(',').Select(x => float.Parse(x)).ToList();
if (landmarkDataList.Count != 63)
{
Debug.Log("Invalid landmark data format.");
return null;
}
float[,] transposedJoint = new float[3, 21];
for (int i = 0; i < 3; i++)
{
for (int j = 0; j < 21; j++)
{
transposedJoint[i, j] = landmarkDataList[i * 21 + j];
}
}
float[,] joint = Transpose(transposedJoint);
int[] v1Indices = { 0, 1, 2, 3, 0, 5, 6, 7, 0, 9, 10, 11, 0, 13, 14, 15, 0, 17, 18, 19 };
int[] v2Indices = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20 };
float[,] v = new float[20, 3];
for (int i = 0; i < 20; i++)
{
for (int j = 0; j < 3; j++)
{
v[i, j] = joint[v2Indices[i], j] - joint[v1Indices[i], j];
}
}
float[,] vNormalized = new float[20, 3];
for (int i = 0; i < 20; i++)
{
float norm = (float)Math.Sqrt(v[i, 0] * v[i, 0] + v[i, 1] * v[i, 1] + v[i, 2] * v[i, 2]);
for (int j = 0; j < 3; j++)
{
vNormalized[i, j] = v[i, j] / norm;
}
}
int[] compareV1Indices = { 0, 1, 2, 4, 5, 6, 8, 9, 10, 12, 13, 14, 16, 17, 18 };
int[] compareV2Indices = { 1, 2, 3, 5, 6, 7, 9, 10, 11, 13, 14, 15, 17, 18, 19 };
float[] angles = new float[15];
for (int i = 0; i < 15; i++)
{
float dotProduct = vNormalized[compareV1Indices[i], 0] * vNormalized[compareV2Indices[i], 0]
+ vNormalized[compareV1Indices[i], 1] * vNormalized[compareV2Indices[i], 1]
+ vNormalized[compareV1Indices[i], 2] * vNormalized[compareV2Indices[i], 2];
angles[i] = Mathf.Acos(dotProduct) * Mathf.Rad2Deg;
}
return angles;
}
private static float[,] Transpose(float[,] matrix)
{
int rows = matrix.GetLength(0);
int cols = matrix.GetLength(1);
float[,] transposed = new float[cols, rows];
for (int i = 0; i < cols; i++)
{
for (int j = 0; j < rows; j++)
{
transposed[i, j] = matrix[j, i];
}
}
return transposed;
}
이제 전처리가 끝났으니 dataset과의 거리를 계산하고 k개만큼의 최근접 이웃을 구해서 배열에 저장한다. 계산에는 유클리드 거리를 사용하였다.
private int PredictGesture(float[] testData)
{
try
{
int k = 5; // KNN의 k 값 설정
int[] nearestIndices = new int[k];
float[] nearestDistances = new float[k];
for (int i = 0; i < k; i++)
{
nearestDistances[i] = float.MaxValue;
}
// 모든 학습 데이터와의 거리 계산
for (int i = 0; i < angles.Length; i++)
{
float distance = CalculateEuclideanDistance(angles[i], testData);
if (distance < nearestDistances[k - 1])
{
for (int j = 0; j < k; j++)
{
if (distance < nearestDistances[j])
{
for (int m = k - 1; m > j; m--)
{
nearestDistances[m] = nearestDistances[m - 1];
nearestIndices[m] = nearestIndices[m - 1];
}
nearestDistances[j] = distance;
nearestIndices[j] = i;
break;
}
}
}
}
// 가장 가까운 이웃들의 라벨 확인
int[] nearestLabels = new int[k];
for (int i = 0; i < k; i++)
{
nearestLabels[i] = labels[nearestIndices[i]];
}
// 최빈값 계산
int[] labelCounts = new int[32]; // 32개의 제스처
foreach (int label in nearestLabels)
{
labelCounts[label]++;
}
int maxCount = 0;
int predictedLabel = -1;
for (int i = 0; i < labelCounts.Length; i++)
{
if (labelCounts[i] > maxCount)
{
maxCount = labelCounts[i];
predictedLabel = i;
}
}
return predictedLabel;
}
catch (Exception e)
{
Debug.LogError($"An error occurred during prediction: {e}");
return -1;
}
}
private float CalculateEuclideanDistance(float[] vector1, float[] vector2)
{
if (vector1.Length != vector2.Length)
{
throw new ArgumentException("Vector dimensions must be the same");
}
float sum = 0.0f;
for (int i = 0; i < vector1.Length; i++)
{
sum += Mathf.Pow(vector1[i] - vector2[i], 2);
}
return Mathf.Sqrt(sum);
}
4. K개의 이웃의 레이블 중 가장 많이 등장하는 레이블로 새로운 데이터 포인트를 분류한다.
private string GetGestureFromLabel(int label)
{
// 라벨에 해당하는 제스처 매핑
string[] gestures = {
"ㄱ", "ㄴ", "ㄷ", "ㄹ", "ㅁ", "ㅂ", "ㅅ", "ㅇ",
"ㅈ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ", "ㅏ", "ㅑ",
"ㅓ", "ㅕ", "ㅗ", "ㅛ", "ㅜ", "ㅠ", "ㅡ", "ㅣ",
"ㅐ", "ㅒ", "ㅔ", "ㅖ", "ㅢ", "ㅚ", "ㅟ", "None"
};
if (label >= 0 && label < gestures.Length)
{
return gestures[label];
}
}
이제 필요한 함수의 작성이 끝났다. 실행을 위한 Update(조건에 따라 OnTriggerStay)문과 전역변수만 설정해주면 된다.
HandTrackingSolution handTrackingSolution; //HandTrackingSolution 클래스의 인스턴스
float[][] angles; // 학습 데이터의 각도
int[] labels; // 학습 데이터의 라벨
public string gesture;
void Start()
{
// HandTrackingSolution 클래스의 인스턴스 찾기
handTrackingSolution = FindObjectOfType<HandTrackingSolution>();
LoadTrainedModelFromResources("dataset");
}
private void OnTriggerStay2D(Collider2D collision)
{
if (collision.CompareTag("suwhaObject")&&!scene2)
{
if (handTrackingSolution != null && handTrackingSolution.data.Count > 0)
{
string lmDataString = handTrackingSolution.GetLmDataString();
if (lmDataString != null)
{
// 가져온 데이터를 각도로 계산
float[] testData = ProcessLandmarkData(lmDataString);
// 예측 수행
int predictedLabel = PredictGesture(testData);
// 예측 결과 출력
gesture = GetGestureFromLabel(predictedLabel);
Debug.Log($"Predicted Gesture: {gesture}");
}
else
{
Debug.LogWarning("No data available in HandTrackingSolution.");
}
}
}
}
이제 Unity에서 UnityMediapipePlugin을 사용하여 KNN 알고리즘을 통해 Mediapipe Hand Landmark Detection을 Gesture Recognition처럼 쓸 수 있다.
'Unity' 카테고리의 다른 글
MediapipeUnityPlugin 설치하기 (1) | 2023.11.24 |
---|