[hands-on #2] 거울치료를 위한 마임 에이전트

이 핸즈온에서는 웹캠으로 사용자의 자세를 읽고, 화면 속 사람형 마임 에이전트가 그 자세를 그대로 따라 하도록 만들어보겠습니다. 목표는 좋은 자세를 판정하거나 치료 효과를 주장하는 것이 아니라, 사용자가 자신의 자세를 시각적으로 다시 보면서 자각하도록 돕는 교육용 프로토타입을 만드는 것입니다.

이 튜토리얼은 의료기기나 재활 치료 시스템을 만드는 과정이 아닙니다. 여기서 “거울치료”는 아이디어의 출발점으로만 사용하며, 결과물은 자세 자각용 인터랙티브 데모로 한정합니다.

완성할 결과물

최종 화면은 다음처럼 구성합니다.

왼쪽 또는 숨겨진 영역: 웹캠 입력
오른쪽 또는 중앙 canvas: 내 자세를 따라 하는 2D 마임 에이전트

주요 기능:
- MediaPipe Pose Landmarker로 pose landmark 추출
- 어깨, 팔, 상체, 머리 위치를 2D 사람형 에이전트에 매핑
- 거울처럼 좌우 반전
- 어깨 기울기, 상체 중심선, 머리 offset을 시각화
- "자세가 나쁩니다"가 아니라 "현재 자세를 그대로 반영 중입니다"처럼 관찰형 문구 표시

처음부터 3D 캐릭터를 붙이면 실습 난도가 크게 올라갑니다. 이 글에서는 2D stick avatar를 MVP로 만들고, 마지막에 Three.js/VRM 아바타로 확장하는 방향만 정리하겠습니다.

기술 스택

레이어 사용할 기술 역할
개발 환경 Vite + TypeScript 브라우저 기반 실습 프로젝트를 빠르게 구성합니다.
웹캠 입력 navigator.mediaDevices.getUserMedia 사용자의 카메라 영상을 브라우저에서 받아옵니다.
자세 추정 MediaPipe Tasks Vision의 @mediapipe/tasks-vision 영상 프레임에서 사람의 pose landmark를 추출합니다.
모델 pose_landmarker_lite.task 실시간 실습에 적합한 가벼운 Pose Landmarker 모델입니다.
렌더링 HTML5 Canvas 2D 관절 점과 선으로 마임 에이전트를 그립니다.
좌표 처리 normalized image coordinates MediaPipe가 주는 x, y 좌표를 canvas 크기에 맞게 변환합니다.
확장 렌더링 Three.js, 선택 사항 2D 에이전트 완성 후 3D 아바타로 확장할 때 사용합니다.

MediaPipe Pose Landmarker는 사람의 자세를 33개 landmark로 출력합니다. 이 실습에서는 전체 landmark가 아니라 아래 관절만 사용해도 충분합니다.

0  nose
11 left_shoulder
12 right_shoulder
13 left_elbow
14 right_elbow
15 left_wrist
16 right_wrist
23 left_hip
24 right_hip

브라우저에서 카메라를 쓰려면 보통 localhost 또는 HTTPS 환경이 필요합니다. Vite 개발 서버는 localhost로 실행되므로 실습에 적합합니다.

1. 프로젝트 생성

새 프로젝트를 만든다고 가정하고 진행하겠습니다.

npm create vite@latest mirror-mime-agent -- --template vanilla-ts
cd mirror-mime-agent
npm install
npm install @mediapipe/tasks-vision
npm run dev

실행 후 브라우저에서 Vite가 안내하는 로컬 주소로 접속합니다.

http://localhost:5173

2. 화면 구조 만들기

index.html은 웹캠 영상과 canvas를 나누어 둡니다. 실습 초반에는 웹캠을 보이게 두고, 마지막에는 원본 영상을 숨긴 뒤 에이전트만 보여주면 됩니다.

<main class="app">
  <section class="stage">
    <video id="video" autoplay playsinline muted></video>
    <canvas id="agent" width="960" height="720"></canvas>
  </section>

  <aside class="panel">
    <h1>Mirror Mime Agent</h1>
    <p id="status">초기화 중입니다.</p>
    <dl>
      <dt>어깨 차이</dt>
      <dd id="shoulderMetric">-</dd>
      <dt>상체 기울기</dt>
      <dd id="trunkMetric">-</dd>
      <dt>머리 offset</dt>
      <dd id="headMetric">-</dd>
    </dl>
  </aside>
</main>

<script type="module" src="/src/main.ts"></script>

간단한 CSS도 추가합니다.

body {
  margin: 0;
  font-family: system-ui, sans-serif;
  background: #111827;
  color: #f9fafb;
}

.app {
  min-height: 100vh;
  display: grid;
  grid-template-columns: minmax(0, 1fr) 320px;
  gap: 24px;
  padding: 24px;
  box-sizing: border-box;
}

.stage {
  position: relative;
  border: 1px solid #374151;
  background: #030712;
}

video,
canvas {
  width: 100%;
  aspect-ratio: 4 / 3;
  display: block;
}

video {
  position: absolute;
  inset: 0;
  opacity: 0.18;
  transform: scaleX(-1);
}

canvas {
  position: relative;
}

.panel {
  border-left: 1px solid #374151;
  padding-left: 24px;
}

videotransform: scaleX(-1)을 준 이유는 사용자가 화면을 거울처럼 느끼도록 하기 위해서입니다. 에이전트 좌표도 같은 방식으로 좌우 반전해서 그릴 예정입니다.

3. MediaPipe Pose Landmarker 초기화

src/main.ts에서 먼저 DOM 요소와 landmark index를 준비합니다.

import {
  FilesetResolver,
  PoseLandmarker,
  type NormalizedLandmark,
} from "@mediapipe/tasks-vision";

const video = document.querySelector<HTMLVideoElement>("#video")!;
const canvas = document.querySelector<HTMLCanvasElement>("#agent")!;
const ctx = canvas.getContext("2d")!;

const statusEl = document.querySelector<HTMLElement>("#status")!;
const shoulderMetricEl = document.querySelector<HTMLElement>("#shoulderMetric")!;
const trunkMetricEl = document.querySelector<HTMLElement>("#trunkMetric")!;
const headMetricEl = document.querySelector<HTMLElement>("#headMetric")!;

const LM = {
  nose: 0,
  leftShoulder: 11,
  rightShoulder: 12,
  leftElbow: 13,
  rightElbow: 14,
  leftWrist: 15,
  rightWrist: 16,
  leftHip: 23,
  rightHip: 24,
} as const;

웹캠을 켜는 함수는 다음과 같습니다.

async function setupCamera() {
  const stream = await navigator.mediaDevices.getUserMedia({
    video: {
      width: { ideal: 960 },
      height: { ideal: 720 },
      facingMode: "user",
    },
    audio: false,
  });

  video.srcObject = stream;
  await video.play();
}

Pose Landmarker는 VIDEO 모드로 초기화합니다. 이 모드는 카메라 영상처럼 계속 들어오는 프레임을 처리할 때 사용합니다.

async function createPoseLandmarker() {
  const vision = await FilesetResolver.forVisionTasks(
    "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm"
  );

  return PoseLandmarker.createFromOptions(vision, {
    baseOptions: {
      modelAssetPath:
        "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/latest/pose_landmarker_lite.task",
      delegate: "GPU",
    },
    runningMode: "VIDEO",
    numPoses: 1,
    minPoseDetectionConfidence: 0.5,
    minPosePresenceConfidence: 0.5,
    minTrackingConfidence: 0.5,
  });
}

만약 일부 브라우저나 노트북에서 GPU delegate가 불안정하면 delegate: "CPU"로 바꾸면 됩니다. 속도는 조금 느려질 수 있지만 디버깅은 더 편합니다.

4. 카메라 프레임에서 landmark 읽기

초기화가 끝나면 requestAnimationFrame 루프에서 현재 비디오 프레임을 Pose Landmarker에 넣습니다.

async function main() {
  statusEl.textContent = "카메라 권한을 요청하고 있습니다.";

  await setupCamera();
  const poseLandmarker = await createPoseLandmarker();

  statusEl.textContent = "현재 자세를 그대로 반영 중입니다.";

  function loop() {
    const result = poseLandmarker.detectForVideo(video, performance.now());
    const pose = result.landmarks[0];

    if (pose) {
      drawAgent(pose);
      updatePosturePanel(pose);
    } else {
      clearCanvas();
      statusEl.textContent = "사람 자세를 찾는 중입니다.";
    }

    requestAnimationFrame(loop);
  }

  loop();
}

main().catch((error) => {
  console.error(error);
  statusEl.textContent = "초기화에 실패했습니다. 카메라 권한과 콘솔 로그를 확인해주세요.";
});

여기까지 되면 매 프레임마다 사용자의 관절 좌표를 얻을 수 있습니다. 다음 단계에서는 이 좌표를 화면 속 마임 에이전트의 관절로 변환합니다.

5. 좌표를 거울처럼 변환하기

MediaPipe의 normalized coordinate는 x, y가 0부터 1 사이입니다. Canvas에 그리려면 실제 픽셀 좌표로 바꿔야 합니다.

function toCanvasPoint(landmark: NormalizedLandmark) {
  return {
    x: (1 - landmark.x) * canvas.width,
    y: landmark.y * canvas.height,
    visibility: landmark.visibility ?? 1,
  };
}

핵심은 x: (1 - landmark.x)입니다. 이렇게 해야 카메라 앞의 사용자가 오른팔을 들었을 때 화면 속 에이전트도 거울처럼 자연스럽게 움직입니다.

6. 2D 마임 에이전트 그리기

먼저 canvas를 지우는 함수와 선을 그리는 함수를 만듭니다.

function clearCanvas() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
}

