PHPで画像の色を抽出して画像全体が何色か判定する

## 1. はじめに
　こんにちは。:slight_smile:
　[CYBIRD Advent Calendar 2024](https://qiita.com/advent-calendar/2024/cybird)の12日目担当の@suzu_ayaです。
　11日目は[@gumita](https://qiita.com/gumita)さんの「[ChatGPT APIを使用してゲームを作ってみた](https://qiita.com/gumita/private/463361ec3cddfc22dcb3)」でした。

## 2. 概要
　個人的興味から、画像を読み込んで画像の代表色が何色かを判定したいな、と考えて色々調べ始めました。
　昨年に引きつづき、今年もできればローカル環境内で処理をしたいな、と思い色々調べた結果、PHPだけでできることがわかりました。
　すでにPHPで読み込んだ画像から画像の全体を平均した色を取得、という処理を実装されている方がいましたので、そちらを参考にさせていただきます。

https://qiita.com/mekkoo/items/ae9628e59914b09d268f

## 3. 準備
　私はWindows環境を使用していますが、特に環境依存するものはないかと思います。
　PHPのインストールについては特に記載はしませんので、[XAMPP](https://www.apachefriends.org/jp/index.html)など自身の環境にあったものを導入されるのがいいと思います。

####  PHP GDライブラリ
　今回は画像を関連の操作が必要になるので、PHPの画像操作用のGDライブラリを使用します。
　私の場合、デフォルトでは拡張モジュールのGDライブラリは有効になっていなかったので、念のため記載をしておきます。
　有効化はphp.iniを修正して行います。
　php.iniの**Dynamic Extensions**内の以下の記載の修正を行いました。
　私の環境の場合、GDライブラリはgd2.dllというファイルだったので、該当行のコメントアウトを外しました。

```diff_ini:php.ini
;extension=fileinfo
- ;extension=gd2
+ extension=gd2
;extension=gettext
```

## 4. PHPで色判定

#### 1. 平均色の抽出
　色の抽出には、PHPの[imagecolorat](https://www.php.net/manual/ja/function.imagecolorat.php)を使用します。
 　この関数は、指定した画像の特定位置のピクセルの色インデックスを取得のですが、カラーコード変換のためにはひと手間必要になります。
　ビットシフト等でも問題ないですが、私は[imagecolorsforindex](https://www.php.net/manual/ja/function.imagecolorsforindex.php)を使用しました。
　この関数は引数で渡した色情報から、red、green、blue、alphaをキーとする連想配列を返します。今回は画像の透過部分については色情報を取得したくなかったので、ちょうどよかったです。
　参考記事を元に実装した、読み込んだ画像の平均色を抽出する処理はこちら。
```php:color.php
class Color
{
    /**
     * 指定のパスの画像を読み込んで、画像の平均色のRGB値を取得する。
     *
     * @param string $filepath 読み込み画像のパス
     * @return array RGB値の連想配列
     */
    public function get_img_average_color(string $filepath)
    {
        // 画像読み込み
        $img  = imagecreatefromjpeg($filepath);

        $width = imagesx($img); // 画像サイズ(横)
        $height = imagesy($img); // 画像サイズ(縦)
        
        $sum_r = 0;
        $sum_g = 0;
        $sum_b = 0;
        
        $pick_count = 0;
        // 大きい画像を考慮して、位置を2ずつ飛ばす
        for ($i = 0; $i < $height; $i+=2) { 
            for ($j = 0; $j < $width; $j+=2) {
                // 指定位置の色を取得
                $rgb = imagecolorat($img, $j, $i);
                // 可読形式で再取得
                $color_info = imagecolorsforindex($img, $rgb);
                
                // 透過部分はスキップ
                if ($color_info['alpha'] >= 127)
                {
                    continue;
                }
                
                $sum_r += $color_info['red'];
                $sum_g += $color_info['green'];
                $sum_b += $color_info['blue'];
                $pick_count++;
            }
        }
        
        // 各色合計値を色取得した数で割って10進数RGBに
        return ['red' => (int)round($sum_r / $pick_count)
                , 'green' => (int)round($sum_g / $pick_count)
                , 'blue' => (int)round($sum_b / $pick_count)];
    }
}
```
　今回はjpeg画像(写真)を使用するのでjpeg用読み込み関数を使用していますが、pngやgif等他関数もあるので、拡張子ごとに処理を変えるとかできれば汎用性が上がるかも。
　alpha値が最大(= 127)の場合は処理を色取得をスキップています。
　これで、平均色の抽出ができました。

#### 2.代表値にまとめる
 　平均色を抽出しただけだと、その色が何色なのか判定するのは人の目で見たり、AIにカラーコードを投げたりなどしないと難しいです。
　カテゴリ化して判定するにしても、カラーコードは、255 * 255 * 255 のパターンがあるので、1つずつ色分けすること自体は可能ですが、労力に見合わないと思いました。
 　そのため、特定範囲の値を代表の値によせれば判定する色数が少なくなるのでは、と考えました(力業)。:muscle:
 　まずは、代表値によせる実装です。
 ```php:color.php
class Color
{
    // RGB代表値のリスト
    const RGB_ROUND_LIST = [
        ['value' => 0, 'min' => 0, 'max' => 42],
        ['value' => 85, 'min' => 43, 'max' => 127],
        ['value' => 170, 'min' => 128, 'max' => 212],
        ['value' => 255, 'min' => 213, 'max' => 255]
    ];
	
    /**
     * 10進数の値から代表値によせた値を返す。
     * 一定範囲の値を代表値によせる。
     *
     * @param int $color_value 10進数RGB値
     * @return int 代表値によせたRGB値
     */
    public function round_color(int $color_value)
    {
        foreach (self::RGB_ROUND_LIST as $round_info)
        {
            if ($color_value >= $round_info['min'] && $color_value <= $round_info['max'])
            {
                return $round_info['value'];
            }
        }
    }
}
``` 
　実装自体は単純で、各代表値に寄せる値の範囲を連想配列で定義し、引数で渡された値がその範囲内であれば、代表値を返す、というもの。
　数が多ければ色のカテゴリ化が大変になり、少ければ色のカテゴリ化が大雑把になりすぎる、ということで手間と正確性を考え、代表値は4つに決定しました。
　これを、red、green、blueの各値で行えば、代表値によせた10進数RGB値ができます。

#### 3.色を判定する
　最後に、代表値によせた色が何色なのか判定します。
　色の分類は、基本色彩語を元に11色(赤、青、黄、緑、紫、オレンジ、茶、ピンク、灰、白、黒)としました。
　代表値は合計4つとしたので、4 * 4 * 4 の64色が上記11色の中のどれに当たるのかを手動で分けました(力業)。:muscle:
　4-2で代表値によせたRGB値を16進カラーコードに変換して、何色に当たるのか判定します。

 ```php:color.php
class Color
{
    // 色の分類
    // カテゴリ分類は基本色彩語を元にしている。カラーコードの分類は独断
    const COLOR_CATEGORY = [
        ['name' => 'red', 'code_list' => ['#550000', '#aa0000', '#aa0055', '#ff0000', '#ff0055']],
        ['name' => 'blue', 'code_list' => ['#000055', '#0000aa', '#0000ff', '#0055aa', '#0055ff', '#00ffff', '#5555ff', '#55aaff', '#55ffff', '#aaffff']],
        ['name' => 'yellow', 'code_list' => ['#ffff00', '#ffff55', '#ffffaa']],
        ['name' => 'green', 'code_list' => ['#005500', '#005555', '#00aa00', '#00aa55', '#00aaaa', '#00aaff', '#00ff00', '#00ff55', '#00ffaa', '#555500', '#55aa00', '#55aa55', '#55aaaa', '#55ff00', '#55ff55', '#55ffaa', '#aaaa00', '#aaaa55', '#aaff00', '#aaff55', '#aaffaa']],
        ['name' => 'orange', 'code_list' => ['#aa5555', '#ff5500', '#ff5555', '#ffaa00', '#ffaa55']],
        ['name' => 'brown', 'code_list' => ['#aa5500']],
        ['name' => 'pink', 'code_list' => ['#ff00aa', '#ff00ff', '#ff55aa', '#ff55ff', '#ffaaaa', '#ffaaff']],
        ['name' => 'purple', 'code_list' => ['#550055', '#5500aa', '#5500ff', '#5555aa', '#aa00aa', '#aa00ff', '#aa55ff', '#aa55ff', '#aaaaff']],
        ['name' => 'gray', 'code_list' => ['#555555', '#aaaaaa']],
        ['name' => 'white', 'code_list' => ['#ffffff']],
        ['name' => 'black', 'code_list' => ['#000000']],
    ];

    /**
     * 10進数のRGB値を16進カラーコードに変換する。
     *
     * @param int $r 10進数R値
     * @param int $g 10進数G値
     * @param int $b 10進数B値
     * @return string 16進カラーコード
     */
    public function convert_rgb_to_hex_color_code(int $r, int $g, int $b)
    {
        return '#' . dechex($r) . dechex($g) . dechex($b);
    }
    
    /**
     * 16進カラーコードが何色かを取得する。
     *
     * @param string $color_code 16進カラーコード
     * @return string 色名
     */
    public function get_color_category_name(string $color_code)
    {
        foreach (self::COLOR_CATEGORY as $category_info)
        {
            if (in_array($color_code, $category_info['code_list']))
            {
                return $category_info['name'];
            }
        }
    }
}
``` 
　力業感がすごいですが、これで読み込んだ画像が何色なのか、を判定することができました。

#### コード全文
　各関数を順番に実行していけば、色の判定までできます。
 ```php:color.php
class Color
{
    // RGB代表値のリスト
    const RGB_ROUND_LIST = [
        ['value' => 0, 'min' => 0, 'max' => 42],
        ['value' => 85, 'min' => 43, 'max' => 127],
        ['value' => 170, 'min' => 128, 'max' => 212],
        ['value' => 255, 'min' => 213, 'max' => 255]
    ];

    // 色の分類
    // カテゴリ分類は基本色彩語を元にしている。カラーコードの分類は独断
    const COLOR_CATEGORY = [
        ['name' => 'red', 'code_list' => ['#550000', '#aa0000', '#aa0055', '#ff0000', '#ff0055']],
        ['name' => 'blue', 'code_list' => ['#000055', '#0000aa', '#0000ff', '#0055aa', '#0055ff', '#00ffff', '#5555ff', '#55aaff', '#55ffff', '#aaffff']],
        ['name' => 'yellow', 'code_list' => ['#ffff00', '#ffff55', '#ffffaa']],
        ['name' => 'green', 'code_list' => ['#005500', '#005555', '#00aa00', '#00aa55', '#00aaaa', '#00aaff', '#00ff00', '#00ff55', '#00ffaa', '#555500', '#55aa00', '#55aa55', '#55aaaa', '#55ff00', '#55ff55', '#55ffaa', '#aaaa00', '#aaaa55', '#aaff00', '#aaff55', '#aaffaa']],
        ['name' => 'orange', 'code_list' => ['#aa5555', '#ff5500', '#ff5555', '#ffaa00', '#ffaa55']],
        ['name' => 'brown', 'code_list' => ['#aa5500']],
        ['name' => 'pink', 'code_list' => ['#ff00aa', '#ff00ff', '#ff55aa', '#ff55ff', '#ffaaaa', '#ffaaff']],
        ['name' => 'purple', 'code_list' => ['#550055', '#5500aa', '#5500ff', '#5555aa', '#aa00aa', '#aa00ff', '#aa55ff', '#aa55ff', '#aaaaff']],
        ['name' => 'gray', 'code_list' => ['#555555', '#aaaaaa']],
        ['name' => 'white', 'code_list' => ['#ffffff']],
        ['name' => 'black', 'code_list' => ['#000000']],
    ];

    /**
     * 指定のパスの画像を読み込んで、画像の平均色のRGB値を取得する。
     *
     * @param string $filepath 読み込み画像のパス
     * @return array RGB値の連想配列
     */
    public function get_img_average_color(string $filepath)
    {
        // 画像読み込み
        $img  = imagecreatefromjpeg($filepath);

        $width = imagesx($img); // 画像サイズ(横)
        $height = imagesy($img); // 画像サイズ(縦)
        
        $sum_r = 0;
        $sum_g = 0;
        $sum_b = 0;
        
        $pick_count = 0;
        // 大きい画像を考慮して、位置を2ずつ飛ばす
        for ($i = 0; $i < $height; $i+=2) { 
            for ($j = 0; $j < $width; $j+=2) {
                // 指定位置の色を取得
                $rgb = imagecolorat($img, $j, $i);
                // 可読形式で再取得
                $color_info = imagecolorsforindex($img, $rgb);
                
                // 透過部分はスキップ
                if ($color_info['alpha'] >= 127)
                {
                    continue;
                }
                
                $sum_r += $color_info['red'];
                $sum_g += $color_info['green'];
                $sum_b += $color_info['blue'];
                $pick_count++;
            }
        }
        
        // 各色合計値を色取得した数で割って10進数RGBに
        return ['red' => (int)round($sum_r / $pick_count)
                , 'green' => (int)round($sum_g / $pick_count)
                , 'blue' => (int)round($sum_b / $pick_count)];
    }

    /**
     * 10進数の値から代表値によせた値を返す。
     * 一定範囲の値を代表値によせる。
     *
     * @param int $color_value 10進数RGB値
     * @return int 代表値によせたRGB値
     */
    public function round_color(int $color_value)
    {
        foreach (self::RGB_ROUND_LIST as $round_info)
        {
            if ($color_value >= $round_info['min'] && $color_value <= $round_info['max'])
            {
                return $round_info['value'];
            }
        }
    }

    /**
     * 10進数のRGB値を16進カラーコードに変換する。
     *
     * @param int $r 10進数R値
     * @param int $g 10進数G値
     * @param int $b 10進数B値
     * @return string 16進カラーコード
     */
    public function convert_rgb_to_hex_color_code(int $r, int $g, int $b)
    {
        return '#' . dechex($r) . dechex($g) . dechex($b);
    }
    
    /**
     * 16進カラーコードが何色かを取得する。
     *
     * @param string $color_code 16進カラーコード
     * @return string 色名
     */
    public function get_color_category_name(string $color_code)
    {
        foreach (self::COLOR_CATEGORY as $category_info)
        {
            if (in_array($color_code, $category_info['code_list']))
            {
                return $category_info['name'];
            }
        }
    }
}
``` 

## 5. 実際にやってみる

　実際にこのコードを使って画像が何色なのか、を判定してみます。
　今回はテストということで、自分のスマホの中からいくつか写真をピックアップしました。
　まずはこの画像。鹿。実家の近くの山で出ました。:deciduous_tree:
 　![shika.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631089/54e2a9e4-e557-98db-9b6b-e1a055a55589.jpeg)
　平均色：`#7a886b`　代表色：`#55aa55`　判定結果：green
　写真の大半が山の緑ですから判定結果が緑は妥当かなと。

　続いて、こちら。ﾈｺﾁｬﾝ。:smiley_cat:
　![neko1.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631089/69b1caba-cd56-e98d-8ae6-a6e945f1d8f6.jpeg)
　平均色：`#a9a89b`　代表色：`#aaaaaa`　判定結果：gray
　いろんな色が混ざり合った結果、灰色に落ちついた感じですかね？

　つづいてもﾈｺﾁｬﾝ。:cat:
　![neko2.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631089/2462b87a-209b-7c40-2172-53221122b24b.jpeg)
　平均色：`#8a847f`　代表色：`#aaaa55`　判定結果：green
　こっちも灰色かと思ったんですが、こっちはなんと緑。
　平均色は灰色っぽいので、代表色を決める部分で緑色によってしまった、ということでしょうか。

　続いて水族館から2枚。右側の亀の躍動感たるや。:turtle:
　![kame.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631089/0d9108de-95d3-963f-04f4-6b7c842547f8.jpeg)
　平均色：`#7a8f83`　代表色：`#55aaaa`　判定結果：green
　代表色は青にも見えるし緑にも見える微妙なラインです。
　砂地部分が1/3くらい締めていますが、一応画像で大部分を占めていそうな緑にきちんと判定されました。

　最後は水族館の大水槽。:fish:
　![suisou.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3631089/25f05dc2-6405-5ed5-283e-9e582a2f5296.jpeg)
　平均色：`#42506b`　代表色：`#555555`　判定結果：gray
　青系かな、と思いましたが、まさかの灰色判定でした。
　平均色は青っぽいので、この画像も代表色の部分で灰色によってしまったんでしょうか。

## 6. さいごに
　色判定は正しく行えていそうな画像とそうでない画像がありました。
　平均色はきちんと画像の平均を取ってきているように見えたので、代表色決定の部分でずれが出てきているようです。
　代表色決定部分については、代表値の数を増やせば対応できる気はしていますが、代表値が増えればその分それで作成できる色も増えるので色の分類も手動で行うのが大変に。
　どこかを自動化するか、手動でも作業可能な代表値の数にするか、色々考える必要がありそうです。
　また、今回は画像サイズが大きくなることを考え、色取得位置を一部スキップしているのですが、スキップ処理をなくすことで平均色がより正確になる可能性があります。
　色を正確なものに近づけるためにできそうなことは色々あるので、もう少し調整を行おうと思います。

　[CYBIRD Advent Calendar 2024](https://qiita.com/advent-calendar/2024/cybird) 13日目は[@cy-naull](https://qiita.com/cy-naull)さんの「ClaudeAIたちってどんな感じなの？」です。
お楽しみに！！:blush: