午後わてんのブログ

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

SSIMで画像を比較するアプリできた、グレースケール版、C#、WPF

この前の続き
実際に画像を比較してみた

画像比較アプリ

f:id:gogowaten:20211001093241p:plain
今回のテストアプリ

  • 左右に比較したい画像ファイルをそれぞれドロップするとSSIMを表示する
  • SSIMの値は最低0から最大は1
  • 0なら全く違う画像、1なら完全一致、0.95以上で見分けがつかないと言われている
  • 比較できるのは左右の画像の縦横ピクセルが同じときだけ
  • グレースケールで比較
  • カラー画像もグレースケールで計算、表示もグレースケール
  • 対応画像形式はjpegpngbmp、gif、tiffなど



f:id:gogowaten:20211001093717p:plain
画像ファイルドロップ



もう一個ドロップすると

f:id:gogowaten:20211001093908p:plain
SSIM表示
SSIMの計算結果が表示される
右上のSSIM再計算ボタンは飾り


続けて別の画像ドロップで

f:id:gogowaten:20211001095535p:plain
別の画像ドロップ
その画像との比較になる

縦横サイズが違う画像をドロップしても計算できないので

f:id:gogowaten:20211001095703p:plain
縦横サイズが違う画像は比較できない
計算結果は表示されない

左上のスライダーは

f:id:gogowaten:20211001094024p:plain
表示倍率変更
等倍から10倍拡大まで整数倍表示する

ダウンロード先

https://github.com/gogowaten/2021WPF/releases/download/SSIM%E3%81%A7%E7%94%BB%E5%83%8F%E6%AF%94%E8%BC%83/20210929_SSIM.zip

github.com



作成、動作環境

動作に必要なのは.NET 5がインストール済みのWindowsで、.NET Frameworkだけでは動かないはず

コード

MainWindow.xaml

<Window x:Class="_20210929_SSIM.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:_20210929_SSIM"
        mc:Ignorable="d"
        Title="MainWindow" Height="350" Width="600"
        AllowDrop="True">
  <Grid UseLayoutRounding="True">
    <DockPanel>
      <DockPanel DockPanel.Dock="Top" Margin="4">
        <Button DockPanel.Dock="Right" Content="SSIM再計算" Click="Button_Click" FontSize="20"/>
        <Slider x:Name="MySliderScale" Value="1" Minimum="1" Maximum="10" SmallChange="1" LargeChange="1"
                TickFrequency="1" IsSnapToTickEnabled="True" Width="100" VerticalAlignment="Center"
                MouseWheel="MySliderScale_MouseWheel"/>
        <TextBlock Text="SSIM" Name="MyTextBlockSSIM" FontSize="20" HorizontalAlignment="Center"/>
      </DockPanel>
      <Grid>
        <Grid.ColumnDefinitions>
          <ColumnDefinition Width="1*"/>
          <ColumnDefinition/>
        </Grid.ColumnDefinitions>
        <Viewbox Stretch="Uniform">
          <TextBlock Text="ここにドロップ" Margin="10"/>
        </Viewbox>
        <DockPanel Grid.Column="0">
          <TextBlock DockPanel.Dock="Bottom" Name="MyTextBlock1" Text="file1"/>
          <ScrollViewer x:Name="MyScrollViewer1"
                        HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
                        AllowDrop="True" Drop="ScrollViewer_Drop_1"
                        ScrollChanged="MyScrollViewer1_ScrollChanged">
            <Image x:Name="MyImage1" Stretch="None">
              <Image.LayoutTransform>
                <ScaleTransform ScaleX="{Binding ElementName=MySliderScale, Path=Value}"
                                ScaleY="{Binding ElementName=MySliderScale, Path=Value}"/>
              </Image.LayoutTransform>
            </Image>
          </ScrollViewer>
        </DockPanel>
        <Viewbox Grid.Column="1" Stretch="Uniform">
          <TextBlock Text="ここにドロップ" Margin="10"/>
        </Viewbox>
        <DockPanel Grid.Column="1">
          <TextBlock DockPanel.Dock="Bottom" Name="MyTextBlock2" Text="file2"/>
          <ScrollViewer x:Name="MyScrollViewer2"
                        HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
                        AllowDrop="True" Drop="ScrollViewer_Drop_2"
                        ScrollChanged="MyScrollViewer2_ScrollChanged">
            <Image x:Name="MyImage2" Stretch="None">
              <Image.LayoutTransform>
                <ScaleTransform ScaleX="{Binding ElementName=MySliderScale, Path=Value}"
                                ScaleY="{Binding ElementName=MySliderScale, Path=Value}"/>
              </Image.LayoutTransform>
            </Image>
          </ScrollViewer>
        </DockPanel>
      </Grid>

    </DockPanel>
  </Grid>
