こんにちは!キャスレーコンサルティングSI(システム・インテグレーション)部の栗田です

Windowsアプリの開発者がWebアプリなるものを作ってみたい!と思うことはよくあります。
しかし以下のような理由で、手を付けられないことがしばしばあります。

1. VisualStudioのようにFormにTextboxなどのコントロールを簡単に貼って、融通の利くアプリは作れるのか。
2. 速度的な問題はどうなのか。メモリの問題は?
3. 自作DLLやOCXのように資源を再利用できるのか。
4. イベント等で動的にコントロール等の内容を書き換えられるのか。(定期的にリロードなんかしたくない)

しかし始めてみると上記のことはもちろん、かなりのことが出来ることを実感できます。
むしろ、わざわざインストーラーを作って配布する手間すらありません。
すごい時代ですよね…。

今回はHTML5+jQueryで、UIと速度面の実験をしてみたいと思います。
それではまず準備から…

Form(またはダイアログ)を再現

まずは以下のようなフォルダおよびファイルの構成となります。
files1
index.html

<meta charset="UTF-8" />
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js"></script>
<script type="text/javascript" src="js/common.js"></script>
<script type="text/javascript" src="js/dialog.js"></script>
<link href="css/common.css" rel="stylesheet" />
<link href="css/dialog.css" rel="stylesheet" />
<!-- ダイアログ -->
<div id="lock">
	<div id="dialog_body">
		<div class="dialog_header">×</div>
		<div id="dialog_msg">ダイアログです。</div>
	</div>
</div>

css/common.css

/* ページ */
html, body {
    height: 100%;
    margin: 0;
}

css/dialog.css

/* 背景ロック用 */
#lock{
	position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
    background: rgba(0, 0, 0, 0.8);
    z-index: 999;
display: none;
}

/* ダイアログ土台 */
#dialog_body {
	position: absolute;
    width: 500px;
    height: 300px;
    background: white;
	border-radius: 9px;
    box-shadow: 3px 3px 10px -1px #000000;
}

/* ダイアログのヘッダー部 */
.dialog_header {
	border-top-left-radius: 8px;
	border-top-right-radius: 8px;
	width: 100%;
	height: 27px;
	letter-spacing: 8px;
	background: #777777;
	color: #FFFFFF;
	font-size: 15px;
	font-weight: bold;
	text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.7);
}

/* ダイアログのメッセージ部 */
#dialog_msg {
    font-size: 14px;
    font-weight: bold;
    padding: 10px;
}

/* ダイアログの閉じるボタン */
.dialog_btn_close {
    float:right;
    font-size:20px;
    margin: 3px;
    cursor: pointer;
}

js/common.js

    /*****************************************************************
     * @name
     * init
     * @description
     * 初期化
     *****************************************************************/
	function init()
    {
        //ダイアログの初期化
        initDialog();

        //ダイアログ表示
        $("#lock").show();
	}

js/dialog.js

    //ダイアログ位置
    var x = null;
    var y = null;

    /*****************************************************************
     * @name
     * initDialog
     * @description
     * ダイアログ初期化
     *****************************************************************/
	function initDialog()
    {
        //エレメントを格納
        var dlg = $("#dialog_body");
        var body = $("body");

        //ダイアログ操作
        //タッチorクリック開始
        dlg.on("touchstart mousedown", function(e){
            //現在座標の格納
            var m = getPos(e);
            x = m.x;
            y = m.y;
        });

        //移動時
        body.on("touchmove mousemove", function(e){
            //xyが存在する場合
            if (x && y){
                //親にイベントを伝達しない
                e.preventDefault();

                //前座標と現在座標で移動量を割り出す
                var m = getPos(e);
                var mx = m.x - x;
                var my = m.y - y;
                dlg.css("top", dlg.position().top + my);
                dlg.css("left", dlg.position().left + mx);

                //現在の座標を格納
                x = m.x;
                y = m.y;
            }
        });

        //タッチorクリック完了時
        dlg.on("touchend mouseup", function(){
            //タッチ始点・終点位置クリア
            x = y = null;
        });

        //閉じる
        $("#close").on("click", function(){
            $("#dialog_body").hide();
            $("#lock").hide();
        });
	}

    /*****************************************************************
     * @name
     * getPos
     * @description
     * マウス位置の取得
     *
     * @param {object} e    イベント情報
     *
     * @return {object} 座標
     *****************************************************************/
    function getPos(e){

        //座標の初期化
        var x = null;
        var y = null;

        if (typeof e.clientX === 'undefined'){
            x = e.originalEvent.changedTouches[0].pageX;
            y = e.originalEvent.changedTouches[0].pageY;
            x -= window.scrollX;
            y -= window.scrollY;

        } else {
            x = e.clientX;
            y = e.clientY;
        }
        return {x:x, y:y};
    }

