（補講）沖ノ鳥島のEEZが日本の領土より広いって本当？面積計測プログラムを800倍高速化した

# はじめに

下記の記事では，日本の領土面積と沖ノ鳥島を中心した EEZ 円の面積を比較するため，両者の高精細地図を作成し，ピクセル数をカウントして比較した。

https://qiita.com/TETSURO1999/items/3c660e2e748781912141

この際，ピクセル数をカウントするプログラムを速攻で作成したが，高精細地図（14244×12642 ピクセル）ということもあって永遠に近い時間が流れたような気がした・・・実際には１画像あたり約２分だったが。今後，遺恨を残さないためにもプログラムの改善を図っておきたい。

# 改めて仕様を整理する

背景が白色のグレイスケール画像において，黒いピクセル数をカウントする。ピクセルの明るさが真っ黒 00h のときカウント１，真っ白 FFh のときカウント０とする。中間値，たとえば 80h のときはカウント $(255 - 128) / 255 = 0.498$ とする。万が一，グレイスケール画像でなかった場合は RGB の平均値とする。αプレーンは無視しよう。

画像フォーマットは PNG, BMP, JPEG など GDI+ がサポートしている形式を対象とする。

開発言語は C#，しかも Windows 内蔵の C# コンパイラでビルドできること，すなわち言語仕様としては .NET Framework までとする。

# 実装コード

## GetPixel 版

前回作成したコードである。一番手軽な方法だが，クッソ遅い。

```c#:PixelCount.cs
using System;
using System.IO;
using System.Drawing;
class PixelCount {
	static	int		Main( string[] args ) {
		if( args.Length < 1 ) {
			Console.Error.WriteLine( "Usage: PixelCount(.exe) [画像ファイル名]" );
			return -1;
		}
		if( !File.Exists( args[0] ) ) {
			Console.Error.WriteLine( "ファイル {0} は存在しません!!", args[0] );
			return -1;
		}
		Bitmap	bitmap = new Bitmap( args[0] );
		long	count = 0;
		for( int y = 0; y < bitmap.Height; y++ ) {
			for( int x = 0; x < bitmap.Width; x++ ) {
				var	color = bitmap.GetPixel( x, y );
				int	value = color.G + color.B + color.R;
				count += 3 * 255 - value;
			}
		}
		Console.WriteLine( (double)count / ( 3 * 255 ) );
		return 0;
	}
}
```

## LockBits & Marshal.Copy 版

まず `Bitmap.LockBits` によって画像をビットマップに展開した後，同サイズのバイト配列にコピーしてアクセスする方法。コピーする際にはアンマネージド／マネージド領域を渡る際の専用のコピー手段 `Marshal.Copy` を使用する必要があるが，コピー後はバイト配列を自由にアクセスできる。C# は多次元配列がサポートされた便利な言語だが，C# の多次元配列は遅くて有名なので一次元配列とする。

画像によっては `Bitmap.Stride` が負の場合もあるので要注意。ただし，本ケースは画像全体をスキャンできればトップダウンであろうとボトムアップであろうと方向は気にしないので絶対値 `stride` として一様に取り扱っている。

```c#:PixelCount2.cs
using System;
using System.IO;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
class PixelCount {
	static	int		Main( string[] args ) {
		if( args.Length < 1 ) {
			Console.Error.WriteLine( "Usage: PixelCount2(.exe) [画像ファイル名]" );
			return -1;
		}
		if( !File.Exists( args[0] ) ) {
			Console.Error.WriteLine( "ファイル {0} は存在しません!!", args[0] );
			return -1;
		}
		Bitmap	bitmap = new Bitmap( args[0] );
		int		width  = bitmap.Width;
		int		height = bitmap.Height;
		var		rect   = new Rectangle( 0, 0, width, height );
		var		format = PixelFormat.Format24bppRgb;
		var		data   = bitmap.LockBits( rect, ImageLockMode.ReadOnly, format );
		int		stride = Math.Abs( data.Stride );
		int		size   = stride * height;
		byte[]	bytes = new byte[size];
		Marshal.Copy( data.Scan0, bytes, 0, size );
		bitmap.UnlockBits( data );
		bitmap.Dispose();
		long	count = 0;
		for( int i = 0; i < size; i += stride ) {
			for( int x = 0, j = i; x < width; x++, j += 3 ) {
				int	value = bytes[j] + bytes[j + 1] + bytes[j + 2];
				count += 3 * 255 - value;
			}
		}
		Console.WriteLine( (double)count / ( 3 * 255 ) );
		return 0;
	}
}
```

## LockBits & unsafe ポインタ版

`Bitmap.LockBits` によって確保された領域を直接アクセスするため，コピーの無駄がなく最速と言われる方法。アクセスにはポインタを使用するためコンパイル時にはオプション `-unsafe` を付ける必要がある。