</Window>




MainWindow.xaml.cs

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;


/// <summary>
/// SSIMで画像の比較
/// ブロックサイズは8x8で1ピクセルずらし
/// 画像ファイルドロップで計算開始
/// </summary>
namespace _20210929_SSIM
{
    /// <summary>
    /// Interaction logic for MainWindow.xaml
    /// </summary>
    public partial class MainWindow : Window
    {
        private const double C1 = 0.01 * 255 * (0.01 * 255);//(0.01*255)^2=6.5025
        private const double C2 = 0.03 * 255 * (0.03 * 255);//(0.03*255)^2=58.5225
        private const double C3 = C2 / 2.0;//58.5225/2=29.26125
        private (BitmapSource bitmap, byte[] pixels) MySource1;
        private (BitmapSource bitmap, byte[] pixels) MySource2;

        public MainWindow()
        {
            InitializeComponent();
            RenderOptions.SetBitmapScalingMode(MyImage1, BitmapScalingMode.NearestNeighbor);
            RenderOptions.SetBitmapScalingMode(MyImage2, BitmapScalingMode.NearestNeighbor);
#if DEBUG
            Top = 0;
            Left = 0;
#endif
        }



        #region SSIM

        private double SSIM8x8(byte[] pixels1, byte[] pixels2, int width, int height)
        {
            if (pixels1.Length != pixels2.Length)
            {
                return double.NaN;
            }

            double total = 0;
            for (int y = 0; y < height - 8; y++)
            {
                for (int x = 0; x < width - 8; x++)
                {
                    (byte[] vs1, byte[] vs2) = Get8x8Windw(pixels1, pixels2, x, y, width);
                    total += SSIM(vs1, vs2);
                }
            }
            double result = total / ((width - 8) * (height - 8));
            return result;
        }
        private (byte[], byte[]) Get8x8Windw(byte[] vs1, byte[] vs2, int xBegin, int yBegin, int stride)
        {
            byte[] wind1 = new byte[8 * 8];
            byte[] wind2 = new byte[8 * 8];
            int count = 0;

            for (int y = yBegin; y < yBegin + 8; y++)
            {
                for (int x = xBegin; x < xBegin + 8; x++)
                {
                    int p = y * stride + x;
                    wind1[count] = vs1[p];
                    wind2[count] = vs2[p];
                    count++;
                }
            }
            return (wind1, wind2);
        }
        private double SSIM(byte[] vs1, byte[] vs2)
        {
            double ave1 = Average(vs1);//平均
            double ave2 = Average(vs2);
            double covar = Covariance(vs1, ave1, vs2, ave2);//共分散
            double vari1 = Variance(vs1, ave1);//分散
            double vari2 = Variance(vs2, ave2);
            double bunsi = (2 * ave1 * ave2 + C1) * (2 * covar + C2);//分子
            double bunbo = (ave1 * ave1 + ave2 * ave2 + C1) * (vari1 + vari2 + C2);//分母
            double ssim = bunsi / bunbo;
            return ssim;
        }

        #endregion SSIM

        #region 基本計算
        /// <summary>
        /// 共分散
        /// </summary>
        /// <param name="vs1"></param>
        /// <param name="ave1"></param>
        /// <param name="vs2"></param>
        /// <param name="ave2"></param>
        /// <returns></returns>
        private double Covariance(byte[] vs1, double ave1, byte[] vs2, double ave2)
        {
            if (vs1.Length != vs2.Length)
            {
                return double.NaN;
            }
            double total = 0;
            for (int i = 0; i < vs1.Length; i++)
            {
                total += (vs1[i] - ave1) * (vs2[i] - ave2);
            }
            return total / vs2.Length;
        }
        private double Covariance(byte[] vs1, byte[] vs2)
        {
            if (vs1.Length != vs2.Length)
            {
                return double.NaN;
            }

            double ave1 = Average(vs1);
            double ave2 = Average(vs2);
            double total = 0;
            for (int i = 0; i < vs1.Length; i++)
            {
                total += (vs1[i] - ave1) * (vs2[i] - ave2);
            }
            return total / vs2.Length;
        }

