😄

Goのwasmで笑い男の顔はめサイトを作ってみた

2024/11/09に公開3

目的

今年は笑い男Yearということで、笑い男の顔はめサイトをGoのwasmで作ってみました!
人気SFシリーズの『攻殻機動隊 STAND ALONE COMPLEX』で天才ハッカーが映像中の全ての顔部分を青い笑った顔のマークに書き換えてしまうという有名なシーンがあり、それを再現しています

成果物

https://x.com/ponyo877/status/1854537269560156625

以下のサイトでお使いのPCブラウザで試せます!
https://laughing-man.pages.dev
なぜかスマートフォンのブラウザで試すと画面が固まってしまうのでPC限定です...

コード

https://github.com/ponyo877/go-wasm-face-play

顔認識

Goの顔認識で最もStar数の多いesimov/pigoを使います
顔全体を覆う四角形の他にも目鼻口などのいわゆる顔のランドマークの認識も行ってくれるので、仮想サングラスや仮想マスクといったことも可能な優れたOSSです
またPure Goなので何の苦労もなくwasmにできてしまうところも嬉しいです
顔認識というと画像処理のOpenCVを連想される方も多いと思いますが、OpenCVはCを含むのでそのままwasmにすることができず、Go+wasm+顔認識となるとpigoが一択という印象です
pigoのサンプル画像
コード

// 識別器detectorは以下から
// https://github.com/esimov/pigo/blob/master/wasm/detector/detector.go
det = detector.NewDetector()
// 顔認識用の特徴量の読み込み
if err := det.UnpackCascades(); err != nil {
	log.Fatal(err)
}
// 顔認証したい画像をグレースケールに変換
// ここではRGBに対して0.2126*R + 0.7152*G + 0.0722*B
pixels := rgbaToGrayscale(goBin, ScreenWidth, ScreenHeight)
// 顔認識処理
// 顔の個数分の座標[0,1]と大きさ[2]が返る
dets := det.DetectFaces(pixels, ScreenHeight, ScreenWidth)
g.faceNum = len(dets)
for i := 0; i < g.faceNum; i++ {
	g.cx, g.cy, g.rad = float64(dets[i][1]), float64(dets[i][0]), float64(dets[i][2])*1.5
}

顔はめ(アニメーション編集)

さて顔はめなのですが、もう顔の中心の座標は取れるのであとは笑い男のマークをそこに描画するだけ、となりそうなのですが、実は原作の笑い男マークはただの画像ではなく、マークの外側の文字列がぐるぐると回っているアニメーションになっているのです
これを再現するためにGoのゲームエンジンのEbitengineを使いました
ゲームエンジンなのですが、その背景には多種の画像処理もできるので使ってみました
まずはアニメーション描画ですが、これは今年出た新機能のMPEG動画再生機能を使いました
https://zenn.dev/eihigh/articles/ebitengine-weekly-4

素材は笑い男の動画をアップしていた厚揚公太さんに許可をいただき使わせていただいています
https://www.youtube.com/watch?v=bgQ0KLFxHec

その後にEbitengineのBlendという画像合成機能を使い、動画の中からマーク部分だけを取り出します
マークを取り出すためのマスク画像を、動画を画像としてコピーしたものにMacのプレビューなどを使って背景をうまく削除したPNGを用意します
そしてBlendのBlendDestinationInを使って動画の顔部分だけを抽出したものを作ります。
https://ebitengine.org/en/examples/blend.html
EbitengineのBlend mode一覧

コード

// MPEGのframeは以下で取得
// https://github.com/hajimehoshi/ebiten/blob/main/examples/video/mpegplayer.go
func drawLaughingMan(screen, frame, mask *ebiten.Image, rad float64, x, y int) {
	sw, sh := screen.Bounds().Dx(), screen.Bounds().Dy()
	fw, fh := frame.Bounds().Dx(), frame.Bounds().Dy()
	wf, hf := float64(sw)/float64(fw), float64(sh)/float64(fh)
	s := wf
	if hf < wf {
		s = hf
	}
	offscreen := ebiten.NewImage(sw, sh)
	offscreen.Clear()
    // はじめにMPEGのフレームを描画
	op := &ebiten.DrawImageOptions{}
	op.GeoM.Scale(s, s)
	offsetX, offsetY := float64(screen.Bounds().Min.X), float64(screen.Bounds().Min.Y)
	op.GeoM.Translate(offsetX+(float64(sw)-float64(fw)*s)/2, offsetY+(float64(sh)-float64(fh)*s)/2)
	op.Filter = ebiten.FilterLinear
	offscreen.DrawImage(frame, op)
    // maskは背景を除いた静止画像
    // MPEGのフレームのmaskと重なる部分を抽出するblend mode
	op.Blend = ebiten.BlendDestinationIn
	offscreen.DrawImage(mask, op)

	op = &ebiten.DrawImageOptions{}

    // 顔の大きさradに合わせてマークの大きさも返る
	s = rad / float64(sh)
	op.GeoM.Scale(s, s)
	op.GeoM.Translate(float64(x), float64(y))

    // 顔の中心とマークの中心(少しずらして自然に)を合わせる
	op.GeoM.Translate(-rad/1.5, -rad/2.0)
	screen.DrawImage(offscreen, op)
}

完成

あとは前述の通り、顔の真ん中にマークの真ん中を合わせるようにして描画すれば完成です!
顔の大きさを使ってマークの大きさも変更するようにすれば、カメラに近ければマークも大きく、遠ければ小さくできるので、カメラからの距離に依存せずに顔全体を覆うように描画ができます

Webカメラからの映像をEbitengineの画像として描画する処理は以前記事にさせていただいた方法を流用しています
https://zenn.dev/ponyo877/articles/3d3cb71bf633f3

まとめ

Goのwasmで笑い男ができました!

ただし、回る文字が数秒で止まってしまったり(MPEGのループ再生ができていない...)、複数人同時の顔はめができていなかったり(なんか重かった)、作中の天才ハッカーには遠く及ばない出来なので、改善できたら都度追記しようと思います!

Discussion

hamaohamao

コードを改変して利用しても大丈夫でしょうか?