Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use WebWorker for MediaPipe Holistic - Fixes #24 #28

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
5 changes: 5 additions & 0 deletions angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
"input": "./node_modules/flag-icons/flags/1x1/",
"output": "./assets/flags/1x1/"
},
{
"glob": "**/*",
"input": "./node_modules/@mediapipe/holistic/",
"output": "./"
},
{
"glob": "*.ttf",
"input": "./node_modules/@sutton-signwriting/font-ttf/font",
Expand Down
7 changes: 4 additions & 3 deletions src/app/components/video/video.component.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {AfterViewInit, Component, ElementRef, HostBinding, Input, ViewChild} from '@angular/core';
import {Store} from '@ngxs/store';
import {combineLatest, firstValueFrom} from 'rxjs';
import {VideoSettings, VideoStateModel} from '../../core/modules/ngxs/store/video/video.state';
import {VideoStateModel} from '../../core/modules/ngxs/store/video/video.state';
import Stats from 'stats.js';
import {distinctUntilChanged, filter, map, takeUntil, tap} from 'rxjs/operators';
import {BaseComponent} from '../base/base.component';
Expand Down Expand Up @@ -112,14 +112,15 @@ export class VideoComponent extends BaseComponent implements AfterViewInit {
.pipe(
map(state => state.videoSettings),
filter(Boolean),
tap(({width, height}) => {
tap(({width, height, aspectRatio}) => {
this.aspectRatio = 'aspect-' + aspectRatio;

this.canvasEl.nativeElement.width = width;
this.canvasEl.nativeElement.height = height;

// It is required to wait for next frame, as grid element might still be resizing
requestAnimationFrame(this.scaleCanvas.bind(this));
}),
tap((settings: VideoSettings) => (this.aspectRatio = 'aspect-' + settings.aspectRatio)),
takeUntil(this.ngUnsubscribe)
)
.subscribe();
Expand Down
8 changes: 4 additions & 4 deletions src/app/modules/animation/animation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {LayersModel} from '@tensorflow/tfjs-layers';
import {Injectable} from '@angular/core';
import {TensorflowService} from '../../core/services/tfjs/tfjs.service';
import {MediapipeHolisticService} from '../../core/services/holistic.service';
import {POSE_LANDMARKS} from '@mediapipe/holistic';

const ANIMATION_KEYS = [
'mixamorigHead.quaternion',
Expand Down Expand Up @@ -80,8 +81,7 @@ export class AnimationService {
}

normalizePose(pose: Pose): Tensor {
const bodyLandmarks =
pose.poseLandmarks || new Array(Object.keys(this.holistic.POSE_LANDMARKS).length).fill(EMPTY_LANDMARK);
const bodyLandmarks = pose.poseLandmarks || new Array(Object.keys(POSE_LANDMARKS).length).fill(EMPTY_LANDMARK);
const leftHandLandmarks = pose.leftHandLandmarks || new Array(21).fill(EMPTY_LANDMARK);
const rightHandLandmarks = pose.rightHandLandmarks || new Array(21).fill(EMPTY_LANDMARK);
const landmarks = bodyLandmarks.concat(leftHandLandmarks, rightHandLandmarks);
Expand All @@ -90,8 +90,8 @@ export class AnimationService {
.tensor(landmarks.map(l => [l.x, l.y, l.z]))
.mul(this.tf.tensor([pose.image.width, pose.image.height, pose.image.width]));

const p1 = tensor.slice(this.holistic.POSE_LANDMARKS.LEFT_SHOULDER, 1);
const p2 = tensor.slice(this.holistic.POSE_LANDMARKS.RIGHT_SHOULDER, 1);
const p1 = tensor.slice(POSE_LANDMARKS.LEFT_SHOULDER, 1);
const p2 = tensor.slice(POSE_LANDMARKS.RIGHT_SHOULDER, 1);

const d = this.tf.sqrt(this.tf.pow(p2.sub(p1), 2).sum());
let normTensor = this.tf.sub(tensor, p1.add(p2).div(2)).div(d);
Expand Down
59 changes: 25 additions & 34 deletions src/app/modules/detector/detector.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import {Tensor} from '@tensorflow/tfjs';
import {EMPTY_LANDMARK, Pose, PoseLandmark} from '../pose/pose.state';
import {LayersModel} from '@tensorflow/tfjs-layers';
import {Injectable} from '@angular/core';
import {POSE_LANDMARKS} from '@mediapipe/holistic';
import {TensorflowService} from '../../core/services/tfjs/tfjs.service';
import {MediapipeHolisticService} from '../../core/services/holistic.service';

const WINDOW_SIZE = 20;

Expand All @@ -19,16 +19,13 @@ export class DetectorService {

sequentialModel: LayersModel;

constructor(private tf: TensorflowService, private holistic: MediapipeHolisticService) {}
constructor(private tf: TensorflowService) {}

async loadModel() {
return Promise.all([
this.holistic.load(),
this.tf
.load()
.then(() => this.tf.loadLayersModel('assets/models/sign-detector/model.json'))
.then(model => (this.sequentialModel = model as unknown as LayersModel)),
]);
return this.tf
.load()
.then(() => this.tf.loadLayersModel('assets/models/sign-detector/model.json'))
.then(model => (this.sequentialModel = model as unknown as LayersModel));
}

distance(p1: PoseLandmark, p2: PoseLandmark): number {
Expand All @@ -38,16 +35,15 @@ export class DetectorService {
}

normalizePose(pose: Pose): PoseLandmark[] {
const bodyLandmarks =
pose.poseLandmarks || new Array(Object.keys(this.holistic.POSE_LANDMARKS).length).fill(EMPTY_LANDMARK);
const bodyLandmarks = pose.poseLandmarks || new Array(Object.keys(POSE_LANDMARKS).length).fill(EMPTY_LANDMARK);
const leftHandLandmarks = pose.leftHandLandmarks || new Array(21).fill(EMPTY_LANDMARK);
const rightHandLandmarks = pose.leftHandLandmarks || new Array(21).fill(EMPTY_LANDMARK);
const landmarks = bodyLandmarks
.concat(leftHandLandmarks, rightHandLandmarks)
.map(l => (this.isValidLandmark(l) ? l : EMPTY_LANDMARK));

const p1 = landmarks[this.holistic.POSE_LANDMARKS.LEFT_SHOULDER];
const p2 = landmarks[this.holistic.POSE_LANDMARKS.RIGHT_SHOULDER];
const p1 = landmarks[POSE_LANDMARKS.LEFT_SHOULDER];
const p2 = landmarks[POSE_LANDMARKS.RIGHT_SHOULDER];

if (p1.x > 0 && p2.x > 0) {
this.shoulderWidth[this.shoulderWidthIndex % WINDOW_SIZE] = this.distance(p1, p2);
Expand All @@ -69,43 +65,38 @@ export class DetectorService {

// TODO remove, this is to be compliant with openpose
const neck = {
x:
(newPose[this.holistic.POSE_LANDMARKS.LEFT_SHOULDER].x +
newPose[this.holistic.POSE_LANDMARKS.RIGHT_SHOULDER].x) /
2,
y:
(newPose[this.holistic.POSE_LANDMARKS.LEFT_SHOULDER].y +
newPose[this.holistic.POSE_LANDMARKS.RIGHT_SHOULDER].y) /
2,
x: (newPose[POSE_LANDMARKS.LEFT_SHOULDER].x + newPose[POSE_LANDMARKS.RIGHT_SHOULDER].x) / 2,
y: (newPose[POSE_LANDMARKS.LEFT_SHOULDER].y + newPose[POSE_LANDMARKS.RIGHT_SHOULDER].y) / 2,
};

return [
newPose[this.holistic.POSE_LANDMARKS.NOSE],
const newFakePose = [
newPose[POSE_LANDMARKS.NOSE],
neck,
newPose[this.holistic.POSE_LANDMARKS.RIGHT_SHOULDER],
newPose[this.holistic.POSE_LANDMARKS.RIGHT_ELBOW],
newPose[this.holistic.POSE_LANDMARKS.RIGHT_WRIST],
newPose[this.holistic.POSE_LANDMARKS.LEFT_SHOULDER],
newPose[this.holistic.POSE_LANDMARKS.LEFT_ELBOW],
newPose[this.holistic.POSE_LANDMARKS.LEFT_WRIST],
newPose[POSE_LANDMARKS.RIGHT_SHOULDER],
newPose[POSE_LANDMARKS.RIGHT_ELBOW],
newPose[POSE_LANDMARKS.RIGHT_WRIST],
newPose[POSE_LANDMARKS.LEFT_SHOULDER],
newPose[POSE_LANDMARKS.LEFT_ELBOW],
newPose[POSE_LANDMARKS.LEFT_WRIST],
EMPTY_LANDMARK,
EMPTY_LANDMARK,
EMPTY_LANDMARK,
EMPTY_LANDMARK,
EMPTY_LANDMARK,
EMPTY_LANDMARK,
EMPTY_LANDMARK,
newPose[this.holistic.POSE_LANDMARKS.RIGHT_EYE],
newPose[this.holistic.POSE_LANDMARKS.LEFT_EYE],
newPose[this.holistic.POSE_LANDMARKS.RIGHT_EAR],
newPose[this.holistic.POSE_LANDMARKS.LEFT_EAR],
newPose[POSE_LANDMARKS.RIGHT_EYE],
newPose[POSE_LANDMARKS.LEFT_EYE],
newPose[POSE_LANDMARKS.RIGHT_EAR],
newPose[POSE_LANDMARKS.LEFT_EAR],
EMPTY_LANDMARK,
EMPTY_LANDMARK,
EMPTY_LANDMARK,
EMPTY_LANDMARK,
EMPTY_LANDMARK,
EMPTY_LANDMARK,
];

return newFakePose;
}

isValidLandmark(l: PoseLandmark): boolean {
Expand Down
6 changes: 6 additions & 0 deletions src/app/modules/pose/pose.actions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import {Pose} from './pose.state';

export class LoadPoseModel {
static readonly type = '[Pose] Load Pose Model';

constructor() {}
}

export class PoseVideoFrame {
static readonly type = '[Pose] Pose Video Frame';

Expand Down
90 changes: 49 additions & 41 deletions src/app/modules/pose/pose.service.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,66 @@
import {Injectable} from '@angular/core';
import {
FACEMESH_FACE_OVAL,
FACEMESH_LEFT_EYE,
FACEMESH_LEFT_EYEBROW,
FACEMESH_LIPS,
FACEMESH_RIGHT_EYE,
FACEMESH_RIGHT_EYEBROW,
FACEMESH_TESSELATION,
HAND_CONNECTIONS,
POSE_CONNECTIONS,
POSE_LANDMARKS,
} from '@mediapipe/holistic';
import * as drawing from '@mediapipe/drawing_utils/drawing_utils.js';
import {Pose, PoseLandmark} from './pose.state';
import {GoogleAnalyticsService} from '../../core/modules/google-analytics/google-analytics.service';
import {MediapipeHolisticService} from '../../core/services/holistic.service';
import * as comlink from 'comlink';
import {transferableImage} from '../../core/helpers/image/transferable';

const IGNORED_BODY_LANDMARKS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 16, 17, 18, 19, 20, 21, 22];

@Injectable({
providedIn: 'root',
})
export class PoseService {
model?: any;
isFirstFrame = true;
onResultsCallbacks = [];

constructor(private ga: GoogleAnalyticsService, private holistic: MediapipeHolisticService) {}
worker: comlink.Remote<{
loadModel: () => Promise<void>;
pose: (imageBitmap: ImageBitmap | ImageData) => Promise<Pose>;
}>;

onResults(onResultsCallback) {
this.onResultsCallbacks.push(onResultsCallback);
}
constructor(private ga: GoogleAnalyticsService) {}

async load(): Promise<void> {
if (this.model) {
if (this.worker) {
return;
}

await this.holistic.load();

await this.ga.trace('pose', 'load', () => {
this.model = new this.holistic.Holistic({locateFile: file => `assets/models/holistic/${file}`});

this.model.setOptions({
upperBodyOnly: false,
modelComplexity: 1,
});

this.model.onResults(results => {
for (const callback of this.onResultsCallbacks) {
callback(results);
}
});
await this.ga.trace('pose', 'load', async () => {
this.worker = comlink.wrap(new Worker(new URL('./pose.worker', import.meta.url)));
await this.worker.loadModel();
});
}

async predict(video: HTMLVideoElement | HTMLImageElement): Promise<void> {
await this.load();
async predict(video: HTMLVideoElement | HTMLImageElement): Promise<Pose> {
const width = (video as HTMLVideoElement).videoWidth ?? video.width;
if (!this.worker || width === 0) {
return null;
}

const frameType = this.isFirstFrame ? 'first-frame' : 'frame';
await this.ga.trace('pose', frameType, () => {
const image = await transferableImage(video);

return this.ga.trace('pose', frameType, async () => {
this.isFirstFrame = false;
return this.model.send({image: video});
const result: Pose = await this.worker.pose(image);
if (!result) {
return null;
}

// result.image = image; // TODO fix
return result;
});
}

Expand All @@ -59,7 +70,7 @@ export class PoseService {
delete filteredLandmarks[l];
}

drawing.drawConnectors(ctx, filteredLandmarks, this.holistic.POSE_CONNECTIONS, {color: '#00FF00'});
drawing.drawConnectors(ctx, filteredLandmarks, POSE_CONNECTIONS, {color: '#00FF00'});
drawing.drawLandmarks(ctx, filteredLandmarks, {color: '#00FF00', fillColor: '#FF0000'});
}

Expand All @@ -70,7 +81,7 @@ export class PoseService {
dotColor: string,
dotFillColor: string
): void {
drawing.drawConnectors(ctx, landmarks, this.holistic.HAND_CONNECTIONS, {color: lineColor});
drawing.drawConnectors(ctx, landmarks, HAND_CONNECTIONS, {color: lineColor});
drawing.drawLandmarks(ctx, landmarks, {
color: dotColor,
fillColor: dotFillColor,
Expand All @@ -82,13 +93,13 @@ export class PoseService {
}

drawFace(landmarks: PoseLandmark[], ctx: CanvasRenderingContext2D): void {
drawing.drawConnectors(ctx, landmarks, this.holistic.FACEMESH_TESSELATION, {color: '#C0C0C070', lineWidth: 1});
drawing.drawConnectors(ctx, landmarks, this.holistic.FACEMESH_RIGHT_EYE, {color: '#FF3030'});
drawing.drawConnectors(ctx, landmarks, this.holistic.FACEMESH_RIGHT_EYEBROW, {color: '#FF3030'});
drawing.drawConnectors(ctx, landmarks, this.holistic.FACEMESH_LEFT_EYE, {color: '#30FF30'});
drawing.drawConnectors(ctx, landmarks, this.holistic.FACEMESH_LEFT_EYEBROW, {color: '#30FF30'});
drawing.drawConnectors(ctx, landmarks, this.holistic.FACEMESH_FACE_OVAL, {color: '#E0E0E0'});
drawing.drawConnectors(ctx, landmarks, this.holistic.FACEMESH_LIPS, {color: '#E0E0E0'});
drawing.drawConnectors(ctx, landmarks, FACEMESH_TESSELATION, {color: '#C0C0C070', lineWidth: 1});
drawing.drawConnectors(ctx, landmarks, FACEMESH_RIGHT_EYE, {color: '#FF3030'});
drawing.drawConnectors(ctx, landmarks, FACEMESH_RIGHT_EYEBROW, {color: '#FF3030'});
drawing.drawConnectors(ctx, landmarks, FACEMESH_LEFT_EYE, {color: '#30FF30'});
drawing.drawConnectors(ctx, landmarks, FACEMESH_LEFT_EYEBROW, {color: '#30FF30'});
drawing.drawConnectors(ctx, landmarks, FACEMESH_FACE_OVAL, {color: '#E0E0E0'});
drawing.drawConnectors(ctx, landmarks, FACEMESH_LIPS, {color: '#E0E0E0'});
}

drawConnect(connectors: PoseLandmark[][], ctx: CanvasRenderingContext2D): void {
Expand All @@ -112,15 +123,12 @@ export class PoseService {

if (pose.rightHandLandmarks) {
ctx.strokeStyle = '#00FF00';
this.drawConnect(
[[pose.poseLandmarks[this.holistic.POSE_LANDMARKS.RIGHT_ELBOW], pose.rightHandLandmarks[0]]],
ctx
);
this.drawConnect([[pose.poseLandmarks[POSE_LANDMARKS.RIGHT_ELBOW], pose.rightHandLandmarks[0]]], ctx);
}

if (pose.leftHandLandmarks) {
ctx.strokeStyle = '#FF0000';
this.drawConnect([[pose.poseLandmarks[this.holistic.POSE_LANDMARKS.LEFT_ELBOW], pose.leftHandLandmarks[0]]], ctx);
this.drawConnect([[pose.poseLandmarks[POSE_LANDMARKS.LEFT_ELBOW], pose.leftHandLandmarks[0]]], ctx);
}
}

Expand Down
Loading