нуль

Web技術を
知る・試す・楽しむ
ためのテックブログ

Coding

Mediapipeで動画の人物を検出してThree.jsでJoy Division風のパルサー波形エフェクト効果を付ける

投稿日:
Mediapipeで動画の人物を検出してThree.jsでJoy Division風のパルサー波形エフェクト効果を付ける
デモを見る

はじめに

Joy DivisionのDisorderのMVを参考にして、動画の人物を検出して、パルサー波形エフェクトの実装方法を解説していきます。

動画の人物を検出する方法は、Mediapipeを使用し検出したマスクを利用して、Three.jsでパルサー波形のようなエフェクトを付けていきます。

Three.jsの開発環境は以前の記事を参考にしてください。
この記事のデモのコードは以下のGitHubリポジトリにあるので、ぜひ参考にしてください。

GitHub - nono-k/threejs-joy-division-disorder-mv
Contribute to nono-k/threejs-joy-division-disorder-mv development by creating an account on GitHub.
GitHub - nono-k/threejs-joy-division-disorder-mv favicon
github.com
GitHub - nono-k/threejs-joy-division-disorder-mv

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というファイルを作成して、以下のコードを記述します。

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.tsmaskTextureをuniformとして渡して、マスクを使用してパルサー波形のようなエフェクトを付けていきます。

それでは、App.tsのコードを見ていきましょう。

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として渡しています。

App.ts
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として渡しています。これでシェーダー側で使用できるようになったので、フラグメントシェーダーで人物検出のマスクを表示させましょう。

fragment.glsl
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コードは次のようになります。

fragment.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にしています。

isOccludedisOnLineの条件に応じて、背景を描画するか線を描画するかを決定しています。

// 線が遮蔽されている場合は背景色を使用
if (isOccluded) {
  finalColor = baseBg;
}
 
// 線が描画されている場合は線の色を使用
if (isOnLine) {
  vec3 lineColor = vec3(1.0);
  finalColor = lineColor;
}

Joy Division風パルサー波形
Joy Division風パルサー波形

以上で、Mediapipeで人物を検出して、Three.jsでJoy Division風のパルサー波形エフェクトを付けることができました。

まとめ

今回は、Mediapipeで動画の人物を検出して、Three.jsでJoy Division風のパルサー波形エフェクトを付ける方法を解説しました。DisorderのMVを忠実には再現できていないですが、Joy Division風のエフェクトを実装できてよかったです。またいつかUnkown Pleasuresのジャケの再現もやってみたいと思います!

Mediapipeで動画の人物を検出してThree.jsでJoy Division風のパルサー波形エフェクト効果を付ける
Mediapipeで動画の人物を検出してThree.jsでJoy Division風のパルサー波形エフェクト効果を付ける

この記事をシェアする