最終更新: a few seconds agoRemove Netlify-related code from main (grafted, HEAD)

WebSocket とスムースな MPR ローディング

現在の CIRCUS RS の問題点

問題 1: MPR 表示と 2D モードの画像表示が遅い

現在の CIRCUS RS の VolumeMprImageSource(GPU 版も含む)や TwoDimensionalImageSource は、内部で DicomVolumeLoader インターフェース(具象クラスとしては RsHttpVolumeLoader)を使ってサーバから画像データを読み出している。しかしその DicomVolumeLoader が「全ボリュームのロードが終わってからそれを通知する」というだけの簡素な機能しか持たないため、全ボリュームのロードが終わってからでないと draw() の結果が表示できない、という問題が生じている。

例えば 2D モードの場合、本来は 1 枚の画像を読むだけで表示はできるはずであるにも関わらず、現状では「シリーズ内の全画像を読み終わるまで何ひとつ表示できない」という状態になっている。

この問題を緩和するため、MPR に関してのみは、すでに DynamicMprImageSource という仕組みがある。これは DicomVolumeLoader に依存せずに MPR 画像を PNG として直接サーバ側に直接要求するものである。さらにこれと VolumeMprImageSource を組み合わせた HybridMprImageSource もあり、これで初期レスポンスとフレームレートの両立ができる、という目論見であった。しかし、DynamicMprImageSource には以下のような問題が存在しているため、現状十分に機能しているとは言い難い状態である。

  • MPR 画像をサーバ側で生成するため、サーバ側で必要な分だけのボリューム(通常の矢状断/冠状断であればシリーズの全画像)がロードされているまで待つ必要がある。サーバ側でのロードスループットは通常 HTTP 通信よりは高速だが、それでも画像枚数が 1000 を超えるような大きなボリュームだとサーバ側のロード自体に 10 秒以上はかかるので、DynamicMprImageSource のレスポンスもそれに応じて遅くなる。
  • ボリュームのローディングとは別に draw ごとに 1 つ HTTP リクエストを発行してしまう(これはビューアの数だけ起こるので CIRCUS DB の場合は典型的には 4 つ)。ウェブブラウザには 1 ドメインへの同時並行リクエスト数制限がある。CIRCUS DB では、並行して存在するボリュームローディングのための通信に加え、タスク関係の処理が恒常的に維持する EventSource の通信も存在するので、DynamicMprImageSource からのHTTP リクエストがそもそも始まりもしないし、長期間帰ってこない多数のリクエストのせいで CIRCUS の他の機能にも支障を来す、という問題が現実に起こっている。
  • Wand ツールのようにクライアント側に全ボリュームがロードされて初めて有効になる機能が登場したため、元々は全く透過的に(ユーザが気にせずに)動作するはずであった HybridMprImageSource の特徴がすでに一部失われている。

この問題を根本的に解決するためには、クライアント側でボリュームを読み込みつつ逐次的にその結果を使って ImageSource を draw() できる仕組みが必要である。2D モードの場合、ViewState の指示に従って現在欲しい画像がローディングできた時点で即座に draw を完了したい。MPR(特に coronal/sagital)の場合、ロード済みの部分だけの部分 MPR 画像が表示され(未ロードの領域は真っ黒)、それが例えば 0.3 秒おきに段階的に更新されていき、全ボリュームを読み終わった後に最終画像が表示される、といったような仕組みが欲しい。

問題 2: ImageSource の draw() をキャンセルできない

これまで ImageSource の draw() コールはキャンセル不可だった。現在進行中の draw が終わらないうちに setViewState() で ViewState の変更があった場合、現行の処理では Viewer は概ね以下のように処理している(実コードは Viewer.ts 内)。

  • その ViewState の要求を、次に使う「待ち」状態のものとしてとりあえず記憶しておく。該当 draw 処理が終わらないうちにさらに何度も setViewState() が呼ばれた場合は最後に呼ばれたもののみを「待ち」状態として保持し、過去の待ち ViewState は忘れる。
  • 現在進行中の draw が終わったら、アノテーションも含めてそれを画面に描画する。
  • 「待ち」状態の ViewState がある場合、それを使って改めて draw() を行う。

