[Flutter] MediaPipe로 스켈레톤 추출하기
0. 준비
아래 라이브러리를 사용해서 사람의 skeleton을 뽑아보겠습니다.ㅎㅎ우와 신난다
https://github.com/flutter-ml/google_ml_kit_flutter
제가 만들고자 하는 것은 다음과 같습니다.
- 앱의 camera를 이용해
- 실시간으로
- 사람의 skeleton을 추출하는 것
추축하고자 하는 최종 결과물을 아래와 같습니다.
packages를 보시면 되게 여러가지 라이브러리들이 있는 걸 볼 수 있는데요,
저는 이 중에 Pose(google_mlkit_pose_detection)만 사용할 것입니다!
+ google_ml_lit_pose_detection은 mediapipe기반이라 skeleton을 뽑아오는 것도 mediapipe의 것과 같습니다.(33개의 포인트+각 포인트별 x, y, z 좌표)
+ 기존의 google_mlkit_pose_detection의 코드에는 갤러리에서 이미지를 가져와서 포즈가 몇 개인지를 추출하는 기능도 있는데, 저는 이 코드는 제외하고 실시간 동영상에서만 포즈를 추출하도록 하겠습니다~
자세한 설명은 아래 공식 문서를 참고해주세요!
https://developers.google.com/ml-kit/vision/pose-detection?hl=ko
1. 앱 생성
flutter create test_get_skeleton
2. 의존성 추가
google ml kit의 다양한 함수들을 사용하기 위한 라이브러리,
google ml kit의 media pipe 라이브러리
camera 라이브러리를 추가해주세요.
dependencies:
// ...
google_mlkit_commons: ^0.3.0
google_mlkit_pose_detection: ^0.6.0
camera: ^0.10.4
3. 코드 작성
그리고는 아래 5개의 파일을 추가해주시면 됩니다!
1. pose_painter.dart: 추출된 스켈레톤을 화면에 그려주는 클래스(기존 깃허브에 있던 파일과 동일)
import 'package:flutter/material.dart';
import 'package:google_mlkit_pose_detection/google_mlkit_pose_detection.dart';
import 'coordinates_translator.dart';
// 스켈레톤을 화면에 그려주는 클래스(추출된 포즈의 관절 랜드마크 배열, 이미지 크기, 이미지 회전 정보)
// translateX, translateY를 사용해 추출된 좌표를 휴대폰 화면 크기에 맞게 변형하여 그려줌
class PosePainter extends CustomPainter {
PosePainter(this.poses, this.absoluteImageSize, this.rotation);
// 추출된 포즈의 랜드마크 리스트
final List<Pose> poses;
// 이미지 크기
final Size absoluteImageSize;
// 이미지 회전 정보
final InputImageRotation rotation;
@override
void paint(Canvas canvas, Size size) {
// 초록: 33개의 관절 포인트(랜드마크) 색깔
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 4.0
..color = Colors.green;
// 노랑: 왼쪽 선 색깔(왼팔~왼다리)
final leftPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3.0
..color = Colors.yellow;
// 초록: 오른쪽 선 색깔(오른팔~오른다리)
final rightPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3.0
..color = Colors.blueAccent;
// 추출된 관절 포인트 갯수만큼 점 그리기
for (final pose in poses) {
pose.landmarks.forEach((_, landmark) {
canvas.drawCircle(
Offset(
translateX(landmark.x, rotation, size, absoluteImageSize),
translateY(landmark.y, rotation, size, absoluteImageSize),
),
1,
paint);
});
// 점1과 점2를 선으로 이어주는 함수(랜드마크 타입1, 랜드마크 타입2, 선 색깔 타입)
void paintLine(
PoseLandmarkType type1, PoseLandmarkType type2, Paint paintType) {
final PoseLandmark joint1 = pose.landmarks[type1]!;
final PoseLandmark joint2 = pose.landmarks[type2]!;
canvas.drawLine(
Offset(translateX(joint1.x, rotation, size, absoluteImageSize),
translateY(joint1.y, rotation, size, absoluteImageSize)),
Offset(translateX(joint2.x, rotation, size, absoluteImageSize),
translateY(joint2.y, rotation, size, absoluteImageSize)),
paintType);
}
//Draw arms
paintLine(
PoseLandmarkType.leftShoulder, PoseLandmarkType.leftElbow, leftPaint);
paintLine(
PoseLandmarkType.leftElbow, PoseLandmarkType.leftWrist, leftPaint);
paintLine(PoseLandmarkType.rightShoulder, PoseLandmarkType.rightElbow,
rightPaint);
paintLine(
PoseLandmarkType.rightElbow, PoseLandmarkType.rightWrist, rightPaint);
//Draw Body
paintLine(
PoseLandmarkType.leftShoulder, PoseLandmarkType.leftHip, leftPaint);
paintLine(PoseLandmarkType.rightShoulder, PoseLandmarkType.rightHip,
rightPaint);
//Draw legs
paintLine(PoseLandmarkType.leftHip, PoseLandmarkType.leftKnee, leftPaint);
paintLine(
PoseLandmarkType.leftKnee, PoseLandmarkType.leftAnkle, leftPaint);
paintLine(
PoseLandmarkType.rightHip, PoseLandmarkType.rightKnee, rightPaint);
paintLine(
PoseLandmarkType.rightKnee, PoseLandmarkType.rightAnkle, rightPaint);
}
}
@override
bool shouldRepaint(covariant PosePainter oldDelegate) {
return oldDelegate.absoluteImageSize != absoluteImageSize ||
oldDelegate.poses != poses;
}
}
2. coordinates_translator.dart: 추출된 스켈레톤을 절대좌표에서 상대좌표로 변경하는 함수
import 'dart:io';
import 'dart:ui';
import 'package:google_mlkit_commons/google_mlkit_commons.dart';
// 추출된 스켈레톤 좌표를 핸드폰 화면에 맞게 좌표 변형
double translateX(
double x, InputImageRotation rotation, Size size, Size absoluteImageSize) {
switch (rotation) {
case InputImageRotation.rotation90deg:
return x *
size.width /
(Platform.isIOS ? absoluteImageSize.width : absoluteImageSize.height);
case InputImageRotation.rotation270deg:
return size.width -
x *
size.width /
(Platform.isIOS
? absoluteImageSize.width
: absoluteImageSize.height);
default:
return x * size.width / absoluteImageSize.width;
}
}
double translateY(
double y, InputImageRotation rotation, Size size, Size absoluteImageSize) {
switch (rotation) {
case InputImageRotation.rotation90deg:
case InputImageRotation.rotation270deg:
return y *
size.height /
(Platform.isIOS ? absoluteImageSize.height : absoluteImageSize.width);
default:
return y * size.height / absoluteImageSize.height;
}
}
3. camera_view.dart: 카메라 화면 UI+ 카메라에서 이미지 받아와서 포즈 추출기에 전달 + 스켈레톤 그려주기 + 줌인 줌아웃 기능 + 전면 후면 카메라 전환 기능
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:google_mlkit_commons/google_mlkit_commons.dart';
import '../main.dart';
// 카메라 화면
class CameraView extends StatefulWidget {
const CameraView(
{Key? key,
required this.customPaint,
required this.onImage,
this.initialDirection = CameraLensDirection.back})
: super(key: key);
// 스켈레톤을 그려주는 객체
final CustomPaint? customPaint;
// 이미지 받을 때마다 실행하는 함수
final Function(InputImage inputImage) onImage;
// 카메라 렌즈 방향 변수
final CameraLensDirection initialDirection;
@override
State<CameraView> createState() => _CameraViewState();
}
class _CameraViewState extends State<CameraView> {
// 카메라를 다루기 위한 변수
CameraController? _controller;
// 카메라 인덱스
int _cameraIndex = -1;
// 확대 축소 레벨
double zoomLevel = 0.0, minZoomLevel = 0.0, maxZoomLevel = 0.0;
// 카메라 렌즈 변경 변수
bool _changingCameraLens = false;
@override
void initState() {
super.initState();
// 카메라 설정. 기기에서 실행 가능한 카메라, 카메라 방향 설정...
if (cameras.any(
(element) =>
element.lensDirection == widget.initialDirection &&
element.sensorOrientation == 90,
)) {
_cameraIndex = cameras.indexOf(
cameras.firstWhere((element) =>
element.lensDirection == widget.initialDirection &&
element.sensorOrientation == 90),
);
} else {
for (var i = 0; i < cameras.length; i++) {
if (cameras[i].lensDirection == widget.initialDirection) {
_cameraIndex = i;
break;
}
}
}
// 카메라 실행 가능하면 포즈 추출 시작
if (_cameraIndex != -1) {
_startLiveFeed();
}
}
@override
void dispose() {
_stopLiveFeed();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
// 카메라 화면 보여주기 + 화면에서 실시간으로 포즈 추출
body: _liveFeedBody(),
// 전면<->후면 변경 버튼
floatingActionButton: _floatingActionButton(),
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
);
}
// 전면<->후면 카메라 변경 버튼
Widget? _floatingActionButton() {
if (cameras.length == 1) return null;
return SizedBox(
height: 70.0,
width: 70.0,
child: FloatingActionButton(
onPressed: _switchLiveCamera,
child: Icon(
Platform.isIOS
? Icons.flip_camera_ios_outlined
: Icons.flip_camera_android_outlined,
size: 40,
),
));
}
// 카메라 화면 보여주기 + 화면에서 실시간으로 포즈 추출
Widget _liveFeedBody() {
if (_controller?.value.isInitialized == false) {
return Container();
}
final size = MediaQuery.of(context).size;
// 화면 및 카메라 비율에 따른 스케일 계산
// 원문: calculate scale depending on screen and camera ratios
// this is actually size.aspectRatio / (1 / camera.aspectRatio)
// because camera preview size is received as landscape
// but we're calculating for portrait orientation
var scale = size.aspectRatio * _controller!.value.aspectRatio;
// to prevent scaling down, invert the value
if (scale < 1) scale = 1 / scale;
return Container(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: <Widget>[
// 전면 후면 변경 시 화면 변경 처리
Transform.scale(
scale: scale,
child: Center(
child: _changingCameraLens
? const Center(
child: Text('Changing camera lens'),
)
: CameraPreview(_controller!),
),
),
// 추출된 스켈레톤 그리기
if (widget.customPaint != null) widget.customPaint!,
// 화면 확대 축소 위젯
Positioned(
bottom: 100,
left: 50,
right: 50,
child: Slider(
value: zoomLevel,
min: minZoomLevel,
max: maxZoomLevel,
onChanged: (newSliderValue) {
setState(() {
zoomLevel = newSliderValue;
_controller!.setZoomLevel(zoomLevel);
});
},
divisions: (maxZoomLevel - 1).toInt() < 1
? null
: (maxZoomLevel - 1).toInt(),
),
),
// if (_byteImage != null)
// Image.memory(_byteImage!, width: 100, height: 100),
// if (_byteImage != null)
// Image(image: MemoryImage(_byteImage!), width: 100, height: 100),
],
),
);
}
// 실시간으로 카메라에서 이미지 받기(비동기적)
Future _startLiveFeed() async {
final camera = cameras[_cameraIndex];
_controller = CameraController(
camera,
ResolutionPreset.high,
enableAudio: false,
);
_controller?.initialize().then((_) {
if (!mounted) {
return;
}
_controller?.getMinZoomLevel().then((value) {
zoomLevel = value;
minZoomLevel = value;
});
_controller?.getMaxZoomLevel().then((value) {
maxZoomLevel = value;
});
// 이미지 받은 것을 _processCameraImage 함수로 처리
_controller?.startImageStream(_processCameraImage);
setState(() {});
});
}
Future _stopLiveFeed() async {
await _controller?.stopImageStream();
await _controller?.dispose();
_controller = null;
}
// 전면<->후면 카메라 변경 함수
Future _switchLiveCamera() async {
setState(() => _changingCameraLens = true);
_cameraIndex = (_cameraIndex + 1) % cameras.length;
await _stopLiveFeed();
await _startLiveFeed();
setState(() => _changingCameraLens = false);
}
// 카메라에서 실시간으로 받아온 이미치 처리: PoseDetectorView에서 받아온 함수인 onImage(이미지에 포즈가 추출되었으면 스켈레톤 그려주는 함수) 실행
Future _processCameraImage(CameraImage image) async {
final WriteBuffer allBytes = WriteBuffer();
for (final Plane plane in image.planes) {
allBytes.putUint8List(plane.bytes);
}
final bytes = allBytes.done().buffer.asUint8List();
final Size imageSize =
Size(image.width.toDouble(), image.height.toDouble());
final camera = cameras[_cameraIndex];
final imageRotation =
InputImageRotationValue.fromRawValue(camera.sensorOrientation);
if (imageRotation == null) return;
final inputImageFormat =
InputImageFormatValue.fromRawValue(image.format.raw);
if (inputImageFormat == null) return;
final planeData = image.planes.map(
(Plane plane) {
return InputImagePlaneMetadata(
bytesPerRow: plane.bytesPerRow,
height: plane.height,
width: plane.width,
);
},
).toList();
final inputImageData = InputImageData(
size: imageSize,
imageRotation: imageRotation,
inputImageFormat: inputImageFormat,
planeData: planeData,
);
final inputImage =
InputImage.fromBytes(bytes: bytes, inputImageData: inputImageData);
// PoseDetectorView에서 받아온 함수인 onImage(이미지에 포즈가 추출되었으면 스켈레톤 그려주는 함수) 실행
widget.onImage(inputImage);
}
}
4. pose_detector_view.dert: 카메라 뷰에서 받은 이미지 정보로 스켈레톤 추출
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:google_mlkit_pose_detection/google_mlkit_pose_detection.dart';
import 'package:test_get_skeleton/pose_painter.dart';
import 'camera_view.dart';
// 카메라에서 스켈레톤 추출하는 화면
class PoseDetectorView extends StatefulWidget {
const PoseDetectorView({super.key});
@override
State<StatefulWidget> createState() => _PoseDetectorViewState();
}
class _PoseDetectorViewState extends State<PoseDetectorView> {
// 스켈레톤 추출 변수 선언(google_mlkit_pose_detection 라이브러리)
final PoseDetector _poseDetector =
PoseDetector(options: PoseDetectorOptions());
bool _canProcess = true;
bool _isBusy = false;
// 스켈레톤 모양을 그려주는 변수
CustomPaint? _customPaint;
// input Map
Map<String, double> inputMap = {};
@override
void dispose() async {
_canProcess = false;
_poseDetector.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
// 카메라뷰 보이기
return CameraView(
// 스켈레톤 그려주는 객체 전달
customPaint: _customPaint,
// 카메라에서 전해주는 이미지 받을 때마다 아래 함수 실행
onImage: (inputImage) {
processImage(inputImage);
},
);
}
// 카메라에서 실시간으로 받아온 이미지 처리: 이미지에 포즈가 추출되었으면 스켈레톤 그려주기
Future<void> processImage(InputImage inputImage) async {
if (!_canProcess) return;
if (_isBusy) return;
_isBusy = true;
// poseDetector에서 추출된 포즈 가져오기
List<Pose> poses = await _poseDetector.processImage(inputImage);
// 이미지가 정상적이면 포즈에 스켈레톤 그려주기
if (inputImage.inputImageData?.size != null &&
inputImage.inputImageData?.imageRotation != null) {
final painter = PosePainter(poses, inputImage.inputImageData!.size,
inputImage.inputImageData!.imageRotation);
_customPaint = CustomPaint(painter: painter);
} else {
// 추출된 포즈 없음
_customPaint = null;
}
_isBusy = false;
if (mounted) {
setState(() {});
}
}
}
5. main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:camera/camera.dart';
import 'package:test_get_skeleton/pose_detector_view.dart';
// 카메라 목록 변수
List<CameraDescription> cameras = [];
Future<void> main() async {
// 비동기 메서드를 사용함
WidgetsFlutterBinding.ensureInitialized();
// 사용 가능한 카메라 목록 받아옴
cameras = await availableCameras();
// 앱 실행
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: Home(),
);
}
}
class Home extends StatelessWidget {
const Home({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Pose Detection App'),
centerTitle: true,
elevation: 0,
),
// 스켈레톤 추출
body: const SafeArea(
child: PoseDetectorView(),
),
);
}
}
+ 혹시 오류가 난다면...
버전 오류가 날 시 android의 app build gradle에서 minSdkVersion, targetSdkVersion을 아래와 같이 설정해주었습니다.(오류문에 버전 몇으로 바꾸라고 나와있었음.)
defaultConfig {
//...
minSdkVersion 21 //flutter.minSdkVersion
targetSdkVersion 31 //flutter.targetSdkVersion
//...
}
자잔 이렇게 하면 코드는 끝입니다.
이제 앱 실행을 해볼 건데
터미널에 flutter clean, flutter run을 해서 앱을 실행해줘야지 실행이 원만하게 잘 되었습니다...
기존 플레이 버튼을 눌렀을때는 무한 로딩이,,,^^
결과
결과는 아래 영상과 같습니다!
캡쳐본도 살포시,,^
사람이 움직이는 순간 스무스하게 스켈레톤이 따라오진 않고 약~~간 버벅이는 느낌이 있지만 봐줄만 한 정도입니다.
미디어 파이프 쌩으로 연결해야하나 걱정 많았는데 라이브러리 하나로 이렇게 간단하게 연결할 수 있다니 넘 행복하군요,,,