        /// <summary>
        /// 分散
        /// </summary>
        /// <param name="vs"></param>
        /// <param name="average"></param>
        /// <returns></returns>
        private double Variance(byte[] vs, double average)
        {
            double total = 0;
            for (int i = 0; i < vs.Length; i++)
            {
                double temp = vs[i] - average;
                total += temp * temp;
            }
            return total / vs.Length;
        }

        /// <summary>
        /// 分散、求め方その2
        /// </summary>
        /// <param name="vs"></param>
        /// <param name="average"></param>
        /// <returns></returns>
        private double Variance分散2(byte[] vs, double average)
        {
            double total = 0;
            for (int i = 0; i < vs.Length; i++)
            {
                total += vs[i] * vs[i];
            }
            return (total / vs.Length) - (average * average);
        }

        /// <summary>
        /// 平均
        /// </summary>
        /// <param name="vs"></param>
        /// <returns></returns>
        private double Average(byte[] vs)
        {
            ulong total = 0;
            for (int i = 0; i < vs.Length; i++)
            {
                total += vs[i];
            }
            return total / (double)vs.Length;
        }
        #endregion 基本計算


        /// <summary>
        /// 画像ファイルパスからPixelFormats.Gray8(グレースケール)のBitmapSourceと輝度の配列を作成
        /// </summary>
        /// <param name="filePath"></param>
        /// <param name="dpiX"></param>
        /// <param name="dpiY"></param>
        /// <returns></returns>
        private (BitmapSource, byte[]) MakeBitmapSourceGray8(string filePath, double dpiX = 96, double dpiY = 96)
        {
            BitmapSource source = null;
            byte[] pixels = null;
            try
            {
                using (var stream = System.IO.File.OpenRead(filePath))
                {
                    source = BitmapFrame.Create(stream);
                    if (source.Format != PixelFormats.Gray8)
                    {
                        source = new FormatConvertedBitmap(source, PixelFormats.Gray8, null, 0);
                    }
                    int w = source.PixelWidth;
                    int h = source.PixelHeight;
                    int stride = (w * source.Format.BitsPerPixel + 7) / 8;
                    pixels = new byte[h * stride];
                    source.CopyPixels(pixels, stride, 0);
                    source = BitmapSource.Create(w, h, dpiX, dpiY, source.Format, source.Palette, pixels, stride);
                };
            }
            catch (Exception)
            { }
            return (source, pixels);
        }




        private void ScrollViewer_Drop_1(object sender, DragEventArgs e)
        {
            if (e.Data.GetDataPresent(DataFormats.FileDrop) == false) return;
            //ファイルパス取得
            var datas = (string[])e.Data.GetData(DataFormats.FileDrop);
            var paths = datas.ToList();
            paths.Sort();
            var temp = MakeBitmapSourceGray8(paths[0]);
            if (temp.Item1 == null && temp.Item2 == null)
            {
                _ = MessageBox.Show("ドロップされたファイルから画像を取得できなかった");
            }
            else
            {
                MySource1 = temp;
                MyImage1.Source = MySource1.bitmap;
                MyTextBlock1.Text = System.IO.Path.GetFileName(paths[0]).ToString();
                if (MyImage2.Source != null && MySource1.pixels.Length == MySource2.pixels.Length)
                {
                    double result = SSIM8x8(MySource1.pixels, MySource2.pixels, MySource1.bitmap.PixelWidth, MySource1.bitmap.PixelHeight);
                    MyTextBlockSSIM.Text = "SSIM = " + result.ToString();
                }
                else
                {
                    MyTextBlockSSIM.Text = "SSIM";
                }
            }

        }