draw がキャンセル不可だった理由はいくつかある。

  • 何も考えずに setViewState() が呼ばれるごとに draw を毎回即座にキャンセルし次の draw を(例えば 50 ms おきに)発行していたのでは、ページング等でドラッグして頻繁に ViewState を切り替えている最中に個々の draw がひとつも終わってくれず、画面が一切更新されない、という状況が発生してしまう。上記の流れなら、多少遅延はするにせよとりあえず精いっぱいのフレームレートは出せる。
  • 何も考えずに draw をキャンセルして再 draw を頻繁に繰り返すと、DynamicMprImageSource のような ImageSource ではサーバに過大な負荷がかかる。上記の流れであれば、前の HTTP リクエストの解決に時間がかかるのであれば、それが終わるまで次のリクエストが発生しない、という状況を自動的に保てていた。
  • 一旦 ImageSource の ready() メソッドを通じて ImageSource が ready 状態にさえなってしまえば、draw() は async とはいえ例えばせいぜい 0.5 秒以内くらいで終わるべきもので、キャンセルできないことによる無駄な処理や待ち時間は微々たるものだ、と考えていた。
  • Promise 処理を中断するための標準的な方法が Web 標準に当時存在しなかった。

ところが上記のように、ある ViewState に対する draw() の結果はプログレッシブに何秒もかけて解決する、という方向になると、draw() は流石にキャンセル可能にならなくてはならない。また AbortController/AbortSignal という、標準的な Promise の中断方法が導入された。

とはいえ最低限のドラフト画像も出ないうちに即キャンセルをしていたのでは、上述のように「ページング中に一切画面が更新できない」という問題が生じるし、いくらキャンセル可能だからといって毎秒 60 回 HTTP リクエストを発行してはキャンセルしまくる、みたいな実装もしてはいけない。ドラッグ中でも最低限のフレームレートを保証しつつ、サーバに負荷を与えないような、何らかの工夫が必要である。

問題 3: 「現在表示されている ViewState」と「現在要求している ViewState」の区別が曖昧

Scrollbar や ReferenceLine といったアノテーションは、マウスポインタに即座にスムースに追従し、「最終的に到達したいと指示した ViewState」に応じて描画されてほしい。さもないと ImageSource の描画が遅い場合に固まったようになってしまう。その一方で、VoxelCloud や Ellipsis などの「普通の」アノテーションは、「今まさに表示済みの ViewState」に対応して描画されないといけない。さもないと表示されている画像とズレたアノテーションが(通常一瞬のこととはいえ)表示されてしまう。

つまり Annotation の draw() コールは、今のように単一の ViewState を受け取るのではなく、「現在表示済みの ViewState」「現在要求中の ViewState」を別々の情報として受け取れるべきである。操作していなければ前者が後者に追いつくことで、両者はいずれ同一になる。

解決方法

以上の問題に対応するため、主に CIRCUS RS(一部 CIRCUS DB)を改修して以下の機能を付ける。

DicomVolumeLoader (RsHttpVolumeLoader) の改善

VolumeLoader 自体と RS Server を拡張して、優先度付きのプログレッシブ読み込みができるようにする。

// これまで
interface DicomVolumeLoader {
  loadMeta(): Promise<DicomVolumeMetadata>;
  loadVolume(): Promise<DicomVolume>;
}

// これから
interface DicomVolumeLoader extends EventTarget {
  loadMetadata(): Promise<DicomVolumeMetadata>;
  loadVolume(): Promise<DicomVolume>;
  setPriority(images: string, priority: number): void; // new
  getVolume(): DicomVolume; // new
  // addEventListener('progress', handler: ProgressHandler): void (inherited)
  // addEventListener('finish', handler): void (inherited)
}

