webでいろんなリップを試せるアプリを作ったので知見をメモ
 Author: 水卜

概要

作ったものはこちら(https://touchlip.koatech.info/)

対面カメラやアップロードした画像に対し、色々なリップを合わせることができます。

こういったソリューションはPerfect社さんが圧倒的ですが、再発明してみました。

Perfectさんの製品は、CHANELのサイトを見ていただければ明らかですが、ラメやマット感など、リップによって異なる質感をうまく再現しています。僕が作ったのは残念ながら色を塗るだけです。

face-api.jsを使って顔認識も描画も全部クライアントサイドで完結しています。

Netlifyの無料枠がまだまだ余裕なので運用コストも無料です。

以下、頑張ったところをメモ。

リップを塗る処理

  1. 顔認識して顔のパーツのランドマークを取る

  2. どこにLandmarksのindex何番が表示されているのかを特定する

  3. どうlandmarksを繋げば綺麗に唇を描けるかを特定する

  4. canvas上で線を結んで塗りつぶす

1はface-api.jsがやってくれるので2以降を自力で作る必要があります。

const mouthLandmarks = [
  {x: 275.4868933089642, y: 330.9715563375305},
  {x: 282.46803207477615, y: 325.7225076276611},
  {x: 292.1396419831185, y: 320.55676439588774},
  {x: 299.4580142327218, y: 321.12185386007536},
  {x: 306.25136206826255, y: 317.9190892297577},
  {x: 321.9393919535546, y: 321.63006702249754},
  {x: 337.6620153017907, y: 323.5335417110275},
  {x: 325.27621345004127, y: 332.3888464290451},
  {x: 315.2544691391854, y: 338.6371144372772},
  {x: 304.66151045044944, y: 341.4427271205734},
  {x: 295.3428850599198, y: 341.4085681754898},
  {x: 285.4118543394475, y: 338.5544467288803}
]
const __fill = (canvasCtx, points) => {
  canvasCtx.beginPath()
  canvasCtx.moveTo(points[0].x, points[0].y)
  points.splice(0, 1)
  points.forEach((point, i) => {
    // pointを結ぶ線を引く。fill()で塗りつぶされる
    canvasCtx.lineTo(point.x, point.y)
    // pointの座標にindexの数字を配置する(デバッグ用)
    canvasCtx.fillText(i, point.x, point.y)
  })
  canvasCtx.fill()
}
canvasCtx.fillStyle = 'rgba(255,0,22,0.15)'
__fill(canvasCtx, mouthLandmarks)

関数ができたので、Vuexのactionから呼び出します。

draw ({ commit, state }, { canvasCtx, detection, color, transparent }) {
  const __mouthPoints = detection.landmarks.getMouth()
  const upperLip = [
    ...__mouthPoints.slice(0, 7),
    __mouthPoints[16],
    __mouthPoints[15],
    __mouthPoints[14],
    __mouthPoints[13],
    __mouthPoints[12]
  ]
  const lowerLip = [
    ...__mouthPoints.slice(6, 12),
    __mouthPoints[0],
    __mouthPoints[12],
    __mouthPoints[19],
    __mouthPoints[18],
    __mouthPoints[17],
    __mouthPoints[16]
  ]
  canvasCtx.fillStyle = `${color.slice(0, -1)}, ${transparent})`
  __fill(canvasCtx, upperLip)
  __fill(canvasCtx, lowerLip)
},

リップが点滅する

当初の実装と事象

対面カメラの映像にリップを塗るために以下を実装します。

  1. videoの内容をcanvasに表示

  2. 顔認識

  3. 唇のランドマーク取得

  4. リップ描画

  5. 1~4をsetIntervalで繰り返す

すると、前のintervalの4で書き込んだリップは、次のintervalの1で書き込まれたvideoに隠れてしまいます。

つまり、2と3の顔認識でラグればラグるほど、リップをvideoで上書きしてからもう一度リップを描画するまでが遅くなってしまい、ユーザーには、リップが点滅しているように見えてしまいます。

解決方法

唇のランドマークを取得したらその位置をキャッシュし、1の後すぐに描画する。

  1. videoの内容をcanvasに表示
  2. 顔認識結果がキャッシュされてたらリップ描画
  3. 顔認識
  4. 認識結果をキャッシュ
  5. 唇のランドマーク取得
  6. リップ描画
  7. 1~5をsetIntervalで繰り返す

しかしまだ若干点滅する。

さらなる改良

(3/14追記)

よくよく考えればリップと顔面を交互に描画するのはナンセンス。

canvasを2つに分けてしまえばリップの点滅はなくなるはず。

ということで、リップはリップ用のcanvasに描画してみたところ、うまくハマった。

video to canvasがiPhoneでインライン再生できない

canvasだけ表示したいのでvideoにhidden要素を指定して隠していましたが、safariではvideoを隠すと再生できないようになっています。

解決方法

#live-video {
  position: absolute;
  top: 10px; left: 10px;
  object-fit: fill;
  transform-origin: left top;
  transform: scale(.1);
}