function drawLine(
  a: NormalizedLandmark,
  b: NormalizedLandmark,
  color = "#e5e7eb",
  width = 8
) {
  const pa = toCanvasPoint(a);
  const pb = toCanvasPoint(b);

  if (pa.visibility < 0.45 || pb.visibility < 0.45) return;

  ctx.strokeStyle = color;
  ctx.lineWidth = width;
  ctx.lineCap = "round";
  ctx.beginPath();
  ctx.moveTo(pa.x, pa.y);
  ctx.lineTo(pb.x, pb.y);
  ctx.stroke();
}

이제 어깨, 팔, 몸통을 연결해서 사람형 에이전트를 그립니다.

function drawAgent(pose: NormalizedLandmark[]) {
  clearCanvas();

  const nose = toCanvasPoint(pose[LM.nose]);

  drawLine(pose[LM.leftShoulder], pose[LM.rightShoulder], "#60a5fa", 10);
  drawLine(pose[LM.leftShoulder], pose[LM.leftElbow]);
  drawLine(pose[LM.leftElbow], pose[LM.leftWrist]);
  drawLine(pose[LM.rightShoulder], pose[LM.rightElbow]);
  drawLine(pose[LM.rightElbow], pose[LM.rightWrist]);
  drawLine(pose[LM.leftShoulder], pose[LM.leftHip], "#a7f3d0", 8);
  drawLine(pose[LM.rightShoulder], pose[LM.rightHip], "#a7f3d0", 8);
  drawLine(pose[LM.leftHip], pose[LM.rightHip], "#a7f3d0", 8);

  ctx.fillStyle = "#facc15";
  ctx.beginPath();
  ctx.arc(nose.x, nose.y - 28, 32, 0, Math.PI * 2);
  ctx.fill();
}

