비대면 체육수업 지원 서비스 개발 회고 || tfjs posenet + react 사용법
프로젝트 진행 동기
"중고등학생 동생이 비대면 수업을 받는 것을 보며 자연스럽게 문제의 심각성을 깨닫고, 해결해야겠다는 결심이 프로젝트 진행으로 이어졌습니다."
코로나19 바이러스로 인해 학생들이 학교에 가지 못하고 집에서 비대면 수업을 받게 되었습니다. 중고등학생 동생이 집에서 수업을 받는 모습을 자연스럽게 지켜보게 되었습니다. 분명히 수업을 하고 있을 시간인데 동생이 침대에 누워서 자는 것을 보고 의아하게 생각했습니다. 저는 자고 있는 동생을 깨워서 수업시간에 왜 잠을 자느냐고 혼을 냈습니다. 그러자 동생은 체육 시간이라며 아무 것도 하지 않고 영상만 틀어준다며 자도 된다고 했습니다. 그때 저는 굉장한 충격을 받았습니다. 비대면 체육 수업이 제대로 이루어지고 있지 않는 문제의 심각성을 인식했고, 선생님 입장에서도 비대면 으로 체육 수업을 진행하기가 어렵다는 사실을 깨달았습니다. 코로나로 인해 비대면 원격 수업이 증가하면서 체육 수업은 신체활동보다는 이론 영상들로 대체되었으며, 실제 대면 수업을 진행하더라도 호흡기계 감염병인 코로나19의 확산 방지를 위해 숨이 차며 비말이 많이 발생되는 활동 수업들은 진행에 어려움을 겪고 있습니다.
"코로나19로 인해 가속화된 온라인/비대면 수업, 그리고 비대면 수업에 준비되지 않은 체육 교과"
코로나19로 인해 우리의 생활 모습이 크게 변화했습니다. 신종 코로나바이러스 감염증(코로나19) 여파로 시장이 온라인 중심으로 급속하게 개편되고, 기술이 비약적으로 발전하면서 앞으로 재택근무 와 온라인 수업이 일상화되는 시대가 도래할 것이라고 전문가들이 예상하고 있습니다. 다른 교과의 경우, 온라인 수업에 대한 대비가 비교적 잘 이루어져 있습니다. 국어, 수학, 영어 같은 교과는 인터넷 강의를 통한 학습이 자연스럽습니다.
실제로 온라인 교육 시장이 크게 성장했습니다. 하지만 체육 교과의 경우, 성장하는 온라인 시장 에 발맞춰 제대로 준비하고 있지 못합니다. 실제로 설문조사 결과들을 보면, 예체능 과목들은 코로나 이후, 만족도가 가장 낮은 과목이자 보충수업이 가장 필요한 과목입니다. 체육 교과는 초중등 교육 과정에서 체력 증진, 인성 함양 등 다양한 방면에서 중요한 역할을 수행합니다. 온라인 교육 시 장의 성장세에 발맞춰 체육 교과 역시 온라인 중심 교육이 원활히 가능하도록 준비된 상태가 되어 야 합니다.
현재 초중등 교육과정에서 체육 수업이 원활히 이루어지지 못하는 문제의 심각성에 공감하고, 앞서나가 체육 수업의 온라인화를 지원할 수 있는 서비스의 필요성에 공감하는 팀원들과 이 문제를 해결하기 위해 본 서비스 기획 및 개발을 시작하게 되었습니다.
프로젝트 요약
AI 비대면 체육 수업 지원 서비스입니다.
1. 선생님은 학생이 수행할 운동을 입력합니다.
2. 학생은 운동 목록에서 선생님이 지시한 운동을 선택합니다.
3. 운동을 하고 인공지능을 통해 자세 피드백을 받습니다. 시스템이 학생의 운동 횟수와 정확도 등을 측정합니다.
4. 학생의 운동 수행 결과가 서버로 전송되어 선생님의 학생 관리 화면에서 확인할 수 있습니다.
수행 역할
프론트엔드 개발과 백엔드 개발을 맡아 서비스 전반을 개발했습니다.
TensorflowJs의 PoseNet 라이브러리를 사용해서 자세의 키포인트 추출을 하고 동작을 검증할 수 있도록 연결하는 역할을 수행했습니다.
tfjs posenet 동작 원리
자바스크립트로 ML 모델을 개발하고 브라우저 혹은 Node.js에서 실행할 수 있는 TensorFlow.js을 활용하여 실시간으로 사람의 자세를 추정하는 human pose estimation을 구현할 수 있습니다.
PoseNet
- PoseNet은 MobileNet 혹은 ResNet 기반의 human pose estimation 네트워크이다.
- TensorFlow.js에서 실행할 수 있기 때문에
a) 웹캠 혹은 핸드폰 카메라 기반으로 어플리케이션 실행이 가능하다
b) 자바 스크립트로 코딩할 수 있다
c) 브라우저 상에서 실행되고, 데이터가 남지 않기 때문에 개인정보 이슈에서 비교적 자유롭다.
저희 서비스에서는 텐서플로우js의 포즈넷을 활용했습니다.
다른 여러 포즈 측정 라이브러리 중 텐서플로우의 포즈넷을 활용한 이유는 별도 설정없이 일반적인 기기의 웹 브라우저에선 누구나 접근 가능하다는 사실 때문이었습니다. 많은 포즈 측정 시스템이 오픈 소스로 제공 되었지만 모두 특수한 하드웨어 또는 카메라와 상당한 시스템 설정이 필요합니다. TensorFlow.js에서 실행되는 PoseNet을 사용하면 괜찮은 웹캠이 장착된 데스크톱 또는 전화를 가진 사람이라면 누구나 웹 브라우저 내에서 사용 가능합니다.
Pose Estimation(포즈 측정, 동작 감지)은 이미지 혹은 영상으로부터 사람 형상을 찾아내고, 주요 관절의 위치 등을 찾아내는 컴퓨터 비전 기술을 의미합니다.
PoseNet에서는 RGB 이미지를 인풋으로 받아 CNN 아키텍처를 거쳐 다음의 아웃풋을 반환합니다.
● pose (pose object) : keypoint의 리스트 & 인스턴스 레벨의 스코어
● pose confidence score : pose에 대한 전반적인 점수 (0-1 사이의 값)
→ 불확실하게 추정한 포즈는 사용하지 않도록 처리 가능
● keypoint : pose를 구성하는 17개의 핵심 관절들. position과 confidence score을 포함
● keypoint confidence score : 추정한 keypoint의 위치가 정확한지에 대한 점수. 신뢰도 (0-1 사이의 값)
● keypoint position : 인풋 이미지 스케일에서 keypoint에 대한 (x,y) 좌표
포즈넷 모델에 이미지를 입력으로 넣어 출력되는 결과 예시입니다.
출력값으로는 관절 키포인트 좌표와 각 키포인트의 pose confidence score를 출력합니다.
아래는 출력값의 예시입니다. 저희 서비스에서는 포즈넷 출력값인 각 키포인트들의 좌표를 활용해 운동 동작을 제대로 하는지 판단하고 있습니다.
{
"score": 0.32371445304906,
"keypoints": [
{ // nose
"position": {
"x": 301.42237830162,
"y": 177.69162777066
},
"score": 0.99799561500549
},
{ // left eye
"position": {
"x": 326.05302262306,
"y": 122.9596464932
},
"score": 0.99766051769257
},
{ // right eye
"position": {
"x": 258.72196650505,
"y": 127.51624706388
},
"score": 0.99926537275314
},
...
]
}
React.js / Next.js 에서 tfjs posenet 활용법 및 웹캠 연결법
먼저 라이브러리를 설치합니다.
npm install @tensorflow-models/posenet
다음 모듈을 불러옵니다.
import * as posenet from '@tensorflow-models/posenet';
const net = await posenet.load();
실제로 리액트에서 활용하는 방법을 코드와 함께 설명드리겠습니다.
import { useRef, useCallback } from "react";
import styled from "styled-components";
import * as posenet from "@tensorflow-models/posenet";
import Webcam from "react-webcam";
import { drawKeypoints, drawSkeleton } from "src/utils/draw";
import estimatePose from "src/utils/estimate-pose";
export default function ExerciseScreen() {
// action: 동작명. 아래 estimatePose 인자를 동작명으로 변경해서 테스트
const [count, step, checkPoses] = estimatePose("hajung");
const checkPose = useCallback((pose) => checkPoses(pose), [checkPoses]);
const webcamRef = useRef(null);
const canvasRef = useRef(null);
const videoWidth = 640;
const videoHeight = 480;
const drawResult = (pose, video, videoWidth, videoHeight, canvas) => {
const ctx = canvas.current.getContext("2d");
canvas.current.width = videoWidth;
canvas.current.height = videoHeight;
drawKeypoints(pose["keypoints"], 0.6, ctx);
drawSkeleton(pose["keypoints"], 0.7, ctx);
};
async function detectWebcamFeed(posenetModel) {
if (
typeof webcamRef.current !== "undefined" &&
webcamRef.current !== null &&
webcamRef.current.video.readyState === 4
) {
// Get Video Properties
const video = webcamRef.current.video;
const videoWidth = webcamRef.current.video.videoWidth;
const videoHeight = webcamRef.current.video.videoHeight;
// Set video width
webcamRef.current.video.width = videoWidth;
webcamRef.current.video.height = videoHeight;
// Make Estimation
const pose = await posenetModel.estimateSinglePose(video);
// Pose Estimation
checkPose(pose);
// Draw Result
drawResult(pose, video, videoWidth, videoHeight, canvasRef);
}
}
const runPosenet = async () => {
const posenetModel = await posenet.load({
inputResolution: { width: videoWidth, height: videoHeight },
scale: 0.8,
});
setInterval(() => {
detectWebcamFeed(posenetModel);
}, 100);
};
runPosenet();
return (
<>
<Wrapper>
<Webcam
ref={webcamRef}
style={{
width: videoWidth,
height: videoHeight,
position: "absolute",
top: 0,
left: 0,
}}
/>
<StyledCanvas
ref={canvasRef}
style={{
width: videoWidth,
height: videoHeight,
}}
/>
</Wrapper>
<p>
step:{step} / count: {count}
</p>
</>
);
}
const Wrapper = styled.div`
width: 640px;
height: 480px;
position: relative;
`;
const StyledCanvas = styled.canvas`
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
`;
위는 웹캠을 연결하고, 웹캠의 입력을 받아와서 tfjs posenet 모델에 입력으로 넣어 스켈레톤을 추출하고 동작 감지 알고리즘에서 감지하도록 하는 코드입니다.
react-webcam 라이브러리를 써서 우선 웹캠 컴포넌트를 연결합니다. useRef 훅을 사용해서 위에서 선언한 ref를 연결해줍니다.
<Webcam
ref={webcamRef}
style={{
width: videoWidth,
height: videoHeight,
position: "absolute",
top: 0,
left: 0,
}}
/>
컴포넌트가 렌더링되면서 runPosenet함수가 실행되게 됩니다.
const runPosenet = async () => {
const posenetModel = await posenet.load({
inputResolution: { width: videoWidth, height: videoHeight },
scale: 0.8,
});
setInterval(() => {
detectWebcamFeed(posenetModel);
}, 100);
};
runPosenet();
const posenetModel은 포즈넷 라이브러리를 불러오고 설정하는 코드입니다.
위 코드에서 setInterval을 통해 0.1초 간격으로 호출되는 detectWebcamFeed함수를 알아봅시다.
async function detectWebcamFeed(posenetModel) {
if (
typeof webcamRef.current !== "undefined" &&
webcamRef.current !== null &&
webcamRef.current.video.readyState === 4
) {
// Get Video Properties
const video = webcamRef.current.video;
const videoWidth = webcamRef.current.video.videoWidth;
const videoHeight = webcamRef.current.video.videoHeight;
// Set video width
webcamRef.current.video.width = videoWidth;
webcamRef.current.video.height = videoHeight;
// Make Estimation
const pose = await posenetModel.estimateSinglePose(video);
// Pose Estimation
checkPose(pose);
// Draw Result
drawResult(pose, video, videoWidth, videoHeight, canvasRef);
}
}
웹캠의 비디오프레임을 포즈넷 모델의 인자로 넣어 포즈 측정을 한 값을 pose 변수에 받아옵니다.
pose를 checkPose의 인자로 넣어 호출합니다. 현재 측정한 포즈들을 계속 입력으로 넣어 정확한 동작으로 운동을 수행하고 있는지 판단하는 checkPose를 호출합니다. checkPose에서 포즈넷에서 감지한 동작들을 가지고 제대로 운동을 수행하는지 판단하게 됩니다.
이후 drawResult함수를 호출해 웹캠 위에 자세 포인트들을 그립니다.
detectWebcamFeed함수에서 사용하는 drawResult 함수 설명입니다.
추출한 키포인트를 화면에 그리는 함수입니다. 웹캠 화면 위에 점과 점을 연결한 스켈레톤 모양이 뜨게 됩니다.
마치 아래 예시처럼요.
const drawResult = (pose, video, videoWidth, videoHeight, canvas) => {
const ctx = canvas.current.getContext("2d");
canvas.current.width = videoWidth;
canvas.current.height = videoHeight;
drawKeypoints(pose["keypoints"], 0.6, ctx);
drawSkeleton(pose["keypoints"], 0.7, ctx);
};
여기서 drawKeypoints와 drawSkeleton은 따로 utils폴더 아래에 분리해두었습니다.
utils/draw 파일의 코드에 정의되어 있습니다.
// src/utils/draw
/**
* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* =============================================================================
*/
import "@tensorflow/tfjs-backend-webgl";
import * as posenet from "@tensorflow-models/posenet";
import * as tf from "@tensorflow/tfjs-core";
const color = "aqua";
const boundingBoxColor = "red";
const lineWidth = 2;
export const tryResNetButtonName = "tryResNetButton";
export const tryResNetButtonText = "[New] Try ResNet50";
const tryResNetButtonTextCss = "width:100%;text-decoration:underline;";
const tryResNetButtonBackgroundCss = "background:#e61d5f;";
function isAndroid() {
return /Android/i.test(navigator.userAgent);
}
function isiOS() {
return /iPhone|iPad|iPod/i.test(navigator.userAgent);
}
export function isMobile() {
return isAndroid() || isiOS();
}
function setDatGuiPropertyCss(propertyText, liCssString, spanCssString = "") {
const spans = document.getElementsByClassName("property-name");
for (let i = 0; i < spans.length; i++) {
const text = spans[i].textContent || spans[i].innerText;
if (text == propertyText) {
spans[i].parentNode.parentNode.style = liCssString;
if (spanCssString !== "") {
spans[i].style = spanCssString;
}
}
}
}
export function updateTryResNetButtonDatGuiCss() {
setDatGuiPropertyCss(
tryResNetButtonText,
tryResNetButtonBackgroundCss,
tryResNetButtonTextCss
);
}
/**
* Toggles between the loading UI and the main canvas UI.
*/
export function toggleLoadingUI(
showLoadingUI,
loadingDivId = "loading",
mainDivId = "main"
) {
if (showLoadingUI) {
document.getElementById(loadingDivId).style.display = "block";
document.getElementById(mainDivId).style.display = "none";
} else {
document.getElementById(loadingDivId).style.display = "none";
document.getElementById(mainDivId).style.display = "block";
}
}
function toTuple({ y, x }) {
return [y, x];
}
export function drawPoint(ctx, y, x, r, color) {
ctx.beginPath();
ctx.arc(x, y, r, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
}
/**
* Draws a line on a canvas, i.e. a joint
*/
export function drawSegment([ay, ax], [by, bx], color, scale, ctx) {
ctx.beginPath();
ctx.moveTo(ax * scale, ay * scale);
ctx.lineTo(bx * scale, by * scale);
ctx.lineWidth = lineWidth;
ctx.strokeStyle = color;
ctx.stroke();
}
/**
* Draws a pose skeleton by looking up all adjacent keypoints/joints
*/
export function drawSkeleton(keypoints, minConfidence, ctx, scale = 1) {
const adjacentKeyPoints = posenet.getAdjacentKeyPoints(
keypoints,
minConfidence
);
adjacentKeyPoints.forEach((keypoints) => {
drawSegment(
toTuple(keypoints[0].position),
toTuple(keypoints[1].position),
color,
scale,
ctx
);
});
}
/**
* Draw pose keypoints onto a canvas
*/
export function drawKeypoints(keypoints, minConfidence, ctx, scale = 1) {
for (let i = 0; i < keypoints.length; i++) {
const keypoint = keypoints[i];
if (keypoint.score < minConfidence) {
continue;
}
const { y, x } = keypoint.position;
drawPoint(ctx, y * scale, x * scale, 3, color);
}
}
/**
* Draw the bounding box of a pose. For example, for a whole person standing
* in an image, the bounding box will begin at the nose and extend to one of
* ankles
*/
export function drawBoundingBox(keypoints, ctx) {
const boundingBox = posenet.getBoundingBox(keypoints);
ctx.rect(
boundingBox.minX,
boundingBox.minY,
boundingBox.maxX - boundingBox.minX,
boundingBox.maxY - boundingBox.minY
);
ctx.strokeStyle = boundingBoxColor;
ctx.stroke();
}
/**
* Converts an arary of pixel data into an ImageData object
*/
export async function renderToCanvas(a, ctx) {
const [height, width] = a.shape;
const imageData = new ImageData(width, height);
const data = await a.data();
for (let i = 0; i < height * width; ++i) {
const j = i * 4;
const k = i * 3;
imageData.data[j + 0] = data[k + 0];
imageData.data[j + 1] = data[k + 1];
imageData.data[j + 2] = data[k + 2];
imageData.data[j + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
}
/**
* Draw an image on a canvas
*/
export function renderImageToCanvas(image, size, canvas) {
canvas.width = size[0];
canvas.height = size[1];
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0);
}
/**
* Draw heatmap values, one of the model outputs, on to the canvas
* Read our blog post for a description of PoseNet's heatmap outputs
* https://medium.com/tensorflow/real-time-human-pose-estimation-in-the-browser-with-tensorflow-js-7dd0bc881cd5
*/
export function drawHeatMapValues(heatMapValues, outputStride, canvas) {
const ctx = canvas.getContext("2d");
const radius = 5;
const scaledValues = heatMapValues.mul(tf.scalar(outputStride, "int32"));
drawPoints(ctx, scaledValues, radius, color);
}
/**
* Used by the drawHeatMapValues method to draw heatmap points on to
* the canvas
*/
function drawPoints(ctx, points, radius, color) {
const data = points.buffer().values;
for (let i = 0; i < data.length; i += 2) {
const pointY = data[i];
const pointX = data[i + 1];
if (pointX !== 0 && pointY !== 0) {
ctx.beginPath();
ctx.arc(pointX, pointY, radius, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
}
}
}
/**
* Draw offset vector values, one of the model outputs, on to the canvas
* Read our blog post for a description of PoseNet's offset vector outputs
* https://medium.com/tensorflow/real-time-human-pose-estimation-in-the-browser-with-tensorflow-js-7dd0bc881cd5
*/
export function drawOffsetVectors(
heatMapValues,
offsets,
outputStride,
scale = 1,
ctx
) {
const offsetPoints = posenet.singlePose.getOffsetPoints(
heatMapValues,
outputStride,
offsets
);
const heatmapData = heatMapValues.buffer().values;
const offsetPointsData = offsetPoints.buffer().values;
for (let i = 0; i < heatmapData.length; i += 2) {
const heatmapY = heatmapData[i] * outputStride;
const heatmapX = heatmapData[i + 1] * outputStride;
const offsetPointY = offsetPointsData[i];
const offsetPointX = offsetPointsData[i + 1];
drawSegment(
[heatmapY, heatmapX],
[offsetPointY, offsetPointX],
color,
scale,
ctx
);
}
}
프로토타입 개발
https://pe-assistant.vercel.app/
저희 프로젝트의 프로토타입 url입니다.
npx create-next-app
create-next-app으로 nextjs app을 만들었습니다.
이후 타입스크립트, prettier, eslint, babel 설정 등을 해줬고, vercel을 통해 배포했습니다.
https://github.com/chorom-ham/pe-assistant에 가셔서 git clone을 통해 다운 받으신 후
npm install
npm run dev
명령어를 통해 저희 프로토타입을 실행해보실 수 있습니다.
프로토타입의 현재 UI입니다. 고도화 진행 예정 중에 있습니다.