例えば、2D モードで TwoDimensionalImageSource と RsHttpVolumeLoader を組み合わせる場合、処理の流れは以下のようになる。

  1. (TwoDimensional)ImageSource は draw() がコールされた際に渡される ViewState を通じて、何枚目の画像が欲しいかを知る。
  2. ImageSource は DicomVolumeLoader に「(最終的にはもちろん全画像が欲しいけど)とりあえず表示したいのが 113 枚目なので優先的に 113 枚目が欲しい」のようにお願いする(volumerLoader.setPriority('113', 100))。
  3. そうお願いされた DicomVolumeLoader はサーバ側に「(今ボリュームのロード中だけど、そのうち)113 枚目の画像を優先的に渡して欲しい」と、その要求を転送する。
  4. サーバ側は後述する PriorityIntegerCaller の仕組みを活用し DicomVolumeLoader からの要求に従いながら画像を送出する。
  5. VolumerLoader はサーバから 113 毎目の画像を(優先的に)受け取った時点で、progress イベントを送出する。
  6. ImageSource 側は progress イベントを通じて 113 枚目がロードされたことに気づくので、即座にそれを draw の結果として返す。

複数の Viewer が間接的に DicomVolumeLoader を共有している場合(CIRCUS DB ではこれがデフォルト)は「私は 15-20 枚目が優先的に欲しい」「私は 72 枚目が優先的に欲しい」などと各ビューアが同じ DicomVolumeLoader に言ってくることになるので、それに応じて DicomVolumeLoader (と、ひいては RS Server)が優先度を付けて対応する。

ボリュームの一部がロードされた場合にスライス単位でそれを通知できる仕組みを追加する。ただし既存のシグネチャは変えず、progress というイベントハンドラを登録したらそちら経由で通知が行くようにする。progress イベントは、現状では RsHttpVolumeLoader のみがこれに対応するようにする。

なお既にサーバ側には「優先度付きのプログレッシブ読み込み」の仕組みがあり、/scan リクエストを処理するときに単一シリーズ内の対応するイメージ番号の DICOM 画像を優先的に読み込むために活用されている。PriorityIntegerLoader.ts 周りのコードを参照。基本的にはこの既存処理が拡張されるイメージである。VolumeLoader はクライアント側で自身で優先度を管理するというよりも、サーバ側に優先度の希望を通知して、サーバ側が優先度を処理し、ローディングと通信を最適化するような形になる。

以下のイメージ図を参照。

Image

PriorityIntegerLoader は上図のピンクで示された 2 つの部分で使う。① は DicomImageRepository から画像を 1 枚ずつ優先度付きで読み出すところであり、既に実装済み。② は WebSocket の部分で VolumeLoader からの要求に応じて 1 枚ずつ画像を送出する部分であり、これは前者とは独立して VolumeLoader からの WebSocket 通信ごとに 1 つ存在する必要がある。

WebSocket 対応

上記のようなことをするためにはクライアント側とサーバで細かい相方向のやりとりが必要になるため、WebSocket を導入する。

  • DicomVolumeLoader 側からは WebSocket を通じ、どのシリーズの画像がどの順番で欲しいかを要求する。「どの順番で欲しいか」の優先度要求は Viewer の操作状況に応じてリアルタイムに変わることに注意。
  • RS Server 側は要求された優先順番に従って VolumeAccessor(VolumeProvider から取得する)から 1 枚ずつスライスを受け取り、1 枚ずつクライアントに送出する。この際の要求順の処理は WebSocket コネクションごとに準備する PriorityIntegerCaller を使う(上記図の ②)。

Koa と WebSocket をどう組み合わせるのか、クライアント側の WebSocket のライブラリとして何を使うべきかについて知見が足りていないので、まずここは要素技術の調査からやる必要がある。

  • socket.io というライブラリが一番有名だが、これは古いライブラリであり WebSocket 未対応のブラウザへのフォールバックなども含まれているようなので、よりシンプルなクライアントライブラリを選択したい。
  • WebSocket を動かすには HTTPS 化がほぼ必須のようなので、恐らくそもそもその対応から始める必要がある。
  • 将来的にはタスクの処理で使っている EventSource も WebSocket に統一したい。ただし上記のボリュームロードは CIRCUS RS の管轄でありコネクションは永続しない(ボリュームロードが終わればコネクションは終了していい)一方、タスクは circus-api の管轄でありブラウザが開いている限り永続的にコネクションを維持するものなので、これらについては別々のコネクションを使うことにはなりそう(それが可能なのは確認済み)。
  • もしかしたら webpack-dev-server(開発時)や nginx(デプロイ時)のようなリバースプロキシの段階で HTTPS は吸収できるかもしれない(その場合は circus-api 自体に HTTPS 化コードを書く必要はない)

