이전에 회사에서 Unity와 IMU 센서 기반 모션캡쳐 글러브를 연동하여 시뮬레이션 환경을 만든 적이 있었다.
당시 실험실에 있던 모션캡쳐 글러브가 너무 예전 것이라 Unity SDK가 지원되지 않았기에, 별도로 Python UDP 브릿지를 구현해서 글러브에서 나오는 스트림 값을 유니티로 쏘아주어 Scene 상의 손 아바타에 매핑을 시도했던 경험이 있는데, 원하는대로 잘 안나왔던 기억이 있다.
그 경험을 살려서, 이번엔 모션캡쳐 장비가 아니라 웹캠 또는 카메라 장비를 통해 양 손을 인식하고 Unity 환경에 실시간 매핑하는 것을 시도해보려 한다.
사용 기술
- Unity, Python, C#, MediaPipe, UDP
-Python 버전은 3.10.11
- 의존성 : mediapipe, opencv-python, numpy를 각각 버전에 맞춰서.
내용
- 노트북 웹캠 화면을 opencv로 캡쳐하고, MediaPipe를 사용하여 21개의 손 Landmark를 인식한 후 Tracking한다. - 이후 원점이 되는 부분을 손목(LM00, Wrist)으로 잡고, 각 랜드마크들의 로컬 좌표를 numpy 배열로 변환 후 바이너리 블록에 담아서 uint8(0~255)값으로 양자화 한 후 UDP로 송신한다.
- Unity에서는 비동기로 UDP 패킷을 수신받고, 파싱한 후 양 손의 랜드마크들을 버퍼에 저장한다.
- Unity Scene에 양 손 랜드마크에 해당하는 Empty Object들을 생성한 후, 각 랜드마크들에 로컬 좌표를 적용한다.

