最終更新: a few seconds ago Remove Netlify-related code from main (grafted, HEAD)
CIRCUS RS 改善
CIRCUS RS 周りのいろいろな改善。以下は項目毎に pull request を分けて下さい。
1. pixelFormat === 'binary' の場合の RawVolume サイズの制限緩和
現状の RawVolume は、pixelFormat === 'binary' の場合に内部 ArrayBuffer (data) の 1 バイトに 8 ボクセル分の値を詰め込んで保持する関係で、 RawVolume サイズのうち x 方向は 8 の倍数でないといけないことになっている(違反すると "Number of pixels along the x axis must be a multiple of 8." というエラーが throw される)。が、これは必ずしも必要な制限ではない。実際世の中の DICOM データには横方向のサイズが 8 の倍数でないものが結構あり、そのようなものを CIRCUS DB で読み込もうとすると VoxelCloud 周りでエラーが発生している。
このため制限を緩和して、例えば 5x5x5 のようなサイズの binary RawVolume でも許容することにする。この場合、1 スライスが 25 bit、5 スライスで 125 bit 必要なので、data 用の ArrayBuffer は 8 の倍数に切り上げて 128 bit (=16 byte) 確保し、最後の 3 bit は使われない無駄ビット(0 にすること)となる。
これにより、1 スライス分のデータがバイト境界と揃わなくなる(つまり隣接する 2 枚のスライスのデータが同一バイト内に保持される)ため、以下のメソッドには明らかに調整が必要となる。
- コンストラクタ:
new ArrayBuffer()のサイズ計算で 8 の倍数への切り上げ処理が必要。 get dataSize: 8 の倍数への切り上げ処理が必要。insertSingleImage: バイト境界が揃っていれば現状通りUint8Array.setでデータコピーすれば良いが、そうでない場合は全バイトについて 1 ~ 7 ビットずつずらしながらコピーする必要があるので結構大変。入力データの最後にパディングビットがある場合はそこの値を正しく無視すること。スライス境界面で無関係な隣接スライスのデータを上書きしてしまわないように気をつけること。getSingleImage: 上記同様にビットをずらしながらのコピーとなる可能性がある。出力データの最後には 0 ~ 7 ビットのパディングが含まれるが、その部分には0を入れることとし、後続スライスのデータが含まれないように気をつける。- 他にもあるかもしれないのでコードをレビューして既存のコードで不具合が生じないか確認。
特に insertSingleImage/getSingleImage 周りは、テストでスライス境界面のビット処理による見つけづらいバグが起きていないことをしっかり確認したい。
なお VoxelCloud ラベルも shrinkToMinimum 付近で x 方向のサイズが 8 の倍数になるよう頑張っているが、この制限も撤廃する。ただし既に CIRCUS DB 側に保存されているラベルのデータ移行まではやらない。
スライス毎にパディングビットを置くことで、スライス先頭ビットをバイト境界と揃える…という実装にはしない。そうすると最も重要な write/read によるボクセルアクセス処理のパフォーマンスに悪影響が出てしまう。insertSingleImage/getSingleImage を使うような場面では基本的にディスク/ネットワーク転送速度が律速段階なので、一度実装すればビットシフト処理による実行時パフォーマンスへの悪影響はほぼ出ない。
2. VoxelCloud を多数「拡張」した場合のパフォーマンス改善
概要
現在の CIRCUS DB では、多く(概ね 3 個以上)の VoelCloud ラベルがあり、それらを何度か切り替えながらブラシ/消しゴムツールで編集していると、ページングなどで激しく処理落ちするという問題が発生している。これが起きているのは expandToMaximum / shrinkToMinimum 周辺の挙動が非効率的だからだと思われる。
前提として、VoxelCloud データが DB に保存されている状態では origin/size は、実際に塗られているボクセルを含む最小限のバウンディングボックス(以下「実 BB」と呼ぶ) となっている(ただし現状では上記の通り x 方向のサイズのみ 8 の倍数となるという縛りがある)。intersectionOfBoxAndPlane による最適化により、draw はこの実 BB と現在の section が交差する場合にのみ、必要最小限の範囲で MPR 計算を実行するようになっている(この状態を「コンパクト状態」と呼ぶ)。VoxelCloud の debugPrint を true にすると動作が確認できる。
ある VoxelCloud に対して BrushTool/EraseTool を初めて使おうとすると、VoxelCloud#expandToMaximum() が呼ばれる。これは、元 DICOM ボリューム全体のどの部位でもすぐ塗れるようにするためのメモリ確保作業であり、具体的には origin が [0, 0, 0] でサイズが元ボリュームと同じである大きな RawVolume を作って、既存の塗りデータをそちらにコピーする(「拡張 (expanded) 状態」)。拡張状態になっても draw() 結果の見た目自体は変わらないのだが、「ほとんど塗られていないのにサイズの大きな RawVolume」が準備されるため、ここで計算量増大が起きる。
つまり拡張状態では、実際に塗られているボクセル範囲(実 BB)は小さくて section とは全く交差してない場合でも、RawVolume の大きな BB と section とはほぼ常に広い範囲で交差しているので、無駄に Viewew のほぼ全体にまたがる MPR 計算が実行される。この拡張状態はリビジョン保存の時に shrinkToMinimum が呼ばれてコンパクト状態に戻るまで続く。
1 つの VoxelCloud を編集しているだけなら問題はあまり顕在化しないが、複数の VoxelCloud を切り替えながら編集していると、拡張状態の VoxelCloud が複数同時に存在することになり、無駄な MPR 計算の繰り返しによりパフォーマンスが急速に悪化していく。shrinkToMinimum はそれ自体がボリューム全体を走査してボクセル範囲を求めるという重い処理となってしまっており(1 秒ほどかかる)、コンパクト状態をより頻繁に行うようにするというのもあまり現実的ではない。
これを改善するため、拡張状態 (_expanded: boolean) を保持するだけでなく、_actualBoundingBox: Box で実 BB も保持して、ブラシ・消しゴムツールの利用ごと(1 回のドラッグごと)に更新するようにする。draw() や shrinkToMinimum() の際もそのデータを使うことで、計算コストを最小限にする。
実装
VoxelCloudに_actualBoundingBox: Boxというプライベートフィールドを追加する。expandToMaximumの際に実 BB を_actualBoundingBoxに保持しておく。VoxelCloudにupdateActualBoundingBox(type: 'added' | 'erased', box: Box)というパブリックメソッドを追加する。BrushTool はaddedを、EraserTool はerasedでこのメソッドを呼び出し、ボクセルの塗り替えが起きた範囲を、ドラッグ完了ごとにBox形式で通知する。メソッド内でboxを使って実 BB を適宜更新する。- 拡張状態でない(
_expanded !== true)場合は throw する。
- 拡張状態でない(
VoxelCloud.draw()を変更する。拡張状態の時は実 BB (_actualBoundingBox) を参照して、MPR 計算自体が必要か否か、必要であれば範囲はどこまでかを決めるようにする。debugPrintでもこの状況が分かるようにする。
shrinkToMinimum()の実装も、全ボリュームの走査をせず_actualBoundingBoxの値を見て再コンパクト化するように書き換える。
updateActualBoundingBox の実装が最も大変と予想される。added の方の実装は Three.js の Box3.union() を使うだけでよく容易なのだが、erased の方の実装は、消した後の実 BB を求めるために現在の実 BB 内のボクセルを部分的にループで走査する必要がある(現状の VoxelCloud#scanBoundingBox の機能を拡張したようなものが必要)。以下の画像に示すとおり、ここの実装を本気で効率よくやろうとすると面倒くさい(昔も実 BB の逐次更新はやりたがったのだがここの面倒くささが理由で一旦諦めた)。

- (a): 元の塗り(緑)と元の実 BB(赤線)。概念説明のため 2 次元で説明。
- (b): 消しゴムツールで 1 回のドラッグが通過した範囲とそれを囲む BB(オレンジ)。dragEnd 時に
updateActualBoundingBoxでこの範囲を通知する。 - (c): この場合はオレンジ枠は赤枠に対して、上方向と右方向にはみ出ている(つまり、上側と右側について「実 BB が消しゴムで削られた」可能性がある)。上側と右側から、塗られているボクセルが見つかるまで走査を行っていく(青矢印)。
- スキャンは +x, -x, +y, -y, +z, -z の各方向について最大 6 回行う(2 次元の場合は +z, -z がないので最大 4 回)。各方向について「はみ出ている」場合にのみ処理が必要。実 BB 内にオレンジ枠が含まれていて「はみ出し」がない場合 (g)、その方向の走査は必要ない。あまりなさそうだが実 BB 外部の「元から何もない場所を消す」操作によってオレンジ枠が実 BB から完全に飛び出している場合 (h) も、その方向の走査は必要ない。
- (d): 消された後の塗り(緑)とボクセル走査後に分かった新しい実 BB(赤実線)。
- (e), (f): こういったパターンも考えられるので正しく対応する。
なお、(c) の部分の実装をサボって、消しゴムの dragEnd 時に元の実 BB を常に 3 次元的に全走査するのでも、今よりはマシなはずなので、ひとまず先にそうすることも検討。たいていの場合実 BB は十分小さいので問題ないが、大きい場合は「消しゴムツールを使うごとに引っかかる」みたいなことが予想されるので最終的には上記を実装したい。
erased 周りはいろいろ最適化の余地がありそうなので、この走査部分については関数化してテストもしっかり書く。具体的には 32x32x32 くらいのダミー RawVolume でいろいろな条件を試す。
3. スクロールバーアノテーションの追加
概要
液晶タブレットやタッチデバイスなど、マウス(ホイール)がない端末で CIRCUS RS を使っていると、「1 枚だけ画像をめくる」という操作がとても困難である。このため 新たな Annotation として、ビューア内に表示するスクロールバーを作成する。(ブラウザネイティブのスクロールバーではなく、Canvas にスクロールバーをカスタム描画する。)
実装
既存の実装としては(マウス操作に応じて viewState.section を更新するという意味で)ReferenceLine が近いので参考にする。
ScrollBar という名前の Annotation クラスを新規作成し、タップ操作などでページめくりができるようにする。デザインは以下の通り(以下は position: 'bottom', color: '#ff00ff' の場合)。

設定可能な項目は以下の通り。
color: stringスクロールバーの色。size: numberスクロールバーの幅。margin: numberビューア端からの距離(margin1 と margin2 に分けた方がいいかも…)。position: 'right' | 'left' | 'top' | 'bottom'表示位置。visibility: 'always' | 'hover'カーソルが近づいたときのみスクロールバーを表示するのか(マウスを想定)、常に表示するのか(タッチデバイスを想定)。
position が right または left の場合、上矢印をクリックするとマウスホイールを上に 1 度回転させたのと同じ効果が起きる。position が top または bottom の場合、左矢印をクリックするとホイールを上に回転させたのと同じ効果が起きる。サム(ドラッグ可能な部分)の長さはボリュームサイズに応じて適宜調整する(ただし最小は size とする)。
RS 側で上記ができた後で、circus-web-ui の CaseDetail にスクロールバーを組み込む(別 PR で)。ReferenceLine と同様に、設定(歯車アイコン)で「表示(大)」「表示(小)」「非表示」を切り替えられるようにするとともに、タッチデバイスかどうかを自動判定して visibility を設定する。