WebSocket によるボリューム通信は、gzip 圧縮と非圧縮の両方に対応する。ネットワークが十分高速な場合は圧縮のための CPU 処理の方がむしろ足をひっぱる可能性があるため圧縮をしない、という選択がとれるようにする。

ImageSource#draw() 周りの改善

「プログレッシブな結果返却」と「キャンセル」の両方に対応する。

// 古いシグネチャ
draw(viewer: Viewer, viewState: ViewState): Promise<ImageData>;

// 新しいシグネチャ

/**
 * 最終結果の場合はその画像のみを、次の画像がある場合は
 * ドラフト画像と次の画像のための Promise を返す
 */
type DrawResult =
  | ImageData
  | { draft: ImageData; next: Promise<DrawResult> };

draw(
  viewer: Viewer,
  viewState: ViewState,
  abortSignal: AbortSignal
): Promise<DrawResult>

実はこのシグネチャ自体は現コードにすでに入っており、プログレッシブに表示を行うための最低限の動作確認も済んでいる。ただしフレームレート維持などの詳細は動作していないので、主にその部分について実装をする。

「サーバに負荷をかけ過ぎないためのスロットリング」は個別の ImageSource(DynamicMprImageSource など)の責任とする。具体的には内部で例えば 200ms などの throttle をかける。一方で「最低限のフレームレートを守るための draw キャンセルの遅延」は Viewer 側が考慮する事項とする。個別の ImageSource は abort signal が飛んできたら即座に現在の draw 処理(レンダリングや HTTP リクエスト)を中断するものとする。ただし個別の ImageSource は、最初のドラフトくらいはできれば 100 ms、最悪でも 500 ms 以内くらいには返すよう、努力すべきである(それすら不可能ならそもそも ready になってはいけない)。

VolumeMprImageSource (GPU 版も含む)のプログレッシブ化

上記の DicomVolumeLoader 改善に対応し、ボリュームが読みこまれている間、プログレッシブに画像が表示されるようにする。ひとまずプログレッシブな MPR 画像の更新頻度は 300 ms とする。

DicomVolumeLoader の progress は全ての DicomVolumeLoader が対応するとは限らないことにする。progress イベントがまったく起きずにいきなり ready になった場合でも、それはそれで対応できるようにすること。

CIRCUS DB でシリーズロード状態を改善

CIRCUS DB では、DicomVolumeLoader が動作中のシリーズに対して左のシリーズ選択ペインで「Loading」というインジケータを表示しているが、ここでロード状況をパーセントで表示するように改善する。

アノテーションドローの改善

Annotation#draw() に渡す DrawOption に情報を追加し、「要求中の ViewState」などの追加情報を受け取れるようにする。Scrollbar や ReferenceLine はそれらを使って自己を描画するようにする。

draw(viewer: Viewer, viewState: ViewState, options: DrawOption): void;

// これまで
interface DrawOption {
  hover: boolean;
}

// これから
interface DrawOption {
  hover: boolean;
  draftImage: boolean; // 描画中の画像がドラフト状態かどうか
  requestingViewState: undefined | ViewState // 「要求待ち」の ViewState
}

hints.draftImage は draw の結果がまだドラフト状態である場合に true になる。hints.requestingViewState は「待ち」状態の ViewState を示す(ない場合は undefined になる)。

これに従って Scrollbar と ReferenceLine の両アノテーションの動作を改善する。

この修正にともない、「待ち」ViewState が変わるだけでアノテーションがドローされるため、ドロー頻度がかなり上がる可能性がある。VoxelCloud 周りでパフォーマンスの問題が万一生じた場合はキャッシュ/メモ化が正しく動作しているか確認を。

その他

DynamicMprImageSource(と Hybrid~)は、今後出番は減る予定だが引退はしない。スマホなど非力なマシンにおいては全ボリュームをロードすることができあにため、メモリ負荷や通信料負荷の観点からもこれは依然必要となることが予想される。

ドラフト画像の仕組みを導入することによる副次的な効果として、ボリュームレンダリングについても「ページング中はドラフト画像を表示し、その後に 1 秒かけて高精細な画像に置換する」のような挙動をとれるようになる。これは今回の作業範囲は含まれない。