オンラインミーティングや勉強会が盛んになってきた昨今、自分が映っているカメラ映像の背景を透過させて、他のアプリケーションと良い感じに一緒に配信したいな〜と思ったので mewcam ( ミュウカム ) という mac アプリを作ってみました。
こんな感じです。
良いですね(表情が固い)。
ダウンロード
GitHub にソースコードとアプリを公開しています。是非ダウンロードして使ってみてください。
今は mac のみの対応ですが、Windows 版もリリースする予定です。もしフィードバックがあれば @zaru までお願いします。
Windows 版もリリースしました! 最新版のダウンロードは GitHub からお願いします。
どうやって作るのか
ここからは作り方です。ポイントだけ簡単に説明します。
最初は普通に Swift で作りました。背景を透過せずに Web カメラの画像をデスクトップ前面に表示するだけなら簡単にできました。
Swift で全画面透過 + 常に最前面に表示する Window
class MainWindow: NSPanel {
init(locationX: CGFloat, locationY: CGFloat) {
super.init(contentRect: NSRect(x: locationX, y: locationY, width: 513, height: 513),
styleMask: [.fullSizeContentView, .nonactivatingPanel],
backing: .buffered,
defer: false)
self.isOpaque = false
self.hasShadow = false
self.backgroundColor = NSColor.clear
self.makeKeyAndOrderFront(nil)
self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
self.level = NSWindow.Level.floating
}
override var canBecomeKey: Bool {
return true
}
}
- NSPanel じゃなくて NSWindow でも要件が満たせる場合もあります
Window 自体をドラッグで移動可能にする
Window のツールバーなどは非表示にしているため、Window を移動するためには表示している View をドラッグして移動できるようにする必要があります。やり方は簡単で、ドラッグ専用の View を置いてあげます。
// ドラッグ可能な View を作成
class WindowDragView: NSView {
override public func mouseDown(with event: NSEvent) {
window?.performDrag(with: event)
}
}
// ViewController とかに配置
let dragView = WindowDragView(frame: NSRect(x: 0, y: 0, width: 513, height: 513))
self.addSubview(dragView)
あとは AVCaptureDevice
や AVCaptureVideoDataOutputSampleBufferDelegate
などで Web カメラと接続し、流れてくるデータを CoreImage を使って CGImage 変換から NSImage にして View に出力すれば良いだけです。
CoreML で背景透過
背景を透過させたいので、調べたところ DeeplabV3 の画像セグメンテーションモデルを使えば、人物を良い感じに切り抜けることがわかりました。macos や iOS には CoreML があるので、それを使って機械学習で背景を透過させることにします。
Apple が配布している学習済みモデルがあるのでこれを利用しました。
使い方は非常に簡単です。ダウンロードした mlmodel ファイルを Xcode のプロジェクトに追加して、コードからモデル名で呼び出すだけです。そして各モデルによって実行するメソッドが変わります。DeepLabV3 の場合は prediction(image: )
でした。
このモデルの場合、返り値が背景や人物などのセグメンテーションされた情報が 1px ずつ全ての座標で埋まった配列になります。なので、背景と判断された座標を全て透過で塗りつぶしているだけで背景透過画像ができます。
let image = NSImage(named: "photo")
image!.size = NSSize(width: 513, height: 513)
let model = DeepLabV3()
guard let output = try? model.prediction(image: image!.pixelBuffer()!) else {
fatalError("Error")
}
let rep = (image?.bitmapImageRepresentation(colorSpaceName: "NSDeviceRGBColorSpace"))!
let newImage = NSImage(size: NSSize(width: 513, height: 513))
for y in 0..<513 {
for x in 0..<513 {
let getY = 512 - y
let index: [NSNumber] = [NSNumber(value: getY), NSNumber(value: x)]
let flag = output.semanticPredictions[index]
if flag == 0 {
rep.setColor(NSColor(deviceRed: 0, green: 0, blue: 0, alpha: 0.0), atX: x, y: y)
}
}
}
newImage.addRepresentation(rep)
ただ、これをリアルタイムで処理しようとすると FPS が 2 程度しか出なく使い物になりませんでした。
Electron + BodyPix で作る
そこで、今度は Google が作っている BodyPix という機械学習のモデルを使うことにしました。これは tensorflow.js の学習済みモデルです。これは本当にすごくてブラウザでリアルタイムに複数人のセグメンテーションができています。
これを使って mac アプリにするには Electron が手っ取り早そうなので、それをチョイスしました。今回初めて作りましたが、本当に簡単にビルドできて感動しました。
Electron で全画面透過 + 常に最前面に表示する Window
const win = new BrowserWindow({
width: 640,
height: 480,
hasShadow: false,
transparent: true,
frame: false,
resizable: true,
alwaysOnTop: true
});
Electron でも全画面透過 + 常に最前面は簡単に作れます。
しかし、他のアプリが全画面表示になった際には、何故か表示されなくなってしまいます。そこで裏技的なハックコードを入れることで回避できます。
app.dock.hide();
win.setAlwaysOnTop(true, 'floating');
win.setVisibleOnAllWorkspaces(true);
win.setFullScreenable(false);
app.dock.show();
何故かわかりませんが、アプリを一瞬隠してから全画面設定を加え、その後表示させると他のアプリが全画面になっても維持してくれます。謎です。
Electron で Window 自体をドラッグで移動可能にする
これもすごく簡単です。body タグにスタイル属性を加えるだけでドラッグ可能になります。
<body style="-webkit-app-region: drag">
BodyPix を使って背景を透過する
BodyPix にはセグメンテーションした情報を canvas に描画する便利なヘルパーメソッドが用意されているのですが、実験的な見せ方のものしかないので、自前で透過処理を書きます。書くと言っても Swift CoreML と同じで愚直に 1px ずつ塗りつぶしていくパターンです。
function _drawToCanvas(canvas, segmentation, originalImage) {
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
ctx.clearRect(0, 0, width, height);
const imageData = ctx.getImageData(0, 0, width, height);
const pixels = imageData.data;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const index = (y * width + x) * 4;
const segmentIndex = y * width + x;
if (segmentation.data[segmentIndex] === 1) {
pixels[index] = originalImage.data[index];
pixels[index + 1] = originalImage.data[index + 1];
pixels[index + 2] = originalImage.data[index + 2];
pixels[index + 3] = originalImage.data[index + 3];
}
}
}
ctx.putImageData(imageData, 0, 0);
}
詳しくは GitHub のコードを見てください。
そんなこんなで Electron でビルドすれば完成です。
余談
今回作った mewcam を ProductHunt に登録してみたところ Today のランキングで6位になることができました。Upvote が 200 超えてとても嬉しかったです。やったね!