Keras.js によるランドマーク検出の Web アプリケーション化

前回の 「CNN でランドマーク検出」 の学習済みモデルを Keras.js を使って Web ブラウザ上で実行できるようにしてみます。

ソースは http://github.com/fits/try_samples/tree/master/blog/20190331/

準備

npm で Keras.js をインストールします。

Keras.js インストール
> npm install --save keras-js

Keras.js に含まれている encoder.py スクリプトを使って、Python の Keras で学習したモデル(model/cnn_landmark_400.h5)を Keras.js 用に変換します。

モデルファイル(HDF5 形式)を Keras.js 用に変換
> python node_modules/keras-js/python/encoder.py model/cnn_landmark_400.h5

生成された .bin ファイル(model/cnn_landmark_400.bin)のパス(URL)を KerasJS.Model へ指定して使う事になります。

ついでに、webpack もインストールしておきます。(webpack コマンドを使うには webpack-cli も必要)

webpack インストール
> npm install --save-dev webpack webpack-cli

Web アプリケーション作成

今回、作成する Web アプリケーションのファイル構成は以下の通りです。

  • index.html
  • js/bundle_app.js
  • js/bundle_worker.js
  • model/cnn_landmark_400.bin

処理は全て Web ブラウザ上で実行するようにし、Keras.js の処理(今回のランドマーク検出)はそれなりに重いので Web Worker として実行します。

index.html の内容は以下の通りです。

index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style type="text/css">
        canvas {
            border: 1px solid;
        }

        canvas.dragging {
            border: 3px solid red;
        }

        table {
            text-align: center;
            border-collapse: collapse;
        }

        table th, table td {
            border: 1px solid;
            padding: 8px;
        }
    </style>
</head>
<body>
    <dialog id="load-dialog">loading model ...</dialog>
    <dialog id="detect-dialog">detecting landmarks ...</dialog>
    <dialog id="error-dialog">ERROR</dialog>

    <div>
        <canvas width="256" height="256"></canvas>
    </div>

    <br>

    <div>
        <div id="landmarks"></div>
    </div>

    <script src="./js/bundle_app.js"></script>
</body>
</html>

bundle_xxx.js を生成するため、以下のような webpack 設定ファイルを用意します。

fs: 'empty' の箇所は Keras.js を webpack で処理するために必要な設定で、これが無いと Module not found: Error: Can't resolve 'fs' のようなエラーが出る事になります。

webpack.config.js
module.exports = {
    entry: {
        bundle_app: __dirname + '/src/app.js',
        bundle_worker: __dirname + '/src/worker.js'
    },
    output: {
        path: __dirname + '/js',
        filename: '[name].js',
    },
    // Keras.js 用の設定
    node: {
        fs: 'empty'
    }
}

Web Worker では Actor モデルのようにメッセージパッシング(postMessage で送信、onmessage で受信)を使ってメインの UI 処理とのデータ連携を行います。

今回は、以下のようなメッセージを Web Worker(Keras.js を使った処理)とやり取りするようにします。

Web Worker(Keras.js の処理)とのメッセージ内容
処理 送信メッセージ 受信メッセージ(成功時) 受信メッセージ(エラー時)
初期化 {type: 'init', url: <モデルファイルのURL>} {type: 'init'} {type: 'init', error: <エラーメッセージ>}
ランドマーク検出 {type: 'predict', input: <入力データ>} {type: 'predict', output: <ランドマーク検出結果>} {type: 'predict', error: <エラーメッセージ>}

(a) ランドマーク検出処理(src/worker.js)

Web Worker として実装するため、postMessage で UI 処理へメッセージを送信し、onmessage でメッセージを受信するようにします。

Keras.js 1.0.3 における Dense 処理の問題

実は、Keras.js 1.0.3 では今回の CNN モデルを正しく処理できません。

というのも、Keras.js 1.0.3 における Dense の処理(GPU を使わない場合)は以下のようになっています。

node_modules/keras-js/lib/layers/core/Dense.js の問題個所
  _callCPU(x) {
    this.output = new _Tensor.default([], [this.units]);

    ・・・
  }

今回の CNN モデルでは Dense の結果が 3次元 (256, 256, 7) になる必要がありますが、上記 Dense 処理では (7) のように 1次元になってしまい正しい結果を得られません。 ※

 ※ ついでに、Keras.js の softmax 処理にも不都合な点があった

そこで、今回は(GPU を使わない事を前提に)Dense_callCPU を実行時に書き換える事で回避しました。

処理内容としては、元の処理を 2重ループ内で実施するようにしています。

Dense 問題の回避措置(src/worker.js)
import KerasJS from 'keras-js'
import { gemv } from 'ndarray-blas-level2'
import ops from 'ndarray-ops'

・・・

// Dense の _callCPU を実行時に変更
KerasJS.layers.Dense.prototype._callCPU = function(x) {
    const h = x.tensor.shape[0]
    const w = x.tensor.shape[1]

    this.output = new KerasJS.Tensor([], [h, w, this.units])

    for (let i = 0; i < h; i++) {
        for (let j = 0; j < w; j++) {

            const xt = x.tensor.pick(i, j)
            const ot = this.output.tensor.pick(i, j)

            if (this.use_bias) {
                ops.assign(ot, this.weights['bias'].tensor)
            }

            gemv(1, this.weights['kernel'].tensor.transpose(1, 0), xt, 1, ot)

            this.activationFunc({tensor: ot})
        }
    }
}

ランドマーク検出の実装

KerasJS.Modelpredict へ入力データを渡したり結果を取り出すにはレイヤー名を指定する必要があり、これらのレイヤー名は iuputLayerNamesoutputLayerNames でそれぞれ取得できます。

predict の結果は、各座標のランドマーク該当確率 (256, 256, 7) となるので、ここではランドマーク毎 ※ に最も確率の高かった座標のみを結果として返すようにしています。

 ※ ランドマーク 0 はランドマークに該当しなかった場合なので結果に含めていない
src/worker.js
import KerasJS from 'keras-js'
import { gemv } from 'ndarray-blas-level2'
import ops from 'ndarray-ops'

let model = null

// モデルデータの読み込み
const loadModel = file => {
    const model = new KerasJS.Model({ filepath: file })

    return model.ready().then(r => model)
}

// Keras.js の Dense 問題への対応
KerasJS.layers.Dense.prototype._callCPU = function(x) {
    ・・・
}

// predict の結果を処理(ランドマーク毎に最も確率の高い座標を抽出)
const detectLandmarks = ts => {
    const res = {}

    for (let h = 0; h < ts.tensor.shape[0]; h++) {
        for (let w = 0; w < ts.tensor.shape[1]; w++) {
            const t = ts.tensor.pick(h, w)

            const wrkProb = {landmark: 0, prob: 0, x: w, y: h}

            for (let c = 0; c < t.shape[0]; c++) {
                const prob = t.get(c)

                if (prob > wrkProb.prob) {
                    wrkProb.landmark = c
                    wrkProb.prob = prob
                }
            }
            // ランドマーク 0 (ランドマークでは無い)は除外
            if (wrkProb.landmark > 0) {
                const curProb = res[wrkProb.landmark]

                if (!curProb || curProb.prob < wrkProb.prob) {
                    res[wrkProb.landmark] = wrkProb
                }
            }
        }
    }

    return res
}

// UI 処理からのメッセージ受信
onmessage = ev => {
    switch (ev.data.type) {
        case 'init':
            loadModel(ev.data.url)
                .then(m => {
                    model = m
                    postMessage({type: ev.data.type})
                })
                .catch(err => {
                    console.log(err)
                    postMessage({type: ev.data.type, error: err.message})
                })

            break
        case 'predict':
            const outputLayerName = model.outputLayerNames[0]

            const shape = model.modelLayersMap.get(outputLayerName)
                                                .output.tensor.shape

            const data = {}
            // 入力データの設定
            data[model.inputLayerNames[0]] = ev.data.input

            Promise.resolve(model.predict(data))
                .then(r => new KerasJS.Tensor(r[outputLayerName], shape)) // predict 実行結果の取り出し
                .then(detectLandmarks)
                .then(r => 
                    // UI 処理へ結果送信
                    postMessage({type: ev.data.type, output: r})
                )
                .catch(err => {
                    console.log(err)
                    postMessage({type: ev.data.type, error: err.message})
                })

            break
    }
}