프로토콜 스펙 정의
Python ↔ Unity 간 프로토콜 : UDP 바이너리 ⇒ 파싱이 가장 빠르고, json 대비 GC 적음
MediaPipe는 손 하나당 21개의 랜드마크를 제공(0번 ~ 20번) ⇒ 손 1개 = 21*(x,y,z)를 기본 단위로 함
- HandTrack UDP (Little Endian)
- 패킷 타입
- 0x01 : HAND_FRAME (한 프레임의 양손 데이터)
- 0X02 : PING
- 0x03 : PONG
- 공통 헤더 : 고정 24byte
- seq로 드랍/역순 프레임을 Unity에서 쉽게 감지
- MediaPipe는 normalized landmark 외에도 world landmarks를 제공할 수 있으며, 이 플래그로 구분할 수 있게 해둠
- 패킷 타입
| 오프셋 | 타입 | 이름 | 설명 |
| 0 | uint32 | magic | ASCII 'H','T','U','1' = 0x31555448 (Hand Tracking Unity v1) |
| 4 | uint16 | version | 1 |
| 6 | uint16 | packetType | 1 = HAND_FRAME |
| 8 | uint32 | seq | 송신 프레임 시퀀스(0부터 증가) |
| 12 | uint64 | timestamp_us | 송신 시간(ms) |
| 20 | uint16 | payload_bytes | payload 길이(bytes) |
| 22 | uint16 | flags | bit0: hasLeft, bit1: hasRight, bit2: coordsAreWorld(0=normalized,1=world) |
- HAND_FRAME Payload (가변, 최대 손 2개)
- payload는 왼손 블록 + 오른손 블록 순서로 붙임
- HandBlock (손 1개 : 2+2+1+1+4+2134 = 260bytes)
- landmark 인덱싱(0~20)은 mediapipe 고정 인덱스를 그대로 사용
- landmarks_xyz는 normalized mode(x,y는 이미지 기준, z는 상대 깊이), world mode(xyz가 world좌표(미터 스케일 근사값)로 제공 가능)
| 오프셋 | 타입 | 이름 | 설명 |
| +0 | uint16 | handId | 0=Left, 1=Right |
| +2 | uint16 | landmarkCount | 항상 21 |
| +4 | uint8 | handednessScore_q | 0~255 |
| +5 | uint8 | reserved0 | 0 |
| +6 | float32 | handConfidence | 감지 신뢰도 (없으면 1.0) |
| +10 | float32 | landmarks_xyz | 21개 * (xyz) |
- 최종 패킷 크기
- 양손 다 있으면 header 24 + 260*2 = 544bytes
- 한손만 있으면 header 24 + 260 = 284bytes
- Unity 파싱 시 규칙
- magic이 다르면 즉시 버림
- version≠1이면 즉시 버림
- payloadBytes≠기대값(260 또는 520 등)과 다르면 버림
- seq가 이전부다 작으면(역순) 기본은 버리고, 옵션으로 가장 최신만 적용하게 할 수 있을듯
0단계 : Python에서 웹캠 ~ UDP 송신까지 구현
hand_udp_sender.py
import cv2 #웹캠 프레임 캡쳐
import time # 타임스탬프 생성
import socket # UDP 소켓 통신
import struct # 바이너리 패킹용
import numpy as np #벡터 좌표연산용
import mediapipe as mp
from mediapipe.tasks import python as mp_python # 태스크 파이썬 래퍼
from mediapipe.tasks.python import vision as mp_vision # 비전 태스크
from pathlib import Path # 파일 경로를 안전하게 다루기 위해 Path 임포트
UDP_IP = "127.0.0.1" #Unity가 실행되는 IP(같은 PC에서 수행될것이므로)
UDP_PORT = 8051 #Unity에서 수신할 UDP 포트 번호
CAM_INDEX = 0 # 사용할 웹캠 인덱스
MODEL_PATH = (Path(__file__).parent / "hand_landmarker.task").resolve() #미디어파이프 랜드 랜드마커 모델의 절대경로
SEND_WORLD_IF_AVAILABLE = True # world_landmarks가 제공된다면 그걸 우선 사용
LOCAL_SCALE = 1.0 #로컬 좌표 스케일(유니티에서 사용할 수 있도록 손목을 원점으로 한 로컬 좌표계로 가공)
MAGIC_HTU1 = 0x31555448 # H T U 1을 리틀 엔디안 uint32로 표현한 매직넘버
VERSION = 1 # 프로토콜 버전
PACKET_TYPE_HAND_FRAME = 0x01 #패킷타입 : HAND_FRAME(1)
HAND_ID_LEFT = 0 #왼손
HAND_ID_RIGHT = 1 #오른손
LANDMARK_COUNT = 21 # 미디어파이프 핸즈의 21개 랜드마크 (1손당)
WRIST_INDEX = 0 #손목 랜드마크의 인덱스
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP 소켓 생성
seq = 0 # 프레임 시퀀스 번호(매프레임 1씩 증가)
def now_us() -> int: # 현재 시각을 ms단위 정수로 반환하는 메서드
return int(time.time() * 1_000_000)# time.time()은 초 단위 float이므로 ie6을 곱해서 us로 변환.
def clamp_u8(x:float)->int: #0~255 범위로 클램프해서 uint8로 만드는 메서드. 클램프란, 범위를 벗어난 값을 경계값으로 고정하는 것을 의미
if x <0.0 : #0미만일때
return 0
if x > 255.0 :
return 255
return int(x) #0미만, 255초과가 아닌 경우에는 정수 변환 후 리턴
def build_hand_block(hand_id:int, handedness_score_0to1:float, confidence:float, landmarks_xyz:np.ndarray)->bytes:
#hand_id는 0(왼쪽) 또는 1(오른쪽)이며, 21개 랜드마크의 로컬좌표를 담은 바이너리 블록을 생성.
# handedness_score_0to1는 0~1 확률값. uint8(0~255)로 양자화해서 송신할 것
# confidence는 감지 신뢰도(없으면 1..0으로 고정) -> float32로 송신
# landmarks_xyz는 (21,3)크기의 numpy 배열, 21개 랜드마크의 x,y,z좌표가 담겨있으며 float32 배열로 평탄화해서 송신
score_q = clamp_u8(handedness_score_0to1 * 255.0)# 0~1 점수를 0~255로 변환하여 uint8로 양자화 (이유? : UDP패킷 크기 절감)
reversed0 = 0 #예약필드(현재 미사용이나, 미래 확장용으로 0으로 세팅)
header_part = struct.pack(
"<HHBBf",
hand_id,
LANDMARK_COUNT,
score_q, # 양자화된 핸디드니스 점수
reversed0, #예약값
float(confidence),
)# HandBlock 헤더(10바이트)를 리틀엔디안으로 패킹
flat = landmarks_xyz.astype(np.float32).reshape(-1) # (21,3)크기의 float32 배열을 평탄화하여 (63,)크기로 변환
coords_part = struct.pack("<" + "f" * (LANDMARK_COUNT * 3), *flat.tolist()) # 63개의 float32 좌표를 리틀엔디안으로 패킹
return header_part + coords_part # HandBlock 전체(260바이트)를 반환.
def landmarks_to_local(landmarks_xyz:np.ndarray)->np.ndarray: # 손목을 원점으로 하는 로컬좌표계로 변환하는 메서드
wrist = landmarks_xyz[WRIST_INDEX].copy() # 손목(0번)좌표를 복사해서 기준점으로 사용
local = landmarks_xyz -wrist #모든 점에서손목 좌표를 빼서 손목을 원점화.
local = local*float(LOCAL_SCALE)#스케일을 곱해서 로컬 좌표 크기를 조절
return local # 변환된 로컬좌표 (21,3)반환
def extract_hand_data(result, hand_i: int):
# result는 HandLandmarkerResult , hand_i번째 손의 좌표/분류 정보를 안전하게 추출
# 반환 : (handedness_label, handedness_score, coords(21,3), confidence, coords_are_world)
# hand_landmarks가 없으면 추출 불가이므로 예외 대신 None 리턴으로 상위에서 skip 처리
if result.hand_landmarks is None: # hand_landmarks 자체가 None이면
return None # 상위 루프에서 무시하도록 None 반환
if hand_i >= len(result.hand_landmarks): # 인덱스가 범위를 벗어나면
return None # 상위 루프에서 무시하도록 None 반환
# handedness는 없거나 길이가 다를 수 있으니 기본값을 준비 # 방어 코드
handedness_label = "Right" # 기본은 Right로
handedness_score = 0.5 # 기본 점수
if (result.handedness is not None) and (hand_i < len(result.handedness)) and (len(result.handedness[hand_i]) > 0): # handedness가 유효하면
handedness = result.handedness[hand_i][0] # 최상위 1개 분류를 선택
handedness_label = handedness.category_name # "Left"/"Right" 레이블
handedness_score = float(handedness.score) # 0~1 점수
# world_landmarks는 옵션일 수 있으니, 가능하면 사용하고 아니면 normalized 사용 # 방어 코드
coords_are_world = False # 좌표가 world인지 여부 기본값
coords = None # 좌표 버퍼 초기화
if SEND_WORLD_IF_AVAILABLE and (result.hand_world_landmarks is not None) and (hand_i < len(result.hand_world_landmarks)): # world가 있으면
lm_list = result.hand_world_landmarks[hand_i] # world 랜드마크 리스트
if lm_list is not None and len(lm_list) == LANDMARK_COUNT: # 21개면 정상으로 간주
coords = np.array([[lm.x, lm.y, lm.z] for lm in lm_list], dtype=np.float32) # (21,3)로 변환
coords_are_world = True # world 플래그 설정
if coords is None: # world를 못 썼으면
lm_list = result.hand_landmarks[hand_i] # normalized 랜드마크 리스트
if lm_list is None or len(lm_list) != LANDMARK_COUNT: # 21개가 아니면
return None # 이 프레임의 해당 손은 스킵
coords = np.array([[lm.x, lm.y, lm.z] for lm in lm_list], dtype=np.float32) # (21,3)로 변환
coords_are_world = False # normalized 플래그 설정
confidence = 1.0 # HandLandmarkerResult에 별도 confidence가 항상 있진 않으므로 기본 1.0
return handedness_label, handedness_score, coords, confidence, coords_are_world # 정상적으로 5개 반환
def handedness_to_hand_id(handedness_label: str) -> int:
# MediaPipe handedness 레이블을 handId(0/1)로 매핑 # Unity 쪽에서 Left/Right 리그를 구분
if handedness_label is None: # 레이블이 없으면
return HAND_ID_RIGHT # 기본 Right 반환
if handedness_label.lower() == "left": # 레이블이 Left면
return HAND_ID_LEFT # 0 반환
if handedness_label.lower() == "right": # 레이블이 Right면
return HAND_ID_RIGHT # 1 반환
return HAND_ID_RIGHT # 그 외(Unknown 등)는 기본 Right 처리
def build_packet(seq_num:int, timestamp_us:int, hand_blocks:list, coords_are_world_any:bool) ->bytes:
"""
seq_num : 프레임 시퀀스(UDP 패킷(헤더 + 페이로드)을 생성)
timestamp_us : 송신시간(마이크로초 단위)->유니티에서 지연측정/동기화에 사용
hand_blocks : 핸드블록 바이트 리스트(최대 2)
coords_are_world : world좌표가 포함되었는지 여부
"""
payload = b"".join(hand_blocks) # 모든 핸드블록들을 순서대로 붙여 페이로드 생성
payload_bytes = len(payload) # 페이로드 길이를 바이트 단위로 계싼
has_left = any((hb[0:2] == struct.pack("<H", HAND_ID_LEFT))for hb in hand_blocks) #왼손 블록 포함 여부 추중
has_right = any((hb[0:2] == struct.pack("<H", HAND_ID_RIGHT)for hb in hand_blocks))#오른손 블록 포함 여부 추정
flags = 0 # flags 초기화
if has_left: # 왼손이 있으면
flags |= 1 << 0 # bit0 설정
if has_right: # 오른손이 있으면
flags |= 1 << 1 # bit1 설정
if coords_are_world_any: # world 좌표를 사용한 손이 하나라도 있으면
flags |= 1 << 2 # bit2 설정(좌표가 world임을 표시)
header = struct.pack(
"<IHHIQHH",
MAGIC_HTU1, # 매직 넘버
VERSION, # 버전
PACKET_TYPE_HAND_FRAME, # 패킷 타입
int(seq_num), # 시퀀스
int(timestamp_us), # 타임스탬프(마이크로초)
int(payload_bytes), # 페이로드 길이
int(flags), # 플래그
)
return header + payload # 완성된패킷 바이트 반환
def main():
if not MODEL_PATH.exists(): # 모델 파일이 실제로 존재하는지 확인
raise FileNotFoundError(f"모델 파일이 없습니다: {MODEL_PATH}") # 없으면 즉시 예외로 위치를 출력
base_options = mp_python.BaseOptions(model_asset_path=str(MODEL_PATH)) # 모델 파일 경로를 MediaPipe에 문자열 경로로 전달
options = mp_vision.HandLandmarkerOptions( # 핸드 랜드마커 옵션 구성
base_options = base_options,# 모델 옵션 연결
num_hands = 2, # 최대 손 2개 인식
min_hand_detection_confidence = 0.5, # 검출 최소 신뢰도
min_hand_presence_confidence = 0.5,# 존재 최소 신뢰도
min_tracking_confidence = 0.5,#트래킹 최소 신뢰도
)
landmarker = mp_vision.HandLandmarker.create_from_options(options)# 핸드 랜드마커 생성
cap = cv2.VideoCapture(CAM_INDEX)#웹캠캡쳐 객체 생성
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)#캡쳐 해상도 너비
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)#캡쳐 해상도 높이
global seq #전역 시퀀스 변수를 사용한다고 명시
prev_hand_count = 0 # 이전 프레임에서 잡힌 손 개수(변화 감지용)
last_log_t = 0.0 # 로그 과다 출력 방지용 마지막 로그 시각
while True:
ok, frame_bgr = cap.read() #웹캠에서 프레임 읽기
if not ok:
continue # 무한루프로 프레임을 처리하면서, 웹캠에서 프레임을 읽는다. 실패 시 다음 루프에서 재시도
frame_rbg = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB) # BGR->RGB로 변환
mp_image = mp.Image(image_format = mp.ImageFormat.SRGB, data = frame_rbg) #미디어파이프 이미지 객체 생성
result = landmarker.detect(mp_image) #핸드 랜드마커로 손 검출 수행
hand_count_now = 0 # 현재 프레임에서 잡힌 손 개수 초기화
if result.hand_landmarks is not None: # 손 랜드마크 리스트가 존재하면
hand_count_now = len(result.hand_landmarks) # 잡힌 손 개수는 리스트 길이
# 1) 손이 "없음→있음"으로 바뀌는 순간에만 로그 출력
if prev_hand_count == 0 and hand_count_now > 0: # 처음으로 손이 잡힌 순간이면
print(f"[HAND DETECTED] count={hand_count_now} seq={seq}") # 손 잡힘 로그 출력
# 2) 계속 잡히는 동안 1초에 1번만 상태 로그
t = time.time() # 현재 시각(초) 측정
if hand_count_now > 0 and (t - last_log_t) >= 1.0: # 손이 잡힌 상태에서 1초가 지났으면
print(f"[HAND TRACKING] count={hand_count_now} seq={seq}") # 주기적 상태 로그 출력
last_log_t = t # 마지막 로그 시각 갱신
# 3) 손이 "있음→없음"으로 바뀌는 순간 로그 출력
if prev_hand_count > 0 and hand_count_now == 0: # 손이 사라진 순간이면
print(f"[HAND LOST] seq={seq}") # 손 놓침 로그 출력
prev_hand_count = hand_count_now # 다음 프레임을 위해 이전값 갱신
hand_blocks = []#이번 프레임에 포함될 핸드블록 초기화
coords_are_world_any = False # world좌표 포함 여부 초기화
if result.hand_landmarks is not None and len(result.hand_landmarks) > 0: # 손이 하나라도 검출되었으면
hand_count = min(len(result.hand_landmarks), 2) # 최대 2개까지만 처리
for i in range(hand_count): # 각 손에 대해 처리
data = extract_hand_data(result, i) # 손 데이터 추출(실패 시 None)
if data is None: # 추출 실패하면
continue # 해당 손은 스킵
handedness_label, handedness_score, coords_xyz, confidence, coords_are_world = data # 정상일 때만 언패킹
coords_are_world_any = coords_are_world_any or coords_are_world # world 포함 여부 누적
local_xyz = landmarks_to_local(coords_xyz) # 손목 원점의 로컬 좌표로 변환
hand_id = handedness_to_hand_id(handedness_label) # Left/Right를 0/1로 변환
block = build_hand_block(hand_id, handedness_score, confidence, local_xyz) # HandBlock 바이너리 생성
hand_blocks.append(block) # 리스트에 추가
ts = now_us() # 송신 타임스탬프 생성
packet = build_packet(seq, ts, hand_blocks, coords_are_world_any) # 패킷 생성(손이 없으면 payload 0)
sock.sendto(packet, (UDP_IP, UDP_PORT)) # Unity로 UDP 송신
seq += 1 # 시퀀스 증가
cv2.imshow("hand_udp_sender", frame_bgr) # 디버깅용으로 원본 화면 표시
key = cv2.waitKey(1) & 0xFF # 키 입력을 1ms 대기하며 확인
if key == 27: # ESC 키(27)면
break # 루프 종료
cap.release() # 웹캠 자원 해제
cv2.destroyAllWindows() # OpenCV 윈도우 닫기
if __name__ == "__main__": # 스크립트가 직접 실행될 때만
main() # 메인 함수 실행
결과