이 단계의 목표는 예쁜 캐릭터가 아니라 “내 자세가 복사되고 있다”는 느낌을 빠르게 만드는 것입니다. 얼굴, 손, 옷 같은 디테일은 나중에 추가하고, 처음에는 어깨선과 상체 중심선이 잘 보이는지가 더 중요합니다.

7. 자세 자각 지표 추가하기

이 데모는 자세를 진단하지 않습니다. 대신 사용자가 자신의 상태를 관찰할 수 있도록 간단한 시각 지표를 보여줍니다.

function midpoint(a: NormalizedLandmark, b: NormalizedLandmark) {
  return {
    x: (a.x + b.x) / 2,
    y: (a.y + b.y) / 2,
  };
}

function updatePosturePanel(pose: NormalizedLandmark[]) {
  const leftShoulder = pose[LM.leftShoulder];
  const rightShoulder = pose[LM.rightShoulder];
  const leftHip = pose[LM.leftHip];
  const rightHip = pose[LM.rightHip];
  const nose = pose[LM.nose];

  const midShoulder = midpoint(leftShoulder, rightShoulder);
  const midHip = midpoint(leftHip, rightHip);

  const shoulderDiffPx = (leftShoulder.y - rightShoulder.y) * canvas.height;
  const trunkLeanDeg =
    Math.atan2(midShoulder.x - midHip.x, Math.abs(midShoulder.y - midHip.y)) *
    (180 / Math.PI);
  const headOffsetPx = (nose.x - midShoulder.x) * canvas.width;

  shoulderMetricEl.textContent = `${shoulderDiffPx.toFixed(1)} px`;
  trunkMetricEl.textContent = `${trunkLeanDeg.toFixed(1)} deg`;
  headMetricEl.textContent = `${headOffsetPx.toFixed(1)} px`;

  statusEl.textContent = "에이전트가 현재 자세를 그대로 따라 하고 있습니다.";
}