        private void ScrollViewer_Drop_2(object sender, DragEventArgs e)
        {
            if (e.Data.GetDataPresent(DataFormats.FileDrop) == false) return;
            //ファイルパス取得
            var datas = (string[])e.Data.GetData(DataFormats.FileDrop);
            var paths = datas.ToList();
            paths.Sort();
            var temp = MakeBitmapSourceGray8(paths[0]);
            if (temp.Item1 == null && temp.Item2 == null)
            {
                _ = MessageBox.Show("ドロップされたファイルから画像を取得できなかった");
            }
            else
            {
                MySource2 = temp;
                MyImage2.Source = MySource2.bitmap;
                MyTextBlock2.Text = System.IO.Path.GetFileName(paths[0]).ToString();
                if (MyImage1.Source != null && MySource1.pixels.Length == MySource2.pixels.Length)
                {
                    double result = SSIM8x8(MySource1.pixels, MySource2.pixels, MySource1.bitmap.PixelWidth, MySource1.bitmap.PixelHeight);
                    MyTextBlockSSIM.Text = "SSIM = " + result.ToString();
                }
                else
                {
                    MyTextBlockSSIM.Text = "SSIM";
                }
            }
        }

        private void Button_Click(object sender, RoutedEventArgs e)
        {
            if (MySource1.bitmap != null && MySource2.bitmap != null)
            {
                double result = SSIM8x8(MySource1.pixels, MySource2.pixels, MySource1.bitmap.PixelWidth, MySource1.bitmap.PixelHeight);
                MyTextBlockSSIM.Text = "SSIM = " + result.ToString();
            }
        }

        private void MyScrollViewer1_ScrollChanged(object sender, ScrollChangedEventArgs e)
        {
            double vOffset = MyScrollViewer1.VerticalOffset;
            double hOffset = MyScrollViewer1.HorizontalOffset;
            if (vOffset != MyScrollViewer2.VerticalOffset)
            {
                MyScrollViewer2.ScrollToVerticalOffset(vOffset);
            }
            if (hOffset != MyScrollViewer2.HorizontalOffset)
            {
                MyScrollViewer2.ScrollToHorizontalOffset(hOffset);
            }

        }

        private void MyScrollViewer2_ScrollChanged(object sender, ScrollChangedEventArgs e)
        {
            double vOffset = MyScrollViewer2.VerticalOffset;
            double hOffset = MyScrollViewer2.HorizontalOffset;
            if (vOffset != MyScrollViewer1.VerticalOffset)
            {
                MyScrollViewer1.ScrollToVerticalOffset(vOffset);
            }
            if (hOffset != MyScrollViewer1.HorizontalOffset)
            {
                MyScrollViewer1.ScrollToHorizontalOffset(hOffset);
            }

        }

        private void MySliderScale_MouseWheel(object sender, MouseWheelEventArgs e)
        {
            if (e.Delta > 0)
            {
                MySliderScale.Value += MySliderScale.SmallChange;
            }
            else
            {
                MySliderScale.Value -= MySliderScale.SmallChange;
            }
        }
    }
}


f:id:gogowaten:20211001125933p:plain
SSIM計算部分
87~189行目は前回と全く同じSSIMに必要な計算部分で、50~86行目が新しい部分
SSIMは大きな画像でも、8x8ピクセルとかの小さな範囲を少しづつずらして計算して、その平均値が最終的な結果になるみたい
今回は8x8ピクセルを1ピクセルづつずらして計算してみた
画像全体の輝度値の配列pixelsから8x8ぶんの配列を得るのが、69行目のGet8x8window
これを1ピクセルずらしているところが、58行目からのfor

これで思うのが計算量が多すぎ、1ピクセルずらしじゃなくて、もっと大まかに4ピクセルずらしとでもいいんじゃないか、そうすれば計算量は16分の1になって(なる?)速くなる
実際Wikipediaにも
en.wikipedia.org

それっぽいことが書いてある気がする
だいたい今回の方法だと640x480程度の画像ならすぐに終わるけど、3264x2448の画像だと4秒もかかる




比較

動画のエンコード、設定やコーデック間でどれくらいの差がでるのか
元動画のコーデックは可逆圧縮のUtVideoRGBでビットレートは200M

f:id:gogowaten:20211001100905p:plain
比較
画像はPS2ゲームの真・三國無双3は光栄より
左が元画像で、右がx265のほぼ初期設定でエンコードしたものでビットレートは4.3M
結果は0.909ってのは、それなりに劣化している(違う画像)ってのを表しているんだと思う

f:id:gogowaten:20211001102132p:plain
x265、i444
エンコードの設定で出力色フォーマットをi420からi444に変更、元動画もRGBだからきれいになるはずなんだけど
結果は0.908
誤差程度とはいっても、これは納得できない
実際に目で見て比較しても
f:id:gogowaten:20211001103909p:plain
i420とi444
同じかな…i444のほうがグラデーションがざらついている感じもする
でも、カラーで見ると
f:id:gogowaten:20211001103832p:plain
i420とi444
i444のほうが元画像に近いのがわかる
i420は体力の赤いバーや文字の輪郭が滲んでいる
ってことはカラー画像はカラー(RGB)で比較したほうがいいってことなのかも