```c#:PixelCount3.cs
using System;
using System.IO;
using System.Drawing;
using System.Drawing.Imaging;
class PixelCount {
	static	int		Main( string[] args ) {
		if( args.Length < 1 ) {
			Console.Error.WriteLine( "Usage: PixelCount3(.exe) [画像ファイル名]" );
			return -1;
		}
		if( !File.Exists( args[0] ) ) {
			Console.Error.WriteLine( "ファイル {0} は存在しません!!", args[0] );
			return -1;
		}
		Bitmap	bitmap = new Bitmap( args[0] );
		int		width  = bitmap.Width;
		int		height = bitmap.Height;
		var		rect   = new Rectangle( 0, 0, width, height );
		var		format = PixelFormat.Format24bppRgb;
		var		data   = bitmap.LockBits( rect, ImageLockMode.ReadOnly, format );
		int		stride = Math.Abs( data.Stride );
		int		size   = stride * height;
		long	count = 0;
unsafe {
		byte*	ptr = (byte*)data.Scan0;
		for( int i = 0; i < size; i += stride ) {
			for( int x = 0, j = i; x < width; x++, j += 3 ) {
				int	value = ptr[j] + ptr[j + 1] + ptr[j + 2];
				count += 3 * 255 - value;
			}
		}
}
		bitmap.UnlockBits( data );
		bitmap.Dispose();
		Console.WriteLine( (double)count / ( 3 * 255 ) );
		return 0;
	}
}
```

# 実行時間の比較

題材は下記の画像ファイルである。14244×12642 ピクセルもある。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3490655/af644190-3551-4e39-8ebb-298ddb0066f0.png">

実行時間の比較結果を表１に示す。

`LockBits` & `Marshal.Copy` にすることにより，100 倍以上の高速化を実現した。`unsafe` ポインタにすればさらに 1.5 倍速くなり，元のプログラムの約 200 倍の高速化を実現した。

<table>
<caption>各種方式と実行時間の比較</caption>
<thead>
<tr><th style="text-align:center">No.</th>
    <th style="text-align:center">方式</th>
    <th style="text-align:center">時間［秒］</th>
    <th style="text-align:center">速度比［－］</th></tr>
</thead>
<tbody>
<tr></tr>
<tr><td style="text-align:right">1</td>
    <td>GetPixel</td>
    <td style="text-align:right">119.28</td>
    <td style="text-align:right">1</td></tr>
<tr></tr>
<tr><td style="text-align:right" style="text-align:right">2</td>
    <td>LockBits & Marshal.Copy</td>
    <td style="text-align:right">1.01</td>
    <td style="text-align:right">118</td></tr>
<tr></tr>
<tr><td style="text-align:right">3</td>
    <td>LockBits & unsafe ポインタ</td>
    <td style="text-align:right">0.62</td>
    <td style="text-align:right">192</td></tr>
</tbody>
</table>

# 延長戦

以上はよくある高速化手段である。参考文献にも書かれているが，十年以上前から行われているいわば常識的なテクニックに近い。

ここまで来ると，処理時間の大半は `Bitmap.LockBits` が占めており，しかも PNG ファイルのデコードよりも `Format24bppRgb` 形式への変換時間のほうが支配的である。であれば，元画像の形式 `Bitmap.PixelFormat` をそのまま利用したほうが余分な変換を行わなくて済むので，格段の高速化が期待できるはずだ。

ただし，画像の形式毎に処理が分かれるのでコーディングはひたすら面倒なことになる。とくに `Format1bppIndexed` や `Format4bppIndexed` の場合は画像の右端の処理が面倒である。

