C#、WPF、バイキュービック補完法での画像の拡大縮小変換に再挑戦した結果、グレースケール専用
参照したところ
画像の拡大「Bicubic法」: koujinz blog
http://koujinz.cocolog-nifty.com/blog/2009/05/bicubic-a97c.html
バイキュービックがどんな感じなのかは、ここがわかりやすい
Bicubic(バイキュービック法)~1.基本編~ | Rain or Shine
https://www.rainorshine.asia/2013/04/03/post2351.html
C#とWindowsFormsでバイキュービックならここ
Bicubic interpolation - Wikipedia
https://en.wikipedia.org/wiki/Bicubic_interpolation
これが理解できたらバイキュービック補完マスターを名乗れると思う
ここからは数式とその定数だけ拝借
このあたりを見てなんとなくわかった気がした
重みの決定方法
バイリニアが距離に対して直線で比例して重みが決まっていたのが、バイキュービックだと曲線になる
ウィキペディアの数式に距離を0.1刻みで入れてグラフにすると
バイリニアが参照する距離は0~1で、これが上下左右だから範囲は2x2ピクセルだった、対してバイキュービックは距離が1伸びて0~2、上下左右にした範囲は4x4ピクセルになる
バイキュービックの重み計算には距離の他にaっている定数も必要、aの値を小さくするとシャープになって、大きくするとぼやけるっていう味付けで、通常は-0.5か-0.75を使うみたいなことが、さっきのウィキペディアには書いてあるけど、対象画像や好みで決めればいいみたいね
参照範囲の決定
その前に、参照点の決定方法はこの前のバイリニアのときと同じ
gogowaten.hatenablog.com
参照点 = (求めたいピクセル座標+0.5)*倍率
0.5足しているのはピクセルの中心で計算するため
倍率は変換の逆倍率なので、5倍拡大のときは1/5=0.2を使うことになる
参照点が(2.25,2.25)だった場合に、どの座標との距離を求めればいいのかと、どのピクセル座標を対象にすればいいのかを試してみた
左上を0とした縦横5x5のピクセル
数字のある赤丸はピクセルの中心座標、数字はただの連番
青色の四角は、参照点rを中心とした4x4の参照範囲
こうしてみると参照範囲に重なっている赤丸が16個あるから、その赤丸それぞれとのx,yの距離で重み計算すればいいってのがわかる、じゃあそのピクセル座標は?
4x4の基準になるピクセル座標を決めて、横に4ピクセルは基準から左に2ピクセル、右に1ピクセルにして、縦4ピクセルは基準から上に2ピクセル、下に1ピクセルにしようと思う
基準ピクセル座標は参照点を四捨五入して決めることにした
参照点r(2.25,2.25)を四捨五入すると(2,2)、これを基準、これを中心に左上に2、右下に1としたので
左上は(2-2=0,2-2=0)、右下は(2+1=3,2+1=3)で
(0,0)から(3,3)の4x4=16ピクセルになった
さっきとは重なっている赤丸が違っている、この赤丸のあるピクセルが参照ピクセルになればいい
同じように四捨五入して基準を求めると
(2.72,2.25)を四捨五入して(3,2)
基準(3,2)からの4x4参照ピクセルは
(3-2=1,2-2=0)、(3+1=4,2+1=3)で
(1,0)から(4,3)
期待通りの参照ピクセル範囲になった
これで参照点からピクセル範囲が確定できるようになった
距離の計算
参照点r(2.75,2.25)から16個の赤丸それぞれとのx,yの距離の計算
赤の1との距離は?
基準はrの四捨五入なので(3,2)
赤の1があるピクセルは基準の左2、基準の上2なので、(3-2,2-2)で(1,0)ってことがわかって
赤の1はその(1,0)の中心座標なので0.5足して、(1+0.5=1.5、0+0.5=0.5)で
(1.5,0.5)
あとはrから引き算で
(2.75-1.5=1.25、2.25-0.5=1.75)で
(1.25,1.75)
xの距離が1.25で、yの距離は1.75
他の赤丸でも計算できるようにしてxだけでみると
x距離 = 絶対値(rのx-(基準のx+n+0.5))
(nは基準から見た赤丸のあるピクセルの相対距離で、-2、-1、0,1、これのどれか)
赤丸2とのx距離は
2.75-(3+(-1)+0.5)=0.25
赤丸13とのx距離は
2.75-(3+0+0.5)=-0.75
距離は絶対値なので0.75になる
y距離も同じ
y距離 = 絶対値(rのy-(基準のy+n+0.5))
これを使って赤丸17との距離は
2.25-(2+1+0.5)=-1.25の絶対値で1.25
これで良さそう
コード
作成動作環境
- Windows 10 Home バージョン 2004
- Visual Studio Community 2019
- WPF
- C#
- .NET 5
グレースケール画像専用
WPFのBitmapSourceが対象
/// <summary> /// バイキュービックで重み計算 /// </summary> /// <param name="d">距離</param> /// <param name="a">定数、-1.0 ~ -0.5 が丁度いい</param> /// <returns></returns> private static double GetWeightCubic(double d, double a = -1.0) { return d switch { 2 => 0, <= 1 => ((a + 2) * (d * d * d)) - ((a + 3) * (d * d)) + 1, < 2 => (a * (d * d * d)) - (5 * a * (d * d)) + (8 * a * d) - (4 * a), _ => 0 }; } /// <summary> /// 画像の縮小、バイキュービック法で補完、PixelFormats.Gray8専用) /// a指定版 /// </summary> /// <param name="source">PixelFormats.Gray8のBitmap</param> /// <param name="width">変換後の横ピクセル数を指定</param> /// <param name="height">変換後の縦ピクセル数を指定</param> /// <param name="a">-0.5~-1.0くらいを指定する、小さくするとシャープ、大きくするとぼかし</param> /// <returns></returns> private BitmapSource BicubicGray8Test(BitmapSource source, int width, int height, double a = -1.0) { //元画像の画素値の配列作成 int sourceWidth = source.PixelWidth; int sourceHeight = source.PixelHeight; int sourceStride = (sourceWidth * source.Format.BitsPerPixel + 7) / 8; byte[] sourcePixels = new byte[sourceHeight * sourceStride]; source.CopyPixels(sourcePixels, sourceStride, 0); //変換後の画像の画素値の配列用 double widthScale = (double)sourceWidth / width;//横倍率 double heightScale = (double)sourceHeight / height; int stride = (width * source.Format.BitsPerPixel + 7) / 8; byte[] pixels = new byte[height * stride]; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { //参照点 double rx = (x + 0.5) * widthScale; double ry = (y + 0.5) * heightScale; //参照点四捨五入で基準 int xKijun = (int)(rx + 0.5); int yKijun = (int)(ry + 0.5); double vv = 0; //参照範囲は基準から左へ2、右へ1の範囲 for (int yy = -2; yy <= 1; yy++)// { //+0.5しているのは中心座標で計算するため double dy = Math.Abs(ry - (yy + yKijun + 0.5));//距離 double yw = GetWeightCubic(dy, a);//重み int yc = yKijun + yy; //マイナス座標や画像サイズを超えていたら、収まるように修正 yc = yc < 0 ? 0 : yc > sourceHeight - 1 ? sourceHeight - 1 : yc; for (int xx = -2; xx <= 1; xx++) { double dx = Math.Abs(rx - (xx + xKijun + 0.5)); double xw = GetWeightCubic(dx, a); int xc = xKijun + xx; xc = xc < 0 ? 0 : xc > sourceWidth - 1 ? sourceWidth - 1 : xc; byte value = sourcePixels[yc * sourceStride + xc]; vv += value * yw * xw; } } //0~255の範囲を超えることがあるので、修正 vv = vv < 0 ? 0 : vv > 255 ? 255 : vv; pixels[y * stride + x] = (byte)(vv + 0.5); } }; BitmapSource bitmap = BitmapSource.Create(width, height, 96, 96, source.Format, null, pixels, stride); return bitmap; }
べき乗計算でMath.Powを使わないのは
gogowaten.hatenablog.com
使うと遅くなることがあるから
テストアプリ
MainWindow.xaml
<Window x:Class="_20210422_バイキュービックとグレースケール画像拡縮.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:_20210422_バイキュービックとグレースケール画像拡縮" mc:Ignorable="d" Title="MainWindow" Height="472" Width="634" AllowDrop="True" Drop="Window_Drop"> <Window.Resources> <Style TargetType="Button"> <Setter Property="Margin" Value="2,10,2,0"/> </Style> </Window.Resources> <Grid> <DockPanel UseLayoutRounding="True"> <StackPanel DockPanel.Dock="Right" Background="White"> <Button x:Name="MyButton1" Content="1/2倍" Click="MyButton1_Click"/> <Button x:Name="MyButton2" Content="1/3倍" Click="MyButton2_Click"/> <Button x:Name="MyButton3" Content="2倍" Click="MyButton3_Click"/> <Button x:Name="MyButton4" Content="3倍" Click="MyButton4_Click"/> <Button x:Name="MyButtonToOrigin" Content="戻す" Click="MyButtonToOrigin_Click"/> <Slider x:Name="MySlider" Minimum="-3.0" Maximum="3.0" SmallChange="0.05" TickFrequency="0.1" IsSnapToTickEnabled="True" Value="-1.0" Width="100" HorizontalAlignment="Center" MouseWheel="MySlider_MouseWheel"> <Slider.LayoutTransform> <RotateTransform Angle="270"/> </Slider.LayoutTransform> </Slider> <TextBlock Text="{Binding ElementName=MySlider, Path=Value, StringFormat=0.00}" HorizontalAlignment="Center"/> <Button x:Name="MyButtonCopy" Content="コピー" Click="MyButtonCopy_Click" Margin="10"/> <Button x:Name="MyButtonPaste" Content="ペースト" Click="MyButtonPaste_Click" Margin="10"/> </StackPanel> <ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> <Image x:Name="MyImage" Stretch="None"/> </ScrollViewer> </DockPanel> </Grid> </Window>
MainWindow.xaml.cs
using System; using System.Linq; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; //Bicubic(バイキュービック法)~1.基本編~ | Rain or Shine //https://www.rainorshine.asia/2013/04/03/post2351.html //Bicubic(バイキュービック法)~3.さらに高速化編~ | Rain or Shine //https://www.rainorshine.asia/2013/12/13/post2497.html // Bicubic interpolation - Wikipedia //https://en.wikipedia.org/wiki/Bicubic_interpolation // 画像の拡大「Bicubic法」: koujinz blog //http://koujinz.cocolog-nifty.com/blog/2009/05/bicubic-a97c.html //画像リサイズ処理のうんちく - Qiita //https://qiita.com/yoya/items/95c37e02762431b1abf0#%E3%82%BB%E3%83%91%E3%83%A9%E3%83%96%E3%83%AB%E3%83%95%E3%82%A3%E3%83%AB%E3%82%BF namespace _20210422_バイキュービックとグレースケール画像拡縮 { /// <summary> /// Interaction logic for MainWindow.xaml /// </summary> public partial class MainWindow : Window { private BitmapSource MyBitmapOrigin; public MainWindow() { InitializeComponent(); #if DEBUG this.Top = 0; this.Left = 0; #endif //this.Background = MakeTileBrush(MakeCheckeredPattern(10, Colors.WhiteSmoke, Colors.LightGray)); } /// <summary> /// バイキュービックで重み計算 /// </summary> /// <param name="d">距離</param> /// <param name="a">定数、-1.0 ~ -0.5 が丁度いい</param> /// <returns></returns> private static double GetWeightCubic(double d, double a = -1.0) { return d switch { 2 => 0, <= 1 => ((a + 2) * (d * d * d)) - ((a + 3) * (d * d)) + 1, < 2 => (a * (d * d * d)) - (5 * a * (d * d)) + (8 * a * d) - (4 * a), _ => 0 }; } /// <summary> /// 画像の縮小、バイキュービック法で補完、PixelFormats.Gray8専用) /// a指定版 /// </summary> /// <param name="source">PixelFormats.Gray8のBitmap</param> /// <param name="width">変換後の横ピクセル数を指定</param> /// <param name="height">変換後の縦ピクセル数を指定</param> /// <param name="a">-0.5~-1.0くらいを指定する、基準は-1.0、小さくするとシャープ、大きくするとぼかし</param> /// <returns></returns> private BitmapSource BicubicGray8Test(BitmapSource source, int width, int height, double a = -1.0) { //元画像の画素値の配列作成 int sourceWidth = source.PixelWidth; int sourceHeight = source.PixelHeight; int sourceStride = (sourceWidth * source.Format.BitsPerPixel + 7) / 8; byte[] sourcePixels = new byte[sourceHeight * sourceStride]; source.CopyPixels(sourcePixels, sourceStride, 0); //変換後の画像の画素値の配列用 double widthScale = (double)sourceWidth / width;//横倍率 double heightScale = (double)sourceHeight / height; int stride = (width * source.Format.BitsPerPixel + 7) / 8; byte[] pixels = new byte[height * stride]; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { //参照点 double rx = (x + 0.5) * widthScale; double ry = (y + 0.5) * heightScale; //参照点四捨五入で基準 int xKijun = (int)(rx + 0.5); int yKijun = (int)(ry + 0.5); double vv = 0; //参照範囲は基準から左へ2、右へ1の範囲 for (int yy = -2; yy <= 1; yy++)// { //+0.5しているのは中心座標で計算するため double dy = Math.Abs(ry - (yy + yKijun + 0.5));//距離 double yw = GetWeightCubic(dy, a);//重み int yc = yKijun + yy; //マイナス座標や画像サイズを超えていたら、収まるように修正 yc = yc < 0 ? 0 : yc > sourceHeight - 1 ? sourceHeight - 1 : yc; for (int xx = -2; xx <= 1; xx++) { double dx = Math.Abs(rx - (xx + xKijun + 0.5)); double xw = GetWeightCubic(dx, a); int xc = xKijun + xx; xc = xc < 0 ? 0 : xc > sourceWidth - 1 ? sourceWidth - 1 : xc; byte value = sourcePixels[yc * sourceStride + xc]; vv += value * yw * xw; } } //0~255の範囲を超えることがあるので、修正 vv = vv < 0 ? 0 : vv > 255 ? 255 : vv; pixels[y * stride + x] = (byte)(vv + 0.5); } }; BitmapSource bitmap = BitmapSource.Create(width, height, 96, 96, source.Format, null, pixels, stride); return bitmap; } /// <summary> /// 画像ファイルパスからPixelFormats.Gray8のBitmapSource作成 /// </summary> /// <param name="filePath"></param> /// <param name="dpiX"></param> /// <param name="dpiY"></param> /// <returns></returns> private BitmapSource MakeBitmapSourceGray8FromFile(string filePath, double dpiX = 96, double dpiY = 96) { BitmapSource source = 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; byte[] 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; } #region コピペ // クリップボードに複数の形式のデータをコピーする - .NET Tips(VB.NET, C#...) //https://dobon.net/vb/dotnet/system/clipboardmultidata.html // アルファ値を失わずに画像のコピペできた、.NET WPFのClipboard - 午後わてんのブログ //https://gogowaten.hatenablog.com/entry/2021/02/10/134406 /// <summary> /// BitmapSourceをPNG形式に変換したものと、そのままの形式の両方をクリップボードにコピーする /// </summary> /// <param name="source"></param> private void ClipboardSetImageWithPng(BitmapSource source) { //DataObjectに入れたいデータを入れて、それをクリップボードにセットする DataObject data = new(); //BitmapSource形式そのままでセット data.SetData(typeof(BitmapSource), source); //PNG形式にエンコードしたものをMemoryStreamして、それをセット //画像をPNGにエンコード PngBitmapEncoder pngEnc = new(); pngEnc.Frames.Add(BitmapFrame.Create(source)); //エンコードした画像をMemoryStreamにSava using var ms = new System.IO.MemoryStream(); pngEnc.Save(ms); data.SetData("PNG", ms); //クリップボードにセット Clipboard.SetDataObject(data, true); } /// <summary> /// クリップボードからBitmapSourceを取り出して返す、PNG(アルファ値保持)形式に対応 /// </summary> /// <returns></returns> private BitmapSource GetImageFromClipboardWithPNG() { BitmapSource source = null; //クリップボードにPNG形式のデータがあったら、それを使ってBitmapFrame作成して返す //なければ普通にClipboardのGetImage、それでもなければnullを返す using var ms = (System.IO.MemoryStream)Clipboard.GetData("PNG"); if (ms != null) { //source = BitmapFrame.Create(ms);//これだと取得できない source = BitmapFrame.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad); } else if (Clipboard.ContainsImage()) { source = Clipboard.GetImage(); } return source; } //ファイルドロップ時 private void Window_Drop(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(); MyBitmapOrigin = MakeBitmapSourceGray8FromFile(paths[0]); MyImage.Source = MyBitmapOrigin; } //ボタンクリック private void MyButton1_Click(object sender, RoutedEventArgs e) { int yoko = (int)Math.Ceiling(MyBitmapOrigin.PixelWidth / 2.0); int tate = (int)Math.Ceiling(MyBitmapOrigin.PixelHeight / 2.0); MyImage.Source = BicubicGray8Test(MyBitmapOrigin, yoko, tate, MySlider.Value); } private void MyButton2_Click(object sender, RoutedEventArgs e) { int yoko = (int)Math.Ceiling(MyBitmapOrigin.PixelWidth / 3.0); int tate = (int)Math.Ceiling(MyBitmapOrigin.PixelHeight / 3.0); //MyImage.Source = BicubicGray8Test(MyBitmapOrigin, yoko, 3, MySlider.Value); MyImage.Source = BicubicGray8Test(MyBitmapOrigin, yoko, tate, MySlider.Value); } private void MyButton3_Click(object sender, RoutedEventArgs e) { MyImage.Source = BicubicGray8Test(MyBitmapOrigin, MyBitmapOrigin.PixelWidth * 2, MyBitmapOrigin.PixelHeight * 2, MySlider.Value); } private void MyButton4_Click(object sender, RoutedEventArgs e) { MyImage.Source = BicubicGray8Test(MyBitmapOrigin, MyBitmapOrigin.PixelWidth * 3, MyBitmapOrigin.PixelHeight * 3, MySlider.Value); } //画像をクリップボードにコピー private void MyButtonCopy_Click(object sender, RoutedEventArgs e) { ClipboardSetImageWithPng((BitmapSource)MyImage.Source); } //クリップボードから画像追加 private void MyButtonPaste_Click(object sender, RoutedEventArgs e) { BitmapSource bitmap = GetImageFromClipboardWithPNG(); if (bitmap != null) { var gray8 = new FormatConvertedBitmap(bitmap, PixelFormats.Gray8, null, 0); MyBitmapOrigin = gray8; MyImage.Source = gray8; } } private void MySlider_MouseWheel(object sender, System.Windows.Input.MouseWheelEventArgs e) { if (e.Delta > 0) MySlider.Value += MySlider.SmallChange; else MySlider.Value -= MySlider.SmallChange; } private void MyButtonToOrigin_Click(object sender, RoutedEventArgs e) { MyImage.Source = MyBitmapOrigin; } #endregion コピペ } }
テスト
普通の写真画像
給付金で購入することができたクエン酸、これとてもいい、これと砂糖、塩、水だけでかんたんにアクエリアスやポカリスエットみたいなのが作れる
価格は500グラムで1000円くらいで、毎日飲んでも2年以上かかりそう、すごい、バイキュービック全然関係ない
どっちも同じに見える
バイキュービックのほうがきれいなのかなあと期待してたけど、バイリニアでも十分綺麗だからかなあ
1/3倍
1/3倍でも変わらないかなあ
定数aの違い
同じに見える...
別の写真画像で
a=1.0も試したけど、あんまり変わらない
図形画像で
Image Resizing for the Web and Email
https://www.cambridgeincolour.com/tutorials/image-resize-for-web.htm
ここから引用した画像
これを使って比較してみる
写真画像では違いがわからなかったけど、この画像では違いが出てきた
元の画像に近いのはバイキュービックのa=1.0とa=0.5
逆に良いとされている-0.5では輪が目立っている、これはこういう特殊な画像だからなんだろうねえ
バイリニアとそっくりなのはa=0.0、これは距離と重みの関係のグラフでもそっくりな線だったから納得
細かく見てみる
これを拡大縮小変換した画像を30倍に拡大表示してピクセルの値も表示
拡大ではバイリニアと比較してぼやけ具合が減っているかな、逆に縮小だとバイリニアよりぼやける傾向
1/3と1/2だけ30倍に拡大表示と値表示
結果はどちらでもいいかなあ、違いはあるけどねえ
同じ、1/2と1/3は全く同じ結果になった
前回でも使った画像
左から黒と白が交互に並んでいる
ほとんど一緒
1/2と1/3を拡大してみると
1/2のバイキュービックは両端だけ違う値になった、これだったら全部同じ値のバイリニアのほうがいいかなあ
1/3は黒と白の差がより出ているバイリニアのほうがいい
前回からの改善
値200の画像
3年前試したときはこれがグラデーションになっていたけど、今回は
元画像と同じ値200になった
原因は画像の周縁部の処理の違いと、ピクセルの中心で計算するかしないかの違いだと思う
周縁部の処理では参照範囲がマイナスになることがある
倍率が0.2だった場合、端のピクセル(0,0)の参照点を計算すると
0+0.5*0.2=0.1
四捨五入して基準座標は(0,0)
参照範囲の4x4マスの一番左上は-2、-2なので
0-2=-2
(-2,-2)とマイナスの値になる、でも画像にマイナスの座標はないから、なにか処理する必要がある
前回はこの処理を重みを0にしてた(なんで?)、今回は重み計算はそのままして、ピクセル座標は一番近い端を使うことにした
これは
画像リサイズ処理のうんちく - Qiita
qiita.com
このエッジリピートってのと同じだと思う
座標が-2や-1だったら座標0のピクセルの値を使う
感想
3年前に試したときは明らかにおかしかったから気になっていたんだよねえ、で、今回のはいいと思う、計算式は全く理解できていないけど、結果としてはバイキュービックで正しく補完できているはず(そうであってくれ)
とはいっても参照点の決め方(0.5足す)や、そこからの参照範囲の決め方も、たぶんこうじゃないかなあとか試して出したものだから、間違っていたり余計な回り道をしている可能性は十分ある
画像リサイズのうんちく (補間フィルタ) - Qiita
https://qiita.com/yoya/items/f167b2598fec98679422#bi-cubic-bc%E3%83%91%E3%83%A9%E3%83%A1%E3%83%BC%E3%82%BF
ここみるとバイキュービックっていっても、いくつか種類があるというか、2つのパラメータ指定でいろいろできるみたい、いつか試してみたい
次は処理の最適化とか高速化して、そのあとカラー版かな
関連記事
縮小処理を書き直したのは2週間後
gogowaten.hatenablog.com
カラー対応は3日後
gogowaten.hatenablog.com
次回は明日
gogowaten.hatenablog.com
前回のWPF記事は6日前
gogowaten.hatenablog.com