この前の続き
実際に画像を比較してみた
画像比較アプリ
- 左右に比較したい画像ファイルをそれぞれドロップするとSSIMを表示する
- SSIMの値は最低0から最大は1
- 0なら全く違う画像、1なら完全一致、0.95以上で見分けがつかないと言われている
- 比較できるのは左右の画像の縦横ピクセルが同じときだけ
- グレースケールで比較
- カラー画像もグレースケールで計算、表示もグレースケール
- 対応画像形式はjpeg、png、bmp、gif、tiffなど
もう一個ドロップすると
SSIMの計算結果が表示される
右上のSSIM再計算ボタンは飾り
続けて別の画像ドロップで
その画像との比較になる
縦横サイズが違う画像をドロップしても計算できないので
計算結果は表示されない
左上のスライダーは
等倍から10倍拡大まで整数倍表示する
ダウンロード先
作成、動作環境
- Windows 10 Home バージョン 21H1
- Visual Studio Community 2019
- WPF
- C#
- .NET 5
動作に必要なのは.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; } } } }
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
画像はPS2ゲームの真・三國無双3は光栄より
左が元画像で、右がx265のほぼ初期設定でエンコードしたものでビットレートは4.3M
結果は0.909ってのは、それなりに劣化している(違う画像)ってのを表しているんだと思う
エンコードの設定で出力色フォーマットをi420からi444に変更、元動画もRGBだからきれいになるはずなんだけど
結果は0.908
誤差程度とはいっても、これは納得できない
実際に目で見て比較しても
同じかな…i444のほうがグラデーションがざらついている感じもする
でも、カラーで見ると
i444のほうが元画像に近いのがわかる
i420は体力の赤いバーや文字の輪郭が滲んでいる
ってことはカラー画像はカラー(RGB)で比較したほうがいいってことなのかも
AV1
今後普及されるとみられるAV1っていう新しいコーデック
結果は0.917
x265よりきれい、ビットレートは3.6MBだからx265より小さいくてきれいってことだけど、実際の見た目だと変わらない印象、それでいてエンコード時間は長いから今のままじゃ使う気にはならない
VP9
これも新し目のコーデック、YouTubeとかで使われている
結果は0.837
かなり低い、見た目でも明らかに劣化していてボケボケ
これはVP9のせいじゃなくて、設定の仕方がよくわかっていないせいかも、エンコード時間もAV1より長くて止まってるんじゃないかってくらい遅い
古から使われているx264
ここまでのコーデックとは世代が違う古いコーデック
結果は0.908とx265とほぼ同じ
それでいてエンコード時間は5倍速い、というか他のコーデックが遅すぎなんだよねえ
fpsでいうとx264が80から100、x265は20から30
エンコード設定を調整して同じビットレートで0.920まで上がった
20Mbpsまで使うと0.975まで上がった、画像で目で見て比較してもほぼ劣化なしに見えるから、動画だと全く見分けがつかない
動画エンコードでSSIMを見るなら
--SSIMをつければログにSSIMの値が記録されるから、それを見ればいいんだけどね、知らなかった
ガンマ値を上げた画像
0.795
画像の内容は同じだけど明るさがぜんぜん違うから、違う画像って判定なんだろうねえ
色相反転
色相を180度変えた画像
0.947と高い値
グレースケールで計算しているから色が違ってもほぼ同じ画像と判定される
やっぱりカラー画像も考えてRGBで計算したほうが良さそう
ごま塩ノイズを付加
0.324とかなり低い値になった
これは意外、0.8から0.9あたりかなと想像していた
ノイズ付加
0.771
これも思っていたより低い
見た目的にはほとんどおなじに見えるけどねえ
ぼかし+黒枠が入っているのに0.960とかなり高い値になった
キャベツ
0.473
ごま塩ノイズ(0.324)よりキャベツのほうが似ているとの判定
同じ画像の場合
SSIMは1
感想
これであっているのかどうかがわからん、SSIMの計算自体はあっていると思うんだけど、画像に対しての使い方が間違っているかも?
カラー(RGB)で計算したほうが良さそう、でもそうすると処理時間が単純に3倍になる
マルチスレッド化やSIMDを使えば速くなるけど難しい、もう忘れた
関連記事
次回は2日後カラー対応
gogowaten.hatenablog.com
前回の記事は昨日
ノイズ付加に使ったアプリは2年前
ぼかし処理に使ったアプリも2年前
色相反転に使ったアプリはいつものPixtack紫陽花