```c#:PixelCount4.cs
using System;
using System.IO;
using System.Drawing;
using System.Drawing.Imaging;
class PixelCount {
	static	int		Main( string[] args ) {
		if( args.Length < 1 ) {
			Console.Error.WriteLine( "Usage: PixelCount4(.exe) [画像ファイル名]" );
			return -1;
		}
		if( !File.Exists( args[0] ) ) {
			Console.Error.WriteLine( "ファイル {0} は存在しません!!", args[0] );
			return -1;
		}
		Bitmap	bitmap = new Bitmap( args[0] );
		int		width  = bitmap.Width;
		int		height = bitmap.Height;
		var		rect   = new Rectangle( 0, 0, width, height );
		var		format = bitmap.PixelFormat;
		var		data   = bitmap.LockBits( rect, ImageLockMode.ReadOnly, format );
		int		stride = Math.Abs( data.Stride );
		int		size   = stride * height;
		long	count = 0;
unsafe {
		byte*	ptr = (byte*)data.Scan0;
		switch( format ) {
		  case PixelFormat.Format1bppIndexed:
			{
				var	a = new int[2];
				for( int i = 0; i < bitmap.Palette.Entries.Length; i++ ) {
					var	color = bitmap.Palette.Entries[i];
					int	value = color.R + color.G + color.B;
					a[i] = 3 * 255 - value;
				}
				var	b = new int[256];
				for( int i = 0; i < 256; i++ ) {
					int	value = 0;
					for( int mask = 0x80; mask > 0; mask >>= 1 )
						value += ( i & mask ) != 0 ? a[1] : a[0];
					b[i] = value;
				}
				for( int i = 0; i < size; i += stride ) {
					int	x, j;
					for( x = 0, j = i; x < width - 7; x += 8, j++ )
						count += b[ptr[j]];
					for( int mask = 0x80; x < width; x++, mask >>= 1 )
						count += ( ptr[j] & mask ) != 0 ? a[1] : a[0];
				}
			}
			break;
		  case PixelFormat.Format4bppIndexed:
			{
				var	a = new int[16];
				for( int i = 0; i < bitmap.Palette.Entries.Length; i++ ) {
					var	color = bitmap.Palette.Entries[i];
					int	value = color.R + color.G + color.B;
					a[i] = 3 * 255 - value;
				}
				var	b = new int[256];
				for( int i = 0; i < 16; i++ )
					for( int j = 0; j < 16; j++ )
						b[i * 16 + j] = a[i] + a[j];
				for( int i = 0; i < size; i += stride ) {
					int	x, j;
					for( x = 0, j = i; x < width - 1; x += 2, j++ )
						count += b[ptr[j]];
					if( x < width )
						count += a[ptr[j]];
				}
			}
			break;
		  case PixelFormat.Format8bppIndexed:
			{
				var	a = new int[256];
				for( int i = 0; i < bitmap.Palette.Entries.Length; i++ ) {
					var	color = bitmap.Palette.Entries[i];
					int	value = color.R + color.G + color.B;
					a[i] = 3 * 255 - value;
				}
				for( int i = 0; i < size; i += stride )
					for( int x = 0, j = i; x < width; x++, j++ )
						count += a[ptr[j]];
			}
			break;
		  case PixelFormat.Format24bppRgb:
			{
				for( int i = 0; i < size; i += stride ) {
					for( int x = 0, j = i; x < width; x++, j += 3 ) {
						int	value = ptr[j] + ptr[j + 1] + ptr[j + 2];
						count += 3 * 255 - value;
					}
				}
			}
			break;
		  case PixelFormat.Format32bppRgb:
			{
				for( int i = 0; i < size; i += stride ) {
					for( int x = 0, j = i; x < width; x++, j += 4 ) {
						int	value = ptr[j] + ptr[j + 1] + ptr[j + 2];
						count += 3 * 255 - value;
					}
				}
			}
			break;
		  default:
			Console.Error.WriteLine( "フォーマット {0} に対応していません!!", format );
			break;
		}
}
		bitmap.UnlockBits( data );
		bitmap.Dispose();
		Console.WriteLine( (double)count / ( 3 * 255 ) );
		return 0;
	}
}
```

ただ，面倒なコーディングの成果は如実に現れて，なんと元のプログラムの約 800 倍の高速化を達成した。なお，今回の題材の画像形式は `Format4bppIndexed` であった。

<table>
<caption>表１　各種方式と実行時間の比較</caption>
<thead>
<tr><th style="text-align:center">No.</th>
    <th style="text-align:center">方式</th>
    <th style="text-align:center">時間［秒］</th>
    <th style="text-align:center">速度比［－］</th></tr>
</thead>
<tbody>
<tr></tr>
<tr><td style="text-align:right">1</td>
    <td>GetPixel</td>
    <td style="text-align:right">119.28</td>
    <td style="text-align:right">1</td></tr>
<tr></tr>
<tr><td style="text-align:right" style="text-align:right">2</td>
    <td>LockBits & Marshal.Copy</td>
    <td style="text-align:right">1.01</td>
    <td style="text-align:right">118</td></tr>
<tr></tr>
<tr><td style="text-align:right">3</td>
    <td>LockBits & unsafe ポインタ</td>
    <td style="text-align:right">0.62</td>
    <td style="text-align:right">192</td></tr>
<tr></tr>
<tr><td style="text-align:right">4</td>
    <td>LockBits & unsafe ポインタ & format 無変換</td>
    <td style="text-align:right">0.15</td>
    <td style="text-align:right">795</td></tr>
</tbody>
</table>

計測環境は ThinkPad X280 Core i7-8550U, Windows10 22H2 である。C# コンパイラは Visual Studio 2022 Commnunity Edition のものを使用した。

# 参考文献

- [簡単な画像処理と読み込み・保存（C#）- Qiita](https://qiita.com/Toshi332/items/2749690489730f32e63d)

- [.NETによる画像処理の高速化Tips：unsafe編（改稿：2015/11/08）- Qiita](https://qiita.com/Nuits/items/da8c11e5b284ad6cb90a)

- [[C#]多次元配列・ジャグ配列・リスト、行列積で最もパフォーマンスが高いのはどれ? - Zenn](https://zenn.dev/tremendous1192/articles/333df9cf3c0170)