C#、WPF、バイリニア法での画像の拡大縮小変換に再挑戦した結果、グレースケール専用
画像の縮小変換
Bilinearバイリニアってのは線形とか直線で、距離が近いほど影響が大きくなる感じ
3x3の画像を2x2に縮小するときにどんな感じになればいいのかエクセルを使って考えてみた
変換後の画像のピクセルの値がどんな値になればいいのかは、そのピクセルの座標が元画像ではどの座標に相当するのかを計算することになる
座標の変換は、もとのサイズ/変換後サイズ、これを倍率として掛け算する
3/2=1.5
(0,1)に1.5をかけると(0 * 1.5=0,1 * 1.5=1.5)で、(0,1.5)になる
ピクセルにも幅がある
例えば(0,0)の場合だと、どんな倍率でも(0,0)にしかならないので、これは不自然
ピクセルにも幅があると考えて、その中心座標を使って計算する
中心座標はピクセル座標に0.5足すだけ
(0,0)の中心座標は(0.5,0.5)、(0,1)は(0.5,1.5)になる
この方法は以前の記事
最近傍補間法で画像の拡大縮小試してみた、2回め - 午後わてんのブログ
https://gogowaten.hatenablog.com/entry/2019/11/08/182906
このときに試してうまくいったので今回もそうしてみた
参照点
(0,1)の中心座標は(0.5,1.5)これに倍率の1.5をかけると
(0.5 * 1.5 = 0.75,1.5 * 1.5 = 2.25)なので
参照点は(0.75,2.25)
参照範囲は1x1
参照点を中心にした1x1の範囲を参照範囲にする
あとはこの範囲に重なるピクセルの値を、面積に応じて取り出せばそれが求める値になる
面積が大きい(参照点から近い)ほど直線的(リニア)に影響が大きくなるので、これがバイリニアってことなんだと思う
参照範囲の左上の座標をbpとして、これを参照範囲の基準点にする
参照点r(0.75,2.25)のbpは(0.25,1.75)
面積
多くの場合参照範囲は4つのピクセルにまたがる形になる、それぞれの面積を求めるにはbpの小数部分を使う
bp(0.25,1.75)の小数部分はxが0.25、yが0.75、これをそれぞれsx、syとすると、ABCDの面積は
A (1-sx) * (1-sy)
B sx * (1-sy)
C (1-sx) * sy
D sx * sy
これで計算できる
結果は
区 | 面積 | 計算 |
---|---|---|
A | 0.1875 | (1-0.25)*(1-0.75)=0.1875 |
B | 0.0625 | 0.25*(1-0.75)=0.0625 |
C | 0.5625 | (1-0.25)*0.75=0.5625 |
D | 0.1875 | 0.25*0.75=0.1875 |
ピクセル座標から値を取得
bpの小数部分を切り捨てると、それがピクセル座標になる
Aのピクセル座標がわかればBCDはその隣なので1を足せばいい
ピクセル座標から値を取得
結果
区 | 値 |
---|---|
A | 200 |
B | 180 |
C | 150 |
D | 130 |
答え
各区の値と面積を掛け算して、合計したのが求める値になる
画素値は整数なので最後に四捨五入して完成
区 | 値 | 面積 | 値*面積 |
---|---|---|---|
A | 200 | 0.1875 | 37.5 |
B | 180 | 0.0625 | 11.25 |
C | 150 | 0.5625 | 84.375 |
D | 130 | 0.1875 | 24.375 |
A+B+C+D=37.5+11.25+84.375+24.375=157.5
157.5を四捨五入して158
エクセルで確認
(0,1)の画素値は158
中心座標じゃなくてピクセル座標で計算した場合
(0,0)をそのまま計算するか、(0,0)の中心座標(0.5,0.5)で計算するかの違い
そのまま計算すると変換後の(0,0)は必ず元画像の(0,0)になる、これは倍率を変えても同じ、0に何かけても0にしかならない
試しに3x3を1x1に縮小変換つまり1ピクセルにしてみると
そのまま計算した場合はやっぱり250になった
中心座標で計算した場合は元画像の中央ピクセル(1,1)が参照範囲になって180になった、こっちのほうが自然
拡大変換に対応
縮小変換とは少し違う
さっきの変換を使って拡大してみるとエラーになる
3x3の画像を2倍の6x6に拡大変換するときに(5,5)のピクセルの値を求めてみると
エラーになる
これは参照範囲が元画像の範囲を超えてしまっているからで、これは右下の(5,5)だけに限らず、周縁部のピクセルは全部エラーになる、例えば左上(0,0)だとマイナスの値になって、画像にマイナス座標はないからエラーになる
なので範囲外になった参照範囲を修正する必要がある、今回は一番近いピクセルに収めることにした
bpの座標を見て0未満なら0に修正
bpのx座標を見て元画像の横ピクセル数-1より大きければ、bpのx座標を横ピクセル数-1に修正
bpのy座標を見て元画像の縦ピクセル数-1より大きければ、bpのy座標を縦ピクセル数-1に修正
ってことにした
2.25は範囲外なので2に修正
BCDのピクセルの値がエラーになっているけど、面積が0のときは面積*値を計算しないで、結果を0にする処理にしてある
-0.25は0に修正されている
コード
縮小変換専用
/// <summary> /// 画像の縮小、バイリニア法で補完、グレースケール専用(PixelFormats.Gray8専用) /// </summary> /// <param name="source">PixelFormats.Gray8のBitmap</param> /// <param name="yoko">変換後の横ピクセル数を指定</param> /// <param name="tate">変換後の縦ピクセル数を指定</param> /// <returns></returns> private BitmapSource BilinearGray8縮小専用(BitmapSource source, int yoko, int tate) { //元画像の画素値の配列作成 int sourceWidth = source.PixelWidth; int sourceHeight = source.PixelHeight; int stride = (sourceWidth * source.Format.BitsPerPixel + 7) / 8; byte[] pixels = new byte[sourceHeight * stride]; source.CopyPixels(pixels, stride, 0); //縮小後の画像の画素値の配列用 double yokoScale = (double)sourceWidth / yoko;//横倍率 double tateScale = (double)sourceHeight / tate; int scaledStride = (yoko * source.Format.BitsPerPixel + 7) / 8; byte[] resultPixels = new byte[tate * scaledStride]; for (int y = 0; y < tate; y++) { for (int x = 0; x < yoko; x++) { //参照点r double rx = (x + 0.5) * yokoScale; double ry = (y + 0.5) * tateScale; //参照範囲の左上座標bp double bpX = rx - 0.5; double bpY = ry - 0.5; //小数部分s double sx = bpX % 1; double sy = bpY % 1; ////面積 double d = sx * sy; double c = (1 - sx) * sy; double b = sx * (1 - sy); double a = 1 - (d + c + b);// (1 - sx) * (1 - sy) //左上ピクセルの座標は //参照範囲の左上座標の小数部分を切り捨て(整数部分) //左上ピクセルのIndex int i = ((int)bpY * stride) + (int)bpX; //値*面積 double aa = pixels[i] * a; double bb = pixels[i + 1] * b; double cc = pixels[i + stride] * c; double dd = pixels[i + stride + 1] * d; //4区を合計して四捨五入で完成 double add = aa + bb + cc + dd; resultPixels[y * scaledStride + x] = (byte)(add + 0.5); } } BitmapSource bitmap = BitmapSource.Create(yoko, tate, 96, 96, source.Format, null, resultPixels, scaledStride); return bitmap; }
縮小変換専用なので拡大に使うとエラーになる
拡大変換対応版
/// <summary> /// 画像の拡大縮小、バイリニア法で補完、グレースケール専用(PixelFormats.Gray8専用) /// </summary> /// <param name="source">PixelFormats.Gray8のBitmap</param> /// <param name="yoko">横ピクセル数を指定</param> /// <param name="tate">縦ピクセル数を指定</param> /// <returns></returns> private BitmapSource BilinearGray8専用(BitmapSource source, int yoko, int tate) { //元画像の画素値の配列作成 int sourceWidth = source.PixelWidth; int sourceHeight = source.PixelHeight; int stride = (sourceWidth * source.Format.BitsPerPixel + 7) / 8; byte[] pixels = new byte[sourceHeight * stride]; source.CopyPixels(pixels, stride, 0); //変換後の画像の画素値の配列用 double yokoScale = (double)sourceWidth / yoko;//横倍率 double tateScale = (double)sourceHeight / tate; int scaledStride = (yoko * source.Format.BitsPerPixel + 7) / 8; byte[] resultPixels = new byte[tate * scaledStride]; for (int y = 0; y < tate; y++) { for (int x = 0; x < yoko; x++) { //参照点r double rx = (x + 0.5) * yokoScale; double ry = (y + 0.5) * tateScale; //参照範囲の左上座標bp double bpX = rx - 0.5; //画像範囲内チェック、参照範囲が画像から外れていたら修正(収める) if (bpX < 0) { bpX = 0; } if (bpX > sourceWidth - 1) { bpX = sourceWidth - 1; } double bpY = ry - 0.5; if (bpY < 0) { bpY = 0; } if (bpY > sourceHeight - 1) { bpY = sourceHeight - 1; } //小数部分s double sx = bpX % 1; double sy = bpY % 1; //面積 double d = sx * sy; double c = (1 - sx) * sy; double b = sx * (1 - sy); double a = 1 - (d + c + b);// (1 - sx) * (1 - sy) //左上ピクセルの座標は //参照範囲の左上座標の小数部分を切り捨て(整数部分) //左上ピクセルのIndex int ia = ((int)bpY * stride) + (int)bpX; //値*面積 double aa = pixels[ia] * a; double bb = 0, cc = 0, dd = 0; //B以降は面積が0より大きいときだけ計算 if (b != 0) { //Aの右ピクセル*Bの面積 bb = pixels[ia + 1] * b; } if (c != 0) { cc = pixels[ia + stride] * c; } if (d != 0) { //Aの右下ピクセル、仮にAが画像右下ピクセルだったとしても //そのときは面積が0のはずだからここは計算されない dd = pixels[ia + stride + 1] * d; } //4区を合計して四捨五入で完成 double add = aa + bb + cc + dd; resultPixels[y * scaledStride + x] = (byte)(add + 0.5); } } BitmapSource bitmap = BitmapSource.Create(yoko, tate, 96, 96, source.Format, null, resultPixels, scaledStride); return bitmap; }
確認アプリのコード
作成動作環境
- Windows 10 Home バージョン 2004
- Visual Studio Community 2019
- WPF
- C#
- .NET 5
MainWindow.xaml
<Window x:Class="_20210414_Bilinearで画像拡大縮小.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:_20210414_Bilinearで画像拡大縮小" mc:Ignorable="d" Title="MainWindow" Height="450" Width="614" AllowDrop="True" Drop="Window_Drop"> <Grid> <DockPanel UseLayoutRounding="True"> <StackPanel DockPanel.Dock="Right"> <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="MyButtonCopy" Content="コピー" Click="MyButtonCopy_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; //画像を拡大縮小、バイリニア法で補完、 //グレースケール専用(PixelFormats.Gray8専用) //カラー画像をドロップした場合はグレースケール画像に変換する //倍率は1/2、1/3、2、3 namespace _20210414_Bilinearで画像拡大縮小 { public partial class MainWindow : Window { private BitmapSource MyBitmapOrigin; public MainWindow() { InitializeComponent(); #if DEBUG this.Top = 0; this.Left = 0; #endif } //縮小拡大対応完成版 /// <summary> /// 画像の拡大縮小、バイリニア法で補完、グレースケール専用(PixelFormats.Gray8専用) /// </summary> /// <param name="source">PixelFormats.Gray8のBitmap</param> /// <param name="yoko">横ピクセル数を指定</param> /// <param name="tate">縦ピクセル数を指定</param> /// <returns></returns> private BitmapSource BilinearGray8専用(BitmapSource source, int yoko, int tate) { //元画像の画素値の配列作成 int sourceWidth = source.PixelWidth; int sourceHeight = source.PixelHeight; int stride = (sourceWidth * source.Format.BitsPerPixel + 7) / 8; byte[] pixels = new byte[sourceHeight * stride]; source.CopyPixels(pixels, stride, 0); //縮小後の画像の画素値の配列用 double yokoScale = (double)sourceWidth / yoko;//横倍率 double tateScale = (double)sourceHeight / tate; int scaledStride = (yoko * source.Format.BitsPerPixel + 7) / 8; byte[] resultPixels = new byte[tate * scaledStride]; for (int y = 0; y < tate; y++) { for (int x = 0; x < yoko; x++) { //参照点r double rx = (x + 0.5) * yokoScale; double ry = (y + 0.5) * tateScale; //参照範囲の左上座標bp double bpX = rx - 0.5; //画像範囲内チェック、参照範囲が画像から外れていたら修正(収める) if (bpX < 0) { bpX = 0; } if (bpX > sourceWidth - 1) { bpX = sourceWidth - 1; } double bpY = ry - 0.5; if (bpY < 0) { bpY = 0; } if (bpY > sourceHeight - 1) { bpY = sourceHeight - 1; } //小数部分s double sx = bpX % 1; double sy = bpY % 1; //面積 double d = sx * sy; double c = (1 - sx) * sy; double b = sx * (1 - sy); double a = 1 - (d + c + b);// (1 - sx) * (1 - sy) //左上ピクセルの座標は //参照範囲の左上座標の小数部分を切り捨て(整数部分) //左上ピクセルのIndex int ia = ((int)bpY * stride) + (int)bpX; //値*面積 double aa = pixels[ia] * a; double bb = 0, cc = 0, dd = 0; //B以降は面積が0より大きいときだけ計算 if (b != 0) { //Aの右ピクセル*Bの面積 bb = pixels[ia + 1] * b; } if (c != 0) { cc = pixels[ia + stride] * c; } if (d != 0) { //Aの右下ピクセル、仮にAが画像右下ピクセルだったとしても //そのときは面積が0のはずだからここは計算されない dd = pixels[ia + stride + 1] * d; } //4区を合計して四捨五入で完成 double add = aa + bb + cc + dd; resultPixels[y * scaledStride + x] = (byte)(add + 0.5); } } BitmapSource bitmap = BitmapSource.Create(yoko, tate, 96, 96, source.Format, null, resultPixels, scaledStride); return bitmap; } //E:\オレ\エクセル\画像処理.xlsm_バイリニア法_$A$599 //縮小専用完成版 /// <summary> /// 画像の縮小、バイリニア法で補完、グレースケール専用(PixelFormats.Gray8専用) /// </summary> /// <param name="source">PixelFormats.Gray8のBitmap</param> /// <param name="yoko">変換後の横ピクセル数を指定</param> /// <param name="tate">変換後の縦ピクセル数を指定</param> /// <returns></returns> private BitmapSource BilinearGray8縮小専用(BitmapSource source, int yoko, int tate) { //元画像の画素値の配列作成 int sourceWidth = source.PixelWidth; int sourceHeight = source.PixelHeight; int stride = (sourceWidth * source.Format.BitsPerPixel + 7) / 8; byte[] pixels = new byte[sourceHeight * stride]; source.CopyPixels(pixels, stride, 0); //変換後の画像の画素値の配列用 double yokoScale = (double)sourceWidth / yoko;//横倍率 double tateScale = (double)sourceHeight / tate; int scaledStride = (yoko * source.Format.BitsPerPixel + 7) / 8; byte[] resultPixels = new byte[tate * scaledStride]; for (int y = 0; y < tate; y++) { for (int x = 0; x < yoko; x++) { //参照点r double rx = (x + 0.5) * yokoScale; double ry = (y + 0.5) * tateScale; //参照範囲の左上座標bp double bpX = rx - 0.5; double bpY = ry - 0.5; //小数部分s double sx = bpX % 1; double sy = bpY % 1; ////面積 double d = sx * sy; double c = (1 - sx) * sy; double b = sx * (1 - sy); double a = 1 - (d + c + b);// (1 - sx) * (1 - sy) //左上ピクセルの座標は //参照範囲の左上座標の小数部分を切り捨て(整数部分) //左上ピクセルのIndex int i = ((int)bpY * stride) + (int)bpX; //値*面積 double aa = pixels[i] * a; double bb = pixels[i + 1] * b; double cc = pixels[i + stride] * c; double dd = pixels[i + stride + 1] * d; //4区を合計して四捨五入で完成 double add = aa + bb + cc + dd; resultPixels[y * scaledStride + x] = (byte)(add + 0.5); } } BitmapSource bitmap = BitmapSource.Create(yoko, tate, 96, 96, source.Format, null, resultPixels, scaledStride); 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; } //ファイルドロップ時 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 = BilinearGray8専用(MyBitmapOrigin, yoko, tate); } 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 = BilinearGray8専用(MyBitmapOrigin, yoko, tate); } private void MyButton3_Click(object sender, RoutedEventArgs e) { MyImage.Source = BilinearGray8専用(MyBitmapOrigin, MyBitmapOrigin.PixelWidth * 2, MyBitmapOrigin.PixelHeight * 2); } private void MyButton4_Click(object sender, RoutedEventArgs e) { MyImage.Source = BilinearGray8専用(MyBitmapOrigin, MyBitmapOrigin.PixelWidth * 3, MyBitmapOrigin.PixelHeight * 3); } //画像をクリップボードにコピー private void MyButtonCopy_Click(object sender, RoutedEventArgs e) { Clipboard.SetImage((BitmapSource)MyImage.Source); } } }
確認
テスト用画像
小さくて見えないので30倍に拡大表示してみると
これはエクセルでの確認で使ったものに合わせてあるので各画素値は
こうなっている
縮小変換
2x2に変換してみる
3x3は1/2倍で2x2になる
3*1/2=1.5
1.5になるけど切り上げにしているので2になる
結果
拡大して確認
画素値は
期待通り!
エクセルで確認したときと同じ値になっている
3x3を1x1に
これも期待通りで、画素値は180になっている
拡大変換
同じ画像を2倍の6x6に拡大
いいね!
3x3を3倍の9x9
周縁部の2ピクセルが同じ値になっているのがイマイチな気がするけど、こうなった
これは参照範囲が画像の外側になったときの修正の仕方で変わってくるのかも、もっといい方法がありそう
写真画像で
使う画像はサイズが1088x816の写真画像
今回はグレースケール専用なので、カラー画像もグレースケールGray8に変換してからの変換になる
元の画像によるんだろうけど、思ったよりかなりきれいに縮小された
1/3
右下の日付がジャリジャリなのが気になるけど、それ以外はいいね
2倍
一部を比較、やっぱりぼやけるねえ、でも期待通り
図形画像を変換
左から黒(0)、白(255)と交互に並んだ32x32の画像
前回はこの画像を変換したときになんか違うなって思った、今回は?
2倍に拡大
見た感じは期待通りになった、あっているかはわからないけど前回より格段に良くなった、もうこれで正解でしょ
1/2倍
これもいいね、画素値はすべて128、こういうのでいいんだよ
リサイズアプリと比較
藤 -Resizer- 3.8.0.231 64bit版
Ralpha Image Resizer 170111
縦縞を1/2
テストアプリの結果は藤の平均画素法ってのと同じ結果になった
Ralphaは両端の処理が違うみたいで全く違う値になっている、全体も127と1小さい値になった
2倍に拡大
今度は逆にRalphaと同じになった
そして藤はずいぶん違う結果になった、藤の平均画素法ってのはバイリニアとは違うのかもしれない、それにしてもぜんぜん違うなあ
エクセルで使った値の画像
1/3倍で1ピクセルは全員180になった!
2倍に拡大はまた藤だけ違っていて、四隅の値を見ると元画像の画素値を超えた値になっている、名前も違うからバイリニア法じゃないみたいねえ
感想
バイリニア法とかで検索すると数式が出てくるけど、それを実際にどう使えばいいのかとか僕のアタマじゃわかんない、なのでエクセル方眼紙で画素値を入れて色塗って、この座標ならこのピクセルの画素値はだいたいこうなるはずだから、そうするにはどうすればいいのかなってこねくり回してできたと思ったのが3回くらいあったかな、ようやくできた
バイキュービック法かランチョス法のまえに、それよりもかんたんなバイリニア法を納得できるようにしておきたかったんだよねえ、3年かかったけど満足
他のアプリと比較するとことで今回の方法があっているの確認したかったけど、よくわからん、でも正解じゃないとしても、かなり近いんじゃないかなあと思うし、何より前回は違和感があった縦縞の変換が、期待していたとおりの結果になったのがいい、とてもいい
次はカラーとマルチスレッドに対応させて、それからバイキュービック法かランチョス法
関連記事
縮小処理を書き直したのは3週間後
gogowaten.hatenablog.com
拡大処理は良かっただけどねえ
完成版は明後日
gogowaten.hatenablog.com
次回は明日
gogowaten.hatenablog.com
前回のWPF記事は5日前
gogowaten.hatenablog.com
前回のバイリニア法は3年前
gogowaten.hatenablog.com
冒頭にある
だいたいあっていると思う
これはフラグだったんだなあ
再近傍補完法で納得できたのは2年前
gogowaten.hatenablog.com
再近傍補完法って呼び方なら、バイリニア法はバイリニア補完法のほうがいいのかな
リサイズアプリのウィンドウキャプチャに使ったアプリは43日前
gogowaten.hatenablog.com