WHITEPLUS TechBlog

Amazon Rekognitionを使った顔写真クロッピング

この記事はWHITEPLUS Advent Calendar 2018 - Qiitaの22日目になります。

どうも皆さん、おはこんばんにちは。WHITEPLUSのエンジニアのkazunkrandsです。

私は、弊社で新規事業として運営している生活サービスに特化した事業者様とユーザーのマッチングプラットフォームである生活手帖というサービスのシステム担当をしてます。

生活手帖では、事業者様が登録しサービスを掲載する流れですが、その際に事業者の代表者の顔写真を掲載する機能があります(顔写真の掲載は、サービスを掲載する上でマストではないのですが、顔写真があった方がユーザーに与える安心感が増すため集客力がアップする傾向にあります)。

しかしながら、事業者様の中には掲載するための写真を選定するのに苦労されている方もちょいちょいいるのが現状です。社内でも圧倒的なkindnessキャラとして名を馳せている私としては心が痛みます。

「何とかしてあげたいな〜」と思い考えたのが、Amazon Rekognitionを使った顔写真クロッピングです。

簡単に言うと、Amazon Rekognitionの顔認証機能を利用し、画像内から顔の部分だけを切り出すというやつです。

jqueryのCropperというプラグインを利用し、ユーザー自身に顔の部分をいい感じに切り抜かせるという自由度の高い方法もあるかと思いますが、圧倒的なkindnessキャラとして名を馳せている私としては物足りません。

自動的にクロッピングしちゃおうではありませんか。

開発環境

今回は、弊社で普及しているWebフレームワークのLaravelで簡単な画像アップローダを作成し、実際に顔部分をクロッピングした画像を生成するようなアプリケーションを作りたいと思います。 環境は以下のような感じです。

  • Laravel 5.5系
  • PHP7.1系

Amazon Rekognitionの導入

アクセス権限の追加

AWSコンソールでIAMで「AmazonRekognitionReadOnlyAccess」というアクセス権限を追加する必要があります(アクセス権限の管理についてはこちらを参照)。

AWS SDKをインストール

composerを使ってAWS SDK for PHPをインストールします。

composer require aws/aws-sdk-php

疎通サンプル(UnitTest)

Aws\Rekognition\RekognitionClientモジュールを使い、DetectFacesなるメソッドを使用し顔検出を行います。 お試しということで、ちょっとしたUnitTestを書いてみました(ちょっとしたものなので、単に疎通が成功したか否かを検出するだけのコードです)。

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Aws\Rekognition\RekognitionClient;

class AmazonRekognitionTest extends TestCase
{
    /**
     * A basic test example.
     *
     * @return void
     */
    public function testBasicTest()
    {
        $sampleImg = 'sample01.jpg';

        $options = [
            'region'      => 'us-west-2',
            'version'     => 'latest',
            'credentials' => [
                'key'    => 'XXXX', // ここを編集してね
                'secret' => 'XXXX', // ここを編集してね
            ]
        ];
        try {
            $client = new RekognitionClient($options);
            // 顔検出
            $result = $client->detectFaces([
                'Image' => [
                    'Bytes' => file_get_contents($sampleImg),
                ],
                'Attributes' => ['DEFAULT']
            ]);
        } catch (\Exception $e) {
            error_log($e->getMessage());
            $this->assertTrue(false);
        }
        var_dump(json_encode($result['FaceDetails']));

        $this->assertTrue(true);
    }
}

実行しうまく顔検出に成功すると、以下のようなログがdumpされます(整形済みjson)。

[
    {
        "BoundingBox": {
            "Width": 0.35630151629447937,
            "Height": 0.46711021661758423,
            "Left": 0.2968243658542633,
            "Top": 0.28137731552124023
        },
        "Landmarks": [
            {
                "Type": "eyeLeft",
                "X": 0.4008345603942871,
                "Y": 0.44954565167427063
            },
            {
                "Type": "eyeRight",
                "X": 0.557925283908844,
                "Y": 0.4117130935192108
            },
            {
                "Type": "mouthLeft",
                "X": 0.44907310605049133,
                "Y": 0.6151294112205505
            },
            {
                "Type": "mouthRight",
                "X": 0.5793680548667908,
                "Y": 0.5829805731773376
            },
            {
                "Type": "nose",
                "X": 0.5226051807403564,
                "Y": 0.5239108204841614
            }
        ],
        "Pose": {
            "Roll": -12.931709289550781,
            "Yaw": 3.4335901737213135,
            "Pitch": -0.31531649827957153
        },
        "Quality": {
            "Brightness": 89.29254150390625,
            "Sharpness": 78.64350128173828
        },
        "Confidence": 99.99980926513672
    }
]

今回、キモとなるのがBoundingBoxです。顔の部分を矩形で描いた情報がここの部分です。それぞれの値は端から端を0〜1のratioとなっています。

  • Width:横幅
  • Height:高さ
  • Left:左端開始位置
  • Top:上端開始位置

疎通が成功したところで、いよいよ簡単なアプリケーションを実装してみます。

簡単なアプリケーション

routes

下2行を追加しただけですが、こんな感じで。 /testでクロッピングページに飛ぶようにしただけです。

<?php
Route::get('/', function () {
    return view('welcome');
});

Route::get('/test', 'TestController@index');
Route::post('/test', 'TestController@post');

Controller

簡単に言うと、アップロードした画像を顔検出し、その情報を用いてクロッピングしています。アップロード画像はローカルstorageに保管しておき、加工しやすいようにしました(※大きな理由はないです)。かなりファットなControllerですが、Serviceに切り出すのが面倒だったためとりあえずこうしてます(汗)。

<?php

namespace App\Http\Controllers;

use Aws\Rekognition\RekognitionClient;
use Illuminate\Http\Request;

class TestController extends Controller
{
    const IMG_PATH = "/storage/img/";

    const THUMBNAIL_PATH = "/storage/thumbnail/";

    public function index()
    {
        return view('test')->with(
            [
                'images' => $this->getStoredImages(true),
                'thumbnails' => $this->getStoredImages(false)
            ]
        );
    }

    public function post()
    {
        if(request()->input('execute')) {
            return $this->execute(request());
        } elseif(request()->input('delete')) {
            return $this->delete();
        }
    }

    public function execute(Request $request)
    {
        $this->validate($request, [
            'file' => [
                // 必須
                'required',
                // アップロードされたファイルであること
                'file',
                // 画像ファイルであること
                'image',
                // MIMEタイプを指定
                'mimes:jpeg,png',
            ]
        ]);

        if ($request->file('file')->isValid()) {
            $org = $request->file->store('public/img');
            $this->cropFaces($org);
            return redirect('/test')->with(
                [
                    'success' => 'クロッピングを実行しました',
                    'orgImages' => $this->getStoredImages(true),
                    'thumbnails' => $this->getStoredImages(false),
                ]
            );
        } else {
            return redirect()->back()
                ->withInput()
                ->withErrors(['file' => '画像がアップロードされていないか不正なデータです。']);
        }
    }

    public function delete()
    {
        $this->deleteStoredImages(true);
        $this->deleteStoredImages(false);
        return redirect('/test')->with(
            [
                'success' => 'お掃除しました',
                'orgImages' => $this->getStoredImages(true),
                'thumbnails' => $this->getStoredImages(false),
            ]
        );
    }

    private function deleteStoredImages($isOrg = true)
    {
        $path = $isOrg ? self::IMG_PATH : self::THUMBNAIL_PATH;
        $imgList = glob(public_path() . $path . "*");
        foreach ($imgList as $img) {
            unlink($img);
        }
    }

    private function getStoredImages($isOrg = true)
    {
        $path = $isOrg ? self::IMG_PATH : self::THUMBNAIL_PATH;
        $imgList = glob(public_path() . $path . "*");
        $images = [];
        foreach ($imgList as $img) {
            $images[] = asset($path . basename($img));
        }

        return $images;
    }

    private function cropFaces($orgFile)
    {
        $orgImg = public_path() . self::IMG_PATH . basename($orgFile);
        $faces = self::detectFaces($orgImg);
        if (!$faces) {
            return null;
        }

        $thumbnailPath = public_path() . self::THUMBNAIL_PATH;
        $filePath = pathinfo($orgImg);
        $size = getimagesize($orgImg);
        $width = $size[0];
        $height = $size[1];

        $idx = 0;
        foreach ($faces as $face) {
            $boundingBox = $face['BoundingBox'];
            $widthRatio = $boundingBox['Width'];
            $heightRatio = $boundingBox['Height'];
            $leftRatio = $boundingBox['Left'];
            $topRatio = $boundingBox['Top'];
            $im = imagecreatefromstring(file_get_contents($orgImg));
            $cropped = imagecrop($im, [
                'x' => round($width * $leftRatio),
                'y' => round($height * $topRatio),
                'width' => round($width * $widthRatio),
                'height' => round($height * $heightRatio)
            ]);
            imagedestroy($im);

            $croppedFileName = sprintf('%s%s_%s.%s', $thumbnailPath, $filePath['filename'], $idx,
                $filePath['extension']);
            imagejpeg($cropped, $croppedFileName);
            imagedestroy($cropped);
            $idx++;
        }
    }

