はじめに
Joy DivisionのDisorderのMVを参考にして、動画の人物を検出して、パルサー波形エフェクトの実装方法を解説していきます。
動画の人物を検出する方法は、Mediapipeを使用し検出したマスクを利用して、Three.jsでパルサー波形のようなエフェクトを付けていきます。
Three.jsの開発環境は以前の記事を参考にしてください。
この記事のデモのコードは以下のGitHubリポジトリにあるので、ぜひ参考にしてください。
Mediapipeで人物を検出する
まずは、Mediapipeをインストールしましょう。
以下のコマンドでインストールできます。
npm install --save @mediapipe/tasks-vision動画はHTML上のvideoタグで取得するので、HTMLは次のようになります。
<style>
video {
display: none;
}
</style>
<video id="video" src="./video/video.mp4" autoplay muted loop></video>
<canvas id="webgl"></canvas>video要素は、取得するだけなので、display:noneで非表示にしています。今回はcanvas要素もひとつだけで完結するようにしています。
それでは、Mediapipeで人物を検出するコードを見ていきましょう。
Media.tsというファイルを作成して、以下のコードを記述します。
import { FilesetResolver, ImageSegmenter, type MPMask } from '@mediapipe/tasks-vision';
import * as THREE from 'three';
export class Media {
video: HTMLVideoElement;
lastVideoTime: number;
imageSegmenter?: ImageSegmenter;
maskTexture: THREE.DataTexture | null = null;
constructor() {
this.video = document.getElementById('video') as HTMLVideoElement;
this.video.play();
this.lastVideoTime = -1;
}
async init() {
const vision = await FilesetResolver.forVisionTasks('https://cdn.jsdelivr.net/npm/@mediapipe/[email protected]/wasm');
this.imageSegmenter = await ImageSegmenter.createFromOptions(vision, {
baseOptions: {
modelAssetPath:
'https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite',
delegate: 'GPU',
},
runningMode: 'VIDEO',
outputCategoryMask: false,
outputConfidenceMasks: true,
});
this.predictLoop();
}
predictLoop = () => {
if (this.video.currentTime !== this.lastVideoTime && this.imageSegmenter) {
this.lastVideoTime = this.video.currentTime;
const startTimeMs = performance.now();
const result = this.imageSegmenter.segmentForVideo(this.video, startTimeMs);
this.updateMaskTexture(result.confidenceMasks?.[0]);
}
requestAnimationFrame(this.predictLoop);
};
updateMaskTexture(mask?: MPMask): void {
if (!mask) return;
const { width, height } = mask;
const data = mask.getAsFloat32Array();
if (!this.maskTexture) {
this.maskTexture = new THREE.DataTexture(data, width, height, THREE.RedFormat, THREE.FloatType);
this.maskTexture.flipY = true;
this.maskTexture.needsUpdate = true;
return;
}
this.maskTexture.image.data?.set(data);
this.maskTexture.needsUpdate = true;
}
}Mediapipeの詳しい解説はしないですが、今回はモデルとして、selfie_segmenterを使用して、人物のマスクを取得しています。Mediapipeのモデルなどは、公式ドキュメントを参考にしてください。
initでMediapipeのAIモデルを読み込んだら、predictLoopで動画のフレームごとに人物を検出して、マスクを取得しています。
updateMaskTextureでは、取得したマスクを初回だけTHREE.DataTextureに変換して、以降はマスクのデータを更新するだけにしています。THREE.DataTextureの第4引数のTHREE.RedFormatは、赤チャンネルのみを使用することを意味しています。Mediapipeのマスクは、人物確率しかないので、赤チャンネルの1つだけで十分です。
また、このままだと動画が反転してしまうので、flipY = trueで反転させています。
メインのApp.tsでは、Media.tsのmaskTextureをuniformとして渡して、マスクを使用してパルサー波形のようなエフェクトを付けていきます。
それでは、App.tsのコードを見ていきましょう。
import * as THREE from 'three';
import { Controls, OrthographicCamera } from './core/Camera';
import { Three } from './core/Three';
import { Media } from './Media';
import fragment from './shaders/fragment.glsl?raw';
import vertex from './shaders/vertex.glsl?raw';
export class App extends Three {
private readonly camera: OrthographicCamera;
private mesh!: THREE.Mesh;
constructor(canvas: HTMLCanvasElement) {
super(canvas);
this.camera = new OrthographicCamera({ left: -1, right: 1, top: 1, bottom: -1, near: 0.1, far: 10 });
this.camera.position.z = 1;
this.init();
}
private async init() {
const media = new Media();
await media.init();
this.createGeometry(media);
window.addEventListener('resize', this.resize.bind(this));
this.renderer.setAnimationLoop(this.animate.bind(this));
}
private createGeometry(media: Media) {
const geometry = new THREE.PlaneGeometry(2, 2);
const video = document.getElementById('video') as HTMLVideoElement;
const videoTexture = new THREE.VideoTexture(video);
videoTexture.minFilter = THREE.LinearFilter;
videoTexture.magFilter = THREE.LinearFilter;
const material = new THREE.ShaderMaterial({
vertexShader: vertex,
fragmentShader: fragment,
transparent: true,
uniforms: {
uMaskTexture: { value: media.maskTexture },
uVideoTexture: { value: videoTexture },
uTime: { value: 0 },
},
});
this.mesh = new THREE.Mesh(geometry, material);
this.scene.add(this.mesh);
}
private animate() {
const delta = this.clock.getDelta();
const material = this.mesh.material as THREE.ShaderMaterial;
if (material.uniforms.uTime) {
material.uniforms.uTime.value += delta;
}
this.renderer.render(this.scene, this.camera);
}
}Three.js側では、板ポリ1枚を使用するだけになります。Media.tsの初期化は、await media.init();で行い、マスクのテクスチャをuniformとして渡しています。
private createGeometry(media: Media) {
const geometry = new THREE.PlaneGeometry(2, 2);
const video = document.getElementById('video') as HTMLVideoElement;
const videoTexture = new THREE.VideoTexture(video);
videoTexture.minFilter = THREE.LinearFilter;
videoTexture.magFilter = THREE.LinearFilter;
const material = new THREE.ShaderMaterial({
vertexShader: vertex,
fragmentShader: fragment,
transparent: true,
uniforms: {
uMaskTexture: { value: media.maskTexture },
uVideoTexture: { value: videoTexture },
uTime: { value: 0 },
},
});
this.mesh = new THREE.Mesh(geometry, material);
this.scene.add(this.mesh);
}人物検出のマスクは、uMaskTextureとして渡して、動画のテクスチャはuVideoTextureとして渡しています。これでシェーダー側で使用できるようになったので、フラグメントシェーダーで人物検出のマスクを表示させましょう。
precision mediump float;
varying vec2 vUv;
uniform sampler2D uMaskTexture;
void main() {
vec2 uv = vUv;
vec4 maskColor = texture(uMaskTexture, uv);
gl_FragColor = maskColor;
}
これで、人物検出のマスクが表示されるようになりました!
最後にJoy Division風のパルサー波形エフェクトを付けていきます。
Joy Division風パルサー波形
Joy Division風パルサー波形のGLSLコードは次のようになります。
precision mediump float;
varying vec2 vUv;
uniform sampler2D uMaskTexture;
uniform sampler2D uVideoTexture;
uniform float uTime;
const float PI = 3.1415926;
void main() {
vec2 uv = vUv;
// ビデオのモノクロ化
vec3 rawVideoColor = texture(uVideoTexture, uv).rgb;
float monoVideo = dot(rawVideoColor, vec3(0.299, 0.587, 0.114));
vec3 videoColor = vec3(monoVideo);
vec3 darkColor = vec3(0.05);
// 時間経過による背景色の変化
float mixFactor = sin(uTime * 0.5) * 0.5 + 0.5;
vec3 baseBg = mix(darkColor, videoColor, mixFactor);
vec3 finalColor = baseBg;
// 中央ほど線が強くなるように重み付け
float centerWeight = sin(uv.x * PI);
// 線の数と太さ
float numLines = 30.0;
float thickness = 0.002;
// フラグ
bool isOccluded = false;
bool isOnLine = false;
for (float i = 0.0; i < numLines; i++) {
float vBase = i / numLines; // 線の基準位置(0.0〜1.0)
// 人物マスクの取得
float maskVal = texture(uMaskTexture, vec2(uv.x, vBase)).r;
// マスクの値に応じて線の高さを変化させる
float displacement = maskVal * 0.05 * centerWeight;
float noise = sin(uv.x * 120.0 + vBase * 20.0 + uTime * 1.5) * 0.0025 * maskVal;
float vLine = vBase + displacement + noise;
// すでに線が描かれている場合は、下の線を描画しないようにする
if (vBase < uv.y && vLine > uv.y) {
isOccluded = true;
}
// 線の太さの範囲内にuv.yがある場合、線を描画する
if (abs(uv.y - vLine) < thickness) {
if (!isOccluded) {
isOnLine = true;
}
}
}
// 線が遮蔽されている場合は背景色を使用
if (isOccluded) {
finalColor = baseBg;
}
// 線が描画されている場合は線の色を使用
if (isOnLine) {
vec3 lineColor = vec3(1.0);
finalColor = lineColor;
}
gl_FragColor = vec4(finalColor, 1.0);
}それではGLSLコードの解説をしていきます。
今回のデモでは、時間経過により動画の透過を変更したいのでmix関数を使用します。
vec2 uv = vUv;
// ビデオのモノクロ化
vec3 rawVideoColor = texture(uVideoTexture, uv).rgb;
float monoVideo = dot(rawVideoColor, vec3(0.299, 0.587, 0.114));
vec3 videoColor = vec3(monoVideo);
vec3 darkColor = vec3(0.05);
// 時間経過による背景色の変化
float mixFactor = sin(uTime * 0.5) * 0.5 + 0.5;
vec3 baseBg = mix(darkColor, videoColor, mixFactor);
vec3 finalColor = baseBg;sin関数を使用して時間経過を表現しています。mixFactorは0.0〜1.0の範囲で変化するので、mix関数で背景色を変化させることができます。
このパルサー波形エフェクトの核となるのは、人物マスクを高さマップ(Height Map)として利用していることです。人物マスクの値が大きいほど、線の高さが高くなるようにしています。
for (float i = 0.0; i < numLines; i++) {
float vBase = i / numLines; // 線の基準位置(0.0〜1.0)
// 人物マスクの取得
float maskVal = texture(uMaskTexture, vec2(uv.x, vBase)).r;
// マスクの値に応じて線の高さを変化させる
float displacement = maskVal * 0.05 * centerWeight;
float noise = sin(uv.x * 120.0 + vBase * 20.0 + uTime * 1.5) * 0.0025 * maskVal;
float vLine = vBase + displacement + noise;
// すでに線が描かれている場合は、下の線を描画しないようにする
if (vBase < uv.y && vLine > uv.y) {
isOccluded = true;
}
// 線の太さの範囲内にuv.yがある場合、線を描画する
if (abs(uv.y - vLine) < thickness) {
if (!isOccluded) {
isOnLine = true;
}
}
}vBase < uv.y && vLine > uv.yの条件は、上に持ち上がった線が現在のピクセルを覆っているかを判定し、下側の線を描画しないことで、複数の線が重なってギザギザになるのを防いでいます。また、abs(uv.y - vLine) < thicknessの条件は、線の太さ(thickness)の範囲内にuv.yがある場合に線を描画するフラグをtrueにしています。
isOccludedかisOnLineの条件に応じて、背景を描画するか線を描画するかを決定しています。
// 線が遮蔽されている場合は背景色を使用
if (isOccluded) {
finalColor = baseBg;
}
// 線が描画されている場合は線の色を使用
if (isOnLine) {
vec3 lineColor = vec3(1.0);
finalColor = lineColor;
}
以上で、Mediapipeで人物を検出して、Three.jsでJoy Division風のパルサー波形エフェクトを付けることができました。
まとめ
今回は、Mediapipeで動画の人物を検出して、Three.jsでJoy Division風のパルサー波形エフェクトを付ける方法を解説しました。DisorderのMVを忠実には再現できていないですが、Joy Division風のエフェクトを実装できてよかったです。またいつかUnkown Pleasuresのジャケの再現もやってみたいと思います!