No.001 画像ファイルへの書き込み方法




RGB 構造体
.ppm フォーマット
Image クラス
サンプルプログラム

これからレイトレーシングによる CG 画像生成プログラムについて 説明していくわけですが、まずは色情報を格納する RGB 構造体の 説明から始めたいと思います。

R,G,B のそれぞれの色情報を持つので、それらを保持する メンバ変数を持たせる事とします。 基本的にそれらの値は読込専用のプロパティとしたい訳ですが、 そのように実装すると遅くなってしまうようです。 学習用なので、速度よりも美しさを取りたいところですが、 ある程度速くないと試行錯誤にも支障が出る可能性もありますので、 まずは大胆に public なメンバ変数として実装してしまいます。

public struct RGB {
		:
	public float R,G,B;
		:
}

次に、CG でよく使う Clamp メソッドを追加します。 Clamp は下限値と上限値を指定し、下限値以下の値であれば下限値に、 上限値以上の値であれば上限値にするメソッドです。

多くの画像フォーマットでは RGB のそれぞれの値として 0 から 255 までを 使用します。つまり、R,G,B のそれぞれの値の下限値が 0、 上限値が 255 という事に他なりません。

.bmp ファイル等に画像を保存する場合、 マイナスの値などは 0 に置き換えられ、逆に 300 などといった 大きな値は 255 に置き換える処理を施します。 もちろん 100 等といった下限値と上限値の間にある値は 何も変更をしないで、そのままの値を使用します。

このような RGB 値の調整処理を Clamp と呼びます。 実際にコードで書くと次のようになります。
struct RGB {
		:
	public float R,G,B;
		:
	public RGB Clamp(float inMin,float inMax) {
		return new RGB(clamp(R,inMin,inMax),
					   clamp(G,inMin,inMax),
					   clamp(B,inMin,inMax));
	}
	float clamp(float inT,float inMin,float inMax) {
		if(inT<inMin) {
			return inMin;
		} else if(inMax<inT) {
			return inMax;
		} else {
			return inT;
		}

		// 三項演算子を使うと、
		// return inT<inMin ? inMin : inMax<inT ? inMax : inT;
		// などとも書ける(でも分かりづらいですよね...)
	}
		:
}
Clamp 自身はどのような上限値、下限値でも動作するのですが、 今後、この一連の説明文書で作成するレイトレーシングにおいて、 最も明るい白色をどのように表すのかを決めておかなければなりません。

もちろん「最も明るい」というのは、古くからある .bmp ファイルのような 普通の画像ファイル(注1)における、という意味です。
注1:OpenEXR 等の High Dynamic Range (HDR) 画像は 現段階では考えません。 しかしメンバ変数である R,G,B は float 型であるので、 HDR 画像にも対応可能であることに注意して下さい。

上で説明したように、RGB 構造体での R,G,B の値は float 型ですので、 (R,G,B)=(255.0f,255.0f,255.0f) を最も明るい白色としても良いのですが、 この一連の説明文書では (r,G,B)=(1.0f,1.0f,1.0f) を最も明るい白色とします。

このように決めると、Clamp で最も多く使われる可能性があるのは Clamp(0,1) の 場合であると予想されます。 よって、より便利に Clamp メソッドを使用できるように、 デフォルト引数で 0 と 1 を設定するようにします。
public RGB Clamp(float inMin=0,float inMax=1) {
	:
}

また、多くの画像フォーマットでは R,G,B 各チャネルの値として、 0 から 1.0 までではなく 0 から 255 の整数値を採用しています。 後ほど説明する .ppm フォーマットでも 0 から 255 の値を使用し、 かつ、出力する数値の型は byte 型ですので、クラスメソッドして 変換メソッドを提供しておきます。
// see Blender
public static byte FloatToByte(float inValue) {
	return inValue<=0.0f ? (byte)0
	 		   : ( inValue>1.0f-0.5f/255.0f ? (byte)255
	  				: (byte)(255.0f*inValue+0.5f) );
}
この方法は Blender のレンダラで見かけた方法です。 単純な方法で済ますならば、Clamp した結果に 255 を掛けるだけですが、 この方法では 1/255 の刻み幅の中央部分を考慮した形になっています。 面白いですね。

