こんにちは。セーフィー株式会社エンジニアの伊藤です。
今回の話はややフロントエンドエンジニア向けの内容となるのですが、UI/UX に関わる話もありエンジニアの方でなくてもお楽しみいただける内容となっていると思います。気軽に読んでいただけると幸いです。
さて、突然ですが、エンジニアのみなさんはユーザーが入力した画像のうち特定の部分だけを切り出したいと思ったことはないでしょうか?セーフィーでは SafieEntrance2という顔認証技術を使った入退場の管理システムの開発において、そんな場面に遭遇しました。
SafieEntrance2 の課題
SafieEntrance2 とは顔認証技術を使った入退場の管理システムで、自分の顔を鍵代わりにしてドアを開けることができるサービスです。こちらのサービスをオフィスに導入すれば、うっかりデスクに社員証を置いたまま外に出てしまったら誰か来るまでドアの前でそわそわしながら待たねばならない、という全ての社会人が抱えている悩みが過去のものとなります。気になった方はこちらのリンク を会社の偉い方に共有してあげてください。
SafieEntrance2 ではユーザーが利用登録するときに Web アプリ上で顔画像を登録します。この時にある程度の大きさでしっかりと正面を向いて顔が写っていないと顔認証の精度が落ちてしまいます。こちらとしては、免許証やパスポートの証明写真のように、ばっちり正面を向いた写真を登録してほしいのですが、何も意識せずに自撮りした写真は、引きで撮影されて顔が小さく映っていたり、写真の中に余計なものが映り込んでいたりします。また画像はスマホで撮影されるパターンが多く、そのままだと解像度が大きすぎるといった問題もありました。
さて、この課題を解決するために、下の図のように登録時に画像の上にガイド用の線を重ねて表示することで、観光地で顔はめパネルを見つけたらどうしても写真が撮りたくなる心理をつつき、顔認証に適した画像を登録させることにしました。
実際の手順は下記の通りで、顔写真の画像を撮影または選択した後に、PCの場合はマウス操作で、スマートフォンの場合はスワイプ・ピンチ操作で、ちょうどよい場所までユーザーの手で画像を移動してもらい、そこだけ切り出して顔写真を登録するという流れです。
ここでまさに画像の特定の範囲だけを切り出すという処理を実装する必要がありました。
良さげな画像切り出しライブラリ
というわけで、上記のような機能を実装するにあたって、良さげなライブラリを探します。
Cropperjs
とりあえず一番有名そうなのが cropperjs でした。実際の動作はリンク先のサンプルを動かすとわかりやすいと思います。
https://fengyuanchen.github.io/cropperjs/
こちらは画像の中から切り出したい範囲をボックスを動かして指定するという方法になります。よく見かける UI ではありますが、今回やりたいのとはちょっと違います。ただでさえ小さなスマホの画面の上で、小さく写った顔をなぞるようにしてボックスを指定するという形になるので、細かい指先の操作が要求されそうです。顔はめパネルに自ら顔をあてに行くというよりは、自分の顔の所に顔はめパネルの方を動かして持ってくるという感じです。というわけでこちらのライブラリは使いませんでした。
Croppie
続いて使えそうなのが Croppie でした。こちらもリンク先のサンプルを動かしていただくと実際の動作がわかりやすいです。
https://foliotek.github.io/Croppie/
こちらの方は切り出す範囲は動かずに、後ろに写った画像の方が動くというスタイルの UI になっております。目指したかったのはこちらの動きに近いです。しかもいろいろ機能もあってかゆいところにも手が届きそうです。
まとめ
今回は画像を切り出したいときに使える良さげなライブラリの紹介でした。世の中には便利なものがたくさんありますね。ただ、この内容だとフロントエンドエンジニア以外の人にとっては何にも面白くないので、こちらの技術を応用して顔はめパネルアプリを作ってみました。
こんな感じの顔ハメ写真が作れます!
以上、現場からでした。
おまけ
ちなみに実際はどうしたかというと、今回紹介したライブラリは利用せずに、全て自前での実装となりました。というのも、細かい動きを実現しようとしたときにライブラリが対応しておらず、やりたいことが実現できなさそうだったのです。ここからはライブラリを使わずに Croppie 的な動きを再現するにはどうすればよいのか、興味のある人だけ続きを読んでみてください。
まずは見本となるシンプルなアプリを用意しましたので、実際に動作を確認しつつ、デベロッパーツールでコードを見ながら読み進めると良いと思います。
https://safiepublic.github.io/kaohame/image.html
画像切り出し Canvas API
画像切り出しには Canvas の drawImage が使えます。
https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage
ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)
こちらの API は、図が示すように画像の切り出したい範囲を引数の sx, sy, sWidth, sHeight で指定するだけのシンプルなものとなっています。各パラメータをユーザーの操作に合わせていかに更新していくかがポイントとなります。
sx, sy はどこから切り出すかのパラメータなので、マウスドラッグやタッチ操作の動きにあわせて良しなに更新すれば良さそうです。sWidth, sHeight はどれだけの範囲を切り出すかを指定するパラメータですが、最終的に切り出した画像のアスペクト比(画像の縦と横の比率)は固定する方針とするので、拡大率を scale としてひとつだけパラメータを持っておけばよいです。というわけで下記の値をグローバル変数として持っておきます。
let sx = 0 let sy = 0 let scale = 1
描画先では Canvas の全領域に描画することになるので、dx, dy はゼロで、dWidth, dHeight は描画先の Canvas のサイズで固定できます。
const dx = 0 const dy = 0 const dWidth = 500 const dHeight = 500
このように定義すると sWidth, sHeight は scale を使って下記のようにあらわすことができます。
const sWidth = dWidth / scale const sHeight = dHeight / scale
こちらについては下の図を見てもらえるとわかりやすいかと思います。下図のように拡大率 (scale) が大きくなればなるほど切り出す範囲を狭くしていく必要があるため、数式の分母の方に scale が来ています。逆に scale が小さくなると、切り出し範囲は広くなり、より全体を見渡せる画像が結果として得られます。scale はマウスホイール操作やタッチのピンチ操作にあわせて更新することで、よくある画像ビューワーの UI を再現できます。
なんだかんだ描画している部分のコードは下記のような感じでとてもシンプルに書けます。
function draw() { const ctx = canvas.getContext('2d') ctx.fillStyle = 'rgb(0, 0, 0)' ctx.fillRect(0, 0, canvas.width, canvas.height) const sWidth = dWidth / scale const sHeight = dHeight / scale ctx.drawImage(srcImage, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight) }
マウス操作による切り出し範囲の変更
続いてマウス操作で切り出す位置を更新しているコードを見ていきます。
切り出し位置 (sx, sy) の変更はマウスドラッグで行いますので、ドラッグ中かどうかを示すフラグを変数 (isMouseDown) として保持してする必要があります。またマウスの移動量を知る必要があるので、マウスの位置もグローバル変数として持っておきます。
let isMouseDown = false let mousePosX = null let mousePosY = null
マウスイベントの mousedown, mouseup, mouseleave を拾って、isMouseDown フラグを On/Off を操作します。また mousedown のタイミングではマウスがクリックされた位置を記憶しておきます。
canvas.addEventListener('mousedown', (e) => { isMouseDown = true mousePosX = e.offsetX mousePosY = e.offsetY }) canvas.addEventListener('mouseup', (e) => { isMouseDown = false }) canvas.addEventListener('mouseleave', (e) => { isMouseDown = false })
mousemove ではドラッグ中の場合のみ、sx, sy を更新しますが、この時、画面上のマウスの移動量をそのまま sx, sy に反映させるのではなく、拡大率に応じて画面上の移動量を入力画像上の移動量に換算するところがポイントです。
canvas.addEventListener('mousemove', (e) => { const newMousePosX = e.offsetX const newMousePosY = e.offsetY if (isMouseDown) { const mouseDiffX = mousePosX - newMousePosX const mouseDiffY = mousePosY - newMousePosY const canvasDiffX = mouseDiffX / canvas.clientWidth * dWidth const canvasDiffY = mouseDiffY / canvas.clientHeight * dHeight const imageDiffX = canvasDiffX / scale const imageDiffY = canvasDiffY / scale const newSx = sx + imageDiffX const newSy = sy + imageDiffY sx = Math.min(Math.max(newSx, 0), maxSx()) sy = Math.min(Math.max(newSy, 0), maxSy()) draw() } mousePosX = newMousePosX mousePosY = newMousePosY })
例えば、マウスが画面上を 100px 動いたとしても、倍率2倍で表示されていた場合、入力画像上では 50px (= 10 / 2) しか動いていません。 このように拡大率に応じて移動量を調整しないと、拡大時はマウス操作よりも大きく画像が動いてすべったような挙動になり、逆に縮小時はドラッグしてもなかなか画像が移動しないということになってしまいます。これを補正しているのが上記の処理です。
次はマウスホイール操作による拡大&縮小のコードです。
canvas.addEventListener('mousewheel', (e) => { e.preventDefault() const prevScale = scale const newScale = e.deltaY > 0 ? scale * 0.95 : scale * 1.05 scale = Math.max(newScale, minScale()) const focusX = mousePosX / canvas.clientWidth * dWidth const focusY = mousePosY / canvas.clientHeight * dHeight const newSx = sx + focusX * (1 / prevScale - 1 / scale) const newSy = sy + focusY * (1 / prevScale - 1 / scale) sx = Math.min(Math.max(newSx, 0), maxSx()) sy = Math.min(Math.max(newSy, 0), maxSy()) draw() })
ホイールの操作に合わせて scale を更新すればよいのですが、コードを見ると同時に sx, sy も更新しています。これはマウスカーソルがある位置にフォーカスしてズームを行いたいからです。こちらも下の図を見てもらうとわかりやすいと思いますが、マウスポインタが指している位置を動かさずに拡大&縮小を行おうとすると、切り出しの開始位置の座標である sx, sy も一緒に動かす必要があるのです。
マウスポインタがある位置の画像上の座標(imagePosX, imagePosY) を数式で表すと次のようになります。
imagePosX = sx + mousePosX / scale
imagePosY = sy + mousePosY / scaleここで、拡大の前後でマウスポインタの指している位置が変わらないように sx, sy を調整するので、拡大前のsx, sy, scale を prevSx, prevSy, prevScale とし、拡大後の sx, sy, scale を newSx, newSy, newScale とすると、拡大の前後で次の式が成り立ちます。
prevSx + mousePosX / prevScale = newSx + mousePosX / newScale
prevSy + mousePosY / prevScale = newSy + mousePosY / newScaleこちらの式を newSx, newSy について解くと次のようになり、補正後の位置が求まります。
newSx = prevSx - mousePosX * ( 1 / prevScale - 1 / newScale )
newSy = prevSy - mousePosY * ( 1 / prevScale - 1 / newScale )サンプルのソースコードでは Canvas 上の位置の補正も入っているので上記の式とはちょっと違うのですが大体こんな感じです。ちなみに Croppie はこのあたりの処理がややあまく、拡大&縮小は常に画像の中心にフォーカスして行われるようになっていました。
タッチ操作による切り出し範囲の変更
スマホの場合はタッチ操作になるので、タッチイベントを拾って処理することになります。少し長いのでちょっとずつ見ていくことにします。
touchmove イベントでタッチした点の座標を配列で受け取ることができます。ただし、取得できる座標は canvas 上の座標ではなくウィンドウ全体の座標となっているので、canvas の位置を差し引いて、canvas 上の座標に変換します。
// タッチの位置を計算する const canvasRect = canvas.getClientRects()[0] const newTouches = [] for (let i = 0; e.touches.length > i; ++i) { newTouches.push({ x: e.touches[i].clientX - canvasRect.left, y: e.touches[i].clientY - canvasRect.top, }) }
続いて画像の移動処理ですが、2本以上の指で操作されることもあるので、戦略としては全てのタッチ点の平均位置を計算して、平均位置の移動量を画像の移動量とすることにしています。これによって2本指以上でも操作が可能です。処理のはじめにタッチ点の数が違う場合に処理を中断している理由は、途中でタッチの点数がかわることによって、平均位置が急激に変化して画像が一瞬で大きく動くということを防ぐためです。
// タッチ数が異なる場合はタッチの位置だけ記憶してリターン if (touches.length !== newTouches.length) { touches = newTouches return } // 全てのタッチの平均位置を計算する const prevTouchMeanPos = { x:0, y:0 } const newTouchMeanPos = { x:0, y:0 } for (let i = 0; touches.length > i; ++i) { prevTouchMeanPos.x += touches[i].x prevTouchMeanPos.y += touches[i].y newTouchMeanPos.x += newTouches[i].x newTouchMeanPos.y += newTouches[i].y } prevTouchMeanPos.x /= touches.length prevTouchMeanPos.y /= touches.length newTouchMeanPos.x /= newTouches.length newTouchMeanPos.y /= newTouches.length // 全てのタッチの平均位置が動いた距離を視点の移動量とする const touchDiffX = prevTouchMeanPos.x - newTouchMeanPos.x const touchDiffY = prevTouchMeanPos.y - newTouchMeanPos.y const canvasDiffX = touchDiffX / canvas.clientWidth * dWidth const canvasDiffY = touchDiffY / canvas.clientHeight * dHeight const imageDiffX = canvasDiffX / scale const imageDiffY = canvasDiffY / scale let newSx = sx + imageDiffX let newSy = sy + imageDiffY
タッチが2点以上の場合は同時に拡大&縮小も行います。こちらの戦略としては、全てのタッチのうち最も離れた2点の距離の変化を拡大率の変化量とすることにします。これにより3本以上の指で操作されたとしてもピンチ操作ができます。またこの時に2点間の中心の位置にフォーカスして拡大&縮小するように sx, sy も更新しており、注目しているポイントに寄っていく動きが実現できます。
// 2 点タッチ以上の場合、拡大縮小も行う if (touches.length >= 2) { // 全てのタッチのうち最も離れた2点の距離の変化を拡大率の変化量とする let prevMaxDistance = 0 for (let i = 0; touches.length > i; ++i) { for (let j = i+1; touches.length > j; ++j) { const p1 = touches[i] const p2 = touches[j] prevMaxDistance = Math.max(prevMaxDistance, calcDistance(p1, p2)) } } let newMaxDistance = 0 for (let i = 0; newTouches.length > i; ++i) { for (let j = i+1; newTouches.length > j; ++j) { const p1 = newTouches[i] const p2 = newTouches[j] newMaxDistance = Math.max(newMaxDistance, calcDistance(p1, p2)) } } const prevScale = scale const newScale = scale * newMaxDistance / prevMaxDistance scale = Math.max(newScale, minScale()) // スケールの変化に合わせて画像の位置更新 const focusX = prevTouchMeanPos.x / canvas.clientWidth * dWidth const focusY = prevTouchMeanPos.y / canvas.clientHeight * dHeight newSx += focusX * (1 / prevScale - 1 / scale) newSy += focusY * (1 / prevScale - 1 / scale) }
ちなみに Croppie はタッチ処理で少し雑な部分がありました。ソースコードを覗いた感じですと、タッチ点を 2 点以上検知した場合は拡大&縮小のみを行う動きとなっており、ピンチ操作しながら移動することができません。またピンチ操作の時に配列の最初の 2 点を使ってスケールを計算しており、3 本以上の指で操作した時に思った通りに動かないことがあります。
https://github.com/Foliotek/Croppie/blob/master/croppie.js
終わりに
長くなりましたが、以上が処理の全容になります。
今回お話ししたような内容は、OS に標準で入っているような画像ビューワーでも当たり前のように実装されている処理でもありますし、Photoshop のような画像編集ツールや draw.io や figma 等のドローイングツールを作ろうとしたときにも必要な処理となります。案外身近なところで出くわすものですが、ちゃんとした解説を見かけたことがなかったので、様々ある実装方法のひとつとしてみなさんの参考になれば幸いです。
最後までお付き合いいただきありがとうございました。