AV1

f:id:gogowaten:20211001105625p:plain
AV1
今後普及されるとみられるAV1っていう新しいコーデック
結果は0.917
x265よりきれい、ビットレートは3.6MBだからx265より小さいくてきれいってことだけど、実際の見た目だと変わらない印象、それでいてエンコード時間は長いから今のままじゃ使う気にはならない

VP9

f:id:gogowaten:20211001110817p:plain
VP9
これも新し目のコーデック、YouTubeとかで使われている
結果は0.837
かなり低い、見た目でも明らかに劣化していてボケボケ
これはVP9のせいじゃなくて、設定の仕方がよくわかっていないせいかも、エンコード時間もAV1より長くて止まってるんじゃないかってくらい遅い

古から使われているx264

f:id:gogowaten:20211001111915p:plain
x264
ここまでのコーデックとは世代が違う古いコーデック
結果は0.908とx265とほぼ同じ
それでいてエンコード時間は5倍速い、というか他のコーデックが遅すぎなんだよねえ
fpsでいうとx264が80から100、x265は20から30

f:id:gogowaten:20211001112816p:plain
x264、5M/bps
エンコード設定を調整して同じビットレートで0.920まで上がった

f:id:gogowaten:20211001132638p:plain
20Mbps
20Mbpsまで使うと0.975まで上がった、画像で目で見て比較してもほぼ劣化なしに見えるから、動画だと全く見分けがつかない

動画エンコードでSSIMを見るなら

f:id:gogowaten:20211001115335p:plain
--SSIMオプション
--SSIMをつければログにSSIMの値が記録されるから、それを見ればいいんだけどね、知らなかった


ガンマ値を上げた画像

f:id:gogowaten:20211001120710p:plain
元の画像
f:id:gogowaten:20211001120724p:plain
ガンマ値変更
f:id:gogowaten:20211001120838p:plain
比較
0.795
画像の内容は同じだけど明るさがぜんぜん違うから、違う画像って判定なんだろうねえ

色相反転

f:id:gogowaten:20211001121220p:plain
色相反転
色相を180度変えた画像
f:id:gogowaten:20211001121314p:plain
比較
0.947と高い値
グレースケールで計算しているから色が違ってもほぼ同じ画像と判定される
やっぱりカラー画像も考えてRGBで計算したほうが良さそう

ごま塩ノイズを付加

f:id:gogowaten:20211001121945p:plain
ごま塩ノイズ
f:id:gogowaten:20211001122022p:plain
ごま塩ノイズ
0.324とかなり低い値になった
これは意外、0.8から0.9あたりかなと想像していた

ノイズ付加

f:id:gogowaten:20211001122432p:plain
ノイズ付加
f:id:gogowaten:20211001122501p:plain
ノイズ付加と比較
0.771
これも思っていたより低い
見た目的にはほとんどおなじに見えるけどねえ



f:id:gogowaten:20211001140637p:plain
ぼかし処理
f:id:gogowaten:20211001140652p:plain
ぼかしと比較
ぼかし+黒枠が入っているのに0.960とかなり高い値になった



キャベツ

f:id:gogowaten:20211001123125p:plain
キャベツ
f:id:gogowaten:20211001123200p:plain
キャベツと比較
0.473
ごま塩ノイズ(0.324)よりキャベツのほうが似ているとの判定

同じ画像の場合

f:id:gogowaten:20211001134028p:plain
同じ画像
SSIMは1



感想

これであっているのかどうかがわからん、SSIMの計算自体はあっていると思うんだけど、画像に対しての使い方が間違っているかも?
カラー(RGB)で計算したほうが良さそう、でもそうすると処理時間が単純に3倍になる
マルチスレッド化やSIMDを使えば速くなるけど難しい、もう忘れた



関連記事

次回は2日後カラー対応
gogowaten.hatenablog.com

前回の記事は昨日



ノイズ付加に使ったアプリは2年前



ぼかし処理に使ったアプリも2年前



色相反転に使ったアプリはいつものPixtack紫陽花

f:id:gogowaten:20211001142027p:plain
Pixtack紫陽花