後は RGB 構造体の演算(加算、乗算、スカラ倍など)を定義して、 ついでに黒色や白色等を構造体変数として定義しておきます。

まとめると次のようになります:[入力用プログラムリスト RGB.pdf は こちら]
// RGB.cs
using System;

namespace ToyBox {
	public partial struct RGB {
		// see Blender
		public static byte FloatToByte(float inValue) {
			return inValue<=0.0f ? (byte)0
				 : ( inValue>1.0f-0.5f/255.0f ? (byte)255
					  : (byte)(255.0f*inValue+0.5f) );
		}

		static RGB sWhite=new RGB(1,1,1);
		static public RGB White {
			get { return sWhite; }
		}

		static public RGB One {
			get { return sWhite; }
		}

		static RGB sBlack=new RGB(0,0,0);
		static public RGB Black {
			get { return sBlack; }
		}

		static public RGB Zero {
			get { return sBlack; }
		}

		static RGB sRed=new RGB(1,0,0);
		static public RGB Red {
			get { return sRed; }
		}

		static RGB sGreen=new RGB(0,1,0);
		static public RGB Green {
			get { return sGreen; }
		}

		static RGB sBlue=new RGB(0,0,1);
		static public RGB Blue {
			get { return sBlue; }
		}

		static RGB sMagenta=new RGB(1,0,1);
		static public RGB Magenta {
			get { return sMagenta; }
		}

		static RGB sCyan=new RGB(0,1,1);
		static public RGB Cyan {
			get { return sCyan; }
		}

		public float R,G,B;

		public RGB(float inR,float inG,float inB) {
			R=inR;
			G=inG;
			B=inB;
		}

		public RGB(RGB inSrcColor) {
			R=inSrcColor.R;
			G=inSrcColor.G;
			B=inSrcColor.B;
		}

		public RGB(float inIntensity) {
			R=G=B=inIntensity;
		}

		public void Init(float inR,float inG,float inB) {
			R=inR;
			G=inG;
			B=inB;
		}

		//-----------------------------------------------------------
		// 演算子オーバーロード
		//-----------------------------------------------------------
		// 単項マイナス
		static public RGB operator-(RGB inColor) {
			return new RGB(-inColor.R,-inColor.G,-inColor.B);
		}

		static public RGB operator+(RGB inColor1,RGB inColor2) {
			float r=inColor1.R+inColor2.R;
			float g=inColor1.G+inColor2.G;
			float b=inColor1.B+inColor2.B;			
			return new RGB(r,g,b);
		}

		static public RGB operator-(RGB inColor1,RGB inColor2) {
			float r=inColor1.R-inColor2.R;
			float g=inColor1.G-inColor2.G;
			float b=inColor1.B-inColor2.B;
			return new RGB(r,g,b);
		}

		static public RGB operator*(RGB inColor1,RGB inColor2) {
			float r=inColor1.R*inColor2.R;
			float g=inColor1.G*inColor2.G;
			float b=inColor1.B*inColor2.B;
			return new RGB(r,g,b);
		}

		static public RGB operator*(float inK,RGB inColor) {
			return new RGB(inK*inColor.R,inK*inColor.G,inK*inColor.B);
		}

		static public RGB operator*(RGB inColor,float inK) {
			return new RGB(inK*inColor.R,inK*inColor.G,inK*inColor.B);
		}

		static public RGB operator/(RGB inColor1,RGB inColor2) {
			float r=inColor1.R/inColor2.R;
			float g=inColor1.G/inColor2.G;
			float b=inColor1.B/inColor2.B;
			return new RGB(r,g,b);
		}