実行結果
マウスもしくはタッチパネルで上下左右に動かすことのできるFormのようなものが生成できました。
「×」をクリックして閉じることができます。
site1
このダイアログを他で使用して頂く場合
今回はダイアログの実体をindex.htmlの11~21行に記載致しましたが、今回とは関係のない別プロジェクトでこのダイアログを使用して頂く場合、以下の修正を行って下さい。
js/dialog.jsのinitDialog()の16行目に下記コードを追加

$('body').append('\
	<!-- ダイアログ -->\
	<div id="lock">\
		<div id="dialog_body">\
			<div class="dialog_header"> × </div>\
			<div id="dialog_msg">ダイアログです。</div>\
		</div>\
	</div>');

使用するhtmlファイルに以下を追加し、dialog.js、dialog.cssを読み込む

<script type="text/javascript" src="js/dialog.js"></script>
<link href="css/dialog.css" rel="stylesheet" />

表示する時は
$(‘#lock’).show();

閉じる場合は
$(‘#lock’).hide();

これでダイアログがbodyに動的に追加されます。
使用したいプロジェクトにdialog.js、dialog.cssをコピーし、htmlで読み込むことで再利用できます。

コントロール(部品)の生成

ダイアログのタグを以下に変更してください。

<!-- ダイアログ -->
<div id="lock">
	<div id="dialog_body">
		<div class="dialog_header">×</div>
		<div id="dialog_msg">ダイアログです。
			<input id="test1" type="text" value="TextBox" />
			<input type="radio" checked="checked" name="test2" value="0" />OptionButton1
			<input type="radio" name="test2" value="1" />OptionButton2
			<input type="checkbox" checked="checked" name="test3" value="0" />CheckBox1
			<input type="checkbox" name="test3" value="1" />CheckBox2
			<select id="test4">
				<option value="">なし</option>
				<option value="0">ComboBox1</option>
				<option value="1">ComboBox2</option>
			</select>
		</div>
	</div>
</div>

実行結果
image123
フォーム上にVisualStudioでいうコントロールが生成されました。
例えば、テキストボックス(test1)の値を取得する場合、
var value = $(“#test1”).val();
で値がvalueに格納されます。

画像フィルタによる速度の検証

次に画像をブラウザ上にドラッグドロップし、その画像に対して処理としては重いと思われる画像処理を実施し、処理速度を計測したいと思います。
ファイル構成は以下となります。
files2
icon.png
icon

index.htmlを置き換えます。

<meta charset="UTF-8" />
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js"></script>
<script type="text/javascript" src="js/common.js"></script>
<script type="text/javascript" src="js/dialog.js"></script>
<script type="text/javascript" src="js/graphic.js"></script>
<link href="css/common.css" rel="stylesheet" />
<link href="css/dialog.css" rel="stylesheet" />
<!-- ボタン -->
<div style="height: 70px;">
	<div class="button" style="float: left; margin-right: 20px;" onclick="filter(MODE_GRAYSCALE);">
		<img alt="" src="image/icon.png" />白黒
	</div>
	<div class="button" style="float: left; margin-right: 20px;" onclick="filter(MODE_MEDIAN_FILTER);">
		<img alt="" src="image/icon.png" />ぼかし
	</div>
	<div class="button" style="float: left; margin-right: 20px;" onclick="filter(MODE_SKIN);">
		<img alt="" src="image/icon.png" />肌抽出
	</div>
</div>
<!-- 描画範囲 -->
<canvas id="canvas" style="clear: both;"></canvas>
<!-- ダイアログ -->
<div id="lock">
	<div id="dialog_body">
		<div class="dialog_header">×</div>
		<div id="dialog_msg">ダイアログです。</div>
	</div>
</div>

css/commmon.cssを置き換えます。

/* ページ */
html, body {
    height: 100%;
    margin: 0;
}

/* ボタン */
.button {
    height: 45px;
    margin: 10px;
    padding: 0;
    cursor: pointer;
    font-size: 12px;
    text-align: center;
    letter-spacing: 2px;
    font-weight: bold;
    color:#4285F4;
    line-height: 1.8em
}

/* ボタンのアイコン */
.button img {
	margin: 0px auto;
    max-width: 100%;
	max-height: 32px;
	padding: 0;
	display:block;
}

js/common.jsを置き換えます。

    /*****************************************************************
     * @name
     * init
     * @description
     * 初期化
     *****************************************************************/
	function init()
    {
        //ダイアログの初期化
        initDialog();

        //画像処理用初期化
        initGraphic();
	}

js/graphic.jsを追加します。


    //定数
    var MODE_GRAYSCALE = 1;         //グレースケール
    var MODE_MEDIAN_FILTER = 2;     //メディアンフィルター
    var MODE_SKIN = 3;              //肌検出

    /*****************************************************************
     * @name
     * initGraphic
     * @description
     * 画像処理用の初期化
     *****************************************************************/
	function initGraphic()
    {
        //エレメントを格納
        var body = $("body");

        //ドラッグドロップ処理
        body.on("dragenter", function(e){
            e.stopPropagation();
            e.preventDefault();
        });
        body.on("dragover", function(e){
            e.stopPropagation();
            e.preventDefault();
        });
        body.on("drop", function(e){
            e.preventDefault();
            //画像のみ許可
            var file = e.originalEvent.dataTransfer.files[0];
            if (!file.type.match(/^image\/(png|jpeg|gif)$/)){
                return;
            }
            //画像が読み込まれた後にCanvasへ描画
            var image = new Image();
            var reader = new FileReader();
            reader.onload = function(e) {
                image.onload = function()
                {
                    //描画
                    var obj = $("#canvas");
                    obj.attr("width", image.width).attr("height", image.height);
                    var ctx = obj[0].getContext("2d");
                    ctx.drawImage(image, 0, 0);
                };
                //ドロップされたファイルを画像ソースとする
                image.src = e.target.result;
            };
            reader.readAsDataURL(file);
        });
	}

    /*****************************************************************
     * @name
     * filter
     * @description
     * ピクセル配列を取得し、画像フィルター実行
     *
     * @param {number} mode モード
     *****************************************************************/
	function filter(mode)
    {
        //ピクセル配列の取得
        var canvas = $("#canvas");
        var ctx = canvas[0].getContext("2d");
        var w = canvas.width();
        var h = canvas.height();
        var imageData = ctx.getImageData(0, 0, w, h);
        var buf = new ArrayBuffer(imageData.data.length);
        buf = imageData.data.buffer;
        var buf8 = new Uint8ClampedArray(buf);
        var pixels = new Uint32Array(buf);

        //各処理を行う
        if (mode === MODE_GRAYSCALE){
            //グレースケール
            grayscale(pixels);

        } else if (mode === MODE_MEDIAN_FILTER){
            //メディアンフィルター(ぼかし)
            median(pixels, w, h);

        } else if (mode === MODE_SKIN){
            //肌検出
            skin(pixels);
        }

        //Canvasに描画
        imageData.data.set(buf8);
        ctx.putImageData(imageData, 0, 0);

        //開放
        pixels = null;
        buf8 = null;
        buf = null;
    }

    /*****************************************************************
     * @name
     * grayscale
     * @description
     * グレースケール
     *
     * @param {array} pixels ピクセル配列
     *****************************************************************/
	function grayscale(pixels)
    {
        var a, r, g, b;     //a:透明度, r:赤, g:緑, b:青

        //UnixTime
        var tmr = new Date().getTime();

        //全ピクセル分ループ
        for (var i = 0, len = pixels.length; i < len; ++i){
            //RGBA分解(ピクセルはABGRで1配列に格納される)
            a = (pixels[i] >> 24) & 0xff;
            b = (pixels[i] >> 16) & 0xff;
            g = (pixels[i] >> 8) & 0xff;
            r = pixels[i] & 0xff;
            //グレースケール化
            var gray = ~~((r * 0.3) + (g * 0.59) + (b * 0.11));
            //格納
            pixels[i] = a << 24 | (gray << 16) | (gray << 8) | gray;
        }

        //経過時間(msec)
        $("#dialog_msg").html((new Date().getTime() - tmr) + " msec");
        $("#lock").show();
    }

    /*****************************************************************
     * @name
     * median
     * @description
     * ぼかし(メディアンフィルター)
     *
     * @param {array} pixels    ピクセル配列
     * @param {number} w        画像の幅
     * @param {number} h        画像の高さ
     *****************************************************************/
	function median(pixels, w, h)
    {
        var m, n;
        var count;
        var t = [];

        //UnixTime
        var tmr = new Date().getTime();

        //フィルター処理
        for (var y = 0; y < h; ++y){
            for (var x = 0; x < w; ++x){
                t = [];
                for (m = y - 1, count = 0, len1 = y + 1; m <= len1; ++m){
                    for (n = x - 1, len2 = x + 1; n <= len2; ++n){
                        if (n >= 0 && n < w && m >= 0 && m < h){
                            t[count++] = pixels[w * m + n];
                        }
                    }
                }
                //バブルソート
                for (m = 0, len3 = count - 1; m < len3; ++m){
                    for (n = m + 1; n < count; ++n){
                        if (t[m] < t[n]) {
                            t[m] = [t[n], t[n] = t[m]][0];
                        }
                    }
                }
                pixels[w * y + x] = t[~~(count / 2)];
            }
        }

        //経過時間(msec)
        $("#dialog_msg").html((new Date().getTime() - tmr) + " msec");
        $("#lock").show();
    }

    /*****************************************************************
     * @name
     * skin
     * @description
     * 肌抽出
     *
     * @param {array} pixels ピクセル配列
     *****************************************************************/
	function skin(pixels)
    {
        var a, r, g, b;     //a:透明度, r:赤, g:緑, b:青

        //UnixTime
        var tmr = new Date().getTime();

        //全ピクセル分ループ
        for (var i = 0, len = pixels.length; i < len; ++i){
            //RGBA分解(ピクセルはABGRで1配列に格納される)
            a = (pixels[i] >> 24) & 0xff;
            b = (pixels[i] >> 16) & 0xff;
            g = (pixels[i] >> 8) & 0xff;
            r = pixels[i] & 0xff;
            //RGB→HSV変換
            var m = toHSV({r:r, g:g, b:b});
            if (m.h < 0.1 && m.s > 0.2){
                pixels[i] = 0xffff0000;
            }
        }

        //経過時間(msec)
        $("#dialog_msg").html((new Date().getTime() - tmr) + " msec");
        $("#lock").show();
    }

    /*****************************************************************
     * @name
     * toHSV
     * @description
     * RGB→HSV変換
     *
     * @param {number} color RGB色
     *
     * @return {object} HSV
     *****************************************************************/
	function toHSV(color)
    {
		var r = color.r / 255;
		var g = color.g / 255;
		var b = color.b / 255;
		var max = Math.max(Math.max(r, g), b);
		var min = Math.min(Math.min(r, g), b);

		var h = max - min;

		if (h > 0){
			if (max === r){
				h = (g - b) / h;
				if (h < 0) {
					h += 6.0;
				}
			} else if (max === g){
				h = 2.0 + (b - r) / h;
			} else {
				h = 4.0 + (r - g) / h;
			}
		}
		h /= 6.0;
		var s = max - min;
		if (max !== 0) {
			s /= max;
		}
		var v = max;

        return {h:h, s:s, v:v};
	}

ドラッグドロップで画像を挿入します。
site2

グレースケール
site3

処理速度で約16ミリ秒です。まずまずだと思います。
site4

ぼかし(メディアン法)
site5
だいだい250msec位でした。
該当ピクセルに対して周辺ピクセルの中央値を取得して格納するため、ループ回数はかなり多くなりますが、こちらもまずまずの速度が出ています。

肌検出
RGBからHSVに色変換し、肌の色彩に近いものを青で塗りつぶします。
大体60msec前後でした。
肌候補のピクセルを抽出して顔の候補を絞り込むなどに使えると思います。
site6

最後に

一連の動作を実施した結果、冒頭に挙げた以下の点をクリア出来ていることが実感できたと思います。

1. VisualStudioのようにFormにTextboxなどのコントロールを簡単に貼って、融通の利くアプリは作れるのか。
⇒フォームやコントロールの生成でWindowsアプリを再現できました。

2. 速度的な問題はどうなのか。メモリの問題は?
⇒画像処理により、幅520×高さ520×RGBA4byte=1081600byteの配列を処理し、50~500msec程度で完了しました。
⇒上記の配列を確保して問題なく連続動作しています。

3. 自作DLLやOCXのように資源を再利用できるのか。
⇒jsファイル上にエレメントを動的に生成するコードを記述し、jsファイル(必要であればcssファイルも)を読み込ますことで資源を再利用できました。

4. イベント等で動的にコントロール等の内容を書き換えられるのか。(定期的にリロードなんかしたくない)
⇒.append()、.html()等でエレメントの内容を動的に書き換えることが可能です。
⇒今回は触れていませんが、クライアントとサーバー間での通信でサーバー側からの応答結果を反映させたい場合は、Ajaxを使用することで動的に結果を反映できます。

今回はクライアントのみの実装でありましたが、サーバー側(PHPやCGIなど)を実装して画像フィルタ結果をアップロードする等、少し手を加えたコーディングにチャレンジして頂ければと思います。

以上となります。
最後まで読んで頂き、大変感謝です!!