【Laravel】写真から色情報を取得し、検索できるようにする

こんにちは❗フリーランス・エンジニアの 九保すこひ です。

さてさて、ここのところあまり外出をしなくなったこともあり、オンラインショップでモノを買うことが増えいます。

そして、最近では服や靴なんかもたまに買うんですが、これに関連してオンラインショップの「ある機能」が便利だなと感じることがありました。

それが・・・・・・

色を使った検索

です。

つまり、赤や青など好きな色を指定し、その色に関連する商品がずらっと表示される機能ですね。

もちろん、精度が重要なオンラインショップでは100%自動で色データを取得しているわけでなないのでしょうが、同時に「自動でやったらどこまでの精度でできるだろう??」という疑問も浮かび上がってきました。

画像そこで❗

今回はLaravelを使って画像から色を抽出し、その色データで検索するという機能をつくってみます。

ぜひ皆さんの学習に役立てば嬉しいです😊✨
(最後にソースコード一式をダウンロードできますよ👍)

「ボトムは基本、赤です👍」

開発環境: Laravel 8.x

やりたいこと

ある画像の中にはどんな色が含まれているかを抽出して保存します。
さらに、その保存した色情報を使って商品検索ができるようにしてみます。

対象の色は次のとおりです。

では楽しくやっていきましょう❗

モデル&DBテーブルをつくる

テーブル構成

今回必要になるテーブルは以下3つです。

  • 商品
  • 商品の画像
  • 商品画像の色

そして、この3つのテーブルの関係は次のとおりです。

つまり、このようになります。

  • 商品商品画像 =  ・・・ 商品の画像は何個でも保存できる
  • 商品画像その色 =  ・・・ 画像の色は何個でも保存できる

なお、画像が複雑になるので描いていませんが、色テーブルには検索ために(親の親になる)商品IDも保存しますので、商品も「」として機能します。

※ちなみに、本来は「画像」と「色」のテーブルをつくり、中間テーブルでつなぐ方がスマートだと思いますが、今回はテストですので、シンプルな構成で実装します。

商品モデル&テーブルをつくる

まずは基本になる商品データを保存するテーブルです。
以下のコマンドを実行してください。

php artisan make:model Item -m

するとファイルが作成されるので、中身を以下のように変更してください。