		static public RGB operator/(RGB inColor,float inK) {
			float invK=1.0f/inK;
			return new RGB(inColor.R*invK,inColor.G*invK,inColor.B*invK);
		}

		static public bool operator==(RGB inColor1,RGB inColor2) {
			return inColor1.R==inColor2.R
			    && inColor1.G==inColor2.G
			 	&& inColor1.B==inColor2.B; 
		}

		static public bool operator!=(RGB inColor1,RGB inColor2) {
			return inColor1.R!=inColor2.R
			    || inColor1.G!=inColor2.G
			    || inColor1.B!=inColor2.B;
		}

		public override bool Equals(object inObj) {
			if(inObj==null || inObj.GetType()!=GetType()) {
				return false;
			}
			RGB rgb=(RGB)inObj;
			return this==rgb;
		}

		public override int GetHashCode() {
			return R.GetHashCode() ^ G.GetHashCode() ^ B.GetHashCode();
		}

		public RGB Clamp(float inMin=0,float inMax=1) {
			return new RGB(clamp(R,inMin,inMax),
						   clamp(G,inMin,inMax),
						   clamp(B,inMin,inMax));
		}
		float clamp(float inT,float inMin,float inMax) {
			if(inT<inMin) {
				return inMin;
			} else if(inMax<inT) {
				return inMax;
			} else {
				return inT;
			}
		}

		override public string ToString() {
			return "["+R+","+G+","+B+"]";
		}
	}
}

次に出力画像フォーマットとして PPM を採用します。 PPM ファイルにはいくつもの形式がありますが、 ファイルサイズを小さくできる raw 形式を用いることにします。

この raw 形式の PPM ファイルフォーマットは、 画像サイズ等の情報が格納されるヘッダ部分を文字情報で書き、 画像データ本体をバイナリで格納する単純な構造のフォーマットです。

具体的には、例えば 1920x1080 の画像だと

P6
1920 1080
255
(以下 RGB の順にバイナリデータが 1920x1080x3 個並ぶ)
となります。

最初の P6 は raw 形式を指定するものです。 次の 1920 1080 は横幅 1920 ピクセル、高さ 1080 ピクセルを表しています。 3 行目の 255 は RGB 各チャネル 8 ビット(0 から 255)までの値をとることを 示しています。

このように .ppm フォーマットの書き出しには特に難しい所はありませんが、 改行コードには少し注意が必要で、LF(\n) を使用して下さい。

実際の書き出しメソッドは次のようになります:
// 教科書(RRT)とは違い、mRaster[y][x] でアクセス
RGB[][] mRaster=null;
	:
public void WritePPM(string inFilePath) {
	using(FileStream fs=new FileStream(inFilePath,
									   FileMode.Create,
									   FileAccess.Write)) {
		writeString(fs,"P6\n");	// \r\n としてはダメ
		writeString(fs,mWidth+" "+mHeight+"\n");
		writeString(fs,"255\n");

		for(int y=0; y<mHeight; y++) {
			for(int x=0; x<mWidth; x++) {
				RGB color=mRaster[y][x];
				color=color.Clamp();
				byte r=RGB.FloatToByte(color.R);
				byte g=RGB.FloatToByte(color.G);
				byte b=RGB.FloatToByte(color.B);
				fs.WriteByte(r);
				fs.WriteByte(g);
				fs.WriteByte(b);
			}
		}
		fs.Close();
	}
}
void writeString(FileStream inFS,string inString) {
	foreach(byte c in Encoding.ASCII.GetBytes(inString)) {
		inFS.WriteByte(c);
	}
}

画像を司るクラスを Image クラスとして設計することとします。 画像を扱うクラスならば、とりあえずはプロパティとして、Width と Height があれば 良いでしょう。