주먹을 쥐거나 손바닥→손등으로 손목을 회전시켰을 때는 바로 HAND LOST 로그가 발생
- HAND_LOST를 3프레임 연속 손 0개일 때만 찍기 위해, hand_count_now 변수를 추가해서, 이 값이 0일 때 카운트를 올리고, 손이 다시 잡히면 0으로 리셋하는 디바운스를 적용
- 상수 LOST_CONSECUTIVE_FRAME = 3 선언
- 손 0개 연속 프레임 카운터 lost_streak = 0 선언
- 트래킹 활성화 플래그 tracking_active = False로 선언
- main함수의 while문 바깥에 선언해야 루프마다 초기화되지 않음
- → 손의 변화가 생기면 LOST가 생긴 후 바로 DETECTED로 정상화 되기는 하지만, 개선하고 싶음.
hand_count_now = 0 # 현재 프레임에서 잡힌 손 개수 초기화 # 손이 없으면 0으로 유지
# result.hand_landmarks가 None이 아닐 때만 길이를 읽음 # None이면 손 0개로 취급
if result.hand_landmarks is not None: # 손 랜드마크 리스트가 존재하면[web:52]
hand_count_now = len(result.hand_landmarks) # 잡힌 손 개수는 리스트 길이[web:52]
# 손이 잡힌 경우 처리 # DETECTED 및 디바운스 리셋
if hand_count_now > 0: # 손이 1개 이상이면
if not tracking_active: # 직전까지 손이 없던 상태였다면(상태 전이)
print(f"[HAND DETECTED] count={hand_count_now} seq={seq}") # 손 잡힘 로그 출력
tracking_active = True # 트래킹 활성 상태로 설정
lost_streak = 0 # 손이 잡혔으니 연속 손 없음 카운터를 리셋
# 손이 안 잡힌 경우 처리 # 연속 0프레임 누적 후 LOST
else: # hand_count_now == 0 이면(= None이든 빈 리스트든 상관없이 0)
if tracking_active: # 이전에 손이 잡힌 상태였을 때만 LOST를 계산
lost_streak += 1 # 연속 손 없음 프레임 카운터 증가
if lost_streak >= LOST_CONSECUTIVE_FRAMES: # 3프레임 연속 손 0개면
print(f"[HAND LOST] seq={seq} lost_streak={lost_streak}") # 그때만 LOST 로그 출력
tracking_active = False # 트래킹 비활성 상태로 전환
lost_streak = 0 # 카운터 리셋
1단계 : Unity에서 UDP 수신 ~ 양 손과 동기화까지 구현
1. PackageManager에서 Animation Rigging 설치

