午後わてんのブログ

ベランダ菜園とWindows用アプリ作成(WPFとC#)

k平均法で減色してみた、設定と結果と処理時間

減色用パレットをk平均法で作成
ダウンロード先
 
今までの単純減色だと使われない色が出てきてしまう
RGB2階調で8色は
白、黒、赤、緑、青、黄色、水色、赤紫なので
イメージ 1
赤や緑がない画像を変換すると
 
イメージ 2
赤、緑、黄色、赤紫の4色は使われない
効率よくない
 
減色用パレットの色の選択でk平均法ってのを試してみた結果
イメージ 3
右のマスに並んでいるのがk平均法で選ばれた色
色数は同じ8でも再現度がかなり上がった
全然違うなあ
 
単純減色の27色とk平均法の20色
イメージ 6
 
イメージ 4
4色に絞っても再現度高い
 
同じ色数指定でも違う結果に
イメージ 5
同じ4色指定でもさっきのとは違う色が選ばれた
k平均法はそういうものみたい
 
 
k平均法、色数4指定の時
  1. 最初にランダムで色を4つ作る、これが最初のパレット
  2. 画像のピクセルの色とパレットの4色と比較、一番近い色に画像の色を分けて4つのグループを作る、これをすべてのピクセルで行う
  3. グループの平均色をパレットの色に置き換えて、2に戻るの繰り返し
あとはループ回数の上限と停止条件の指定
上限は100回、
停止条件は元のパレット色とグループの平均色の差が1未満になった時、とか
 
色の距離、色の差
今回は一番単純な方法でRGBの各色の差の2乗を足したのをルート
比較するだけなら最後のルートの計算は必要ないかな
色1と色2の距離なら
(色1のR-色2のR)^2+(色1のG-色2のG)^2+(色1のB-色2のB)^2
これのルート
色1(155,75,34)、色2(181,147,5)の距離は
(155-182)^2+(75-147)^2+(34-5)^2
729+5184+841=6754
√6754≒81.86
81.86
 
うーん、Google 日本語入力はべき乗の計算はできるけどルートの計算はできないみたい
 
平均色
これも単純に全ピクセルのRGBそれぞれを足したのをピクセル数で割っただけで計算した
 

デザイン画面

f:id:gogowaten:20191212004227p:plain

<Window x:Class="_20180226_代表色選択k平均法.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:_20180226_代表色選択k平均法"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="525">
  <Grid>
    <Grid.ColumnDefinitions>
      <ColumnDefinition/>
      <ColumnDefinition Width="200"/>
    </Grid.ColumnDefinitions>
    <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto">
      <Image Name="MyImage" UseLayoutRounding="True" Stretch="None"/>
    </ScrollViewer>
    <StackPanel Grid.Column="1">
      <StackPanel Orientation="Horizontal">
        <TextBlock Text="色数" VerticalAlignment="Center" Margin="4,0" FontSize="18"/>
        <TextBox Name="NumericTextBox" VerticalContentAlignment="Center" HorizontalContentAlignment="Right"
                 Text="{Binding ElementName=NumericScrollBar, Path=Value, UpdateSourceTrigger=PropertyChanged}"
                 Width="40" FontSize="18"/>
        <ScrollBar Name="NumericScrollBar" Value="3" Minimum="1" Maximum="256" SmallChange="1" LargeChange="1"
                   RenderTransformOrigin="0.5,0.5">
          <ScrollBar.RenderTransform>
            <RotateTransform Angle="180"/>
          </ScrollBar.RenderTransform>
        </ScrollBar>
        
      </StackPanel>
      <Button Name="Button1" Content="button1"/>
      <TextBlock Name="TextBlockLoopCount" Text="loop count"/>
      <WrapPanel Name="MyWrapPanel">
        
      </WrapPanel>
    </StackPanel>
  </Grid>
</Window>
 
C#のコード
<feff>using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
using System.IO;


namespace _20180226_代表色選択k平均法
{
    /// <summary>
    /// MainWindow.xaml の相互作用ロジック
    /// </summary>
    public partial class MainWindow : Window
    {
        BitmapSource OriginBitmap;
        string ImageFileFullPath;
        Border[] MyPalette;
        const int MAX_PALETTE_COLOR_COUNT = 20;

        public MainWindow()
        {
            InitializeComponent();
            this.Title = this.ToString();
            this.AllowDrop = true;
            this.Drop += MainWindow_Drop;

            Button1.Click += Button1_Click;

            //パレットの色表示用のBorder作成
            AddBorders();
        }


        private void Button1_Click(object sender, RoutedEventArgs e)
        {
            //パレットの色表示を初期化
            PalettePanColorDel();
            if (OriginBitmap == null) { return; }
            //パレットを作成して、画像を減色
            MyImage.Source = ReduceColor減色(OriginBitmap, (int)NumericScrollBar.Value);
        }


        //パレットの色表示を初期化
        private void PalettePanColorDel()
        {
            for (int i = 0; i < MAX_PALETTE_COLOR_COUNT; ++i)
            {
                MyPalette[i].Background = null;
            }

        }
        
        //ランダム色のパレット作成
        private Color[] GetRandomColorPalette(int paletteCapacity)
        {
            Color[] colors = new Color[paletteCapacity];
            Random random = new Random();
            byte[] r = new byte[3];
            for (int i = 0; i < colors.Length; ++i)
            {
                random.NextBytes(r);
                colors[i] = Color.FromRgb(r[0], r[1], r[2]);
                Console.WriteLine(colors[i].ToString());
            }
            return colors;
        }

        /// <summary>
        /// k平均法を使ってパレットを作成して減色
        /// 色の距離はRGB各色の差の2乗を足したのを√
        /// </summary>
        /// <param name="source">PixelFormatPbgra32のBitmapSource</param>
        /// <param name="colorCount">パレットの色数</param>
        /// <returns>PixelFormatPbgra32のBitmapSource</returns>
        private BitmapSource ReduceColor減色(BitmapSource source, int colorCount)
        {            
            string neko = "start" + "\n";//色と色差の変化の確認用
            var wb = new WriteableBitmap(source);
            int h = wb.PixelHeight;
            int w = wb.PixelWidth;
            int stride = wb.BackBufferStride;
            byte[] pixels = new byte[h * stride];
            wb.CopyPixels(pixels, stride, 0);
            long p = 0;
            Color myColor;
            int pIndex;//パレットのインデックス
            double distance, min, diff = 0;//色の距離、最小値、新旧色の距離
            //パレット
            Color[] palette = GetRandomColorPalette(colorCount);
            for (int i = 0; i < palette.Length; ++i)
            {
                neko += palette[i].ToString() + "\n";
            }
            //グループ分けした色を入れる色List
            List<Color>[] colorList = new List<Color>[palette.Length];

            for (int j = 0; j < 100; ++j)
            {
                //色List作成(初期化)
                for (int i = 0; i < palette.Length; ++i)
                {
                    colorList[i] = new List<Color>();
                }

                for (int y = 0; y < h; ++y)
                {
                    for (int x = 0; x < w; ++x)
                    {
                        p = y * stride + (x * 4);
                        myColor = Color.FromRgb(pixels[p + 2], pixels[p + 1], pixels[p]);
                        pIndex = 0;
                        distance = GetColorDistance(myColor, palette[0]);
                        min = distance;
                        //グループ分け
                        //距離が近(短)いパレットの色のインデックス取得して
                        //そのインデックスの色Listを追加してグループ分け
                        for (int i = 1; i < palette.Length; ++i)
                        {
                            distance = GetColorDistance(myColor, palette[i]);
                            if (min > distance)
                            {
                                min = distance;
                                pIndex = i;
                            }
                        }
                        colorList[pIndex].Add(myColor);//色Listに追加
                    }
                }

                //グループ分けした色の平均色から新しいパレット作成
                Color[] newPalette = new Color[palette.Length];
                for (int i = 0; i < newPalette.Length; ++i)
                {
                    myColor = GetAverageGolor(colorList[i]);//平均色取得(新しい色)
                    neko += myColor.ToString() + "\n";
                    diff += GetColorDistance(palette[i], myColor);
                    palette[i] = myColor;//新しい色で上書き
                }

                //古いパレットと新しいパレットの色の差が1以下ならループ抜け、新パレット完成
                TextBlockLoopCount.Text = "ループ回数 = " + j.ToString();
                diff /= palette.Length;
                neko += diff.ToString() + "\n";
                if (diff < 1f) { break; }
                diff = 0;
            }

            //パレットの色表示
            for (int i = 0; i < palette.Length; ++i)
            {
                MyPalette[i].Background = new SolidColorBrush(palette[i]);
            }
            Console.WriteLine(neko);

            //パレットの色で減色
            for (int y = 0; y < h; ++y)
            {
                for (int x = 0; x < w; ++x)
                {
                    p = y * stride + (x * 4);
                    myColor = Color.FromRgb(pixels[p + 2], pixels[p + 1], pixels[p]);
                    min = GetColorDistance(myColor, palette[0]);
                    pIndex = 0;
                    for (int i = 0; i < palette.Length; ++i)
                    {
                        distance = GetColorDistance(myColor, palette[i]);
                        if (min > distance)
                        {
                            min = distance;
                            pIndex = i;
                        }
                    }
                    myColor = palette[pIndex];
                    pixels[p + 2] = myColor.R;
                    pixels[p + 1] = myColor.G;
                    pixels[p] = myColor.B;
                }
            }
            wb.WritePixels(new Int32Rect(0, 0, w, h), pixels, stride, 0);
            return wb;
        }

        //距離
        private double GetColorDistance(Color c1, Color c2)
        {
            return Math.Sqrt(
                Math.Pow(c1.R - c2.R, 2) +
                Math.Pow(c1.G - c2.G, 2) +
                Math.Pow(c1.B - c2.B, 2));
        }

        //ColorListの平均色を返す
        private Color GetAverageGolor(List<Color> colorList)
        {
            long r = 0, g = 0, b = 0;
            int cCount = colorList.Count;
            if (cCount == 0)
            {
                return Color.FromRgb(127, 127, 127);
            }
            for (int i = 0; i < cCount; ++i)
            {
                r += colorList[i].R;
                g += colorList[i].G;
                b += colorList[i].B;
            }

            return Color.FromRgb((byte)(r / cCount), (byte)(g / cCount), (byte)(b / cCount));
        }

        //パレットの色表示用のBorder作成
        private void AddBorders()
        {
            NumericScrollBar.Maximum = MAX_PALETTE_COLOR_COUNT;
            MyPalette = new Border[MAX_PALETTE_COLOR_COUNT];
            Border border;
            for (int i = 0; i < MyPalette.Length; i++)
            {
                border = new Border()
                {
                    Width = 20,
                    Height = 20,
                    BorderBrush = new SolidColorBrush(Colors.AliceBlue),
                    BorderThickness = new Thickness(1f),
                    Margin = new Thickness(1f)
                };
                MyPalette[i] = border;
                MyWrapPanel.Children.Add(border);
            }
        }




        private void MainWindow_Drop(object sender, DragEventArgs e)
        {
            if (e.Data.GetDataPresent(DataFormats.FileDrop) == false) { return; }
            string[] filePath = (string[])e.Data.GetData(DataFormats.FileDrop);
            OriginBitmap = GetBitmapSourceWithChangePixelFormat2(filePath[0], PixelFormats.Pbgra32, 96, 96);

            if (OriginBitmap == null)
            {
                MessageBox.Show("not Image");
            }
            else
            {
                MyImage.Source = OriginBitmap;
                ImageFileFullPath = filePath[0];
            }
        }


        /// <summary>
        ///  ファイルパスとPixelFormatを指定してBitmapSourceを取得、dpiの変更は任意
        /// </summary>
        /// <param name="filePath">画像ファイルのフルパス</param>
        /// <param name="pixelFormat">PixelFormatsの中からどれかを指定</param>
        /// <param name="dpiX">無指定なら画像ファイルで指定されているdpiになる</param>
        /// <param name="dpiY">無指定なら画像ファイルで指定されているdpiになる</param>
        /// <returns></returns>
        private BitmapSource GetBitmapSourceWithChangePixelFormat2(
            string filePath, PixelFormat pixelFormat, double dpiX = 0, double dpiY = 0)
        {
            BitmapSource source = null;
            try
            {
                using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read))
                {
                    var bf = BitmapFrame.Create(fs);
                    var convertedBitmap = new FormatConvertedBitmap(bf, pixelFormat, null, 0);
                    int w = convertedBitmap.PixelWidth;
                    int h = convertedBitmap.PixelHeight;
                    int stride = (w * pixelFormat.BitsPerPixel + 7) / 8;
                    byte[] pixels = new byte[h * stride];
                    convertedBitmap.CopyPixels(pixels, stride, 0);
                    //dpi指定がなければ元の画像と同じdpiにする
                    if (dpiX == 0) { dpiX = bf.DpiX; }
                    if (dpiY == 0) { dpiY = bf.DpiY; }
                    //dpiを指定してBitmapSource作成
                    source = BitmapSource.Create(
                        w, h, dpiX, dpiY,
                        convertedBitmap.Format,
                        convertedBitmap.Palette, pixels, stride);
                };
            }
            catch (Exception)
            {

            }

            return source;
        }
    }
}
 