適当なコストラクタと上で説明した .ppm ファイルへの書き出し ルーチン、および指定した座標に指定した色を配置する Set メソッドを用意して、 Image クラスの定義を行います。

実際の Image クラスの定義は次のようになります: [入力用プログラムリスト Image.pdf は こちら]
// Image.cs

using System;
using System.IO;
using System.Text;

namespace ToyBox {
	public partial class Image {
		// 教科書(RRT)とは違い、mRaster[y][x] でアクセス
		RGB[][] mRaster=null;
		int mWidth=-1;
		int mHeight=-1;

		public int Width {
			get { return mWidth; }
		}

		public int Height {
			get { return mHeight; }
		}

		// 各ピクセルへのアクセッサ
		// 次回(第2回)で使用予定
		public RGB this[int inX,int inY] {
			get { return mRaster[inY][inX]; }
		}

		public Image() {
			// empty
		}

		public Image(int inWidth,int inHeight) {
			init(inWidth,inHeight);
		}

		public Image(int inWidth,int inHeight,RGB inBackgroundColor) {
			init(inWidth,inHeight);
			for(int y=0; y<mHeight; y++) {
				for(int x=0; x<mWidth; x++) {
					mRaster[y][x]=inBackgroundColor;
				}
			}
		}

		public bool Set(int inX,int inY,RGB inColor) {
			if(inX<0 || mWidth<=inX || inY<0 || mHeight<=inY) {
				return false;
			}
			mRaster[inY][inX]=inColor;
			return true;
		}

		public void WritePPM(string inFilePath) {
			using(FileStream fs=new FileStream(inFilePath,
											   FileMode.Create,
											   FileAccess.Write)) {
				writeString(fs,"P6\n");	// \r\n としてはダメ
				writeString(fs,mWidth+" "+mHeight+"\n");
				writeString(fs,"255\n");

				for(int y=0; y<mHeight; y++) {
					for(int x=0; x<mWidth; x++) {
						RGB color=mRaster[y][x];
						color=color.Clamp();
						byte r=RGB.FloatToByte(color.R);
						byte g=RGB.FloatToByte(color.G);
						byte b=RGB.FloatToByte(color.B);
						fs.WriteByte(r);
						fs.WriteByte(g);
						fs.WriteByte(b);
					}
				}
				fs.Close();
			}
		}
		void writeString(FileStream inFS,string inString) {
			foreach(byte c in Encoding.ASCII.GetBytes(inString)) {
				inFS.WriteByte(c);
			}
		}

		void init(int inWidth,int inHeight) {
			mWidth=inWidth;
			mHeight=inHeight;

			mRaster=new RGB[inHeight][];
			for(int y=0; y<inHeight; y++) {
				mRaster[y]=new RGB[inWidth];
			}
		}
	}
}

最後に、作成した Image クラスを使って実際に画像ファイルを 生成してみることにします。 Image クラスの使い方は次のようになります。

[入力用プログラムリスト Program001.pdf は こちら]
// Program001.cs

using System;

namespace ToyBox {
	class Program {
		static void Main(string[] args) {
			const int kImageWidth =100;
			const int kImageHeight=50;
			Image image=new Image(kImageWidth,kImageHeight);

			int n=Math.Min(kImageWidth,kImageHeight);
			for(int i=0; i<n; i++) {
				// 色の計算式は適当
				image.Set(i,i,new RGB(((i+8)*15%256)/255.0f,
									  (i*5%256)/255.0f,
									  (i*13%256)/255.0f));
			}
			image.WritePPM("testImage001.ppm");
		}
	}
}

このサンプルプログラムを実行すると、 このページの冒頭にあるような画像が testImage001.ppm として 生成されます (注:このページの冒頭にある画像は、 生成した画像を 600x300 に拡大したものです)。

生成した .ppm 画像は GIMP 等のソフトで確認することができます
(GIMP はこちらのサイトから入手可能 http://www.gimp.org)。