(b) UI 処理(src/app.js)

画像データの変換(入力データの作成)

KerasJS.Model で predict するために、今回のケースでは画像データを 256(高さ)× 256(幅)× 3(RGB) サイズの 1次元配列 Float32Array へ変換する必要があります。

今回は以下のように canvas を利用して変換を行いました。

ImageData.data は RGBA 並びの 1次元配列 Uint8ClampedArray となっているので、RGB 部分のみを取り出して(A の内容を除外する)Float32Array を生成しています。

ちなみに、今回の CNN モデル自体は画像サイズに依存しない(Fully Convolutional Networks 的な)構成になっています。

そのため、任意サイズの画像を処理する事もできるのですが、現時点の Keras.js ではそんな事を考慮してくれていないので、実現するにはそれなりの工夫が必要になります。(一応、実現は可能でした)

ここでは、単純に canvas へ描画した 256x256 範囲の内容だけ(つまりは固定サイズ)を使うようにしています。※

 ※ この方法では 256x256 以外のサイズで欠けや余白の入り込みが発生する
画像データ変換部分(src/app.js)
・・・
const imageTypes = ['image/jpeg']

const canvas = document.getElementsByTagName('canvas')[0]
const ctx = canvas.getContext('2d')

・・・

// RGBA 並びの Uint8ClampedArray を RGB 並びの Float32Array へ変換
const imgToArray = imgData => new Float32Array(
    imgData.data.reduce(
        (acc, v, i) => {
            // RGBA の A 部分を除外
            if (i % 4 != 3) {
                acc.push(v)
            }
            return acc
        },
        []
     )
)
// 画像の読み込み
const loadImage = url => new Promise(resolve => {
    const img = new Image()

    img.addEventListener('load', () => {
        ctx.clearRect(0, 0, canvas.width, canvas.height)

        // 画像サイズが canvas よりも小さい場合の考慮
        const w = Math.min(img.width, canvas.width)
        const h = Math.min(img.height, canvas.height)

        // canvas へ画像を描画
        ctx.drawImage(img, 0, 0, w, h, 0, 0, w, h)

        // ImageData の取得
        const d = ctx.getImageData(0, 0, canvas.width, canvas.height)

        resolve(imgToArray(d))
    })

    img.src = url
})

・・・

// モデルデータ読み込み完了時の処理
const ready = () => {
    canvas.addEventListener('dragover', ev => {
        ev.preventDefault()
        canvas.classList.add('dragging')
    }, false)

    canvas.addEventListener('dragleave', ev => {
        canvas.classList.remove('dragging')
    }, false)

    // ドロップ時の処理
    canvas.addEventListener('drop', ev => {
        ev.preventDefault()
        canvas.classList.remove('dragging')

        const file = ev.dataTransfer.files[0]

        if (imageTypes.includes(file.type)) {
            ・・・
            const reader = new FileReader()

            reader.onload = ev => {
                loadImage(reader.result)
                    .then(img => {
                        ・・・
                    })
            }

            reader.readAsDataURL(file)
        }
    }, false)
}

・・・

Web Worker との連携

Web Worker とメッセージをやり取りし、ランドマークの検出結果を描画する部分の実装です。

Web Worker との連携部分(src/app.js)
const colors = ['rgb(255, 255, 255)', 'rgb(255, 0, 0)', 'rgb(0, 255, 0)', 'rgb(0, 0, 255)', 'rgb(255, 255, 0)', 'rgb(0, 255, 255)', 'rgb(255, 0, 255)']

const radius = 5
const imageTypes = ['image/jpeg']

const modelFile = '../model/cnn_landmark_400.bin'

// Web Worker の作成
const worker = new Worker('./js/bundle_worker.js')

・・・

// 検出したランドマークを canvas へ描画
const drawLandmarks = lms => {
    Object.values(lms).forEach(v => {
        ctx.fillStyle = colors[v.landmark]
        ctx.beginPath()
        ctx.arc(v.x, v.y, radius, 0, Math.PI * 2, false)
        ctx.fill()
    })
}

・・・

// 検出したランドマークの内容を table(HTML)化して表示
const showLandmarksInfo = lms => {
    ・・・

    infoNode.innerHTML = `
      <table>
        <tr>
          <th>landmark</th>
          <th>coordinate</th>
          <th>prob</th>
        </tr>
        ${rowsHtml}
      </table>
    `
}

// モデルデータ読み込み完了後
const ready = () => {
    ・・・
    canvas.addEventListener('drop', ev => {
        ・・・
        if (imageTypes.includes(file.type)) {
            ・・・
            reader.onload = ev => {
                loadImage(reader.result)
                    .then(img => {
                        detectDialog.showModal()
                        // Web Worker へのランドマーク検出指示
                        worker.postMessage({type: 'predict', input: img})
                    })
            }

            reader.readAsDataURL(file)
        }
    }, false)
}

// Web Worker からのメッセージ受信
worker.onmessage = ev => {
    if (ev.data.error) {
        ・・・
    }
    else {
        switch (ev.data.type) {
            case 'init':
                ready()

                loadDialog.close()

                break
            case 'predict':
                const res = ev.data.output

                console.log(res)
                detectDialog.close()

                drawLandmarks(res)
                showLandmarksInfo(res)

                break
        }
    }
}

loadDialog.showModal()
// Web Worker へのモデルデータ読み込み指示
worker.postMessage({type: 'init', url: modelFile})

(c) ビルド

webpack コマンドを実行し、js/bundle_app.js と js/bundle_worker.js を生成します。

webpack によるビルド
> webpack

(d) 動作確認

HTTP サーバーを使って動作確認を行います。 今回は http-server を使って実行しました。

http-server 実行
> http-server

Starting up http-server, serving ./
Available on:
  ・・・
  http://127.0.0.1:8080
Hit CTRL-C to stop the server

http://localhost:8080/index.htmlChrome ※ でアクセスして画像ファイルをドラッグアンドドロップすると以下のような結果となりました。

f:id:fits:20190331192210j:plain

 ※ HTMLDialogElement.showModal() を使っている関係で
    現時点では Chrome でしか動作しませんが、
    dialog 以外の部分(Keras.js の処理等)は
    Firefox でも動作するようになっています

TypeScript で funfix を使用 - tsc, FuseBox

funfixJavaScript, TypeScript, Flow の関数型プログラミング用ライブラリで、Fantasy LandStatic Land ※ に準拠し Scala の Option, Either, Try, Future 等と同等の型が用意されているようです。

 ※ JavaScript 用に Monoid や Monad 等の代数的構造に関する仕様を定義したもの

今回は Option を使った単純な処理を TypeScript で実装し Node.js で実行してみます。

ソースは http://github.com/fits/try_samples/tree/master/blog/20180730/

はじめに

Option を使った下記サンプルをコンパイルして実行します。

サンプルソース
import { Option, Some } from 'funfix'

const f = (ma, mb) => ma.flatMap(a => mb.map(b => `${a} + ${b} = ${a + b}`))

const d1 = Some(10)
const d2 = Some(2)

console.log( d1 )

console.log('-----')

console.log( f(d1, d2) )
console.log( f(d1, Option.none()) )

console.log('-----')

console.log( f(d1, d2).getOrElse('none') )
console.log( f(d1, Option.none()).getOrElse('none') )

ビルドと実行

上記ソースファイルを以下の 2通りでビルドして実行してみます。

  • (a) tsc 利用
  • (b) FuseBox 利用

(a) tsc を利用する場合

tsc コマンドを使って TypeScript のソースをコンパイルします。

まずは typescript と funfix モジュールをそれぞれインストールします。