2. UDP 수신 및 데이터 매핑 테스트를 위해, Hierarchy에 아래와 같이 구조를 만든다.


3. UDP 데이터를 수신하고 파싱한 후, 메인 쓰레드에서 LM의 트랜스폼을 업데이트하는 스크립트를 작성한다.
- UdpClient를 8051포트로 열고, 백그라운드에서 계속 수신(ReceiveAsync()사용)
- 수신한 바이트 배열에서
- header(24byte) 파싱 후 magic=HTU1 확인
- payload에서 HandBlock(손 당 260byte) 0~ 2개 파싱
- (x,y,z) 21개를 Vector3[21]로 복원
- Thread-Safe하게 마지막 프레임 데이터를 보관하고, Unity 메인 스레드에서(Update에서) LM00~LM20의 Transform에 적용
- —> 즉, 백그라운드에서 udp받고, 메인 스레드에서 트랜스폼 업데이트
UdpHandReceiverHTU1.cs
using System;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
public class UdpHandReceiverHTU1 : MonoBehaviour
{
[Header("UDP Settings")]
[SerializeField] private int listenPort = 8051;// 파이썬이 보내는 포트
[SerializeField] private bool logPackets = true;//패킷 수신 로그 출력 여부
[Header("Landmarks Settings")]
[SerializeField] private Transform handTrackingRoot;//로컬좌표 기준이 되는 로트 오브젝트
[SerializeField] private Transform[] leftLandmarks = new Transform[21];//왼손 랜드마크 오브젝트 배열
[SerializeField] private Transform[] rightLandmarks = new Transform[21];//오른손 랜드마크 오브젝트 배열
[Header("Scale")]
[SerializeField] private float positionScale = 1.0f;//파이썬 로컬 좌표를 유니티 단위로 스케일하기 위한 값
private const uint MAGIC_HTU1 = 0x31555448;//매직 넘버
private const ushort VERSION = 1;//프로토콜 버전
private const ushort PACKET_TYPE_HAND_FRAME = 0x01;//핸드 프레임 패킷 타입
private const int HEADER_SIZE = 24;//헤더 크기(바이트)
private const int LANDMARK_COUNT = 21;//랜드마크 개수
private const int HAND_BLOCK_SIZE = 260;//핸드블록 크기(바이트)
private UdpClient udp;//UDP 클라이언트
private CancellationTokenSource cts;// 수신 루프 중단 토큰
private readonly object frameLock = new object();//스레드 동기화용 락 객체. --> UDP 수신 스레드와 메인 스레드 간의 데이터 동기화를 위해 필요. UDP 수신은 메인과 별도의 스레드에서 이루어지기 때문에 수신된 데이터를 메인 스레드에서 접근할 때 데이터 일관성을 보장하기 위해 락이 필요
private bool hasNewFrame = false; //새 프레임 도착 여부
private bool hasLeft = false;//왼손 데이터 존재 여부
private bool hasRight = false;//오른손 데이터 존재 여부
private Vector3[] leftPos = new Vector3[LANDMARK_COUNT];//왼손 로컬 좌표 버퍼
private Vector3[] rightPos = new Vector3[LANDMARK_COUNT];//오른손 로컬 좌표 버퍼
private void Reset()// 컴포넌트 추가 시 기본값 세팅하는 메서드
{
handTrackingRoot = transform.parent;//부모가 핸드트래킹 루트라면 자동 할당 시도
}
void Start()
{
if(handTrackingRoot==null)
{
Debug.LogError("handTrackingRoot가 비어있음");
enabled = false;//컴포넌트 비활성화 후 종료
return;
}
udp = new UdpClient(listenPort);//지정 포트로 UDP 클라이언트 생성
cts = new CancellationTokenSource();//취소 토큰 생성. 이 토큰을 통해 수신 루프를 중단할 수 있음
_=ReceiveLoopAsync(cts.Token);//비동기 수신 루프 시작(백그라운드 스레드에서 실행)
}
void Update()
{
bool apply;//적용 여부
bool left;//왼손 있음 여부
bool right;//오른손 있음 여부
Vector3[] leftBuffer = null;//왼손 버퍼 참조
Vector3[] rightBuffer = null;//오른손 버퍼 참조
lock(frameLock)//스레드 동기화 시작
{
apply = hasNewFrame;//새 프레임인지 확인
if(!apply) return;//새 프레임이 없으면 바로 종료
hasNewFrame = false;//소비했으므로 플래그 내리기
left = hasLeft;//왼손 데이터 존재 여부 복사
right = hasRight;//오른손 데이터 존재 여부 복사
leftBuffer = leftPos;//왼손 버퍼 참조 복사
rightBuffer = rightPos;//오른손 버퍼 참조 복사
}
if(left)//왼손이 있다면
ApplyLandmarksLocal(leftLandmarks, leftBuffer);//왼손 랜드마크 적용
if(right)//오른손이 있다면
ApplyLandmarksLocal(rightLandmarks, rightBuffer);//오른손 랜드마크 적용
}
private async Task ReceiveLoopAsync(CancellationToken token)//백그라운드에서 UDP 수신 루프를 실행하는 비동기 메서드
{
IPEndPoint any = new IPEndPoint(IPAddress.Any, 0);// 임의 송신자 엔드포인트 -> 모든 송신자로부터 수신 대기
while(!token.IsCancellationRequested)//취소 전까지 반복
{
UdpReceiveResult recv;// 수신 결과 객체
try
{
recv = await udp.ReceiveAsync();//비동기 수신
}
catch(ObjectDisposedException)
{
break;//소켓이 닫히면 루프 종료
}
catch(OperationCanceledException)
{
break;//취소 요청 시 루프 종료
}
catch(Exception ex)
{
Debug.LogError($"UDP Receive 예외처리 : {ex.Message}");
continue;//다음 수신으로. 예외 발생 시에도 루프를 계속 돌기 위함
}
byte[] data = recv.Buffer;//수신 데이터 버퍼
if(data == null || data.Length < HEADER_SIZE)//최소 헤더보다 작으면
continue;//무시하고 다음 수신으로
if(!TryParseAndStoreFrame(data))//파싱 실패 시
continue;// 무시하고 다음 수신으로
if(logPackets)//로그 옵션 True 시
Debug.Log($"HTU1 패킷 수신 : {data.Length} 바이트" );
}
}
private void ApplyLandmarksLocal(Transform[] targets, Vector3[] src)//트랜스폼에 로컬 좌표를 적용하는 메서드
{
if(targets == null || targets.Length <LANDMARK_COUNT)//타겟 배열이 부족하면
return;
for(int i=0; i<LANDMARK_COUNT; i++)//21개 랜드마크를 반복하면서, 스케일을 적용하고 로컬좌표를 적용한다
{
Transform target = targets[i];//타겟 트랜스폼 i번째
if(target==null)
continue;// 비어있다면 스킵
Vector3 pos = src[i] * positionScale;//스케일 적용
target.localPosition = pos;//핸드 트래킹 루트의 로컬 좌표로 이동
}
}
private bool TryParseAndStoreFrame(byte[] data)// HTU1 프레임을 파싱하고 버퍼에 저장하는 메서드
{
int offset = 0; // 읽기 오프셋
uint magic = ReadUInt32LE(data, ref offset); // 매직 읽기
ushort version = ReadUInt16LE(data, ref offset); // 버전 읽기
ushort packetType = ReadUInt16LE(data, ref offset); // 패킷 타입 읽기
uint seq = ReadUInt32LE(data, ref offset); // 시퀀스 읽기
ulong timestampUs = ReadUInt64LE(data, ref offset); // 타임스탬프 읽기
ushort payloadBytes = ReadUInt16LE(data, ref offset); // 페이로드 길이 읽기
ushort flags = ReadUInt16LE(data, ref offset); // 플래그 읽기
if (magic != MAGIC_HTU1) // 매직이 다르면
return false; // 무시
if (version != VERSION) // 버전이 다르면
return false;
if (packetType != PACKET_TYPE_HAND_FRAME) // 타입이 HAND_FRAME이 아니면
return false;
if (data.Length < HEADER_SIZE + payloadBytes) // 길이가 모자라면
return false;
bool gotLeft = false; // 왼손 파싱 성공 여부
bool gotRight = false; // 오른손 파싱 성공 여부
Vector3[] ltmp = new Vector3[LANDMARK_COUNT]; // 이번 프레임 왼손 임시 버퍼
Vector3[] rtmp = new Vector3[LANDMARK_COUNT]; // 이번 프레임 오른손 임시 버퍼
int payloadOffset = HEADER_SIZE; // 페이로드 시작 오프셋
int remaining = payloadBytes; // 남은 페이로드 바이트
while (remaining >= HAND_BLOCK_SIZE) // HandBlock 단위로 반복
{
int handStart = payloadOffset; // 블록 시작점 기록
ushort handId = ReadUInt16LE(data, ref payloadOffset); // handId 읽기
ushort landmarkCount = ReadUInt16LE(data, ref payloadOffset); // landmarkCount 읽기
byte handednessScoreQ = data[payloadOffset++]; // score_q 읽기
byte reserved0 = data[payloadOffset++]; // reserved 읽기
float confidence = ReadFloat32LE(data, ref payloadOffset); // confidence 읽기
if (landmarkCount != LANDMARK_COUNT) // 21개가 아니면
{
payloadOffset = handStart + HAND_BLOCK_SIZE; // 블록 끝으로 점프
remaining -= HAND_BLOCK_SIZE; // 남은 길이 감소
continue; // 다음 블록
}
for (int i = 0; i < LANDMARK_COUNT; i++) // 21개 랜드마크 반복
{
float x = ReadFloat32LE(data, ref payloadOffset); // x
float y = ReadFloat32LE(data, ref payloadOffset); // y
float z = ReadFloat32LE(data, ref payloadOffset); // z
Vector3 p = new Vector3(x, y, z);
if (handId == 0) // 왼손이면
ltmp[i] = p; // 왼손 버퍼에 기록
else if (handId == 1) // 오른손이면
rtmp[i] = p; // 오른손 버퍼에 기록
}
if (handId == 0) // 왼손 블록 완료
gotLeft = true; // 왼손 있음 표시
if (handId == 1) // 오른손 블록 완료
gotRight = true; // 오른손 있음 표시
remaining -= HAND_BLOCK_SIZE; // 남은 길이 감소
}
lock (frameLock) // 공유 버퍼 갱신은 락으로 보호
{
hasLeft = gotLeft; // 왼손 플래그 저장
hasRight = gotRight; // 오른손 플래그 저장
if (gotLeft) // 왼손이 있으면
Array.Copy(ltmp, leftPos, LANDMARK_COUNT); // 왼손 버퍼 복사
if (gotRight) // 오른손이 있으면
Array.Copy(rtmp, rightPos, LANDMARK_COUNT); // 오른손 버퍼 복사
hasNewFrame = true; // 새 프레임 도착 표시
}
return true; // 파싱 성공
}
private static ushort ReadUInt16LE(byte[] data, ref int offset)// uint16을 리틀엔디안으로 읽는 헬퍼메서드
{
ushort v = (ushort)(data[offset] | (data[offset + 1] << 8)); // 바이트 결합
offset += 2; // 오프셋 이동
return v; // 값 반환
}
private static uint ReadUInt32LE(byte[] data, ref int offset)//uint32를 리틀엔디안으로 읽는 헬퍼메서드
{
uint v = (uint)(data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24)); // 바이트 결합
offset += 4; // 오프셋 이동
return v; // 값 반환
}
private static ulong ReadUInt64LE(byte[] data, ref int offset)//uint64를 리틀엔디안으로 읽는 헬퍼메서드
{
ulong lo = ReadUInt32LE(data, ref offset); // 하위 32비트 읽기
ulong hi = ReadUInt32LE(data, ref offset); // 상위 32비트 읽기
return lo | (hi << 32); // 결합 후 반환
}
private static float ReadFloat32LE(byte[] data, ref int offset)//float32를 리틀엔디안으로 읽는 헬퍼메서드
{
float v = BitConverter.ToSingle(data, offset); // 현재 플랫폼 리틀엔디안 기준으로 float 변환
offset += 4; // 오프셋 이동
return v; // 값 반환
}
private void OnDestroy()
{
if(cts!=null)//토큰이 있다면
{
cts.Cancel();//수신 루프 취소 요쳥
cts.Dispose();//토큰 자원 해제
cts=null;//참조 제거
}
if(udp!=null)//udp 클라이언트가 있다면
{
udp.Close();//UDP 소켓 닫기
udp.Dispose();//자원 해제
udp=null;//참조 제거
}
}
}
4. UdpHandReceiver 객체에 이 스크립트를 추가하고, 아래와 같이 인스펙터에서 세팅

