2018年5月7日月曜日

輪切り画像の描画処理はこれで完成

拡張メソッド、ラムダ式のエントリーを挟んだ前回のエントリーで、輪切り画像の描画のための基本的な部品は出来上がりました。

前回までの進捗を整理すると、


  • データファイルをOpen File Dialogを使って選択
  • Visual BasicのCSVパーサーを使ってCSVのデータをArrayListに格納
  • プロット
  • 輪切りする場所を指定する為のUIを実装(X座標を指定するかY座標を指定するかを排他的に選択
  • マウスでクリックした場所の座標を拾う(X座標のみ)
  • 拾った座標に最も近いデータをArrayListから探してくる(X座標のみ)


というところまで、実装済みです。この後は、


  • 奥から描画していくようにデータを並び替え(隠面処理)
  • マウスでクリックした場所の座標を拾う(前回はX軸だけだったのでY軸も含めて)
  • 拾った座標に最も近い座標をArrayListから探してくる(X座標、Y座標ともに)
  • ArrayListから見つけてきた一番近い座標を含む全てのデータを取得
  • プロット

という機能を実装すれば、輪切り画像の描画処理は完成となります。

奥から描画していくようにデータを並び替え


立体的な物体を画面に描くことは、物体の形状を平面に投影するということになります。その際に気をつけなければいけないのが、見える場所は見えるように、隠れて見えない場所は見えないように描くこと。

人間が鉛筆で絵を描く場合は、隠れて見えない場所は描かないのでしょうが、コンピューターに描かせる場合は、隠れて見えない場所は描かない、という処理をしてあげる必要があります。これを隠面処理と言います。

隠面処理には色々な方法があるようですが、今回のプログラムでは、見ている場所から遠い点から描画して、後ろを遮るものが前にある場合は、後ろの点を重ね書きして隠してしまう方法を採用します。一番単純な方法だからです。

プログラムが読むデータファイルは、データの順番が都合の良いように並んでいるとは限らない、と仮定すると、描画する前にデータを画面奥から手前に向かう座標軸、今回の場合はZ軸に関して並べ替える必要があります。

並べ替えの手法は、「業務開発で使えるプログラム」様にて勉強させていただきました。

描画する点のデータは、X座標、Y座標、Z座標及び点における測定値の大きさ、という4つのデータがひとかたまりになっています。これらをExcelのソートのように、1つのデータ、Excelのワークシートに例えるなら、1つのカラムをキーにして並べ替えたいわけです。

これは、バラバラのArrayListのままではなかなか難しいので、データテーブルというデータ形式を使って表形式のデータにまとめてから取り扱うのがよさそうです。

            //
            // 後でデータの並べ替えが必要になるので、プロットデータをデータテーブルに格納
            //
            table.Columns.Add("x", Type.GetType("System.Int32"));
            table.Columns.Add("y", Type.GetType("System.Int32"));
            table.Columns.Add("z", Type.GetType("System.Int32"));
            table.Columns.Add("dt", Type.GetType("System.Int32"));

            for (int i = 0; i < x_axis_dt.Count; ++i)
            {
                //
                // 実座標をウィンドウ座標に変換するためにオフセット加算
                //
                int x = (int)x_axis_dt[i] + Constants.offset_x;
                int y = -1 * ((int)y_axis_dt[i]) + Constants.offset_y;
                int z = (int)z_axis_dt[i] + Constants.offset_z;
                int d = (int)dt_dt[i];

                x_axis.Add(x);
                y_axis.Add(y);
                z_axis.Add(z);
                dt.Add(d);

                table.Rows.Add(x, y, z, d);  //データ1行分追加
            }
            
            // データテーブルのソートをするために、Dataviewを使う
            DataView dv = new DataView(table);
            dv.Sort = "z";        // 昇順 -> Z座標の数値が大きい方が手前。小さい方が奥
            //dv.Sort = "z DESC"; // 降順 -> Z座業の数値が小さい方が手前、大きい方が奥
            table = dv.ToTable();

データテーブルを作った後は、DataViewというクラスを使って、簡単にデータテーブルの並べ替えをすることができます。DataViewにデータテーブルを設定して、並べ替えをして、並べ替えたデータを元のデータテーブルに書き戻すまで、DataView dv = new DataView(table);の行からたった3行で記述することができます。

マウスでクリックした場所の座標を拾う


前回、X軸に垂直な平面に関して輪切りを行う場合について、クリックした場所の座標を拾う処理を実装しました。今回は、Y軸に垂直な平面に関して輪切りを行う場合の処理も合わせて組み込みます。

作成したプログラムの中では、Form1の上にあるpictureboxに対する描画処理が終了したあと、ユーザーが輪切り画像描画のボタンを押すことにより輪切り画像描画モードに遷移します。

このモードに遷移すると、Clickイベント、MouseMoveイベント、MouseHoverイベントが有効となり、マウスカーソルがpictureboxの上を動くたびにマウスカーソルの座標が、クラス内のグローバル変数であるmouse_x、mouse_yの中に記録され、更新され続けます。

このmouse_x、mouse_yのデータを拾ってくることで、マウスでクリックした場所の座標を知ることが出来ます。

拾った座標に最も近い座標をArrayListから探してくる

輪切り画像の描画を行うメソッドであるdrawSlice()の冒頭で、button1が有効かbutton2が有効かを調べて、X軸に関して輪切りをするか、Y軸に関して輪切りをするかを切り分け、それぞれでX軸の場合はmouse_x、Y軸の場合はmouse_yの値を取り出し、これに最も近い値をArrayList x_axis_dt_int(X軸に関して輪切りにする場合) y_axis_dt_int(Y軸に関して輪切りにする場合)から見つけてきます。

下記のプログラムリストにおいて、

// マウスでポイントした座標に最も近いデータ上の座標を拾ってくる
nearest_point = x_axis_dt_int.Nearest(mouse_x);  
nearest_point = y_axis_dt_int.Nearest(mouse_y);

の部分です。上はX軸に関して、下はY軸に関して輪切りにする場合の座標取得です。
ただし、ここで気をつけたいのは、まだ描画処理が走っていない状態で、ArrayListから最も近い値を探してくる処理を走らせてはいけないことです。これを防ぐ為に、描画データを格納するArrayListにデータが入っているかどうかを、逆に言えばArrayListが空でないかどうかをテストする処理を入れています。これは拡張メソッドIsEmpty()として定義しています。

実はあえて拡張メソッドにする必要もないのですが、使うときの書きやすさで、空かどうかをbool値で返す拡張メソッドにしてみました。

ArrayListから見つけてきた一番近い座標を含む全てのデータを取得


拡張メソッドNearest()を使ってArrayListから探してきた数値(X座標もしくはY座標)を含む座標データを、座標データが格納されているArrayListから取得します。

座標データは、データファイルのCSVデータを読み込んだ後、座標変換を行い、一番奥にある座標から順に並ぶように並べ替えたものが、x_axis_dt_int、y_axis_dt_int、z_axis_dt_int、dt_dt_intに格納されています。

この座標データに対して、X軸に対して垂直な面で輪切りにするのであればx_axis_dt_intからx_axis_dt_int.Nearest()で探してきた数値と等しい座標データを全て、Y軸に対して垂直な面で輪切りにするのであればy_axis_dt_intからy_axis_dt_int.Nearest()で探してきた数値と等しい座標データを全て取得します。

プロット


記事ではデータの取得とプロットを別項目にしましたが、実際にはNearest()で探した数値と同じX座標、もしくはY座標を持ったデータかどうかをif分で振り分けて、目的の座標であれば、そのままプロットの色を決めてプロットしてしまうような処理にしました。

以下、上記説明をコーディングしたプログラムリストを以下に示します。pictureBox1_Click()は、pictureBox1の上でマウスをクリックしたときに発生するイベントのイベントハンドラー、drawSlice()は実際に輪切り画像を描画するメソッド、IsEmpty()はArrayListの中身が空かどうかをテストするための拡張メソッドとなります。ArrayListの中身が空だった場合は、何もしないで呼び出し元に戻ります。

なお、拡張メソッドIsEmpty()は、コガネブログ様で教えていただきました。ありがとうございます。

        /*
         * pictureboxの上でマウスをクリックしたときに発生するイベント
         * 何も描かれていない状態でスライス描画処理を呼び出すと、データが入っているリストに
         * 中身がある前提の処理に
         * 支障を来すし、何も描かれていない状態でスライス描画処理を呼び出すことは意味が
         * 無いことなので、何も描かれて
         * いないときにはスライス描画処理が行われないようにする必要がある。
         * その判断をここで実施。
         */
        private void pictureBox1_Click(object sender, EventArgs e)
        {
            // データを入れるリストが単独で空であることはあり得ないので、
            // X軸もしくはY軸の座標データがあるかどうか一つだけのテストで判断
           if (!x_axis.IsEmpty())
            {
                drawSlice();  // データのリストが空でなければスライスを描く処理を呼び出す
            }
            else
            {
                MessageBox.Show("何も描かれていません");
            }
        }
        /*
         * スライス画像を作成する
         */
         private void drawSlice()
        {
            int nearest_point;
            Color col = new Color();

            Form3 frm3 = new Form3();
            frm3.Show();

            Bitmap img = new Bitmap(Constants.size_slicebox_x, Constants.size_slicebox_y);

            if (button1_on)
            {

               // マウスでポイントした座標に最も近いデータ上の座標を拾ってくる
               nearest_point = x_axis_dt_int.Nearest(mouse_x);  
                frm3.Text = "Slice by X-axis : " + nearest_point;
               // ArrayListの全ての要素数でループを回し、一番近い数値に関してだけ描画処理を動かす
                for (int i = 0; i < x_axis_dt.Count; ++i) 
                {
                    if (x_axis_dt_int[i] == nearest_point) 
                    {
                        // プロットデータの内容から描画色を決定
                        if (dt[i] != 0)   // データが0の時は描画しない
                        {
                            switch (dt[i])
                            {
                                case 0:
                                    col = Color.Black;
                                    break;
                                case 1:
                                    col = Color.Blue;
                                    break;
                                case 2:
                                    col = Color.Red;
                                    break;
                                case 3:
                                    col = Color.Purple;
                                    break;
                                case 4:
                                    col = Color.Green;
                                    break;
                                case 5:
                                    col = Color.Yellow;
                                    break;
                                case 6:
                                    col = Color.Violet;
                                    break;
                                case 7:
                                    col = Color.White;
                                    break;
                                default:
                                    col = Color.Magenta;
                                    break;
                            }
                        }
                        img.SetPixel(z_axis[i], y_axis[i], col);
                    }
                }
                frm3.pictureBox1.Image = img;
            }

            //
            // Y軸でスライスする場合の処理
            //
            if(button2_on)
            {
                nearest_point = y_axis_dt_int.Nearest(mouse_y);
                frm3.Text = "Slice by Y-axis : " + nearest_point;

                for (int i = 0; i < y_axis_dt.Count; ++i)
                {
                    if (    y_axis_dt_int[i] == nearest_point)
                    {
                        // プロットデータの内容から描画色を決定
                        if (dt[i] != 0)   // データが0の時は描画しない
                        {
                            switch (dt[i])
                            {
                                case 0:
                                    col = Color.Black;
                                    break;
                                case 1:
                                    col = Color.Blue;
                                    break;
                                case 2:
                                    col = Color.Red;
                                    break;
                                case 3:
                                    col = Color.Purple;
                                    break;
                                case 4:
                                    col = Color.Green;
                                    break;
                                case 5:
                                    col = Color.Yellow;
                                    break;
                                case 6:
                                    col = Color.Violet;
                                    break;
                                case 7:
                                    col = Color.White;
                                    break;
                                default:
                                    col = Color.Magenta;
                                    break;
                            }
                        }
                        img.SetPixel(x_axis[i], z_axis[i], col);
                    }
                }
                frm3.pictureBox1.Image = img;
            }

/*
 * 配列やリストが空かどうかを返す拡張メソッドisEmpty.cs
 * http://baba-s.hatenablog.com/entry/2015/07/17/100000
 */
using System.Collections.Generic;

public static class IListExtentions
{
    public static bool IsEmpty<T>( this IList<T> self )
    {
        return self.Count == 0;
    }
}

下に、プログラムを動作させた様子をキャプチャした動画を示します。輪切り画像がきちんと表示できればよかったのですが、色々な都合で綺麗に出ていないことをお許し下さい。


ソースファイルはここにzipファイルにして置きます。ソースのみですので、Visual Studioでビルドして動かす場合には、色々な設定が必要になると思います。それに関しては、誠に勝手ながら、各自にてお願いいたします。また、プログラムソースは学習用の使用に留めて下さいますようお願いいたします。

本プログラムもWindows10上のVisual Studio 2017 Communityエディションで開発、動作確認しています。動作環境として.Net Frameworkを使用していますので、Mac OS版のVisual Studio 2017では動かすことはできません。GUIを全て書き換える必要があると思います。

なお、プログラムソースの内容については無保証です。ご質問は可能な範囲でお受けいたしますが、回答にお時間を頂く場合もございますことをご了承下さい。

2018年4月17日火曜日

さらに立ち止まって、拡張メソッドの研究

前々回のエントリーで出て来た下記のリスト、この中で記述されているのが、前回のエントリーで研究したラムダ式と、今日やる拡張メソッドです。

using System;
using System.Collections.Generic;
using System.Linq;

public static class IEnumerableExtensions
{
    public static int Nearest(this IEnumerable<int> self, int target)
    {
        var min = self.Min(c => Math.Abs(c - target));
        return self.First(c => Math.Abs(c - target) == min);
    }
}

拡張メソッドとは何か?という簡単な説明が、Qiitaのkuwapp様記事にありました。

メソッドを追加しようと思ったら、普通はソースを編集してメソッドを追加して再コンパイルするか、派生クラスを作るしかないのですが、この拡張メソッドはそれをせずにクラスの追加が出来るように見えるものです。

上記で引用させていただいた記事には具体的に記述方法の説明はありません。記述方法などに関する詳しい説明は、いつも勉強させていただいている++C++ ; //未確認飛行 C様の記事にあります。

静的クラスの中に静的メソッドを定義し、その中で第一引数にthis修飾語を付けた静的メソッドを定義する、ということのようです。

下記リストではこの拡張メソッドを、スライス画像を描くためのメソッドの中で呼び出していますが、それは下記のように記述しています。

        /*
         * スライス画像を作成する
         */
         private void drawSlice()
        {
            Bitmap img = new Bitmap(Constants.size_slicebox_x, Constants.size_slicebox_y);
            MessageBox.Show("Slice by "+x_axis.Nearest(mouse_x));  //描画は未実装、座標をメッセージボックスに
        }

x_axisというArrayListには、Nearestというメソッドは元々ありませんが、拡張メソッドを定義しているので、あたかもメソッドが拡張されたかのように記述できている、という訳です。

拡張メソッドについては、ここまでにします。

2018年4月13日金曜日

ここで、ちょっと立ち止まってラムダ式の研究

とにかく最後まで進める為に、輪切り画像の生成に集中して先に進もうと思っていました。しかし、前回の記事を書いていて、ラムダ式に引っかかりがあって、ある程度理解できないと気が済まなくなりました。

一旦立ち止まって、ラムダ式の研究をしてみたいと思います。

お付き合いください。

たまにお世話になっている(とは言っても、勝手に拝見しているだけなんですけど)Qitaのアカウントrawr様の記事から、以下の例を考えてみます。

Listに入っている文字列の後ろに、何か決まった文字列を追加したいとき、foreach文を使って書くと以下のようになると思います。foreachが2回出て来ますが、最初のforeachは後ろに文字列を追加する処理、2番目に出てくるforeachは、処理結果をコンソールに出力するためのものです。

リスト1

using System;
using System.Collections.Generic;
using System.Linq;

namespace lamda
{
    class Program
    {
        static void Main(string[] args)
        {
            var list = new List<string>();  // Listクラスのオブジェクトとしてlistを生成
            list.Add("aaa");  // listの要素を追加していきます
            list.Add("bbb");
            list.Add("ccc");
            list.Add("ddd");
            list.Add("eee");
            list.Add("fff");
            list.Add("ggg");

          // 処理結果を格納する為のListクラスのオブジェクトresultを生成        
            var result = new List<string>();  

            foreach ( var s in list )
            {
                result.Add( s + ".hoge");
            }
            
            foreach ( var s in result )
            {
                Console.WriteLine( s );
            }
        }
    }
}
ここで出てくるListクラスは初めて出てくるものです。Listは以前、「CSVのデータを一旦変数に格納、型変換をして描画処理を行います」のエントリーで出て来たArrayListによく似た可変長配列を実現するためのクラスです。 
ArrayListとの違いはこちらのページに説明があります。できるだけListを使いましょう、とのこと。 
ArrayListでは、各要素の型はオブジェクト型で、どんな型のデータでも格納できるものの、使うときに必ずキャストする必要があるのに対して、Listではオブジェクトを生成するときに決まってしまうと書いてあります。使うときに必ずキャストしなければならないのは、ちょっと面倒ですし、各要素の型が要素ごとに違う、なんていう設計はしないとおもいますので、最初に適切に型が決まった方が使いやすそうです。
このブログで取り扱っている描画処理のプログラムに於いては、この辺の事情も良くわからずにコーディングしていたので、ArrayListを使用しており、それをそのまま載せています。

ここで、参考にさせていただいたQiitaの筆者様は、もう少し簡潔に書けないものかと考えられたわけですね。

そこで、Selectメソッドというメソッドに登場してもらいます。上のリストはもう少し簡潔に以下のように書くことが出来るようです。

リスト2

using System;
using System.Collections.Generic;
using System.Linq;

namespace lamda2
{
    class Program
    {
        static void Main(string[] args)
        {
            var list = new List<string>();
            list.Add("aaa");
            list.Add("bbb");
            list.Add("ccc");
            list.Add("ddd");
            list.Add("eee");
            list.Add("fff");
            list.Add("ggg");

            // データの格納先を生成するとともに、データの加工メソッドをSelectメソッドに渡す
            var result = list.Select( append ); 

            foreach( string s in result )
            {
                Console.WriteLine( s );
            }
        }

        private  static string append( string s )
        {
            return s + "hoge";
        }
    }
}

ここで、見慣れない書法が出て来ました。var result = list.Select( append );です。

list.Select()はメソッドですが、メソッドの引数としてメソッドの名前を指定しています。更にスッキリしなくなったので、色々調べ回ったところ、これはDelegateの一種で、Func<T, TResult>によるDelegateである、ということのようです。マイクロソフトの説明がここにあります。

メソッドとは、クラスに属する関数、もしくは手続きなので、メソッドに渡す引数は本来数値もしくは文字、文字列といった「値」です。しかし、今回のようにメソッドにメソッドを渡したい、というニーズがあり、このような拡張がなされたものと思います。今回の例では、Selectメソッドの中で、Listオブジェクトのメンバー全てに対して、メンバーを加工する方法をSelectメソッドに与えたいのです。

C#は進化し続ける高級言語のようで、あとから色々な仕様追加がなされていますね。勉強する側の立場からすると、言語仕様的にスッキリしなくてわかりにくい、という印象を受けます。

さて、リスト2で定義されてSelectメソッドに渡されているappendメソッドは、たった1行の小さなメソッドです。このぐらいのメソッドであれば、わざわざ別の場所で定義して使うよりも、もっと簡潔な書き方ができないか?ということで考えられたのが以下の書き方です。

var result = list.Select( (string s) => { return s + ".txt"; } );

こうやって、インライン展開して書くと、わざわざ別のところでメソッドを定義しなくてもよくなります。これがラムダ式なのだそうです。見慣れたラムダ式とは若干違いますね。

ここで、


  • 引数の型が自明(型推論できる)の場合は省略可能
  • メソッドが1文しかない場合は、{}やreturnを省略可能
  • 引数が1つしかない場合は()を省略可能


といったルールがあるようで、上記リストは

var result = list.Select( s => s + ".txt" );

と書き換えることが出来る、ということになります。これで、割によく見るラムダ式になりました。

これから、最初のリストは以下のように書き換えることが出来ます。

リスト3

using System;
using System.Collections.Generic;
using System.Linq;

namespace lamda3
{
    class Program
    {
        static void Main(string[] args)
        {
            var list = new List<string>();
            list.Add("aaa");
            list.Add("bbb");
            list.Add("ccc");
            list.Add("ddd");
            list.Add("eee");
            list.Add("fff");
            list.Add("ggg");

            var result = list.Select( s => s + "hoge" );

            foreach( string s in result )       
            {
                Console.WriteLine( s );
            }
        }
    }
}

リスト1に書いたforeach節は、この1行のラムダ式で書き表された、ということになります。

さて、ここからは余談になります。

ここで、var result = list.Select( s => s + "hoge" );なる式でresultに結果を受け取ると、resultはIEnumerable<T>型となります。これを、ToList()メソッドでList<T> 型に変換してあげると、foreach節をより短い記述にすることが出来ます。

リスト4

using System;
using System.Collections.Generic;
using System.Linq;

namespace lamda3
{
    class Program
    {
        static void Main(string[] args)
        {
            var list = new List<string>();
            list.Add("aaa");
            list.Add("bbb");
            list.Add("ccc");
            list.Add("ddd");
            list.Add("eee");
            list.Add("fff");
            list.Add("ggg");

            var result = list.Select( s => s + "hoge" ).ToList();

            result.ForEach( Console.WriteLine );
        }
    }
}

結果出力のforeach節も、1行で書き表されてしまいました。

今までの流れからすると、ラムダ式を使うとメソッドに手続きを渡すことが出来る、ということになります。ただ、手続きを受け取る側もそれに応じた書き方をする必要があるようです。それはまた別の機会に書こうと思います。

今回はここまでにします。