이 값들은 진단용 수치가 아닙니다. 예를 들어 shoulderDiffPx는 양쪽 어깨의 화면상 높이 차이이고, trunkLeanDeg는 상체 중심선이 좌우로 얼마나 기울어 보이는지 계산한 근사값입니다. 실제 임상적 해석과는 다르게 다뤄야 합니다.

문구도 평가형보다 관찰형으로 쓰는 것이 좋습니다.

좋은 문구:
- 에이전트가 현재 자세를 그대로 따라 하고 있습니다.
- 어깨선 차이가 커졌습니다.
- 상체 중심선이 왼쪽으로 기울어 보입니다.
- 방금 자세와 현재 자세를 비교해보세요.

피해야 할 문구:
- 자세가 나쁩니다.
- 교정이 필요합니다.
- 위험한 자세입니다.
- 치료 효과가 있습니다.

8. 중심선과 어깨선 강조하기

사용자가 자세를 더 쉽게 자각하려면 숫자보다 선이 더 직관적입니다. 어깨선은 파란색, 상체 중심선은 초록색으로 강조해보겠습니다.

function drawGuideLines(pose: NormalizedLandmark[]) {
  const leftShoulder = pose[LM.leftShoulder];
  const rightShoulder = pose[LM.rightShoulder];
  const leftHip = pose[LM.leftHip];
  const rightHip = pose[LM.rightHip];

  const midShoulder = midpoint(leftShoulder, rightShoulder);
  const midHip = midpoint(leftHip, rightHip);

  const shoulderA = toCanvasPoint(leftShoulder);
  const shoulderB = toCanvasPoint(rightShoulder);
  const trunkA = {
    x: (1 - midShoulder.x) * canvas.width,
    y: midShoulder.y * canvas.height,
  };
  const trunkB = {
    x: (1 - midHip.x) * canvas.width,
    y: midHip.y * canvas.height,
  };

  ctx.strokeStyle = "#38bdf8";
  ctx.lineWidth = 4;
  ctx.beginPath();
  ctx.moveTo(shoulderA.x, shoulderA.y);
  ctx.lineTo(shoulderB.x, shoulderB.y);
  ctx.stroke();

  ctx.strokeStyle = "#34d399";
  ctx.lineWidth = 4;
  ctx.beginPath();
  ctx.moveTo(trunkA.x, trunkA.y);
  ctx.lineTo(trunkB.x, trunkB.y);
  ctx.stroke();
}

drawAgent의 마지막에 이 함수를 호출합니다.