ループ抜けの条件は
ループ回数100以上かパレット新旧の色差が0(全く同じ)になったときにしてあるので時間がかかる
 
処理時間は
画像の大きさ(ピクセル数)
指定色数
ループの回数
この3つが大きいほど時間がかかる
 
イメージ 8
192x256ピクセルの画像の処理時間
 
3色、ループ抜けの条件の色差0
イメージ 13
最長で28回は約3秒
こんな小さな画像で3色指定でもこんなに時間がかかる
最短では8回は約1秒
画像の大きさと色数は変化させていないから
単純にループ回数で時間が決まる
選ばれた色はどれもそんなに差がないからループ回数は少ない方がいいけど
完全一致までのループ回数は最初のパレットの色がランダムで決められて
これで差がつくので運だねえ
ループ回数で出てくる数値は偏っていいて多いのは12前後、8や9はなかなかでない、27,28はめったにでなくて20回に1回くらい、17以上26以下は40回試して1回もでなかった
ウィザードリィのキャラ作成時のボーナスポイントみたい
 
 
ループ抜けの条件を緩くしてみる
イメージ 9
色の差が0(全く同じ)から1未満に変更
イメージ 12
最短で4回、最長は12回、平均だと8あたりかな
体感処理時間は約1秒だった
ループ回数が28から8へと3分の1になったから
時間も3分の1になったみたい
変換結果の画像は3秒も1秒のも見た目同じなので
色の差は1未満でも全然行ける
 