5. 동기화 결과


잘 움직인다. 아직 Empty Object라서 움직임이 눈에 안보일 뿐, 각 LM들을 클릭하면 손 움직임에 따라서 LM들의 위치가 변화하고 있다.

2단계 : 손 아바타 매핑 및 시각화
지금 손목은 원점이라서, 실제 매핑을 위해 Unity에서 랜드마크 3점으로 손의 좌표계(손바닥 평면)을 추정하여 Orientation을 만든 뒤 그 위에 손가락 관절 회전을 계산해야 함
- 아바타의 매핑 방식 : Animation Rigging으로 타겟 추종
- Two Bone IK는 2본 체인을 타겟으로 보내는 제약
- 손가락은 보통 3개의 뼈(근위, 중간, 원위)라서, TwoBone IK가 딱 맞지는 않기 때문에, 여기서는 Multi-Parent 제약을 사용하여 "리깅된 손 아바타의 손가락 "과 "LM의 로컬 좌표가 매핑될 Target 오브젝트"를 매핑한다.
유니티에서 손 아바타 가져오기
- 휴머노이드의 HumanBodyBones로 손가락 뼈를 찾을 수 있음
- 손만 따로 제공되는 에셋이 없어서, 우선 리깅된 휴머노이드 프리팹에서 손만 쓰기로 함