function drawAgent(pose: NormalizedLandmark[]) {
  clearCanvas();

  const nose = toCanvasPoint(pose[LM.nose]);

  drawLine(pose[LM.leftShoulder], pose[LM.rightShoulder], "#60a5fa", 10);
  drawLine(pose[LM.leftShoulder], pose[LM.leftElbow]);
  drawLine(pose[LM.leftElbow], pose[LM.leftWrist]);
  drawLine(pose[LM.rightShoulder], pose[LM.rightElbow]);
  drawLine(pose[LM.rightElbow], pose[LM.rightWrist]);
  drawLine(pose[LM.leftShoulder], pose[LM.leftHip], "#a7f3d0", 8);
  drawLine(pose[LM.rightShoulder], pose[LM.rightHip], "#a7f3d0", 8);
  drawLine(pose[LM.leftHip], pose[LM.rightHip], "#a7f3d0", 8);

  ctx.fillStyle = "#facc15";
  ctx.beginPath();
  ctx.arc(nose.x, nose.y - 28, 32, 0, Math.PI * 2);
  ctx.fill();

  drawGuideLines(pose);
}

이제 사용자가 한쪽으로 기대거나 어깨를 비대칭으로 두면, 에이전트와 가이드라인이 같이 움직입니다.

9. 실습 진행 순서

60분 세션이라면 다음 순서로 진행하면 무리가 없습니다.

시간 할 일 산출물
0-5분 목표와 주의사항 설명 치료가 아닌 자세 자각 프로토타입으로 범위를 정합니다.
5-15분 Vite 프로젝트 생성, 웹캠 연결 브라우저에서 카메라 영상이 보입니다.
15-25분 MediaPipe Pose Landmarker 실행 pose landmark가 콘솔 또는 화면에 출력됩니다.
25-35분 2D 마임 에이전트 그리기 어깨, 팔, 몸통이 선으로 연결됩니다.
35-45분 좌우 반전과 자세 지표 추가 거울처럼 움직이고 어깨/상체 수치가 표시됩니다.
45-55분 가이드라인과 관찰형 UI 추가 어깨선과 중심선이 강조됩니다.
55-60분 데모와 회고 어떤 자세가 자주 반복되는지 사용자가 직접 관찰합니다.

10. MVP 체크리스트

실습 결과물은 아래 항목이 되면 충분합니다.

[ ] 브라우저에서 웹캠 권한을 요청합니다.
[ ] MediaPipe Pose Landmarker가 1명의 pose를 추적합니다.
[ ] 2D 마임 에이전트가 어깨, 팔, 상체를 따라 합니다.
[ ] 화면 속 에이전트가 거울처럼 좌우 반전되어 움직입니다.
[ ] 어깨선과 상체 중심선이 시각적으로 강조됩니다.
[ ] 문구가 평가형이 아니라 관찰형으로 작성되어 있습니다.

여기까지 완성하면 “AI가 자세를 판단하는 시스템”이 아니라 “사용자의 자세를 그대로 비추는 마임 에이전트”가 됩니다.

11. 확장 방향

첫 번째 확장은 웹캠 원본을 숨기는 것입니다. 원본 영상을 계속 보여주면 사용자가 얼굴이나 외모에 신경 쓸 수 있으므로, 실습 후반에는 videoopacity0으로 낮추고 에이전트만 보이게 만들 수 있습니다.

video {
  opacity: 0;
}

두 번째 확장은 “기준 자세 저장”입니다. 사용자가 스스로 괜찮다고 느끼는 순간의 pose를 저장하고, 현재 pose와 비교합니다.

let baselinePose: NormalizedLandmark[] | null = null;

function saveBaseline(currentPose: NormalizedLandmark[]) {
  baselinePose = currentPose.map((landmark) => ({ ...landmark }));
}

여기서도 기준은 시스템이 강제하지 않고 사용자가 직접 정하는 편이 좋습니다.

세 번째 확장은 Three.js 기반 3D 아바타입니다. 이때는 단순히 landmark 위치를 선으로 잇는 것이 아니라, 관절 방향을 bone rotation으로 변환해야 합니다.

pose landmark
→ 관절 사이 벡터 계산
→ bone 방향 추정
→ joint rotation 변환
→ 3D avatar rig에 적용

3D까지 가면 @pixiv/three-vrm 같은 VRM 로더나 Kalidokit 같은 rigging solver를 참고할 수 있습니다. 다만 핸즈온 1회차에서는 2D Canvas 버전을 먼저 완성하는 것이 가장 현실적입니다.

참고 자료