もっと緩くして色差5未満
イメージ 10
イメージ 11
最短でループ回数2、多くても7回
時間だと0.5秒から1秒くらいかな、色差1未満のと変わんない
パレットの色で赤系統のがでた、ランダム性があると面白い
 
ここまでの結果だとループ抜けの条件の色差は1未満でいいかな、5未満でも良い結果だけど時間が1未満のときと変わんないからねえ
 
 
 
色数10の場合
ループ抜け条件色差0
イメージ 14
ループ回数23から60
処理時間8秒から20秒
色数を増やすと時間かかるけど全体の色としては少ない赤系も選ばれるようになった
 
 
ループ抜け条件色差1未満
イメージ 15
ループ回数11~18
処理時間3~4秒
赤系統が選ばれないこともあったけど結果は良好
やっぱりループ抜け条件色差は1未満でいいかな
 
 
ループ抜け条件色差5未満
イメージ 16
ループ回数3~5
処理時間0.5~1.5秒
なぜか色数3のときとループ回数がほとんど変わらない、速い
変換結果も悪くないから5未満でもいいかなあ
 
 
色数20
ループ抜け条件色差0
イメージ 17
ループ回数51で21秒、38は16秒
 
 
ループ抜け条件色差1未満
イメージ 18
ループ回数20回は9秒、14回は7秒
変換結果は色差0のときよりもいい感じがする、少なくとも劣ってはいないなあ
 
 
ループ抜け条件色差5
イメージ 19
ループ回数2~4、処理時間3秒
処理時間は速いけど変換結果はいまいちなところもあるなあ
でも速いからいい結果が出るまで繰り返すのも面白いかも
 