database/migrations/****_**_**_******_create_items_table.php

<?php

// 省略

class CreateItemsTable extends Migration
{
    public function up()
    {
        Schema::create('items', function (Blueprint $table) {
            $table->id();
            $table->string('name')->comment('商品名');
            $table->text('description')->comment('説明文');
            $table->integer('price')->comment('金額');
            $table->timestamps();
        });
    }

また、一緒に作成されたモデルの中にリレーションシップを追加しておいてください。

app/Models/Item.php

<?php

// 省略

class Item extends Model
{
    use HasFactory;

    // Relationship
    public function item_images() {

        return $this->hasMany('App\Models\ItemImage', 'item_id', 'id');

    }

    public function item_image_colors() {

        return $this->hasMany('App\Models\ItemImageColor', 'item_id', 'id');

    }
}

商品画像モデル&テーブルをつくる

次に商品画像モデルです。
以下のコマンドを実行してください。

php artisan make:model ItemImage -m

中身を以下のように変更します。

database/migrations/****_**_**_******_create_item_images_table.php

<?php

// 省略

class CreateItemImagesTable extends Migration
{
    public function up()
    {
        Schema::create('item_images', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('item_id')->comment('商品ID');
            $table->string('path')->comment('ファイルパス');
            $table->timestamps();

            $table->foreign('item_id')->references('id')->on('items');
        });
    }

画像から抽出した色のモデル&テーブルをつくる

最後に色データを保存するテーブルです。
コマンドを実行してファイルを作成してください。

php artisan make:model ItemImageColor -m

中身は以下のようになります。

database/migrations/****_**_**_******_create_item_image_colors_table.php

<?php

// 省略

class CreateItemImageColorsTable extends Migration
{
    public function up()
    {
        Schema::create('item_image_colors', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('item_id')->comment('商品ID');
            $table->unsignedBigInteger('item_image_id')->comment('商品画像ID');
            $table->string('color')->comment('色'); // `red`, `blue`, `green` ...
            $table->timestamps();

            $table->foreign('item_id')->references('id')->on('items');
            $table->foreign('item_image_id')->references('id')->on('item_images');
        });
    }

テストデータをつくる

続いて、開発をしやすくするためにダミーの商品データをつくるコードを書いていきます。

今回はLaravel 8.xから新しくなったFactoryを使って実装してみましょう。
以下のコマンドを実行してください。

php artisan make:factory ItemFactory --model=Item

すると、Factoryファイルが作成されるので、中身を以下のように変更します。

database/factories/ItemFactory.php

<?php

// 省略

class ItemFactory extends Factory
{
    protected $model = Item::class;
    private $number = 0; // 👈 連番用の変数

    public function definition()
    {
        $this->number++;

        return [
            'name' => 'テスト商品名 - '. $this->number,
            'description' => "テスト説明文\nテスト説明文\nテスト説明文 - ". $this->number,
            'price' => $this->faker->randomDigitNotNull() * 1000
        ];
    }
}

変更が完了したら、ItemFactoryが有効になるようにSeederファイルへ登録しておきます。

database/seeders/DatabaseSeeder.php

<?php

// 省略

class DatabaseSeeder extends Seeder
{
    public function run()
    {
        Item::factory(25)->create(); // 👈 ここを追加しました
    }
}

では、この状態でマイグレーションを実行しましょう。
以下のコマンドを実行してください。

php artisan migrate:fresh --seed

実行が完了すると、実際のテーブルはこうなりました。

パッケージをインストールする

次に、色抽出するためのパッケージをインストールしておきましょう。

thephpleague / color-extractor

このパッケージを使うと、画像の中で使われている色を簡単に抽出することができるようになります。

composer require league/color-extractor:0.3.*

画像が登録されたら色を取得する

では、ここからが本題の画像の色を取り出す部分になります❗
今回テストで使う画像はこちらの4枚です。

※ちなみにヒマワリの種はカナダでおやつとして売られているspitzです。(おいしいのに、日本で売ってない・・・😭)

イベントをつくる

今回は、DBテーブルitem_imagesに画像情報が保存されたら自動的に色を取得するようにします。

では、そのためにItemImageSavedというイベントをつくりましょう。
以下のコマンドを実行してください。

php artisan make:event ItemImageSaved

するとファイルが作成されるので、中身を以下のように変更します。

app/Events/ItemImageSaved.php

<?php

namespace App\Events;

use App\Models\ItemImage;
use App\Models\ItemImageColor;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use League\ColorExtractor\Color;
use League\ColorExtractor\Palette;

class ItemImageSaved
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    const THRESHOLD = 20; // 何%以上色があれば保存するか
    const BASE_COLORS = [  // 基準となる色
        'red' => [128, 0, 0],
        'blue' => [0, 0, 128],
        'yellow' => [128, 128, 0],
        'green' => [0, 128, 0],
        'black' => [0, 0, 0],
        'gray' => [128, 128, 128],
        'white' => [255, 255, 255]
    ];

    public function __construct(ItemImage $item_image)
    {
        $path = $item_image->path;
        $palette = Palette::fromFilename($path);
        $all_color_count = 0;
        $extracted_color_counts = [
            'red' => 0,
            'blue' => 0,
            'yellow' => 0,
            'green' => 0,
        ];

        foreach($palette as $color => $count) {

            $extracted_rgb = array_values(
                Color::fromIntToRgb($color)
            );
            $min_distance = 765; // 最大距離からスタート
            $color_key = '';

            foreach(self::BASE_COLORS as $key => $rgb) {

                $color_distance = $this->getColorDistance($extracted_rgb, $rgb);

                if($color_distance < $min_distance) {

                    $min_distance = $color_distance;
                    $color_key = $key;

                }

            }

            if(in_array($color_key, ['red', 'blue', 'yellow', 'green'])) {

                $extracted_color_counts[$color_key] += $count;
                $all_color_count += $count;

            }

        }

        foreach($extracted_color_counts as $color_key => $count) {

            if($count > 0 && in_array($color_key, ['red', 'blue', 'yellow', 'green'])) {

                $percentage = $count / $all_color_count * 100;

                if($percentage > self::THRESHOLD) { // しきい値より大きければ保存する

                    $item_image_color = new ItemImageColor();
                    $item_image_color->item_id = $item_image->item_id;
                    $item_image_color->item_image_id = $item_image->id;
                    $item_image_color->color = $color_key;
                    $item_image_color->save();

                }

            }

        }

    }

    function getColorDistance($color_1, $color_2) { // 2つの色がどれだけ離れているかを取得

        return abs($color_1[0] - $color_2[0]) +
            abs($color_1[1] - $color_2[1]) +
            abs($color_1[2] - $color_2[2]);

    }

}

この中でやっている手順は次のとおりです。

  1. 画像の中から色データを抽出
  2. 抽出した色が「どの色に(距離的に)近いか?」をチェックし、色を判別
  3. 判別した色データを集計して、もし設定した値(しきい値)よりも大きければ色が存在しているものとしてデータ保存

そして、この流れを実装するために重要な点は、「今回は使わない黒や、白などのモノトーンも色も抽出する」部分です。これは、例えば黒に近い色は黒として処理しないと、一番近い別の色として判別してしまうからです。

また、コードを見て「おや?」と思った人もいるかもしれませんが、BASE_COLORSの中身にRBG色の最大値255を使わず128を使っている部分があります(つまり、微妙な赤や青です)

これは、写真などで色抽出をする場合、自然界には「完全な赤」や「完全な青」はほぼ存在しない、結果として、チェックする色との距離が大きすぎ、赤や青として判別されないためです。(こういうチューニングって難しいですね・・・😫)

では、コードの中身についてご紹介します。

まず__construct()の中でインストールしたパッケージを使って色を抽出しています。

少し注意が必要ですが、このパッケージは「画像のドットごとに情報を取得できる」のではなく、存在している色がいくつ画像に入っているかという集計済みデータを提供してくれます。

そのため、ループは以下のような形になります。

foreach($palette as $color => $count) {

    // $colorが「16進数の色コード」で、$countが「件数」です

}

また、「2つの色がどれだけ近いか?」を計算しているのが、getColorDistance()で、RBGの3色の差(の絶対値)を合計しています。(つまり、まったく同じ色なら結果はゼロですし、色が違っていれば違っているほどこの値は大きくなっていきます)

そして、画像内の全ての色がどの色なのかを判別した結果、定数THRESHOLDのパーセントよりも多く存在していれば、item_image_colorsにその色データを保存するようになっています。

イベントを有効にする

では、先ほど作成したItemImageSavedイベントをモデルにセットします。

app/Models/ItemImage.php

<?php

namespace App\Models;

use App\Events\ItemImageSaved; // 👈 ここを追加しました
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class ItemImage extends Model
{
    use HasFactory;

    // 👇 ここを追加しました
    protected $dispatchesEvents = [
        'saved' => ItemImageSaved::class
    ];
}

テストしてみる

では、実際にテストしてみましょう❗

まずは画像を登録して色を抽出するコードです。(テストなのでルートに書いてますが、本来はルート + コントローラーで実装してください😊)

routes/web.php

// 省略

Route::get('color_extract', function(){

    for($i = 1 ; $i <= 4 ; $i++) {

        $image_path = public_path('images/color_extract_'. $i .'.jpg');

        $item_image = new \App\Models\ItemImage();
        $item_image->item_id = $i;
        $item_image->path = $image_path;
        $item_image->save();

    }

});

ID1〜4の商品に先ほどご紹介した画像をひとつずつ保存&色抽出をします。

では実行した結果です。

抽出自体はうまくいっています。
では、本当に正しい色が抽出できているか以下のコードで見てみましょう❗

Route::get('show_items', function(){

    $items = \App\Models\Item::with('item_image_colors')
        ->whereHas('item_image_colors') // 色データがあるものだけ
        ->get();

    echo '<div style="text-align:center;">';

    foreach ($items as $item) {

        echo $item->name .'<br>';
        echo '<img style="height:150px;" src="/images/color_extract_'. $item->id .'.jpg"><br>';
        echo '色: '. $item->item_image_colors->pluck('color')->implode(', ');
        echo '<hr>';

    }

    echo '</div>';

});

これを実行した結果は次のとおりです。

どうでしょう。
それなりにうまく色が取得できているんじゃないでしょうか。

では、最後に指定した色が含まれているデータだけを取得するコードです。
青で指定して取得してみましょう。

Route::get('search_item_by_color', function(){

    $items = \App\Models\Item::with('item_image_colors')
        ->whereHas('item_image_colors', function($q){ // リレーションシップ先で絞り込み

            $q->where('color', 'blue'); // 👈 青データがある商品だけ

        })
        ->get();
    dd($items->toArray());

});

実行結果はこうなりました。

成功です😊✨

なお、検索に対応させる(色データが送信されたときだけ絞り込む)場合は以下のようにwhen()を使うと便利ですよ👍

Route::get('search_item_by_color', function(){

    $color = request('color');

    $items = \App\Models\Item::with('item_image_colors')
        ->when($color, function($query, $color){ // 👈 値があるときだけ実行されます

            if(in_array($color, ['red', 'blue', 'yellow', 'green'])) {

                $query->whereHas('item_image_colors', function($q) use($color) {

                    $q->where('color', $color);

                });

            }

        })
        ->get();
    dd($items->toArray());

});

when()はデータが存在している場合だけ実行されるメソッドです。

ダウンロードする

今回実際に開発したソースコード一式を以下からダウンロードできます。

【Laravel】写真から色情報を取得

※今回使用した4枚の写真も入っています。
※ただし、マイグレーションなどはご自身で実行していただく必要があります。

おわりに

ということで今回は、Laravelを使って画像から色を抽出&検索ができるようにしてみました。

ちなみに今回開発してみた感想としましては、やはり「チューニングが難しい」の一言です。

記事の中でも書きましたが、最初は「青なのに黒」と判別されてしまうなど精度に問題があったためいろいろと工夫する必要がありました。😅

また、これも重要な点だと思いますが、通常写真を撮るときはメインの物体だけでなく、その物体を置いているテーブルや背景が映り込んでしまいます。

ただ、この映り込みにももちろん色が入っているわけで、それを除去するには機械学習の「物体検出」などで不要な部分をカットしてから色検出するなどの工夫も必要かもしれません。(今回は手動でカットしました)

とはいえ、今回の画像だけでなく別の画像でも試してみましたが、結果はそれほど外してはいませんでした。

ぜひ皆さんもやってみてくださいね。

ではでは〜❗

「無性にFF5がやりたくなってきました・・・」

今回の技術をつかった開発のご依頼、お待ちしております😊✨ お問い合わせ また、個人レッスンや、わかりにくい部分がありましたらからお気軽にご連絡ください。 どうぞよろしくお願いいたします!
このエントリーをはてなブックマークに追加       follow us in feedly