이제, LM 21개 →Target → Animation Rigging이 손가락 뼈를 따라가게 구성해야 함
- 각 손마다 RigTargets(LM으로부터 계산된 타겟 트랜스폼들)을 추가
- RigTarget 자식
- R_Thumb_Prox_Target..과 같이 손가락별 타겟을 둔다
- 이 타겟들은 해당 본이 바라봐야 할 방향을 나타내는 회전을 갖도록 만든다.
- RigTarget 자식
- 랜드마크 21개로부터 각 손가락 관절용 회전 타겟(transform)을 매 프레임 계산해서 갱신한다.,
- 매 프레임, 각 관절에 대해 방향 벡터를 만들고, LookRotation(다음 관절로 가는 방향, 손바닥 법선)으로 각 관절 타겟의 회전을 설정
- 손바닥 법선은 wrist(0), index_mcp(5), pinky_mcp(17)으로 구한 평면 normal을 사용(좌/우 손은 부호가 달라서 뒤집을 수 있음)
- 각 손가락 Bone(Thumb, Index, Middle, Ring, Little의 Prox, Inter, Dist)에 Multi-Parent Constraint를 걸어서 타겟 회전을 따라가게 한다.
- 손가락 본의 로컬 Forward = x축 방향 → LookRotation을 쓰면 축이 90도로 틀어질 거니까, Z→X축 변환 오프셋을 곱해서 X축이 랜드마크 방향을 바라보게 하자.
- Animation Rigging 세팅
- 손 프리팹 루트에 RigBuilder 추가
- 자식에 Rig 오브젝트 생성
- Rig 아래에 각 손가락 본들에 대해서, Multi-Parent Constraint 추가
- Source에 R_Thumb_Prox_Target 등을 넣고 Weight=1
- Maintain Offset은 처음엔 온오프 둘다 테스트(축이 안맞으면 On으로 시작)
- —> IK처럼 위치를 맞추는게 아니라, 본이 바라보는 방향을 타겟 회전으로 맞추는 식으로 접근
타겟 생성 및 갱신 스크립트
HandLandmarkTargetsFromLM.cs
using UnityEngine;
public class HandLandmarkTargetsFromLM : MonoBehaviour // 랜드마크로부터 관절 회전 타겟을 만드는 스크립트
{
[Header("Landmarks (Local Space under HandTrackingRoot)")]
[SerializeField] private Transform handTrackingRoot; // 랜드마크 로컬 기준 루트
[SerializeField] private Transform[] leftLM = new Transform[21]; // 왼손 LM_00~LM_20
[SerializeField] private Transform[] rightLM = new Transform[21]; // 오른손 LM_00~LM_20
[Header("Targets (Rotation only)")] // 인스펙터 그룹
[SerializeField] private Transform[] leftFingerTargets = new Transform[15]; // 왼손 관절 타겟 15개(Thumb3 + Index3 + Middle3 + Ring3 + Little3)
[SerializeField] private Transform[] rightFingerTargets = new Transform[15]; // 오른손 관절 타겟 15개
[Header("Options")] // 인스펙터 그룹
[SerializeField] private bool applyPosition = false; // 타겟 위치도 LM 위치로 둘지(디버그용)
[SerializeField] private float targetPositionScale = 1.0f; // 위치 적용 시 스케일
private static readonly Quaternion ZForwardToXForward = Quaternion.AngleAxis(-90f, Vector3.up); // LookRotation(Z forward)을 X forward로 돌리는 오프셋
private void Start()
{
if (handTrackingRoot == null) // 루트가 비었으면
handTrackingRoot = transform; // 자기 자신을 루트로 가정
}
private void LateUpdate() // 애니메이션/리깅 업데이트 이후 적용하려면 LateUpdate가 편함
{
UpdateHandTargets(leftLM, leftFingerTargets, isLeft: true); // 왼손 타겟 갱신
UpdateHandTargets(rightLM, rightFingerTargets, isLeft: false); // 오른손 타겟 갱신
}
private void UpdateHandTargets(Transform[] lm, Transform[] targets, bool isLeft) // 한 손의 타겟들을 갱신
{
if (lm == null || lm.Length < 21) return; // 랜드마크 배열 부족 시 종료
if (targets == null || targets.Length < 15) return; // 타겟 배열 부족 시 종료
// 손바닥 up(법선) 계산: wrist(0), index_mcp(5), pinky_mcp(17)로 평면 법선을 구성 # LM 인덱스는 MediaPipe 고정
Vector3 w = lm[0].localPosition; // 손목 로컬 위치
Vector3 i5 = lm[5].localPosition; // 검지 MCP 로컬 위치
Vector3 p17 = lm[17].localPosition; // 새끼 MCP 로컬 위치
Vector3 palmNormal = Vector3.Cross(i5 - w, p17 - w).normalized; // 손바닥 평면 법선(로컬)
if (!isLeft) palmNormal = -palmNormal; // 오른손은 좌표계에 따라 법선이 반대로 느껴질 수 있어 보정(필요 없으면 제거)
// 엄지(1-2,2-3,3-4) # Target index: 0,1,2
SetTargetRot(lm, targets, 0, from: 1, to: 2, palmUp: palmNormal); // Thumb Prox
SetTargetRot(lm, targets, 1, from: 2, to: 3, palmUp: palmNormal); // Thumb Inter
SetTargetRot(lm, targets, 2, from: 3, to: 4, palmUp: palmNormal); // Thumb Dist
// 검지(5-6,6-7,7-8) # Target index: 3,4,5
SetTargetRot(lm, targets, 3, from: 5, to: 6, palmUp: palmNormal); // Index Prox
SetTargetRot(lm, targets, 4, from: 6, to: 7, palmUp: palmNormal); // Index Inter
SetTargetRot(lm, targets, 5, from: 7, to: 8, palmUp: palmNormal); // Index Dist
// 중지(9-10,10-11,11-12) # Target index: 6,7,8
SetTargetRot(lm, targets, 6, from: 9, to: 10, palmUp: palmNormal); // Middle Prox
SetTargetRot(lm, targets, 7, from: 10, to: 11, palmUp: palmNormal); // Middle Inter
SetTargetRot(lm, targets, 8, from: 11, to: 12, palmUp: palmNormal); // Middle Dist
// 약지(13-14,14-15,15-16) # Target index: 9,10,11
SetTargetRot(lm, targets, 9, from: 13, to: 14, palmUp: palmNormal); // Ring Prox
SetTargetRot(lm, targets, 10, from: 14, to: 15, palmUp: palmNormal); // Ring Inter
SetTargetRot(lm, targets, 11, from: 15, to: 16, palmUp: palmNormal); // Ring Dist
// 새끼(17-18,18-19,19-20) # Target index: 12,13,14
SetTargetRot(lm, targets, 12, from: 17, to: 18, palmUp: palmNormal); // Little Prox
SetTargetRot(lm, targets, 13, from: 18, to: 19, palmUp: palmNormal); // Little Inter
SetTargetRot(lm, targets, 14, from: 19, to: 20, palmUp: palmNormal); // Little Dist
}
private void SetTargetRot(Transform[] lm, Transform[] targets, int targetIndex, int from, int to, Vector3 palmUp) // 하나의 관절 타겟 회전 설정
{
Transform t = targets[targetIndex]; // 타겟 Transform 가져오기
if (t == null) return; // 타겟이 비면 종료
Vector3 a = lm[from].localPosition; // 시작 관절 위치
Vector3 b = lm[to].localPosition; // 다음 관절 위치
Vector3 dir = (b - a); // 관절 진행 방향 벡터
if (dir.sqrMagnitude < 1e-8f) return; // 방향이 0에 가깝다면 종료(불안정 방지)
Quaternion lookZ = Quaternion.LookRotation(dir.normalized, palmUp); // Z축이 dir을 바라보는 회전 생성
Quaternion lookX = lookZ * ZForwardToXForward; // Z-forward 기준 회전을 X-forward 기준으로 변환(본 forward=X축)
t.localRotation = lookX; // 타겟 로컬 회전 적용
if (applyPosition) // 위치도 적용한다면
t.localPosition = a * targetPositionScale; // 타겟 위치를 관절 위치에 둠(디버그용)
}
}
2차 동기화 결과