小さな画像で色数3から20までの
ループ抜け条件の色差の設定は
1未満が良さそう
色差0(完全一致)は時間がかなり伸びるのに結果は1未満とほとんど同じだから使わないかなあ
5未満はブレる分、面白さがある
 
 
ループ抜け条件色差100未満
イメージ 23
意外に良い結果もよく出る
ループもほぼ0回なので一瞬で終わる
 
 
 
 
もう少し大きい画像の場合

f:id:gogowaten:20191212005204p:plain

1024x768ピクセルの画像
色数20でループ抜け条件色差1未満の結果

f:id:gogowaten:20191212005231p:plain

ループ回数は29で
処理時間は3分10秒!
 
同じ条件で色数だけ3に変更

f:id:gogowaten:20191212005244p:plain

ループ回数は3で
処理時間は7秒
 
ループ抜け条件色差0で20色も試したんだけど時間かかりすぎて中止した
10分経過した時には75回目のループで色差は0.3414…だったので
たぶんループ上限の100回まで行っただろうから15分位かかったのかも
ムリ過ぎる
 
今回の方法でk平均法を使った減色は厳しいけど
小さな画像256x192程度なら1~7秒でなんとか使える
大きな画像でも全ピクセルを比較するんじゃなくて限定すれば良さそう
 
 
処理ループ中の色の変化
4色、ループ抜け条件色差1未満
イメージ 24
横がループ回数、縦がパレットの色
0が最初のランダム色、赤系の色があったけどすぐに消えている
4ループ以降は見た目的にはほとんど変化ない
こうしてみるのも面白いなあ
エクセル方眼紙も相変わらず便利
 