typescript インストール
> npm install --save-dev typescript
funfix インストール
> npm install --save funfix

この状態で sample.ts ファイルをコンパイルしてみると、型関係のエラーが出るものの sample.js は正常に作られました。

コンパイル1
> tsc sample.ts

node_modules/funfix-core/dist/disjunctions.d.ts:775:14 - error TS2416: Property 'value' in type 'TNone' is not assignable to the same property in base type 'Option<never>'.
  Type 'undefined' is not assignable to type 'never'.

775     readonly value: undefined;
                 ~~~~~


node_modules/funfix-effect/dist/eval.d.ts:256:42 - error TS2304: Cannot find name 'Iterable'.

256     static sequence<A>(list: Eval<A>[] | Iterable<Eval<A>>): Eval<A[]>;
                                             ~~~~~~~~
・・・

sample.js を実行してみると特に問題無く動作します。

実行1
> node sample.js

TSome { _isEmpty: false, value: 10 }
-----
TSome { _isEmpty: false, value: '10 + 2 = 12' }
TNone { _isEmpty: true, value: undefined }
-----
10 + 2 = 12
none

これで一応は動いたわけですが、コンパイル時にエラーが出るというのも望ましい状態ではないので、エラー部分を解決してみます。

他にも方法があるかもしれませんが、ここでは以下のように対応します。

  • (1) Property 'value' in type 'TNone' ・・・ 'Option<never>' のエラーに対して tsc 実行時に --strictNullChecks オプションを指定して対応
  • (2) Cannot find name 'Iterable' 等のエラーに対して @types/node をインストールして対応

strictNullChecks は tsc の実行時オプションで指定する以外にも設定ファイル tsconfig.json で設定する事もできるので、ここでは tsconfig.json ファイルを使います。

(1) tsconfig.json
{
  "compilerOptions": {
    "strictNullChecks": true
  }
}

次に @types/node をインストールします。

@types/node には Node.js で実行するための型定義(Node.js 依存の API 等)が TypeScript 用に定義されています。

(2) @types/node インストール
> npm install --save-dev @types/node

この状態で tsc を実行すると先程のようなエラーは出なくなりました。(tsconfig.json を適用するため tsc コマンドへ引数を指定していない点に注意)

コンパイル2
> tsc

実行結果にも差異はありません。

実行2
> node sample.js

TSome { _isEmpty: false, value: 10 }
-----
TSome { _isEmpty: false, value: '10 + 2 = 12' }
TNone { _isEmpty: true, value: undefined }
-----
10 + 2 = 12
none

最終的な package.json の内容は以下の通りです。

package.json
{
  "name": "sample",
  "version": "1.0.0",
  "devDependencies": {
    "@types/node": "^10.5.4",
    "typescript": "^2.9.2"
  },
  "dependencies": {
    "funfix": "^7.0.1"
  }
}

(b) FuseBox を利用する場合

次に、モジュールバンドラーの FuseBox を使用してみます。(以降は (a) とは異なるディレクトリで実施)

なお、ここでは npm の代わりに yarn を使っていますが、npm でも特に問題はありません。

yarn のインストール例(npm 使用)
> npm install -g yarn

(b-1) 型チェック無し

まずは typescript, fuse-box, funfix をそれぞれインストールしておきます。

typescript と fuse-box インストール
> yarn add --dev typescript fuse-box
funfix インストール
> yarn add funfix

FuseBox ではビルド定義を JavaScript のコードで記載します。 とりあえずは必要最小限の設定を行いました。

bundle で指定した名称が init の $name に適用されるため、*.tsコンパイル結果と依存モジュールの内容をバンドルして bundle.js へ出力する事になります。

なお、> でロード時に実行する(コードを記載した)ファイルを指定します。

fuse.js (FuseBox ビルド定義)
const {FuseBox} = require('fuse-box')

const fuse = FuseBox.init({
    output: '$name.js'
})

fuse.bundle('bundle').instructions('> *.ts')

fuse.run()

上記スクリプトを実行してビルド(TypeScript のコンパイルとバンドル)を行います。

ビルド
> node fuse.js

--- FuseBox 3.4.0 ---
  → Generating recommended tsconfig.json:  ・・・\sample_fusebox1\tsconfig.json
  → Typescript script target: ES7

--------------------------
Bundle "bundle"

    sample.js
└──  (1 files,  700 Bytes) default
└── funfix-core 34.4 kB (1 files)
└── funfix-effect 43.1 kB (1 files)
└── funfix-exec 79.5 kB (1 files)
└── funfix 1 kB (1 files)
size: 158.7 kB in 765ms

初回実行時にデフォルト設定の tsconfig.json が作られました。(tsconfig.json が存在しない場合)

tsc の時のような型関係のエラーは出ていませんが、これは FuseBox がデフォルトで TypeScript の型チェックをしていない事が原因のようです。

型チェックを実施するには fuse-box-typechecker プラグインを使う必要がありそうです。

実行
> node bundle.js

TSome { _isEmpty: false, value: 10 }
-----
TSome { _isEmpty: false, value: '10 + 2 = 12' }
TNone { _isEmpty: true, value: undefined }
-----
10 + 2 = 12
none

package.json の内容は以下の通りです。

package.json
{
  "name": "sample_fusebox1",
  "version": "1.0.0",
  "main": "bundle.js",
  "license": "MIT",
  "devDependencies": {
    "fuse-box": "^3.4.0",
    "typescript": "^2.9.2"
  },
  "dependencies": {
    "funfix": "^7.0.1"
  }
}

(b-2) 型チェック有り

TypeScript の型チェックを行うようにしてみます。

まずは、(b-1) と同じ構成に fuse-box-typechecker プラグインを加えます。

fuse-box-typechecker を追加インストール
> yarn add --dev fuse-box-typechecker

次に、fuse.js へ fuse-box-typechecker プラグインの設定を追加します。

TypeChecker で型チェックにエラーがあった場合、例外が throw されるようにはなっていないため、ここではエラーがあった場合に Error を throw して fuse.run() を実行しないようにしてみました。

ただし、こうすると tsconfig.json を予め用意しておく必要があります。(TypeChecker に tsconfig.json が必要)

fuse.js (FuseBox ビルド定義)
const {FuseBox} = require('fuse-box')
const {TypeChecker} = require('fuse-box-typechecker')

// fuse-box-typechecker の設定
const testSync = TypeChecker({
    tsConfig: './tsconfig.json'
})

const fuse = FuseBox.init({
    output: '$name.js'
})

fuse.bundle('bundle').instructions('> *.ts')

testSync.runPromise()
    .then(n => {
        if (n != 0) {
            // 型チェックでエラーがあった場合
            throw new Error(n)
        }
        // 型チェックでエラーがなかった場合
        return fuse.run()
    })
    .catch(console.error)

これで、ビルド時に (a) と同様の型エラーが出るようになりました。

ビルド1
> node fuse.js

・・・
--- FuseBox 3.4.0 ---

Typechecker plugin(promisesync) .
Time:Sun Jul 29 2018 12:40:47 GMT+0900 (GMT+09:00)

File errors:
└── .\node_modules\funfix-core\dist\disjunctions.d.ts
   | ・・・\sample_fusebox2\node_modules\funfix-core\dist\disjunctions.d.ts (775,14) (Error:TS2416) Property 'value' in type 'TNone' is not assignable to the same property in base type 'Option<never>'.
  Type 'undefined' is not assignable to type 'never'.

Errors:1
└── Options: 0
└── Global: 0
└── Syntactic: 0
└── Semantic: 1
└── TsLint: 0

Typechecking time: 4116ms
Quitting typechecker

・・・

ここで、Iterable の型エラーが出ていないのは fuse-box-typechecker のインストール時に @types/node もインストールされているためです。

(a) と同様に strictNullChecks の設定を tsconfig.json追記して、このエラーを解決します。

tsconfig.json へ strictNullChecks の設定を追加
{
  "compilerOptions": {
    "module": "commonjs",
    "target": "ES7",
    ・・・
    "strictNullChecks": true
  }
}