- 되긴 한다. 손의 움직임, 손가락의 굽힘과 마디 마디의 움직임이 인식되어 Scene에서 랜드마크와 매핑된 Squere 타겟들이 움직이는 것을 확인할 수 있다.
- 문제
- 카메라(mediapipe ↔ unity 간 축이 맞지 않음. 상하 좌우 모두 반대로 되어 있음
- mediapipe와 unity의 좌표계가 다르기 때문. unity는 Y가 위쪽, Z가 앞쪽으로 설정되어 있고, 이미지 기반인 mediapipe는 Y가 아래쪽으로 증가하기 때문
- 손목 원점 로컬 좌표를 파이썬에서 보내고 있기 때문에, Unity측에서 좌표 변환을 수행한다
- 카메라(mediapipe ↔ unity 간 축이 맞지 않음. 상하 좌우 모두 반대로 되어 있음
private void ApplyLandmarksLocal(Transform[] targets, Vector3[] src)//트랜스폼에 로컬 좌표를 적용하는 메서드
{
if(targets == null || targets.Length <LANDMARK_COUNT)//타겟 배열이 부족하면
return;
for(int i=0; i<LANDMARK_COUNT; i++)//21개 랜드마크를 반복하면서, 스케일을 적용하고 로컬좌표를 적용한다
{
Transform target = targets[i];//타겟 트랜스폼 i번째
if(target==null)
continue;// 비어있다면 스킵
Vector3 pos = src[i] * positionScale;//스케일 적용
target.localPosition = pos;//핸드 트래킹 루트의 로컬 좌표로 이동
}
}
3차 동기화 결과

- 떨림(jitter)보정 필요
- mediapipe 계열 랜드마크는 프레임별 노이즈가 있어서, 보통 저역통과 필터나 속도 적응형 필터를 사용하여 Target의 Transform에 들어가는 위치/회전신호에 시간 필터를 건다고 한다.
- lerp / slerp 등도 생각해 봤는데, 현재 3d object로 손가락 마디를 표현하고 있기 때문에 선형보간 함수를 쓰면 손가락의 펴짐과 오브젝트들의 rotation이 100% 동기화되지 않는다.
- 그럼 어떻게 하나?
- → Quaternion.RotateTowards()를 사용하면, 손의 움직임을 초당 N도 만큼 따라가게 할 수 있다. 목표 값이 고정되면 확실히 수렴되니까, 이를 사용하여 타겟 위치가 나의 손 동작을 안정적으로 따라가게 해보자.
- 기존 HandLandmarkTargetsFromLM.cs에 아래 필드들을 추가한다.
[Header("Smoothing")]
[SerializeField] private bool enableSmoothing = true;//스무딩 활성화 여부
[SerializeField] private float maxRotateDegPerSec = 2000.0f;// 타겟의 회전 최대 추종 속도(deg/sec) -> 높일수록 빠르게 따라간다. jitter가 심하면 600~1500 수준으로 낮추어본다.
[SerializeField] private float maxPosUnitsPerSec = 50.0f;// applyPosition==true일 때 타겟 위치 최대 추종 속도(units/sec)
이후, SetTargetRot()메서드 내 타겟의 회전과 위치를 계산하는 곳을 아래처럼 바꾼다.
if(!enableSmoothing)//스무딩 비활성화시
{
target.localRotation = lookX;//즉시 적용
}
else
{
float maxStep = maxRotateDegPerSec * Time.deltaTime;//이번 프레임에 회전할 수 있는 최대 각도를 계산한다
target.localRotation = Quaternion.RotateTowards(target.localRotation, lookX, maxStep);//초당 제한 속도로 목표 회전에 수렴하도록 한다,. RotateToward는 한 프레임에 최대 각도만큼 회전하므로, Target이 고정되면 반드시 도달하고, Target이 계속 변해도 과도한지터를 줄인다.
}
/*
if (applyPosition) // 위치도 적용한다면
target.localPosition = a * targetPositionScale; // 타겟 위치를 관절 위치에 둠(디버그용)
*/
if(applyPosition)//위치도 적용한다면
{
Vector3 targetPos = a * targetPositionScale;//Target의 위치 계산
if(!enableSmoothing)//스무딩 비활 시
{
target.localPosition = targetPos;//즉시 적용
}
else
{
float maxMove = maxPosUnitsPerSec * Time.deltaTime;//이번 프레임에 이동할 수 있는 최대 거리 계산
target.localPosition = Vector3.MoveTowards(target.localPosition, targetPos, maxMove);//속도 제한으로 위치 수렴
}
}
4차 동기화 결과
손가락 마디를 Cube로 표현하고 스무딩을 적용하니까 제법 손 같아 졌다.
'ETC' 카테고리의 다른 글
| Unity + Python MediaPipe 기반 양 손 인식하기 (2) (0) | 2026.02.04 |
|---|---|
| Unity 6 Preview 체험후기 이벤트 경품 (6) | 2024.09.23 |