canvasの左上にvideoも小さく表示するようにしました。transform: scale(.001)とかにしてしまえば見えないも同然です。

webRTCのアスペクト比がなかなか合わない

解決方法

getUserMediaのconstraintsで設定します。

const constraints = {
  aspectRatio: 0.75
}
const stream = await navigator.mediaDevices.getUserMedia({ video: constraints })

ちなみにこれでもiPhone safariではアスペクト比をいじれませんでした。

気合いで3:4のcanvasに合わせる関数を実装しました。

drawImage ({ commit, state }, { canvasDiv, canvasCtx, imagePath }) {
  const image = new Image()
  image.addEventListener('load', () => {
    let width, height, xOffset, yOffset
    if (image.width * 1.34 > image.height) {
      height = canvasDiv.height
      width = image.width * (canvasDiv.height / image.height)
      xOffset = -(width - canvasDiv.width) / 2
      yOffset = 0
    } else {
      width = canvasDiv.width
      height = image.height * (canvasDiv.width / image.width)
      yOffset = -(height - canvasDiv.width) / 2
      xOffset = 0
    }
    canvasCtx.drawImage(image, xOffset, yOffset, width, height)
  })
  image.src = imagePath
}

ちなみにこれでもダメでした。

方針を切り替え、スマホではカメラモードを使えないようにしてしまいます。

以下のような関数を作ってユーザーのデバイスを取得し、PC以外の端末ではカメラモードを使えないようにしてしまいました。

const getDevice = () => {
  const ua = navigator.userAgent
  if (
    (ua.indexOf('iPhone') > 0 || ua.indexOf('iPod') > 0 || ua.indexOf('Android') > 0) && ua.indexOf('Mobile') > 0) {
    return 'sp'
  } else if (ua.indexOf('iPad') > 0 || ua.indexOf('Android') > 0) {
    return 'tab'
  } else {
    return 'other'
  }
}

export default {
  getDevice,
}

iPhoneで画像アップロードすると回転する

iPhoneの画像はExifと呼ばれるメタデータを持っています。

ファイルサイズ、位置情報、撮影日時、回転情報などがこれに含まれます。

これを描画時に反映させないと意図しない方向に回転して表示されてしまいます。

解決方法

便利なライブラリを使って解決しました。

ライブラリインストール

npm install blueimp-load-image

インポート

import loadImage from 'blueimp-load-image'

実装例

onImageChange (file) {
  if (file !== undefined && file !== null) {
    if (file.name.lastIndexOf('.') <= 0) {
      return
    }
    loadImage.parseMetaData(file, (data) => {
      const options = {
        canvas: true
      }
      if (data.exif) {
        options.orientation = data.exif.get('Orientation')
      }
      loadImage(file, async (canvas) => {
        this.imageUrl = canvas.toDataURL('image/jpeg')
        // 顔認識と描画処理
      }, options)
    })
  } else {
    this.imageUrl = ''
  }
}

リップの製品情報を取得

こんな感じでデータを作っていきます。

公式サイトを訪問し、カラーピッカーで一つ一つリップの色を取得する地獄のような作業でした。

地球上の全ブランドを網羅するぐらいの気概で開発を始めましたが、ここで心折れました。

brands: [
  {
    name: 'THREE',
    items: [
      {
        name: 'THREE Daringly Distinct Lipstick',
        colors: [
          {id: '01', code: 'rgb(198,29,67)'},
          {id: '02', code: 'rgb(189,38,79)'},
          {id: '03', code: 'rgb(208,62,80)'},
          {id: '04', code: 'rgb(221,72,110)'},
          {id: '05', code: 'rgb(218,83,126)'},
          {id: '06', code: 'rgb(232,114,136)'},
          {id: '07', code: 'rgb(233,79,111)'},
          {id: '08', code: 'rgb(232,57,74)'},
          {id: '09', code: 'rgb(234,110,146)'},
        ]
      },
      {
        name: 'THREE Daringly Demure Lipstick',
        colors: [
          {id: '01', code: 'rgb(216,49,105)'},
          {id: '02', code: 'rgb(239,53,66)'},
          {id: '03', code: 'rgb(241,81,105)'},
          {id: '04', code: 'rgb(221,80,97)'},
          {id: '05', code: 'rgb(210,97,97)'},
          {id: '06', code: 'rgb(183,74,111)'},
          {id: '07', code: 'rgb(128,17,33)'},
          {id: '08', code: 'rgb(159,24,48)'},
          {id: '09', code: 'rgb(95,19,24)'},
        ]
      }
    ]
  },
]

終わり

リップの情報を取ってくるのが辛すぎたので、このプロダクトのことは一旦忘れることにしました。

それでも技術的に以下の知見が得られたので作ってよかったです。

  • webRTC
  • canvas
  • 顔認識