これでビルドが成功するようになりました。

ビルド2
> node fuse.js

・・・
Typechecker name: undefined
Typechecker basepath: ・・・\sample_fusebox2
Typechecker tsconfig: ・・・\sample_fusebox2\tsconfig.json
--- FuseBox 3.4.0 ---

Typechecker plugin(promisesync) .
Time:Sun Jul 29 2018 12:44:57 GMT+0900 (GMT+09:00)
All good, no errors :-)
Typechecking time: 4103ms
Quitting typechecker

killing worker  → Typescript config file:  \tsconfig.json
  → Typescript script target: ES7

--------------------------
Bundle "bundle"

    sample.js
└──  (1 files,  700 Bytes) default
└── funfix-core 34.4 kB (1 files)
└── funfix-effect 43.1 kB (1 files)
└── funfix-exec 79.5 kB (1 files)
└── funfix 1 kB (1 files)
size: 158.7 kB in 664ms
実行結果
> node bundle.js

TSome { _isEmpty: false, value: 10 }
-----
TSome { _isEmpty: false, value: '10 + 2 = 12' }
TNone { _isEmpty: true, value: undefined }
-----
10 + 2 = 12
none

package.json の内容は以下の通りです。

package.json
{
  "name": "sample_fusebox2",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "devDependencies": {
    "fuse-box": "^3.4.0",
    "fuse-box-typechecker": "^2.10.0",
    "typescript": "^2.9.2"
  },
  "dependencies": {
    "funfix": "^7.0.1"
  }
}

Kubernetes の Watch API とタイムアウト

Kubernetes の Watch API を下記クライアントライブラリを使って試してみました。

ソースは http://github.com/fits/try_samples/tree/master/blog/20180409/

はじめに

下記のコマンドを実行して Javascript Kubernetes Client をインストールしておきます。

Javascript Kubernetes Client のインストール
> npm install @kubernetes/client-node

Watch API による Pod の監視

Watch APIdefault Namespace の Pod に関するイベントを監視して、イベントのタイプと Pod 名を標準出力する処理を実装してみます。

watch の第一引数に Watch API のエンドポイント URL、第三引数でイベントハンドラを指定します。(第二引数はクエリパラメータ)

今回は Pod を監視していますが、default Namespace の Deployment を監視する場合は endpoint/apis/apps/v1/namespaces/default/deployments とします。

なお、$HOME/.kube/config もしくは %USERPROFILE%\.kube\config ファイルから Kubernetes への接続情報を取得するようにしています。

sample_watch_pod.js
const k8s = require('@kubernetes/client-node')
// default Namespace の Pod
const endpoint = '/api/v1/namespaces/default/pods'

// Windows 環境用の設定
if (!process.env.HOME) {
    process.env.HOME = process.env.USERPROFILE
}

const conf = new k8s.KubeConfig()
conf.loadFromFile(`${process.env.HOME}/.kube/config`)

const w = new k8s.Watch(conf)

w.watch(
    endpoint,
    {}, 
    (type, obj) => {
        console.log(`${type} : ${obj.metadata.name}`)
    },
    err => {
        if (err) {
            console.error(err)
        }
        else {
            console.log('done')
        }
    }
)

動作確認

今回、Kubernetes の環境を minikube で用意します。

minikube コマンドを使って start を実行するとローカル用の Kubernetes 環境が立ち上がります。

その際に、%USERPROFILE%\.kube\config ファイル等が作られます。

minikube 開始
> minikube start

・・・
Starting local Kubernetes v1.9.0 cluster...
Starting VM...
Getting VM IP address...
Moving files into cluster...
Setting up certs...
Connecting to cluster...
Setting up kubeconfig...
Starting cluster components...
Kubectl is now configured to use the cluster.
Loading cached images from config file.

Watchスクリプトを実行します。

sample_watch_pod.js の実行
> node sample_watch_pod.js

下記 YAML ファイルを使って、Kubernetes 環境へ nginx 実行用の Deployment と Service を作成してみます。

nginx.yaml (nginx 用の Deployment と Service 定義)
apiVersion: v1
kind: Service
metadata:
  name: nginx-service
  labels:
    app: nginx
spec:
  ports:
  - name: http
    port: 80
    nodePort: 30001
  selector:
    app: nginx
  type: NodePort

---

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deploy
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx
        ports:
        - containerPort: 80

kubectl を使って Deployment と Service を作成します。

Deployment と Service 作成
> kubectl create -f nginx.yaml

service "nginx-service" created
deployment "nginx-deploy" created

Watch の結果は以下のようになりました。

sample_watch_pod.js の結果1
> node sample_watch_pod.js

ADDED : nginx-deploy-679dc9c764-r9ds5
MODIFIED : nginx-deploy-679dc9c764-r9ds5
ADDED : nginx-deploy-679dc9c764-54d5d
MODIFIED : nginx-deploy-679dc9c764-r9ds5
MODIFIED : nginx-deploy-679dc9c764-54d5d
MODIFIED : nginx-deploy-679dc9c764-54d5d
MODIFIED : nginx-deploy-679dc9c764-r9ds5
MODIFIED : nginx-deploy-679dc9c764-54d5d

ここで、いつまでも接続が続くわけでは無く、minikube の環境では 40分程度(ただし、毎回異なる)で接続が切れ以下のようになりました。

sample_watch_pod.js の結果2 (一定時間経過後)
> node sample_watch_pod.js

・・・
done

タイムアウト時間の確認

Watch API の接続が切れる原因を探ってみます。

Kubernetes と minikube のソースから、タイムアウトに関係していると思われる箇所 timeout = time.Duration(float64(minRequestTimeout) * (rand.Float64() + 1.0)) を見つけました。※

 ※ minikube では localkube 内で Kubernetes の API Server を実行しているようです

これだと、タイムアウトは 30 ~ 60分でランダムに決まる事になりそうなので、接続の切れる時間が毎回異なるという現象に合致します。

ソース kubernetes/staging/src/k8s.io/apiserver/pkg/endpoints/handlers/get.go
func ListResource(r rest.Lister, rw rest.Watcher, scope RequestScope, forceWatch bool, minRequestTimeout time.Duration) http.HandlerFunc {
    return func(w http.ResponseWriter, req *http.Request) {
        ・・・
        if opts.Watch || forceWatch {
            ・・・
            timeout := time.Duration(0)
            if opts.TimeoutSeconds != nil {
                timeout = time.Duration(*opts.TimeoutSeconds) * time.Second
            }
            if timeout == 0 && minRequestTimeout > 0 {
                timeout = time.Duration(float64(minRequestTimeout) * (rand.Float64() + 1.0))
            }
            glog.V(2).Infof("Starting watch for %s, rv=%s labels=%s fields=%s timeout=%s", req.URL.Path, opts.ResourceVersion, opts.LabelSelector, opts.FieldSelector, timeout)

            ・・・
            return
        }
        ・・・
    }
}
ソース minikube/pkg/localkube/apiserver.go
// defaults from apiserver command
config.GenericServerRunOptions.MinRequestTimeout = 1800

get.go の処理ではログレベル 2 でタイムアウトの値をログ出力しているので(glog.V(2).Infof(・・・) の箇所)ログから確認できそうです。

ただし、普通に minikube start で実行してもログレベル 2 のログは見れないようなので、minikube を -v <ログレベル> オプションを使って起動しなおします。

ログレベル 2 で miinkube 開始
> minikube start -v 2

Watchスクリプトを実行します。

sample_watch_pod.js の実行
> node sample_watch_pod.js

・・・

minikube logs でログ内容を確認してみると、get.go が出力しているタイムアウトの値を確認できました。

ログ確認
> minikube logs

・・・
Apr 08 01:00:30 minikube localkube[2995]: I0408 01:00:30.533448    2995 get.go:238] Starting watch for /api/v1/namespaces/default/pods, rv= labels= fields= timeout=58m38.2420124s
・・・

Ramda で階層的なグルーピング

JavaScript 用の関数型ライブラリ Ramda で階層的なグルーピングを行ってみます。