イメージ 25
赤系がでた場合
最初の色は元画像にはなさそうな色だったのにループ回数は短め
ピンクは白系、黄色は赤系に変化した、こうなるんだなあ
 
 
イメージ 27
イメージ 26
ループを重ねるごとにマイルドになる感じかなあ
グループ分けした色の平均色を繰り返すからそんな感じなのかも
 
イメージ 28
イメージ 29
ループ初期にはトマトの花の黄色があったのに消えてしまった
1ループ目で十分な気がする
やっぱりループ抜け条件の色差は緩いのもありだなあ
 
 
ループごとの色差も記録してみた
イメージ 30
3~5ループくらいからは小さな変化、数値的には3.5~11.1
平均は5.58
 
 
 
参照したところ
k-means法で画像を減色するサンプルコード - めもめも
http://enakai00.hatenablog.com/entry/2015/04/14/181305
色の距離(色差)の計算方法 - Qiita
https://qiita.com/shinido/items/2904fa1e9a6c78650b93
 
 
使い方わからんからただのファイル置き場になっている
 
 
 
関連記事
 
2018/03/14は10日後
減色変換一覧表を使って処理時間を短縮してみた ( ソフトウェア ) - 午後わてんのブログ - Yahoo!ブログ
https://blogs.yahoo.co.jp/gogowaten/15412874.html
 
1週間前
単純減色(ポスタライズ?)試してみた、WPFC# ( ソフトウェア ) - 午後わてんのブログ - Yahoo!ブログ
https://blogs.yahoo.co.jp/gogowaten/15388558.html
 
 
2018/03/13は9日後
手抜きで時間を短縮、k平均法を使った減色パレットの作成 ( ソフトウェア ) - 午後わてんのブログ - Yahoo!ブログ
https://blogs.yahoo.co.jp/gogowaten/15410540.html