    private static function detectFaces($file)
    {
        $options = [
            'region' => 'us-west-2',
            'version' => 'latest',
            'credentials' => [
                'key' => 'XXXX',    // ここを編集してね
                'secret' => 'XXXX'  // ここを編集してね
            ]
        ];
        $result = null;
        try {
            $client = new RekognitionClient($options);
            // 顔検出
            $result = $client->detectFaces([
                'Image' => [
                    'Bytes' => file_get_contents($file),
                ],
                'Attributes' => ['DEFAULT']
            ]);
        } catch (\Exception $e) {
            echo $e->getMessage() . PHP_EOL;
        }

        return isset($result['FaceDetails']) ? $result['FaceDetails'] : null;
    }
}

View

Viewに関してもシンプルです。 executeボタンでクロッピングを実行し、結果を画面下部に表示させるだけのものです。 executeするたび、ローカルstorageにアップロードした画像がどんどん溜まっていくのが嫌なので、一応deleteボタンは用意しています(押すとstorage内の画像が全てお掃除されます)。

{!! Form::open(['url' => '/test', 'method' => 'post', 'files' => true]) !!}

@if (session('success'))
  <div class="alert alert-success">{{ session('success') }}</div>
@endif

@if ($errors->any())
  <div class="alert alert-danger">
    <ul>
      @foreach ($errors->all() as $error)
        <li>{{ $error }}</li>
      @endforeach
    </ul>
  </div>
@endif

<div class="form-group">
  {!! Form::label('file', '', ['class' => 'control-label']) !!}
  {!! Form::file('file') !!}
</div>

<div class="form-group">
  {!! Form::submit('execute', ['class' => 'btn btn-default', 'name' => 'execute']) !!}
</div>
<div class="form-group">
  {!! Form::submit('delete', ['class' => 'btn btn-default', 'name' => 'delete']) !!}
</div>
{!! Form::close() !!}

@if (isset($images))
  <p>アップロード済みオリジナル画像一覧(width:50%)</p>
  @foreach ($images as $image)
    <img src="{{ $image }}" width="50%"/>
  @endforeach
@endif
@if (isset($thumbnails))
  <p>クロッピング結果</p>
  @foreach ($thumbnails as $thumbnail)
    <img src="{{ $thumbnail }}" width="100px"/>
  @endforeach
@endif

完成品

localhostで/testを開くとこんな感じになります。

f:id:kazunkrands:20181214023437p:plain
完成品(初期)

実際に画像を選択し、executeしたものがこれです。

f:id:kazunkrands:20181214023645p:plain
execute後

一応、顔部分の切り抜きはできていますが、欲を言えば正方形で切り抜いてくれると嬉しいですね・・・。まあ、正方形に加工しろって話なんですが、顔部分の切り抜きという目的は達成したため今回はこれで良しとしてます。

まとめ

Amazon Rekognitionを使えば、いい感じに顔検出してくれます。detectFacesを呼んでやるだけで、とても簡単で便利です。

また、Amazon Rekognitionは顔の表情の検出もできるらしいので、あんまり硬すぎる表情の写真はNG!みたいなバリデーションも作れるかも知れません(変顔とかも分かるのか?)。

また、検出した顔は若干アップ気味なので、もう少しマージン調整が必要だったりします。生活手帖では頭全体と肩くらいまで見えた方が好ましいですね。

ええ、そうです。三国志好きです。クロッピングのサンプル画像は中国ドラマの「Three Kingdoms」のロゴです。1話40minくらいで全95話あるのですが、もう4周くらい観てます(2010年のドラマなのですが、懲りずに観まくってます)。

参考

最後に

明日は、弊社で新規事業として運営している生活手帖のエンジニアのkazunk・・・ええ、そうです。私です。僭越ながら明日も私なのです。 明日は「Pusherを使ったリアルタイム通知機能」です。よろしくお願いします。

そして、WHITEPLUSでは一緒に働く仲間を募集しています。 www.wantedly.com