ソースは http://github.com/fits/try_samples/tree/master/blog/20180220/

はじめに

概要

今回は、以下のデータに対して階層的なグルーピングと集計処理を適用します。

データ
const data = [
    {category: 'A', item: 'A01', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A02', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A01', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A01', date: '2018-02-02', value: 20},
    {category: 'A', item: 'A03', date: '2018-02-03', value: 2},
    {category: 'B', item: 'B01', date: '2018-02-02', value: 1},
    {category: 'A', item: 'A03', date: '2018-02-03', value: 5},
    {category: 'A', item: 'A01', date: '2018-02-02', value: 2},
    {category: 'B', item: 'B01', date: '2018-02-03', value: 3},
    {category: 'B', item: 'B01', date: '2018-02-04', value: 1},
    {category: 'C', item: 'C01', date: '2018-02-01', value: 1},
    {category: 'B', item: 'B01', date: '2018-02-04', value: 10}
]

具体的には、上記category item date の順に階層的にグルーピングした後、value の合計値を算出して以下のようにします。

処理結果
{
  A: {
     A01: { '2018-02-01': 2, '2018-02-02': 22 },
     A02: { '2018-02-01': 1 },
     A03: { '2018-02-03': 7 }
  },
  B: { B01: { '2018-02-02': 1, '2018-02-03': 3, '2018-02-04': 11 } },
  C: { C01: { '2018-02-01': 1 } }
}

Ramda インストール

Ramda は以下のようにインストールしておきます。

> npm install ramda

実装

(a) 階層的なグルーピングと集計

まずは、処理方法を確認するため、順番に処理を実施してみます。

1. category でグルーピング(1層目)

指定項目によるグルーピング処理は R.groupBy で行えます。 category でグルーピングする処理は以下のようになります。

category グルーピング処理
const R = require('ramda')

const data = [
    {category: 'A', item: 'A01', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A02', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A01', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A01', date: '2018-02-02', value: 20},
    {category: 'A', item: 'A03', date: '2018-02-03', value: 2},
    {category: 'B', item: 'B01', date: '2018-02-02', value: 1},
    {category: 'A', item: 'A03', date: '2018-02-03', value: 5},
    {category: 'A', item: 'A01', date: '2018-02-02', value: 2},
    {category: 'B', item: 'B01', date: '2018-02-03', value: 3},
    {category: 'B', item: 'B01', date: '2018-02-04', value: 1},
    {category: 'C', item: 'C01', date: '2018-02-01', value: 1},
    {category: 'B', item: 'B01', date: '2018-02-04', value: 10}
]

const res1 = R.groupBy(R.prop('category'), data)

console.log(res1)
category グルーピング結果
{ A:
   [ { category: 'A', item: 'A01', date: '2018-02-01', value: 1 },
     { category: 'A', item: 'A02', date: '2018-02-01', value: 1 },
     { category: 'A', item: 'A01', date: '2018-02-01', value: 1 },
     { category: 'A', item: 'A01', date: '2018-02-02', value: 20 },
     { category: 'A', item: 'A03', date: '2018-02-03', value: 2 },
     { category: 'A', item: 'A03', date: '2018-02-03', value: 5 },
     { category: 'A', item: 'A01', date: '2018-02-02', value: 2 } ],
  B:
   [ { category: 'B', item: 'B01', date: '2018-02-02', value: 1 },
     { category: 'B', item: 'B01', date: '2018-02-03', value: 3 },
     { category: 'B', item: 'B01', date: '2018-02-04', value: 1 },
     { category: 'B', item: 'B01', date: '2018-02-04', value: 10 } ],
  C:
   [ { category: 'C', item: 'C01', date: '2018-02-01', value: 1 } ] }

2. item でグルーピング(2層目)

category のグルーピング結果を item で更にグルーピングするには res1 の値部分 ([ { category: 'A', ・・・}, ・・・ ] 等) に R.groupBy を適用します。

これは R.mapObjIndexed で実施できます。

item グルーピング処理
const res2 = R.mapObjIndexed(R.groupBy(R.prop('item')), res1)

console.log(res2)
item グルーピング結果
{ A:
   { A01: [ [Object], [Object], [Object], [Object] ],
     A02: [ [Object] ],
     A03: [ [Object], [Object] ] },
  B: { B01: [ [Object], [Object], [Object], [Object] ] },
  C: { C01: [ [Object] ] } }

3. date でグルーピング(3層目)

更に date でグルーピングするには R.mapObjIndexed を重ねて R.groupBy を適用します。

date グルーピング処理
const res3 = R.mapObjIndexed(R.mapObjIndexed(R.groupBy(R.prop('date'))), res2)

console.log(res3)
date グルーピング結果
{ A:
   { A01: { '2018-02-01': [Array], '2018-02-02': [Array] },
     A02: { '2018-02-01': [Array] },
     A03: { '2018-02-03': [Array] } },
  B:
   { B01:
      { '2018-02-02': [Array],
        '2018-02-03': [Array],
        '2018-02-04': [Array] } },
  C: { C01: { '2018-02-01': [Array] } } }

4. value の合計

最後に、R.groupBy の代わりに value を合計する処理(以下の sumValue)へ R.mapObjIndexed を階層分だけ重ねて適用すれば完成です。

value 合計処理
const sumValue = R.reduce((a, b) => a + b.value, 0)

const res4 = R.mapObjIndexed(R.mapObjIndexed(R.mapObjIndexed(sumValue)), res3)

console.log(res4)
value 合計結果
{ A:
   { A01: { '2018-02-01': 2, '2018-02-02': 22 },
     A02: { '2018-02-01': 1 },
     A03: { '2018-02-03': 7 } },
  B: { B01: { '2018-02-02': 1, '2018-02-03': 3, '2018-02-04': 11 } },
  C: { C01: { '2018-02-01': 1 } } }

(b) N階層のグルーピングと集計

次は、汎用的に使えるような実装にしてみます。

任意の処理に対して指定回数だけ R.mapObjIndexed を重ねる処理があると便利なので applyObjIndexedN として実装しました。

(a) で実施したように、階層的なグルーピングは R.mapObjIndexed を階層分重ねた R.groupBy を繰り返し適用していくだけですので R.reduce で実装できます。(以下の groupByMulti

ちなみに、階層的にグルーピングする実装例は Ramda の Cookbook(groupByMultiple) にありましたが、変数へ再代入したりと手続き的な実装内容になっているのが気になりました。

sample.js
const R = require('ramda')

const data = [
    {category: 'A', item: 'A01', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A02', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A01', date: '2018-02-01', value: 1},
    {category: 'A', item: 'A01', date: '2018-02-02', value: 20},
    {category: 'A', item: 'A03', date: '2018-02-03', value: 2},
    {category: 'B', item: 'B01', date: '2018-02-02', value: 1},
    {category: 'A', item: 'A03', date: '2018-02-03', value: 5},
    {category: 'A', item: 'A01', date: '2018-02-02', value: 2},
    {category: 'B', item: 'B01', date: '2018-02-03', value: 3},
    {category: 'B', item: 'B01', date: '2018-02-04', value: 1},
    {category: 'C', item: 'C01', date: '2018-02-01', value: 1},
    {category: 'B', item: 'B01', date: '2018-02-04', value: 10}
]

/* 
  指定回数(n)だけ R.mapObjIndexed を重ねた任意の処理(fn)を
  data を引数にして実行する処理
*/
const applyObjIndexedN = R.curry((n, fn, data) =>
    R.reduce(
        (a, b) => R.mapObjIndexed(a), 
        fn, 
        R.range(0, n)
    )(data)
)

// 階層的なグルーピング処理
const groupByMulti = R.curry((fields, data) => 
    R.reduce(
        (a, b) => applyObjIndexedN(b, R.groupBy(R.prop(fields[b])), a),
        data, 
        R.range(0, fields.length)
    )
)

const cols = ['category', 'item', 'date']

const sumValue = R.reduce((a, b) => a + b.value, 0)

const sumMultiGroups = R.pipe(
    groupByMulti(cols), // グルーピング処理
    applyObjIndexedN(cols.length, sumValue) // 合計処理
)

console.log( sumMultiGroups(data) )
実行結果
> node sample.js

{ A:
   { A01: { '2018-02-01': 2, '2018-02-02': 22 },
     A02: { '2018-02-01': 1 },
     A03: { '2018-02-03': 7 } },
  B: { B01: { '2018-02-02': 1, '2018-02-03': 3, '2018-02-04': 11 } },
  C: { C01: { '2018-02-01': 1 } } }

MQTT Broker をローカル実行

以下の MQTT Broker をそれぞれローカルで実行してみました。

ソースは http://github.com/fits/try_samples/tree/master/blog/20170910/

Mosca

Mosca は Node.js 用の MQTT Broker です。

npm でインストールして mosca コマンドで実行できます。

インストール例
> npm install mosca
実行例
> mosca -v

       +++.+++:   ,+++    +++;   '+++    +++.
      ++.+++.++   ++.++  ++,'+  `+',++  ++,++
      +`  +,  +: .+  .+  +;  +; '+  '+  +`  +`
      +`  +.  +: ,+  `+  ++  +; '+  ;+  +   +.
      +`  +.  +: ,+  `+   +'    '+      +   +.
      +`  +.  +: ,+  `+   :+.   '+      +++++.
      +`  +.  +: ,+  `+    ++   '+      +++++.
      +`  +.  +: ,+  `+     ++  '+      +   +.
      +`  +.  +: ,+  `+  +:  +: '+  ;+  +   +.
      +`  +.  +: .+  .+  +;  +; '+  '+  +   +.
      +`  +.  +:  ++;++  ++'++   ++'+'  +   +.
      +`  +.  +:   +++    +++.   ,++'   +   +.
{"pid":11260,"hostname":"host1","name":"mosca","level":30,"time":1504448625943,"msg":"server started","mqtt":1883,"v":1}

ログが JSON で出力されていますが、pino ※ を使えば以下のようにログを整形して出力してくれます。

 ※ mosca 2.5.2 を npm install すると pino もインストールされました
実行例 - pino 利用
> mosca -v | pino

       +++.+++:   ,+++    +++;   '+++    +++.
      ++.+++.++   ++.++  ++,'+  `+',++  ++,++
      +`  +,  +: .+  .+  +;  +; '+  '+  +`  +`
      +`  +.  +: ,+  `+  ++  +; '+  ;+  +   +.
      +`  +.  +: ,+  `+   +'    '+      +   +.
      +`  +.  +: ,+  `+   :+.   '+      +++++.
      +`  +.  +: ,+  `+    ++   '+      +++++.
      +`  +.  +: ,+  `+     ++  '+      +   +.
      +`  +.  +: ,+  `+  +:  +: '+  ;+  +   +.
      +`  +.  +: .+  .+  +;  +; '+  '+  +   +.
      +`  +.  +:  ++;++  ++'++   ++'+'  +   +.
      +`  +.  +:   +++    +++.   ,++'   +   +.
[2017-09-03T14:24:23.929Z] INFO (mosca/3124 on host1): server started
    mqtt: 1883

サーバー組み込み実行

Mosca を組み込み実行するコードは以下の通りです。

実際は new mosca.Server() だけでサーバーが起動するのですが、そのままだとクライアントからの接続状況が分かり難いのでログ出力しています。

mosca_run.js
const mosca = require('mosca')

const server = new mosca.Server()

server.on('ready', () => console.log('server started'))

server.on('clientConnected', client => 
    console.log(`client connected: ${client.id}`))

server.on('published', (packet) => 
    console.log(`published: ${JSON.stringify(packet)}`))

クライアント処理

MQTT.js をインストールして MQTT のクライアント処理を実装してみます。

MQTT.js インストール例
> npm install mqtt

まずは、指定のトピックへメッセージを publish する処理です。

publish_sample.js
const mqtt = require('mqtt')
const client = mqtt.connect('mqtt://localhost')

const topic = process.argv[2]
const msg = process.argv[3]

client.on('connect', () => {
    client.publish(topic, msg)

    client.end()
})

次に、指定のトピックを subscribe する処理です。

subscribe_sample.js
const mqtt = require('mqtt')
const client = mqtt.connect('mqtt://localhost')

const topic = process.argv[2]

client.on('connect', () => {
    client.subscribe(topic)
})

client.on('message', (topic, msg) => {
    console.log(`topic: ${topic}, msg: ${msg}`)
})

動作確認

まずは Mosca サーバーを実行しておきます。(mosca コマンドで実行しても可)

サーバー組み込み実行
> node mosca_run.js

server started

次に data トピックを subscribe してみます。

subscribe 実行
> node subscribe_sample.js data

data トピックへ sample1 ~ 3 という文字列を publish してみます。

publish 実行
> node publish_sample.js data sample1
> node publish_sample.js data sample2
> node publish_sample.js data sample3

subscribe 側にメッセージが出力されました。

subscribe の結果
topic: data, msg: sample1
topic: data, msg: sample2
topic: data, msg: sample3

Moquette

Moquette は Java 用の MQTT Broker です。

distribution-0.10-bundle-tar.tar.gz をダウンロード・解凍した後、bin/moquette.bat や bin/moquette.sh で実行できるようですが、moquette.bat の内容に問題があって、そのままでは Java 8 で実行できませんでした。(## の行を削除するか rem を付けて、JAVA_OPTS の設定を削る等が必要でした)

サーバー組み込み実行

組み込み実行は io.moquette.server.Servermain メソッドを呼び出すだけです。

ただし、このままでは IllegalArgumentException: Can't locate file "null" となってしまうので config/moquette.conf ファイルを作成しておきます。(デフォルト設定を使うのなら中身は空でよい)

moquette_run.groovy
@GrabResolver(name = 'bintray', root = 'https://jcenter.bintray.com')
@Grab('io.moquette:moquette-broker:0.10')
@Grab('org.slf4j:slf4j-simple:1.7.25')
import io.moquette.server.Server

Server.main(args)

動作確認

Mosca と同様に動作確認を行ってみます。

サーバー組み込み実行
> groovy moquette_run.groovy

・・・
[main] INFO io.moquette.server.netty.NettyAcceptor - Server has been bound. host=0.0.0.0, port=1883
[main] INFO io.moquette.server.netty.NettyAcceptor - Configuring Websocket MQTT transport
[main] INFO io.moquette.server.netty.NettyAcceptor - Property websocket_port has been setted to disabled. Websocket MQTT will be disabled
[main] INFO io.moquette.server.Server - Moquette server has been initialized successfully
Server started, version 0.10

data トピックを subscribe してみます。

subscribe 実行
> node subscribe_sample.js data

data トピックへ sample1 ~ 3 という文字列を publish してみます。

publish 実行
> node publish_sample.js data sample1
> node publish_sample.js data sample2
> node publish_sample.js data sample3

subscribe 側にメッセージが出力されました。

subscribe の結果
topic: data, msg: sample1
topic: data, msg: sample2
topic: data, msg: sample3

D3.js で HAR ファイルから散布図を作成

前回、HAR (HTTP ARchive) ファイルから Python で作成した散布図を D3.js を使って SVG として作ってみました。

ソースは http://github.com/fits/try_samples/tree/master/blog/20170515/

はじめに

Node.js で D3.js を使用するために d3jsdom をインストールしておきます。

依存ライブラリのインストー
npm install --save d3 jsdom
package.json
{
  ・・・
  "dependencies": {
    "d3": "^4.8.0",
    "jsdom": "^10.1.0"
  }
}

散布図(SVG)の作成

標準入力から HAR ファイルの内容(JSON)を受け取り、time と bodySize の該当位置へ円を描画する事で散布図を表現してみました。

円の色をサブタイプ毎に分けることで色分けしています。

ついでに、ツールチップの表示やマウスイベント処理を追加してみました。

Web ブラウザで SVG ファイルを開いた際にマウスイベントを処理できるように attr を使ってマウスイベント処理(該当する円のサイズを変更)を設定しています。

index.js
const d3 = require('d3');

const jsdom = require('jsdom');
const { JSDOM } = jsdom;

const w = 800;
const h = 500;
const margin = { left: 80, top: 20, bottom: 50, right: 200 };
const legendMargin = { top: 30 };

const fontSize = '12pt';
const circleRadius = 5;

// X軸(time)の値の範囲
const xDomain = [0, 5000];
// Y軸(bodySize)の値の範囲
const yDomain = [0, 180000];

const colorMap = {
    'javascript': 'blue',
    'x-javascript': 'blue',
    'json': 'green',
    'gif': 'tomato',
    'jpeg': 'red',
    'png': 'pink',
    'html': 'lime',
    'css': 'turquoise'
};

const dom = new JSDOM();
const document = dom.window.document;

const toSubType = mtype => mtype.split(';')[0].split('/').pop();
const toColor = stype => colorMap[stype] ? colorMap[stype] : 'black';

process.stdin.resume();

let json = '';

process.stdin.on('data', chunk => json += chunk);

process.stdin.on('end', () => {
    const data = JSON.parse(json);

    const df = data.log.entries.map( d => {
        return {
            'url': d.request.url,
            'subType': toSubType(d.response.content.mimeType),
            'bodySize': d.response.bodySize,
            'time': d.time
        };
    });

    const svg = d3.select(document.body)
        .append('svg')
            .attr('xmlns', 'http://www.w3.org/2000/svg')
            .attr('width', w + margin.left + margin.right)
            .attr('height', h + margin.top + margin.bottom)
            .append('g')
                .attr('transform', `translate(${margin.left}, ${margin.top})`);

    const x = d3.scaleLinear().range([0, w]).domain(xDomain);
    const y = d3.scaleLinear().range([h, 0]).domain(yDomain);

    const xAxis = d3.axisBottom(x);
    const yAxis = d3.axisLeft(y);

    // X軸
    svg.append('g')
        .attr('transform', `translate(0, ${h})`)
        .call(xAxis);
    // Y軸
    svg.append('g')
        .call(yAxis);

    // X軸ラベル
    svg.append('text')
        .attr('x', w / 2)
        .attr('y', h + 40)
        .style('font-size', fontSize)
        .text('Time (ms)');
    // Y軸ラベル
    svg.append('text')
        .attr('x', -h / 2)
        .attr('y', -(margin.left) / 1.5)
        .style('font-size', fontSize)
        .attr('transform', 'rotate(-90)')
        .text('Body Size');

    // 円
    const point = svg.selectAll('circle')
        .data(df)
        .enter()
            .append('circle');
    // 円の設定
    point.attr('class', d => d.subType)
        .attr('cx', d => x(d.time))
        .attr('cy', d => y(d.bodySize))
        .attr('r', circleRadius)
        .attr('fill', d => toColor(d.subType))
        .append('title') // ツールチップの設定
            .text(d => d.url);

    // 凡例
    const legend = svg.selectAll('.legend')
        .data(d3.entries(colorMap))
        .enter()
            .append('g')
                .attr('class', 'legend')
                .attr('transform', (d, i) => {
                    const left = w + margin.left;
                    const top = margin.top + i * legendMargin.top;
                    return `translate(${left}, ${top})`;
                });

    legend.append('circle')
        .attr('r', circleRadius)
        .attr('fill', d => d.value);

    legend.append('text')
        .attr('x', circleRadius * 2)
        .attr('y', 4)
        .style('font-size', fontSize)
        // マウスイベント処理(該当する円のサイズを変更)
        .attr('onmouseover', d => 
            `document.querySelectorAll('circle.${d.key}').forEach(d => d.setAttribute('r', ${circleRadius} * 2))`)
        .attr('onmouseout', d => 
            `document.querySelectorAll('circle.${d.key}').forEach(d => d.setAttribute('r', ${circleRadius}))`)
        // 凡例のラベル
        .text(d => d.key);

    // SVG の出力
    console.log(document.body.innerHTML);
});
実行例
node index.js < a.har > a.svg

実行結果例

a.svg

f:id:fits:20170515220405p:plain

b.svg

f:id:fits:20170515220428p:plain

node-ffi で OpenCL を使う2 - 演算の実行

node-ffi で OpenCL を使う」 に続き、Node.js を使って OpenCL の演算を実施してみます。

サンプルソースhttp://github.com/fits/try_samples/tree/master/blog/20160725/

はじめに

演算の実行には ref-array モジュールを使った方が便利なため、node-ffi をインストールした環境へ追加でインストールしておきます。

ref-array インストール例
> npm install ref-array

OpenCL の演算実行サンプル

今回は配列の要素を 3乗する OpenCL のコード(以下)を Node.js から実行する事にします。

cube.cl
__kernel void cube(
    __global float* input,
    __global float* output,
    const unsigned int count)
{
    int i = get_global_id(0);

    if (i < count) {
        output[i] = input[i] * input[i] * input[i];
    }
}

サンプルコード概要

上記 cube.cl を実行する Node.js サンプルコードの全体像です。(OpenCLAPI は try-finally 内で呼び出しています)

OpenCL 演算の入力値として data 変数の値を使用します。OpenCL のコードはファイルから読み込んで code 変数へ設定しています。

OpenCL APIclCreateXXX で作成したリソースは clReleaseXXX で解放するようなので、解放処理を都度 releaseList へ追加しておき、finally で実行するようにしています。

なお、OpenCL API のエラーコード取得には以下の 2通りがあります。(使用する関数による)

  • 関数の戻り値でエラーコードを取得
  • 関数の引数(ポインタ)でエラーコードを取得
calc.js (全体)
'use strict';

const fs = require('fs');
const ref = require('ref');
const ArrayType = require('ref-array');
const ffi = require('ffi');

const CL_DEVICE_TYPE_DEFAULT = 1;

const CL_MEM_READ_WRITE = (1 << 0);
const CL_MEM_WRITE_ONLY = (1 << 1);
const CL_MEM_READ_ONLY = (1 << 2);
const CL_MEM_USE_HOST_PTR = (1 << 3);
const CL_MEM_ALLOC_HOST_PTR = (1 << 4);
const CL_MEM_COPY_HOST_PTR = (1 << 5);

const intPtr = ref.refType(ref.types.int32);
const uintPtr = ref.refType(ref.types.uint32);
const sizeTPtr = ref.refType('size_t');
const StringArray = ArrayType('string');

const clLib = (process.platform == 'win32') ? 'OpenCL' : 'libOpenCL';

// 使用する OpenCL の関数定義
const openCl = ffi.Library(clLib, {
    'clGetPlatformIDs': ['int', ['uint', sizeTPtr, uintPtr]],
    'clGetDeviceIDs': ['int', ['size_t', 'ulong', 'uint', sizeTPtr, uintPtr]],
    'clCreateContext': ['pointer', ['pointer', 'uint', sizeTPtr, 'pointer', 'pointer', intPtr]],
    'clReleaseContext': ['int', ['pointer']],
    'clCreateProgramWithSource': ['pointer', ['pointer', 'uint', StringArray, sizeTPtr, intPtr]],
    'clBuildProgram': ['int', ['pointer', 'uint', sizeTPtr, 'string', 'pointer', 'pointer']],
    'clReleaseProgram': ['int', ['pointer']],
    'clCreateKernel': ['pointer', ['pointer', 'string', intPtr]],
    'clReleaseKernel': ['int', ['pointer']],
    'clCreateBuffer': ['pointer', ['pointer', 'ulong', 'size_t', 'pointer', intPtr]],
    'clReleaseMemObject': ['int', ['pointer']],
    'clSetKernelArg': ['int', ['pointer', 'uint', 'size_t', 'pointer']],
    'clCreateCommandQueue': ['pointer', ['pointer', 'size_t', 'ulong', intPtr]],
    'clReleaseCommandQueue': ['int', ['pointer']],
    'clEnqueueReadBuffer': ['int', ['pointer', 'pointer', 'bool', 'size_t', 'size_t', 'pointer', 'uint', 'pointer', 'pointer']],
    'clEnqueueNDRangeKernel': ['int', ['pointer', 'pointer', 'uint', sizeTPtr, sizeTPtr, sizeTPtr, 'uint', 'pointer', 'pointer']]
});

// エラーチェック
const checkError = (err, title = '') => {
    if (err instanceof Buffer) {
        // ポインタの場合はエラーコードを取り出す
        err = intPtr.get(err);
    }

    if (err != 0) {
        throw new Error(`${title} Error: ${err}`);
    }
};

// 演算対象データ
const data = [1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8, 9.9];

const functionName = process.argv[2]
// OpenCL コードの読み込み(ファイルから)
const code = fs.readFileSync(process.argv[3]);

const releaseList = [];

try {
    ・・・ OpenCL API の呼び出し処理 ・・・

} finally {
    // リソースの解放
    releaseList.reverse().forEach( f => f() );
}

clCreateProgramWithSourceOpenCL のコードを渡す際に、ref-array で作成した String の配列 StringArray を使っています。

OpenCL 処理部分

今回は以下のような OpenCL API を使っています。

番号 概要 OpenCL 関数名
(1) プラットフォーム取得 clGetPlatformIDs
(2) バイス取得 clGetDeviceIDs
(3) コンテキスト作成 clCreateContext
(4) コマンドキュー作成 clCreateCommandQueue
(5) プログラム作成 clCreateProgramWithSource
(6) プログラムのビルド clBuildProgram
(7) カーネル作成 clCreateKernel
(8) 引数用のバッファ作成 clCreateBuffer
(9) 引数の設定 clSetKernelArg
(10) 処理の実行 clEnqueueNDRangeKernel
(11) 結果の取得 clEnqueueReadBuffer

OpenCL のコードを実行するには (6) のように API を使ってビルドする必要があります。

Node.js と OpenCL 間で配列データ等をやりとりするには (8) で作ったバッファを使います。(入力値をバッファへ書き込んで、出力値をバッファから読み出す)

また、今回は clEnqueueNDRangeKernel を使って実行しましたが、clEnqueueTask を使って実行する方法もあります。

calc.js (OpenCL 処理部分)
・・・
try {
    const platformIdsPtr = ref.alloc(sizeTPtr);
    // (1) プラットフォーム取得
    let res = openCl.clGetPlatformIDs(1, platformIdsPtr, null);

    checkError(res, 'clGetPlatformIDs');

    const platformId = sizeTPtr.get(platformIdsPtr);

    const deviceIdsPtr = ref.alloc(sizeTPtr);
    // (2) デバイス取得 (デフォルトをとりあえず使用)
    res = openCl.clGetDeviceIDs(platformId, CL_DEVICE_TYPE_DEFAULT, 1, deviceIdsPtr, null);

    checkError(res, 'clGetDeviceIDs');

    const deviceId = sizeTPtr.get(deviceIdsPtr);

    const errPtr = ref.alloc(intPtr);
    // (3) コンテキスト作成
    const ctx = openCl.clCreateContext(null, 1, deviceIdsPtr, null, null, errPtr);

    checkError(errPtr, 'clCreateContext');
    releaseList.push( () => openCl.clReleaseContext(ctx) );
    // (4) コマンドキュー作成
    const queue = openCl.clCreateCommandQueue(ctx, deviceId, 0, errPtr);

    checkError(errPtr, 'clCreateCommandQueue');
    releaseList.push( () => openCl.clReleaseCommandQueue(queue) );

    const codeArray = new StringArray([code.toString()]);
    // (5) プログラム作成
    const program = openCl.clCreateProgramWithSource(ctx, 1, codeArray, null, errPtr);

    checkError(errPtr, 'clCreateProgramWithSource');
    releaseList.push( () => openCl.clReleaseProgram(program) );
    // (6) プログラムのビルド
    res = openCl.clBuildProgram(program, 1, deviceIdsPtr, null, null, null)

    checkError(res, 'clBuildProgram');
    // (7) カーネル作成
    const kernel = openCl.clCreateKernel(program, functionName, errPtr);

    checkError(errPtr, 'clCreateKernel');
    releaseList.push( () => openCl.clReleaseKernel(kernel) );

    const FixedFloatArray = ArrayType('float', data.length);
    // 入力データ
    const inputData = new FixedFloatArray(data);

    const bufSize = inputData.buffer.length;
    // (8) 引数用のバッファ作成(入力用)し inputData の内容を書き込む
    const inClBuf = openCl.clCreateBuffer(ctx, CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR, bufSize, inputData.buffer, errPtr);

    checkError(errPtr, 'clCreateBuffer In');
    releaseList.push( () => openCl.clReleaseMemObject(inClBuf) );

    // (8) 引数用のバッファ作成(出力用)
    const outClBuf = openCl.clCreateBuffer(ctx, CL_MEM_WRITE_ONLY, bufSize, null, errPtr);

    checkError(errPtr, 'clCreateBuffer Out');
    releaseList.push( () => openCl.clReleaseMemObject(outClBuf) );

    const inClBufRef = inClBuf.ref();
    // (9) 引数の設定
    res = openCl.clSetKernelArg(kernel, 0, inClBufRef.length, inClBufRef);

    checkError(res, 'clSetKernelArg 0');

    const outClBufRef = outClBuf.ref();
    // (9) 引数の設定
    res = openCl.clSetKernelArg(kernel, 1, outClBufRef.length, outClBufRef);

    checkError(res, 'clSetKernelArg 1');

    const ct = ref.alloc(ref.types.uint32, data.length);

    // (9) 引数の設定
    res = openCl.clSetKernelArg(kernel, 2, ct.length, ct);

    checkError(res, 'clSetKernelArg 2');

    const globalPtr = ref.alloc(sizeTPtr);
    sizeTPtr.set(globalPtr, 0, data.length);
    // (10) 処理の実行
    res = openCl.clEnqueueNDRangeKernel(queue, kernel, 1, null, globalPtr, null, 0, null, null);

    checkError(res, 'clEnqueueNDRangeKernel');

    const resData = new FixedFloatArray();

    // (11) 結果の取得 (outClBuf の内容を resData へ)
    res = openCl.clEnqueueReadBuffer(queue, outClBuf, true, 0, resData.buffer.length, resData.buffer, 0, null, null);

    checkError(res, 'clEnqueueReadBuffer');

    // 結果出力
    for (let i = 0; i < resData.length; i++) {
        console.log(resData[i]);
    }

} finally {
    // リソースの解放
    releaseList.reverse().forEach( f => f() );
}

動作確認

今回は以下の Node.js を使って WindowsLinux の両方で動作確認します。

  • Node.js v6.3.1

(a) Windows で実行

node-ffi で OpenCL を使う」 で構築した環境へ ref-array をインストールして実行しました。

実行結果 (Windows
> node calc.js cube cube.cl

1.3310000896453857
10.648000717163086
35.93699645996094
85.18400573730469
166.375
287.4959716796875
456.532958984375
681.4720458984375
970.2988891601562

(b) Linux で実行

前回 の Docker イメージを使って実行します。

calc.js と cube.cl を /vagrant/work へ配置し、Docker コンテナからは /work でアクセスできるようにマッピングしました。

Docker コンテナ実行
$ docker run --rm -it -v /vagrant/work:/work sample/opencl:0.1 bash
Node.js と必要なモジュールのインストール
# curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.31.2/install.sh | bash
・・・
# source ~/.bashrc

# nvm install v6.3.1
・・・
# npm install -g node-gyp
・・・
# cd /work
# npm install ffi ref-array
・・・

/work 内で実行します。

実行結果 (Linux
# node calc.js cube cube.cl

1.3310000896453857
10.648000717163086
35.93699645996094
85.18400573730469
166.375
287.4959716796875
456.532958984375
681.4720458984375
970.2988891601562