Appearance
医療系:CT/スキャンデータについて調査
仕様書
1. 概要
本システムは、DICOMデータ(医用画像)のフォルダを読み込み、3Dとしてレンダリングし、コントローラを用いて操作・閲覧するUnityアプリケーションです。
- VolumeRenderedObject を生成し、透過表示・Visibilityウィンドウの調整・拡大縮小・移動・回転などの操作が可能
- UI(確認ダイアログ) による操作確認(削除/終了時)を実装
2. 動作環境
- Unity 2021 以降 (Input System が導入済み)
- Windows(PC上での動作を想定)
- ゲームパッド: Xboxコントローラや類似のコントローラ
3. 機能一覧
DICOMフォルダ読み込み
- Startボタン押下 → StandaloneFileBrowser でフォルダを選択 → フォルダ内のDICOMデータを読み込み
- 読み込んだデータは
Application.persistentDataPath下にコピーし、リスト管理。再表示可能
3Dモデルの操作
- 移動: 右スティック (Value(Vector2))
- 回転: 左スティック (Value(Vector2))
- VisibilityMin 調整: D-pad 左右(連続入力)
- VisibilityMax 調整: ボタン East/West(右側にある4つのボタンの右左) の押しっぱなし (LateUpdateで
IsPressed()監視) - 拡大縮小: D-pad 上(拡大)/下(縮小)
- 位置リセット: 右側にある4つのボタンの下を短押し。読み込んだ直後の位置・回転・拡大率に戻す
- シーン初期化: 右側にある4つのボタンの下を長押し。現在の3Dオブジェクトを破棄し、最初のデータセットを再ロード
削除/終了の確認ダイアログ
- Selectボタン(短押し) で 削除 を試みる → ダイアログ表示
- ダイアログ中は3Dモデルを一時的に非表示
- Yesを選択 → 本当にデータ削除
- Noを選択 → 非表示解除(3Dオブジェクト再表示)
- Selectボタン(長押し) で 終了 → ダイアログ表示
- Yes: アプリ終了
- No: キャンセル(3D再表示)
- Selectボタン(短押し) で 削除 を試みる → ダイアログ表示
次のモデル/前のモデル
- シーンに複数のDICOMフォルダがある場合
- 右トリガー(奥・手前問わず) → 次のフォルダの3Dモデルをロード
- 左トリガー(奥・手前問わず) → 前のフォルダの3Dモデルをロード
レンダリングモード切り替え
- Xボタン押下 →
- DirectVolumeRendering
- MaximumIntensityProjection
- Isosurface を順番にサイクル
- Xボタン押下 →
4. 操作方法(コントローラ)詳細
以下に、Xboxコントローラを想定したボタン割り当てをまとめます。
4.1. コントローラ全体イメージ (テキスト擬似図)
後で画像を作製します。
csharp
コピーする編集する
(XBOX 風コントローラ)
[LB] [RB]
_____ _____
/ \___/ \
[LT] ---> / \ <--- [RT]
| [X] |
| [Y] [B] |
| [A] |
\ _________ /
[LS] --> \_/ \__/
\ DPAD /
[LS CLICK] [RS CLICK]
\_________________/
[SELECT] [START]- LS: Left Stick
- RS: Right Stick
- LB/ RB: Left/Right Bumper
- LT / RT: Left/Right Trigger
- DPad: 十字キー(上下左右)
- A/B/X/Y: フェイスボタン
4.2. 各ボタン機能一覧
- Left Stick(LS)
- 回転 (上下左右に倒す→モデルを回転)
- Right Stick(RS)
- 移動 (上下左右に倒す→モデルを平行移動)
- D-pad
- 左右: VisibilityMin の調整
- 上: 拡大 (Zoom in)
- 下: 縮小 (Zoom out)
- Bボタン(Button South)
- 短押し: リセット (位置・回転・拡大率を初期状態に戻す)
- 長押し: シーン初期化 (再ロード)
- Select ボタン
- 短押し: データ削除の確認ダイアログを表示
- Yes → データを削除し、モデル破棄
- No → 戻る(モデル再表示)
- 長押し: アプリ終了確認ダイアログ
- Yes →
Application.Quit() - No → 戻る
- Yes →
- 短押し: データ削除の確認ダイアログを表示
- Start ボタン
- DICOMフォルダを開く(StandaloneFileBrowser) → DICOM データ読み込み
- Xボタン(Button North)
- レンダリングモードをサイクル切り替え(Direct / MIP / Isosurface)
- (LB/RB/ etc.)
- (未使用 or 追加機能に応じて割り当て可)
5. UIフロー(ダイアログ)
- 削除ダイアログ
- Select(短押し)→ 3Dモデルを
SetActive(false)非表示→ 「Are you sure to delete?」- 十字キー左右でYes/Noをハイライト
- A/X/Y/B(任意のFaceボタン)押下で確定
- Yes →
DeleteCurrentDataset() - No → 3Dモデルを
SetActive(true)で再表示
- Yes →
- Select(短押し)→ 3Dモデルを
- 終了ダイアログ
- Select(長押し)→ 3Dモデルを
SetActive(false)→ 「Are you sure to quit?」- Yes →
Application.Quit() - No → 戻る(再表示)
- Yes →
- Select(長押し)→ 3Dモデルを
UI 操作中は IsDialogActive() フラグがtrueになるため、他の操作(回転/移動/Visibility変更等)を無効にする。
6. 実行時のシーケンス例
- ユーザーがStartボタン
- フォルダ選択 →
LoadDicomFolder(...)で3Dオブジェクト生成
- フォルダ選択 →
- 右スティックで移動し、左スティックで回転
- 十字キー上で拡大/下で縮小
- 十字キー左右でVisibilityMinを上下げ
- B(短押し) で位置をリセット(初期状態へ)
- Xボタン でレンダーモードを切り替え(Direct → MIP → Isosurface → …)
- Select(短押し) で「削除しますか?」ダイアログ → Yesで削除 / Noでキャンセル
- Select(長押し) で「終了しますか?」ダイアログ → Yesでアプリ終了 / Noでキャンセル
7. コントローラーイメージ(簡易ASCII)
以下はテキストでのイメージ例です(実際の資料では画像を推奨):
後で画像を作製します。 ChatGPTに作成してもらった画像です。まだ少し難があります 画像内に日本語を書くのは人の手でしたほうが良いかも。


mathematica
コピーする編集する
[LB] [RB]
(unused) ____ ____ (unused)
/ \
[LT] (unused) < > (unused) [RT]
| [Y] [X] |
| [B] [A] |
\ D-Pad /
(Left Stick) (Select) (Start) (Right Stick)
-------------------------------------------
Button / Stick | 機能
-------------------------------------------
[LS (Left Stick)] | 回転
[RS (Right Stick)] | 移動
[DPad Left/Right] | VisibilityMin 調整
[DPad Up/Down] | 拡大/縮小 (Zoom)
[B Short Press] | Reset (位置/回転/拡大率)
[B Long Press] | シーン初期化 (最初のデータに戻る)
[Select Short Press] | 削除ダイアログ表示
[Select Long Press] | 終了ダイアログ表示
[Start] | DICOMフォルダ読み込み
[X] | レンダーモード切替
-------------------------------------------8. 実装ファイルとアタッチ方法
- DicomController.cs
- シーン上の EmptyObject などにアタッチ
- Inspectorで
ConfirmationDialogフィールドを**ConfirmationDialogUI* (UI側) に割り当て - コントローラのボタン入力を受け取り、3D操作やダイアログ呼び出しを行う
- ConfirmationDialogUI.cs
- Canvas(あるいはPanel)にアタッチ
dialogRoot(GameObject) にダイアログ全体を割り当て → 初期SetActive(false)yesText/noText/messageTextに 各 TMP_Text を割り当てShow(...)を呼ぶとダイアログがアクティブ化、Yes/Noが選択可能に
- 依存する他スクリプト/Asset
GamepadControls(InputAction)StandaloneFileBrowser(SFB)UnityVolumeRendering(VolumeRenderedObject, VolumeObjectFactory etc.)
9. 注意・留意点
- ロード中フラグ
isLoading = true中は、他の入力を無視(実装済)
- 削除ダイアログ
- No でキャンセルしたら3Dオブジェクト再表示が必須
- Unity API
Destroy()やSetActive(false/true)はメインスレッド内で呼び出す
- ランタイムでの配置
Canvasは UI レイヤー、DicomControllerはシーンルート等。- Inspectorで フィールドを正しく紐づけること
10. まとめ
- ゲームパッドをフル活用したDICOM 3Dビューワ
- ボタン割り当てに応じて3D操作とUI確認を行い、誤操作削減 (削除/終了ダイアログ)
- 実際の資料ではコントローラ画像に矢印を引き、**「LS → 回転」「RS → 移動」「B → リセット/初期化」「Select → 削除/終了ダイアログ」**などを記載する形がおすすめ
- ダイアログ表示中は3Dオブジェクトを一時的に隠し、No選択時に元に戻す、Yes選択時は削除/終了確定
この仕様に沿って開発すれば、十字キー/スティック/フェイスボタンを活用した直感的なDICOM 3Dビューアが実現可能です。
初期調査
DICOM
TIFFスタック
RAW(VOL)
JPEG
TIFF
DICOM
2DRAW
STL
URL置き場
CT(コンピュータ断層撮影)の原理と構造について|松定プレシジョン
★使えそう
CT検査 - 独立行政法人国立病院機構 近畿中央呼吸器センター
X線非破壊検査シリーズ② X線CT像で得られる情報とその見方|X線非破壊検査装置・工業X線CTなら松定プレシジョン
CTとは、どういう仕組み? | CTに関する疑問にお答えします | キヤノンメディカル
よくある質問 | 産業用CTスキャンサービス | 株式会社JMC
(工業系)
DICOM×Unity調査
DICOMのデータ形式
を見た限りでは連番の写真群
拡張子が.dcmで、一枚だけの画像であってもDICOMとする模様
1.Unity DICOM Volume Rendering
Unity で DICOM 画像をレンダリングしてみる - 凹みTips
DICOM→Texture2D→Texture3Dという形。そのままの利用では患者情報などが失われるかも。
2.Unityボリュームレンダリング←これが一番使いやすい★
simpleITKというインポーターを使用。
3.CTScanVisualiser
実装に関して
目標:2画面でのオブジェクトの管理・2Dの画像の表示(既存の機能を応用)
2画面表示に関するドキュメントをChatGPTに読み込ませ作成を試みたがエラーが起きたため、LKGのExamplesの5を改造する形で進める
一応2画面表示の.exeができた
LoadボタンとViewボタン、Deleteボタンの作成:
一度ロードしたオブジェクトをjsonデータとして保存、2DUIから選択できるようにする。
Loadボタンを押したらフォルダが開くようになったが表示されない…
build時に出てくるエラー
csharp
SerializedObjectNotCreatableException: Object at index 0 is null
UnityEditor.Editor.CreateSerializedObject () (at <852a96aeaa894abe80349589fdb9b964>:0)
UnityEditor.Editor.GetSerializedObjectInternal () (at <852a96aeaa894abe80349589fdb9b964>:0)
UnityEditor.Editor.get_serializedObject () (at <852a96aeaa894abe80349589fdb9b964>:0)
UnityEditor.Editor.DoDrawDefaultInspector () (at <852a96aeaa894abe80349589fdb9b964>:0)
UnityEditor.Editor.DrawDefaultInspector () (at <852a96aeaa894abe80349589fdb9b964>:0)
LookingGlass.Editor.DualMonitorApplication.DualMonitorApplicationManagerEditor.OnInspectorGUI () (at Assets/LookingGlass/Scripts/LookingGlass.Editor/DualMonitorApplicationManagerEditor.cs:96)
UnityEditor.UIElements.InspectorElement+<>c__DisplayClass77_0.<CreateInspectorElementUsingIMGUI>b__0 () (at <852a96aeaa894abe80349589fdb9b964>:0)
UnityEngine.GUIUtility:ProcessEvent(Int32, IntPtr, Boolean&)が原因かと思ったが、なにも編集を行っていない元のサンプルでも同じエラーが発生していたため違うっぽい
アプリ内でPlayすると追加されることを確認。build後のexeだと正常に動いていない模様
DICOMとは
通常の画像のみではなく、データ内に患者情報などが付帯しているもの。
DICOMが複数そろうことで3D化することもできる。
DICOMのデータを探す際に注意がいる点として、
1枚のみのDICOMでも、DICOMという扱いになるため、3Dにする必要がある際は複数枚が連番になっているDICOMデータを探す必要がある。
DICOMデータとはなにか|医療のためのPythonプログラミング

連番となっているDICOMが入っているフォルダをインポートするだけで3Dオブジェクトとして読み込み可能
オブジェクトなので移動回転が簡単にできる。
例:
(利用サンプルデータ:DICOMビューア|ZioCubeユーザーサポート|ザイオソフト株式会社)


また見やすいように調整も可能

URPやHDRPではなさそう


オブジェクトネームに患者情報などを残すことに成功
テストデータなのでわかりにくいですが、ZA-Sample002-ZioCubeがテストデータの患者名で20000101が撮影日となっています。ほかに使用したいデータがあれば下のDICOMのタグについてを参照してください。
作成したスクリプト:
SimpleITKDICOMImporter.cs
csharp#if UVR_USE_SIMPLEITK using UnityEngine; using System; using itk.simple; using System.Runtime.InteropServices; using System.Collections.Generic; using System.IO; using System.Threading.Tasks; namespace UnityVolumeRendering { /// <summary> /// SimpleITK-based DICOM importer. /// Has support for JPEG2000 and more. /// </summary> public class SimpleITKDICOMImporter : IImageSequenceImporter { public class ImageSequenceSlice : IImageSequenceFile { public string filePath; public string GetFilePath() { return filePath; } } public class ImageSequenceSeries : IImageSequenceSeries { public List<ImageSequenceSlice> files = new List<ImageSequenceSlice>(); public IEnumerable<IImageSequenceFile> GetFiles() { return files; } } /// <summary> /// LoadSeries: フォルダ内からシリーズを抽出し、複数の <see cref="ImageSequenceSeries"/> を返す /// </summary> public IEnumerable<IImageSequenceSeries> LoadSeries(IEnumerable<string> files, ImageSequenceImportSettings settings) { List<ImageSequenceSeries> seriesList = LoadSeriesInternal(files); return seriesList; } /// <summary> /// LoadSeriesAsync: 非同期版 /// </summary> public async Task<IEnumerable<IImageSequenceSeries>> LoadSeriesAsync(IEnumerable<string> files, ImageSequenceImportSettings settings) { List<ImageSequenceSeries> seriesList = null; await Task.Run(() => seriesList = LoadSeriesInternal(files)); return seriesList; } private List<ImageSequenceSeries> LoadSeriesInternal(IEnumerable<string> files) { // ファイルが含まれるディレクトリをリストアップ HashSet<string> directories = new HashSet<string>(); foreach (string file in files) { string dir = Path.GetDirectoryName(file); if (!directories.Contains(dir)) directories.Add(dir); } // DICOM シリーズID を検索 List<ImageSequenceSeries> seriesList = new List<ImageSequenceSeries>(); Dictionary<string, VectorString> directorySeries = new Dictionary<string, VectorString>(); foreach (string directory in directories) { VectorString seriesIDs = ImageSeriesReader.GetGDCMSeriesIDs(directory); directorySeries.Add(directory, seriesIDs); } // 見つかった seriesID ごとにファイルをまとめる foreach (var dirSeries in directorySeries) { foreach (string seriesID in dirSeries.Value) { VectorString dicom_names = ImageSeriesReader.GetGDCMSeriesFileNames(dirSeries.Key, seriesID); ImageSequenceSeries series = new ImageSequenceSeries(); foreach (string file in dicom_names) { ImageSequenceSlice sliceFile = new ImageSequenceSlice(); sliceFile.filePath = file; series.files.Add(sliceFile); } seriesList.Add(series); } } return seriesList; } /// <summary> /// ImportSeries: シリーズ(複数ファイル)を 3D VolumeDataset として読み込む /// </summary> public VolumeDataset ImportSeries(IImageSequenceSeries series, ImageSequenceImportSettings settings) { // SimpleITK Image Image image = null; float[] pixelData = null; VectorUInt32 size = null; VectorString dicomNames = null; // Create dataset VolumeDataset volumeDataset = ScriptableObject.CreateInstance<VolumeDataset>(); ImageSequenceSeries sequenceSeries = (ImageSequenceSeries)series; if (sequenceSeries.files.Count == 0) { Debug.LogError("Empty series. No files to load."); return null; } bool clampHounsfield = PlayerPrefs.GetInt("ClampHounsfield") > 0; // 実際のインポート処理 ImportSeriesInternal(out dicomNames, sequenceSeries, out image, out size, out pixelData, volumeDataset, clampHounsfield); return volumeDataset; } /// <summary> /// ImportSeriesAsync: 非同期版 /// </summary> public async Task<VolumeDataset> ImportSeriesAsync(IImageSequenceSeries series, ImageSequenceImportSettings settings) { Image image = null; float[] pixelData = null; VectorUInt32 size = null; VectorString dicomNames = null; // Create dataset VolumeDataset volumeDataset = ScriptableObject.CreateInstance<VolumeDataset>(); ImageSequenceSeries sequenceSeries = (ImageSequenceSeries)series; if (sequenceSeries.files.Count == 0) { Debug.LogError("Empty series. No files to load."); settings.progressHandler.Fail(); return null; } bool clampHounsfield = PlayerPrefs.GetInt("ClampHounsfield") > 0; await Task.Run(() => { ImportSeriesInternal(out dicomNames, sequenceSeries, out image, out size, out pixelData, volumeDataset, clampHounsfield); }); return volumeDataset; } /// <summary> /// 実際のインポート処理 (内部) /// - 先頭ファイルを単体読み込みして患者名_撮影日を取得 /// - 複数ファイルで3D化 /// - PixelData, Spacingの取得 /// - datasetName を患者名_撮影日に設定 /// </summary> private void ImportSeriesInternal( out VectorString dicomNames, ImageSequenceSeries sequenceSeries, out Image image, out VectorUInt32 size, out float[] pixelData, VolumeDataset volumeDataset, bool clampHounsfield) { image = null; size = null; pixelData = null; dicomNames = new VectorString(); // --------------------------------------------- // (1) 先頭ファイルを単体で読み込みし、患者名_撮影日 を取得 // --------------------------------------------- string firstDicomFile = sequenceSeries.files[0].filePath; string datasetName = Path.GetFileName(firstDicomFile) + "_fallback"; // フォールバック try { ImageFileReader singleFileReader = new ImageFileReader(); singleFileReader.SetFileName(firstDicomFile); Image singleFileImage = singleFileReader.Execute(); // タグがあれば使う if (singleFileImage.HasMetaDataKey("0010|0010") && singleFileImage.HasMetaDataKey("0008|0020")) { string patientName = singleFileImage.GetMetaData("0010|0010"); string studyDate = singleFileImage.GetMetaData("0008|0020"); // 必要なら studyDate を "YYYY-MM-DD" などに整形 datasetName = $"{patientName}_{studyDate}"; } else { Debug.LogWarning($"[SimpleITKDICOMImporter] (0010|0010) or (0008|0020) not found in the first DICOM file => fallback used."); } } catch (Exception e) { Debug.LogWarning($"[SimpleITKDICOMImporter] Failed to read single file metadata: {e.Message}"); } volumeDataset.datasetName = datasetName; // --------------------------------------------- // (2) シリーズ全体(複数ファイル)を読み込み => 3D化 // --------------------------------------------- ImageSeriesReader reader = new ImageSeriesReader(); // 連番ファイルを全部追加 foreach (var dicomFile in sequenceSeries.files) { dicomNames.Add(dicomFile.filePath); } reader.SetFileNames(dicomNames); // 3D イメージを取得 image = reader.Execute(); // (3) RSA (Right, Superior, Anterior) オリエンテーションへ変換 image = SimpleITK.DICOMOrient(image, "RSA"); // (4) float32 へキャスト image = SimpleITK.Cast(image, PixelIDValueEnum.sitkFloat32); // (5) サイズ計算 size = image.GetSize(); int numPixels = 1; for (int dim = 0; dim < image.GetDimension(); dim++) numPixels *= (int)size[dim]; // (6) ピクセルバッファ pixelData = new float[numPixels]; IntPtr imgBuffer = image.GetBufferAsFloat(); Marshal.Copy(imgBuffer, pixelData, 0, numPixels); // (7) Hounsfield clamp if (clampHounsfield) { for (int i = 0; i < pixelData.Length; i++) { pixelData[i] = Mathf.Clamp(pixelData[i], -1024.0f, 3071.0f); } } // (8) Spacing取得 VectorDouble spacing = image.GetSpacing(); // (9) VolumeDataset に反映 volumeDataset.data = pixelData; volumeDataset.dimX = (int)size[0]; volumeDataset.dimY = (int)size[1]; volumeDataset.dimZ = (int)size[2]; volumeDataset.filePath = dicomNames[0]; volumeDataset.scale = new Vector3( (float)(spacing[0] * size[0]) / 1000.0f, // mm -> m (float)(spacing[1] * size[1]) / 1000.0f, (float)(spacing[2] * size[2]) / 1000.0f ); volumeDataset.FixDimensions(); } } } #endif
ランタイムロードの実装:
ランタイムロードの実装にはこちらを使用
GitHub - gkngkc/UnityStandaloneFileBrowser: A native file browser for unity standalone platforms
作成したスクリプト:
csharpusing System.Collections.Generic; using System.Linq; using System.IO; using System.Threading.Tasks; using UnityEngine; using SFB; // StandaloneFileBrowserを使う場合 // using System.Windows.Forms; // WindowsFormsを使う場合はこちら namespace UnityVolumeRendering { public class RuntimeDicomLoader : MonoBehaviour { void Update() { // キー押下判定 (例: A キー) if (Input.GetKeyDown(KeyCode.A)) { OpenDicomFolder(); } } /// <summary> /// フォルダ選択ダイアログを開き、選択されたフォルダをDICOMとして読み込む /// </summary> private void OpenDicomFolder() { // StandaloneFileBrowserを使う場合 var paths = StandaloneFileBrowser.OpenFolderPanel("Select DICOM Folder", "", false); if (paths.Length > 0 && !string.IsNullOrEmpty(paths[0])) { string folderPath = paths[0]; Debug.Log("[KeyboardDicomLoader] Selected folder: " + folderPath); // 非同期でDICOM読み込み開始 LoadDicomFolder(folderPath); } else { Debug.Log("[KeyboardDicomLoader] Folder selection canceled or empty."); } } /// <summary> /// 指定フォルダ内のDICOMファイルを読み込む (非同期) /// </summary> private async void LoadDicomFolder(string folderPath) { // (1) フォルダ内のファイルをすべて取得 IEnumerable<string> files = Directory.GetFiles(folderPath, "*.*", SearchOption.AllDirectories); // (2) DICOM用のImporterを生成 IImageSequenceImporter importer = ImporterFactory.CreateImageSequenceImporter(ImageSequenceFormat.DICOM); if (importer == null) { Debug.LogError("[KeyboardDicomLoader] DICOM importer not available. Check if UVR_USE_SIMPLEITK or openDicom is enabled."); return; } // (3) シリーズ一覧を取得 (フォルダ内に複数シリーズがある場合がある) var seriesList = await importer.LoadSeriesAsync(files, new ImageSequenceImportSettings()); if (seriesList == null || !seriesList.Any()) { Debug.LogWarning("[KeyboardDicomLoader] No DICOM series found in folder: " + folderPath); return; } // (4) 1つ目のシリーズを実際に読み込む IImageSequenceSeries firstSeries = seriesList.First(); VolumeDataset dataset = await importer.ImportSeriesAsync(firstSeries, new ImageSequenceImportSettings()); if (dataset == null) { Debug.LogError("[KeyboardDicomLoader] Failed to import DICOM dataset."); return; } // (5) 3Dオブジェクトを生成 VolumeRenderedObject volObj = await VolumeObjectFactory.CreateObjectAsync(dataset); if (volObj != null) { Debug.Log($"[KeyboardDicomLoader] Volume object created: {volObj.name}"); } } } }
テスト用としてキーボードで「a」を押したらファイル選択ができるようになっています。


ビルド後のアプリ内でDICOMを読み込むことに成功
レンダリング方法とVisible value rangeの変更の実装
1を押したらDirectVolumeRenderingに 2を押したらMaximum Intensity Projectionに 3を押したらIsosurfaceRenderingになるように。
ゲームパッドでは、右側のボタンの上側を押すことでローテーションするように
また、Visible value rangeの調整は、minとmaxの調整ができるが、UI上の問題が起きそうだったのでとりあえずはminの調整を行うのみにしています。それでも実用的なうえ直感的にもわかりやすくなっています。
minに関しては左右矢印キーで調整可能。3のIsosurfaceRenderingで行うとわかりやすいと思います。
作成したスクリプト:
RuntimeDicomLoader.cs
csharpusing UnityEngine; using UnityVolumeRendering; using System.Collections.Generic; using System.Linq; using System.IO; using System.Threading.Tasks; using SFB; // StandaloneFileBrowser を使用する場合 using TMPro; // TextMeshPro using VolumeRenderMode = UnityVolumeRendering.RenderMode; public class RuntimeDicomLoader : MonoBehaviour { /// <summary> /// 現在表示中の VolumeRenderedObject (1つだけ) /// </summary> private VolumeRenderedObject currentVolumeObject = null; /// <summary> /// オブジェクト名を表示するための TMP_Text (インスペクターで設定) /// </summary> [SerializeField] private TMP_Text objectNameText; // 読み込んだ直後の位置・回転・スケールを保持 private Vector3 initialPosition; private Quaternion initialRotation; private Vector3 initialScale; // 左ドラッグ移動用 private bool isLeftDragging = false; private Vector3 lastMouseWorldPos; private void Update() { if (currentVolumeObject != null) { // 現在の visibility window を取得(xが min, y が max) Vector2 currentWindow = currentVolumeObject.GetVisibilityWindow(); // 1フレームごとに変化させる値。必要に応じて調整してください。 float delta = 0.01f; bool changed = false; // 左矢印: min を小さくする(0以下にならないようにClamp) if (Input.GetKey(KeyCode.LeftArrow)) { currentWindow.x = Mathf.Max(0.0f, currentWindow.x - delta); changed = true; } // 右矢印: min を大きくする(max を超えないようにClamp、ここでは max-0.01f まで) if (Input.GetKey(KeyCode.RightArrow)) { currentWindow.x = Mathf.Min(currentWindow.y - 0.01f, currentWindow.x + delta); changed = true; } if (changed) { currentVolumeObject.SetVisibilityWindow(currentWindow); Debug.Log($"[RuntimeDicomLoader] Visibility window min changed to: {currentWindow.x}"); } } // Aキー: DICOMフォルダ読み込み if (Input.GetKeyDown(KeyCode.A)) { OpenDicomFolder(); } // 右クリックドラッグ: 回転 if (currentVolumeObject != null && Input.GetMouseButton(1)) { RotateCurrentObject(); } // マウスホイール: 拡大縮小 if (currentVolumeObject != null) { ZoomCurrentObject(); } // Escキー: アプリ終了 (Editor上では終了しない) if (Input.GetKeyDown(KeyCode.Escape)) { Application.Quit(); Debug.Log("[RuntimeDicomLoader] Application.Quit() called."); } // Zキー: 元の位置・回転・スケールに戻す if (Input.GetKeyDown(KeyCode.Z)) { ResetObjectTransform(); } // 1キー: DirectVolumeRenderingに切り替え if (Input.GetKeyDown(KeyCode.Alpha1)) { if (currentVolumeObject != null) { currentVolumeObject.SetRenderMode(VolumeRenderMode.DirectVolumeRendering); Debug.Log("[RuntimeDicomLoader] Render mode set to Direct Volume Rendering."); } } // 2キー: Maximum Intensity Projectionに切り替え if (Input.GetKeyDown(KeyCode.Alpha2)) { if (currentVolumeObject != null) { currentVolumeObject.SetRenderMode(VolumeRenderMode.MaximumIntensityProjectipon); Debug.Log("[RuntimeDicomLoader] Render mode set to Maximum Intensity Projection."); } } // 3キー: IsosurfaceRenderingに切り替え if (Input.GetKeyDown(KeyCode.Alpha3)) { if (currentVolumeObject != null) { currentVolumeObject.SetRenderMode(VolumeRenderMode.IsosurfaceRendering); Debug.Log("[RuntimeDicomLoader] Render mode set to Isosurface Rendering."); } } // 左クリック押下: ドラッグ開始 if (Input.GetMouseButtonDown(0)) { if (currentVolumeObject != null) { isLeftDragging = true; // クリック時点のマウス座標をワールド座標に変換して記録 lastMouseWorldPos = GetMouseWorldPositionOnPlane(currentVolumeObject.transform.position.z); } } // 左クリック離した: ドラッグ終了 if (Input.GetMouseButtonUp(0)) { isLeftDragging = false; } // 左クリックドラッグ中: 移動 if (isLeftDragging && currentVolumeObject != null) { MoveCurrentObject(); } } /// <summary> /// フォルダ選択ダイアログを開き、DICOMフォルダを読み込み /// </summary> private void OpenDicomFolder() { var paths = StandaloneFileBrowser.OpenFolderPanel("Select DICOM Folder", "", false); if (paths.Length > 0 && !string.IsNullOrEmpty(paths[0])) { string folderPath = paths[0]; Debug.Log("[RuntimeDicomLoader] Selected folder: " + folderPath); LoadDicomFolder(folderPath); } else { Debug.Log("[RuntimeDicomLoader] Folder selection canceled."); } } /// <summary> /// 指定フォルダ内の DICOM ファイルを読み込み、既存オブジェクトを破棄して新オブジェクトを生成 /// </summary> private async void LoadDicomFolder(string folderPath) { // (1) ファイル一覧 IEnumerable<string> files = Directory.GetFiles(folderPath, "*.*", SearchOption.AllDirectories); // (2) DICOMインポーター IImageSequenceImporter importer = ImporterFactory.CreateImageSequenceImporter(ImageSequenceFormat.DICOM); if (importer == null) { Debug.LogError("[RuntimeDicomLoader] DICOM importer not available."); return; } // (3) シリーズ一覧を取得 var seriesList = await importer.LoadSeriesAsync(files, new ImageSequenceImportSettings()); if (seriesList == null || !seriesList.Any()) { Debug.LogWarning("[RuntimeDicomLoader] No DICOM series found in folder: " + folderPath); return; } // (4) 最初のシリーズをインポート IImageSequenceSeries firstSeries = seriesList.First(); VolumeDataset dataset = await importer.ImportSeriesAsync(firstSeries, new ImageSequenceImportSettings()); if (dataset == null) { Debug.LogError("[RuntimeDicomLoader] Failed to import DICOM dataset."); return; } // 既存オブジェクトを破棄 if (currentVolumeObject != null) { Destroy(currentVolumeObject.gameObject); currentVolumeObject = null; } // (5) 新しい VolumeRenderedObject を生成 VolumeRenderedObject newObj = await VolumeObjectFactory.CreateObjectAsync(dataset); if (newObj != null) { currentVolumeObject = newObj; Debug.Log($"[RuntimeDicomLoader] New volume object created: {newObj.name}"); // 読み込んだ直後の位置・回転・スケールを保存 initialPosition = newObj.transform.position; initialRotation = newObj.transform.rotation; initialScale = newObj.transform.localScale; // TMPにオブジェクト名を表示 if (objectNameText != null) { objectNameText.text = newObj.name; } } } /// <summary> /// 右クリックドラッグで回転 /// </summary> private void RotateCurrentObject() { float rotX = Input.GetAxis("Mouse X"); float rotY = Input.GetAxis("Mouse Y"); // 回転スピード(要望どおり 500f) float rotationSpeed = 500f; currentVolumeObject.transform.Rotate( Vector3.up, rotX * rotationSpeed * Time.deltaTime, Space.World ); currentVolumeObject.transform.Rotate( Vector3.right, -rotY * rotationSpeed * Time.deltaTime, Space.World ); } /// <summary> /// マウスホイールで拡大縮小 /// </summary> private void ZoomCurrentObject() { float scroll = Input.GetAxis("Mouse ScrollWheel"); if (Mathf.Abs(scroll) > 0.001f) { float scaleFactor = 1.0f + scroll * 0.2f; currentVolumeObject.transform.localScale *= scaleFactor; } } /// <summary> /// 左ドラッグで移動 /// </summary> private void MoveCurrentObject() { // 現在のマウスワールド座標を取得 float zVal = currentVolumeObject.transform.position.z; Vector3 mouseWorldPos = GetMouseWorldPositionOnPlane(zVal); // 前フレームからの差分 Vector3 delta = mouseWorldPos - lastMouseWorldPos; // オブジェクトを移動 currentVolumeObject.transform.position += delta; // 今回のマウス位置を次回用に保持 lastMouseWorldPos = mouseWorldPos; } /// <summary> /// 画面上のマウス座標を、Z=zValue の平面上のワールド座標へ変換 /// </summary> private Vector3 GetMouseWorldPositionOnPlane(float zValue) { Camera cam = Camera.main; if (cam == null) return Vector3.zero; // "カメラ座標からzValueまでの距離" を depth として ScreenToWorldPoint float distance = Mathf.Abs(cam.transform.position.z - zValue); Vector3 screenPos = new Vector3(Input.mousePosition.x, Input.mousePosition.y, distance); return cam.ScreenToWorldPoint(screenPos); } /// <summary> /// Zキー: 読み込んだ直後の位置・回転・スケールに戻す /// </summary> private void ResetObjectTransform() { if (currentVolumeObject == null) return; currentVolumeObject.transform.position = initialPosition; currentVolumeObject.transform.rotation = initialRotation; currentVolumeObject.transform.localScale = initialScale; } }
DICOMコントローラー対応
PackageManagerよりInputsystemをインストール

Window/Analysis/inputDebug



作成スクリプト
csharp
using UnityEngine;
using UnityEngine.InputSystem; // 新Input System
using UnityVolumeRendering;
using VolumeRenderMode = UnityVolumeRendering.RenderMode;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Threading.Tasks;
using TMPro;
using SFB; // StandaloneFileBrowser 用
public class DicomController : MonoBehaviour
{
/// <summary> 現在表示中の VolumeRenderedObject (1つだけ) </summary>
private VolumeRenderedObject currentVolumeObject = null;
/// <summary> オブジェクト名を表示するための TMP_Text (インスペクターで設定) </summary>
[SerializeField]
private TMP_Text objectNameText;
// 読み込んだ直後の位置・回転・スケールを保持
private Vector3 initialPosition;
private Quaternion initialRotation;
private Vector3 initialScale;
// Visibility Window変更用の1フレームごとの変化量
private float visibilityDelta = 0.01f;
// ゲームパッド移動・回転用
private Vector2 moveInput = Vector2.zero;
private Vector2 rotateInput = Vector2.zero;
// レンダリングモードをサイクリックに変更するための管理
private int currentRenderModeIndex = 1; // 1=DirectVolume, 2=MIP, 3=Isosurface
// --------------------
// 新Input System関連
// --------------------
// PlayerInput / InputActionAsset をインスペクターで参照しても良いですが、
// ここではスクリプト内で InputActions (Auto-generated C#) を生成して使うサンプルにします。
private GamepadControls controls; // 自作のInputActionsクラス(例)
private void Awake()
{
// 自動生成した C#クラス(GamepadControls)をインスタンス化
controls = new GamepadControls();
// --- ボタン/1回押下系: performedイベントに登録 ---
controls.Player.OpenDicom.performed += ctx => OpenDicomFolder();
controls.Player.ResetTransform.performed += ctx => ResetObjectTransform();
controls.Player.VisibilityMinLeft.performed += ctx => VisibilityMinDecrease();
controls.Player.VisibilityMinRight.performed += ctx => VisibilityMinIncrease();
controls.Player.Quit.performed += ctx => QuitApplication();
controls.Player.CycleRenderMode.performed += ctx => CycleRenderMode();
// --- 移動/回転(連続入力) ---
// performed/canceled の両方を購読して、moveInput/rotateInput の値を取得・リセット
controls.Player.Move.performed += ctx => moveInput = ctx.ReadValue<Vector2>();
controls.Player.Move.canceled += ctx => moveInput = Vector2.zero;
controls.Player.Rotate.performed += ctx => rotateInput = ctx.ReadValue<Vector2>();
controls.Player.Rotate.canceled += ctx => rotateInput = Vector2.zero;
// Shoulderボタンのズームは「押しっぱなし」なら毎フレーム処理したいので、Updateで .IsPressed() を見る方式にします
}
private void OnEnable()
{
controls.Enable();
}
private void OnDisable()
{
controls.Disable();
}
private void Update()
{
// キーボード 1/2/3 でのレンダーモード切り替え(既存機能はそのまま)
if (Input.GetKeyDown(KeyCode.Alpha1))
{
SetRenderMode(1);
}
else if (Input.GetKeyDown(KeyCode.Alpha2))
{
SetRenderMode(2);
}
else if (Input.GetKeyDown(KeyCode.Alpha3))
{
SetRenderMode(3);
}
// --- ゲームパッドの肩ボタン(LB/RB)で拡大/縮小 ---
// ZoomIn (RightShoulder)
if (controls.Player.ZoomIn.IsPressed())
{
ZoomCurrentObject(+1.0f);
}
// ZoomOut (LeftShoulder)
if (controls.Player.ZoomOut.IsPressed())
{
ZoomCurrentObject(-1.0f);
}
// --- Right Stick で移動 ---
if (currentVolumeObject != null && moveInput.sqrMagnitude > 0.0001f)
{
// 速度や感度は適宜調整してください
float moveSpeed = 0.02f;
Vector3 delta = new Vector3(moveInput.x, moveInput.y, 0f) * moveSpeed;
currentVolumeObject.transform.position += delta;
}
// --- Left Stick で回転 ---
if (currentVolumeObject != null && rotateInput.sqrMagnitude > 0.0001f)
{
float rotationSpeed = 250f;
// rotateInput.x を左右回転(Y軸)、rotateInput.y を上下回転(X軸)に割り当て
currentVolumeObject.transform.Rotate(
Vector3.up,
rotateInput.x * rotationSpeed * Time.deltaTime,
Space.World
);
currentVolumeObject.transform.Rotate(
Vector3.right,
-rotateInput.y * rotationSpeed * Time.deltaTime,
Space.World
);
}
}
/// <summary>
/// Visibility window の min を小さくする (LeftArrow / D-Pad Left)
/// </summary>
private void VisibilityMinDecrease()
{
if (currentVolumeObject == null) return;
Vector2 window = currentVolumeObject.GetVisibilityWindow();
window.x = Mathf.Max(0.0f, window.x - visibilityDelta);
currentVolumeObject.SetVisibilityWindow(window);
Debug.Log($"[RuntimeDicomLoader] Visibility window min changed to: {window.x}");
}
/// <summary>
/// Visibility window の min を大きくする (RightArrow / D-Pad Right)
/// </summary>
private void VisibilityMinIncrease()
{
if (currentVolumeObject == null) return;
Vector2 window = currentVolumeObject.GetVisibilityWindow();
// min が max を超えないように調整
window.x = Mathf.Min(window.y - 0.01f, window.x + visibilityDelta);
currentVolumeObject.SetVisibilityWindow(window);
Debug.Log($"[RuntimeDicomLoader] Visibility window min changed to: {window.x}");
}
/// <summary>
/// RenderMode を1→2→3→1と切り替える (ゲームパッドのButtonNorthで実行)
/// </summary>
private void CycleRenderMode()
{
currentRenderModeIndex++;
if (currentRenderModeIndex > 3)
currentRenderModeIndex = 1;
SetRenderMode(currentRenderModeIndex);
}
/// <summary>
/// レンダリングモードを番号で指定して切り替える
/// </summary>
private void SetRenderMode(int index)
{
if (currentVolumeObject == null) return;
switch (index)
{
case 1:
currentVolumeObject.SetRenderMode(VolumeRenderMode.DirectVolumeRendering);
Debug.Log("[RuntimeDicomLoader] Render mode set to Direct Volume Rendering.");
currentRenderModeIndex = 1;
break;
case 2:
currentVolumeObject.SetRenderMode(VolumeRenderMode.MaximumIntensityProjectipon);
Debug.Log("[RuntimeDicomLoader] Render mode set to Maximum Intensity Projection.");
currentRenderModeIndex = 2;
break;
case 3:
currentVolumeObject.SetRenderMode(VolumeRenderMode.IsosurfaceRendering);
Debug.Log("[RuntimeDicomLoader] Render mode set to Isosurface Rendering.");
currentRenderModeIndex = 3;
break;
}
}
/// <summary>
/// DICOMフォルダを開く (Aキー / Start ボタン)
/// </summary>
private void OpenDicomFolder()
{
var paths = StandaloneFileBrowser.OpenFolderPanel("Select DICOM Folder", "", false);
if (paths.Length > 0 && !string.IsNullOrEmpty(paths[0]))
{
string folderPath = paths[0];
Debug.Log("[RuntimeDicomLoader] Selected folder: " + folderPath);
LoadDicomFolder(folderPath);
}
else
{
Debug.Log("[RuntimeDicomLoader] Folder selection canceled.");
}
}
/// <summary>
/// 指定フォルダ内の DICOM ファイルを読み込み、既存オブジェクトを破棄して新オブジェクトを生成
/// </summary>
private async void LoadDicomFolder(string folderPath)
{
IEnumerable<string> files = Directory.GetFiles(folderPath, "*.*", SearchOption.AllDirectories);
IImageSequenceImporter importer = ImporterFactory.CreateImageSequenceImporter(ImageSequenceFormat.DICOM);
if (importer == null)
{
Debug.LogError("[RuntimeDicomLoader] DICOM importer not available.");
return;
}
var seriesList = await importer.LoadSeriesAsync(files, new ImageSequenceImportSettings());
if (seriesList == null || !seriesList.Any())
{
Debug.LogWarning("[RuntimeDicomLoader] No DICOM series found in folder: " + folderPath);
return;
}
IImageSequenceSeries firstSeries = seriesList.First();
VolumeDataset dataset = await importer.ImportSeriesAsync(firstSeries, new ImageSequenceImportSettings());
if (dataset == null)
{
Debug.LogError("[RuntimeDicomLoader] Failed to import DICOM dataset.");
return;
}
// 既存オブジェクトを破棄
if (currentVolumeObject != null)
{
Destroy(currentVolumeObject.gameObject);
currentVolumeObject = null;
}
// 新しい VolumeRenderedObject を生成
VolumeRenderedObject newObj = await VolumeObjectFactory.CreateObjectAsync(dataset);
if (newObj != null)
{
currentVolumeObject = newObj;
Debug.Log($"[RuntimeDicomLoader] New volume object created: {newObj.name}");
initialPosition = newObj.transform.position;
initialRotation = newObj.transform.rotation;
initialScale = newObj.transform.localScale;
if (objectNameText != null)
{
objectNameText.text = newObj.name;
}
}
}
/// <summary>
/// (Zキー / ButtonSouth) 読み込んだ直後の位置・回転・スケールに戻す
/// </summary>
private void ResetObjectTransform()
{
if (currentVolumeObject == null) return;
currentVolumeObject.transform.position = initialPosition;
currentVolumeObject.transform.rotation = initialRotation;
currentVolumeObject.transform.localScale = initialScale;
Debug.Log("[RuntimeDicomLoader] Reset to initial transform.");
}
/// <summary>
/// (Escキー / Selectボタン) アプリ終了
/// </summary>
private void QuitApplication()
{
#if UNITY_EDITOR
Debug.Log("[RuntimeDicomLoader] Application.Quit() called. (Editor上では実行されません)");
#else
Application.Quit();
#endif
}
/// <summary>
/// 肩ボタン(RB/LB)で拡大・縮小
/// zoomDirection: +1なら拡大, -1なら縮小
/// </summary>
private void ZoomCurrentObject(float zoomDirection)
{
if (currentVolumeObject == null) return;
// 拡大・縮小のスピード
float factor = 1.0f + zoomDirection * 0.02f;
currentVolumeObject.transform.localScale *= factor;
}
}オープンキャンパス用最終コード
csharp
using UnityEngine;
using UnityEngine.InputSystem; // 新Input System
using UnityVolumeRendering;
using VolumeRenderMode = UnityVolumeRendering.RenderMode;
using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Threading.Tasks;
using TMPro;
using SFB; // StandaloneFileBrowser 用
using System; // for Serializable, etc.
public class DicomController : MonoBehaviour
{
/// <summary> 現在表示中の VolumeRenderedObject (1つだけ) </summary>
private VolumeRenderedObject currentVolumeObject = null;
/// <summary> オブジェクト名を表示するための TMP_Text (インスペクターで設定) </summary>
[SerializeField]
private TMP_Text objectNameText;
// 読み込んだ直後の位置・回転・スケールを保持
private Vector3 initialPosition;
private Quaternion initialRotation;
private Vector3 initialScale;
// Visibility Window変更用の1フレームごとの変化量
private float visibilityDelta = 0.01f;
// ゲームパッド移動・回転用
private Vector2 moveInput = Vector2.zero;
private Vector2 rotateInput = Vector2.zero;
// レンダリングモードをサイクリックに変更するための管理
private int currentRenderModeIndex = 1; // 1=DirectVolume, 2=MIP, 3=Isosurface
// -------------------------------------------------------
// 「一度ロードしたフォルダ」をストックするためのリスト
// -------------------------------------------------------
private List<string> savedDatasetFolders = new List<string>(); // StreamingAssets 内にコピーしたパス一覧
private int currentIndex = -1; // 現在表示している savedDatasetFolders のインデックス
// JSON保存用のファイル名(StreamingAssets直下でも可)
private string listJsonPath;
// --------------------
// 新Input System関連
// --------------------
private GamepadControls controls; // 自動生成されたInputActionsクラス(例)
private void Awake()
{
// 自動生成した C#クラス(GamepadControls)をインスタンス化
controls = new GamepadControls();
// --- ボタン/1回押下系: performedイベントに登録 ---
controls.Player.OpenDicom.performed += ctx => OpenDicomFolder();
controls.Player.ResetTransform.performed += ctx => ResetObjectTransform();
/*
controls.Player.VisibilityMinLeft.performed += ctx => VisibilityMinDecrease();
controls.Player.VisibilityMinRight.performed += ctx => VisibilityMinIncrease();
*/
controls.Player.Quit.performed += ctx => QuitApplication();
controls.Player.CycleRenderMode.performed += ctx => CycleRenderMode();
// 新規追加: ShowPrev(LeftTrigger) / ShowNext(RightTrigger) / DeleteData(D-pad Down)
controls.Player.ShowPrev.performed += ctx => ShowPreviousDataset();
controls.Player.ShowNext.performed += ctx => ShowNextDataset();
controls.Player.DeleteData.performed += ctx => DeleteCurrentDataset();
// --- 移動/回転(連続入力) ---
controls.Player.Move.performed += ctx => moveInput = ctx.ReadValue<Vector2>();
controls.Player.Move.canceled += ctx => moveInput = Vector2.zero;
controls.Player.Rotate.performed += ctx => rotateInput = ctx.ReadValue<Vector2>();
controls.Player.Rotate.canceled += ctx => rotateInput = Vector2.zero;
// Shoulderボタン(LB/RB)のズームは Update() で .IsPressed() を見る方式
// JSONファイルのパス設定 (StreamingAssets/DicomCacheList.json等)
string streamingPath = Application.streamingAssetsPath;
if (!Directory.Exists(streamingPath))
{
Directory.CreateDirectory(streamingPath);
}
listJsonPath = Path.Combine(streamingPath, "DicomCacheList.json");
// アプリ起動時にリストを読み込み
LoadSavedList();
}
private void OnEnable()
{
controls.Enable();
}
private void OnDisable()
{
controls.Disable();
// 終了時などにリストを保存
SaveSavedList();
}
private void Update()
{
if (currentVolumeObject != null)
{
// 連続調整の速度(1秒あたりどのくらい変化させるか)を設定
float visibilityChangeSpeed = 0.2f; // 大きめなら速く変わる、好みで調整
// D-pad 左を押しっぱなしの間、minを継続的に小さくする
if (controls.Player.VisibilityMinLeft.IsPressed())
{
Vector2 window = currentVolumeObject.GetVisibilityWindow();
// 1フレームごとの変化量(秒ごとに変化したいので deltaTime を掛ける)
float delta = visibilityChangeSpeed * Time.deltaTime;
window.x = Mathf.Max(0.0f, window.x - delta);
currentVolumeObject.SetVisibilityWindow(window);
}
// D-pad 右を押しっぱなしの間、minを継続的に大きくする
if (controls.Player.VisibilityMinRight.IsPressed())
{
Vector2 window = currentVolumeObject.GetVisibilityWindow();
float delta = visibilityChangeSpeed * Time.deltaTime;
// min が max を超えないように少し余裕を残してClamp
// (ここでは max - 0.01f などで固定しているが、お好みで)
window.x = Mathf.Min(window.y - 0.01f, window.x + delta);
currentVolumeObject.SetVisibilityWindow(window);
}
}
// キーボード 1/2/3 でのレンダーモード切り替え(既存機能はそのまま)
if (Input.GetKeyDown(KeyCode.Alpha1))
{
SetRenderMode(1);
}
else if (Input.GetKeyDown(KeyCode.Alpha2))
{
SetRenderMode(2);
}
else if (Input.GetKeyDown(KeyCode.Alpha3))
{
SetRenderMode(3);
}
// --- ゲームパッドの肩ボタン(LB/RB)で拡大/縮小 ---
// ZoomIn (RightShoulder)
if (controls.Player.ZoomIn.IsPressed())
{
ZoomCurrentObject(+1.0f);
}
// ZoomOut (LeftShoulder)
if (controls.Player.ZoomOut.IsPressed())
{
ZoomCurrentObject(-1.0f);
}
// --- Right Stick で移動 ---
if (currentVolumeObject != null && moveInput.sqrMagnitude > 0.0001f)
{
float moveSpeed = 0.02f;
Vector3 delta = new Vector3(moveInput.x, moveInput.y, 0f) * moveSpeed;
currentVolumeObject.transform.position += delta;
}
// --- Left Stick で回転 ---
if (currentVolumeObject != null && rotateInput.sqrMagnitude > 0.0001f)
{
float rotationSpeed = 250f;
currentVolumeObject.transform.Rotate(
Vector3.up,
rotateInput.x * rotationSpeed * Time.deltaTime,
Space.World
);
currentVolumeObject.transform.Rotate(
Vector3.right,
-rotateInput.y * rotationSpeed * Time.deltaTime,
Space.World
);
}
}
// -----------------------------------------------------------
// Visibility window 関連
// -----------------------------------------------------------
private void VisibilityMinDecrease()
{
if (currentVolumeObject == null) return;
Vector2 window = currentVolumeObject.GetVisibilityWindow();
window.x = Mathf.Max(0.0f, window.x - visibilityDelta);
currentVolumeObject.SetVisibilityWindow(window);
Debug.Log($"[RuntimeDicomLoader] Visibility window min changed to: {window.x}");
}
private void VisibilityMinIncrease()
{
if (currentVolumeObject == null) return;
Vector2 window = currentVolumeObject.GetVisibilityWindow();
window.x = Mathf.Min(window.y - 0.01f, window.x + visibilityDelta);
currentVolumeObject.SetVisibilityWindow(window);
Debug.Log($"[RuntimeDicomLoader] Visibility window min changed to: {window.x}");
}
// -----------------------------------------------------------
// レンダリングモードのサイクリック切り替え
// -----------------------------------------------------------
private void CycleRenderMode()
{
currentRenderModeIndex++;
if (currentRenderModeIndex > 3)
currentRenderModeIndex = 1;
SetRenderMode(currentRenderModeIndex);
}
private void SetRenderMode(int index)
{
if (currentVolumeObject == null) return;
switch (index)
{
case 1:
currentVolumeObject.SetRenderMode(VolumeRenderMode.DirectVolumeRendering);
Debug.Log("[RuntimeDicomLoader] Render mode set to Direct Volume Rendering.");
currentRenderModeIndex = 1;
break;
case 2:
currentVolumeObject.SetRenderMode(VolumeRenderMode.MaximumIntensityProjectipon);
Debug.Log("[RuntimeDicomLoader] Render mode set to Maximum Intensity Projection.");
currentRenderModeIndex = 2;
break;
case 3:
currentVolumeObject.SetRenderMode(VolumeRenderMode.IsosurfaceRendering);
Debug.Log("[RuntimeDicomLoader] Render mode set to Isosurface Rendering.");
currentRenderModeIndex = 3;
break;
}
}
// -----------------------------------------------------------
// DICOMフォルダを開く + StreamingAssetsに保存
// -----------------------------------------------------------
private void OpenDicomFolder()
{
var paths = StandaloneFileBrowser.OpenFolderPanel("Select DICOM Folder", "", false);
if (paths.Length > 0 && !string.IsNullOrEmpty(paths[0]))
{
string originalFolderPath = paths[0];
Debug.Log("[RuntimeDicomLoader] Selected folder: " + originalFolderPath);
// フォルダをStreamingAssets内にコピー (サブフォルダ名は日付などで一意にする例)
string savedFolderName = $"Dicom_{DateTime.Now.ToString("yyyyMMdd_HHmmss")}";
string destRoot = Path.Combine(Application.streamingAssetsPath, "DicomCache");
if (!Directory.Exists(destRoot))
Directory.CreateDirectory(destRoot);
string destFolderPath = Path.Combine(destRoot, savedFolderName);
CopyFolder(originalFolderPath, destFolderPath);
// 保存リストに追加し、末尾を表示
savedDatasetFolders.Add(destFolderPath);
currentIndex = savedDatasetFolders.Count - 1;
SaveSavedList();
// DICOMデータをロード
LoadDicomFolder(destFolderPath);
}
else
{
Debug.Log("[RuntimeDicomLoader] Folder selection canceled.");
}
}
/// <summary>
/// 指定フォルダ内の DICOM ファイルを読み込み、既存オブジェクトを破棄して新オブジェクトを生成
/// </summary>
private async void LoadDicomFolder(string folderPath)
{
if (string.IsNullOrEmpty(folderPath) || !Directory.Exists(folderPath))
{
Debug.LogError($"[RuntimeDicomLoader] Invalid folder path: {folderPath}");
return;
}
IEnumerable<string> files = Directory.GetFiles(folderPath, "*.*", SearchOption.AllDirectories);
IImageSequenceImporter importer = ImporterFactory.CreateImageSequenceImporter(ImageSequenceFormat.DICOM);
if (importer == null)
{
Debug.LogError("[RuntimeDicomLoader] DICOM importer not available.");
return;
}
var seriesList = await importer.LoadSeriesAsync(files, new ImageSequenceImportSettings());
if (seriesList == null || !seriesList.Any())
{
Debug.LogWarning("[RuntimeDicomLoader] No DICOM series found in folder: " + folderPath);
return;
}
IImageSequenceSeries firstSeries = seriesList.First();
VolumeDataset dataset = await importer.ImportSeriesAsync(firstSeries, new ImageSequenceImportSettings());
if (dataset == null)
{
Debug.LogError("[RuntimeDicomLoader] Failed to import DICOM dataset.");
return;
}
// 既存オブジェクトを破棄
if (currentVolumeObject != null)
{
Destroy(currentVolumeObject.gameObject);
currentVolumeObject = null;
}
// 新しい VolumeRenderedObject を生成
VolumeRenderedObject newObj = await VolumeObjectFactory.CreateObjectAsync(dataset);
if (newObj != null)
{
currentVolumeObject = newObj;
Debug.Log($"[RuntimeDicomLoader] New volume object created: {newObj.name}");
initialPosition = newObj.transform.position;
initialRotation = newObj.transform.rotation;
initialScale = newObj.transform.localScale;
if (objectNameText != null)
{
objectNameText.text = newObj.name;
}
}
}
// -----------------------------------------------------------
// リセット・アプリ終了
// -----------------------------------------------------------
private void ResetObjectTransform()
{
if (currentVolumeObject == null) return;
currentVolumeObject.transform.position = initialPosition;
currentVolumeObject.transform.rotation = initialRotation;
currentVolumeObject.transform.localScale = initialScale;
Debug.Log("[RuntimeDicomLoader] Reset to initial transform.");
}
private void QuitApplication()
{
#if UNITY_EDITOR
Debug.Log("[RuntimeDicomLoader] Application.Quit() called. (Editor上では実行されません)");
#else
Application.Quit();
#endif
}
/// <summary>
/// 肩ボタン(RB/LB)で拡大・縮小
/// zoomDirection: +1なら拡大, -1なら縮小
/// </summary>
private void ZoomCurrentObject(float zoomDirection)
{
if (currentVolumeObject == null) return;
float factor = 1.0f + zoomDirection * 0.02f;
currentVolumeObject.transform.localScale *= factor;
}
// ----------------------------------------------------------------
// 前後のデータセットを表示 (LeftTrigger / RightTrigger)
// ----------------------------------------------------------------
private void ShowPreviousDataset()
{
if (savedDatasetFolders.Count == 0) return;
// (currentIndex - 1) が負になっても、プラスに直すために一度 +Count してから %Count
currentIndex = (currentIndex - 1 + savedDatasetFolders.Count) % savedDatasetFolders.Count;
LoadDicomFolder(savedDatasetFolders[currentIndex]);
}
private void ShowNextDataset()
{
if (savedDatasetFolders.Count == 0) return;
currentIndex = (currentIndex + 1) % savedDatasetFolders.Count;
LoadDicomFolder(savedDatasetFolders[currentIndex]);
}
// ----------------------------------------------------------------
// 現在表示しているものを削除 (D-pad Down)
// ----------------------------------------------------------------
private void DeleteCurrentDataset()
{
if (currentIndex < 0 || currentIndex >= savedDatasetFolders.Count)
{
Debug.Log("[RuntimeDicomLoader] No dataset to delete.");
return;
}
string folderToDelete = savedDatasetFolders[currentIndex];
// 物理フォルダを削除
if (Directory.Exists(folderToDelete))
{
RemoveFolder(folderToDelete);
}
// リストから除去
savedDatasetFolders.RemoveAt(currentIndex);
Debug.Log($"[RuntimeDicomLoader] Deleted dataset folder: {folderToDelete}");
// index の再調整
if (savedDatasetFolders.Count == 0)
{
// すべて消えたら何も表示しない
currentIndex = -1;
if (currentVolumeObject != null)
{
Destroy(currentVolumeObject.gameObject);
currentVolumeObject = null;
}
if (objectNameText)
objectNameText.text = "";
}
else
{
if (currentIndex >= savedDatasetFolders.Count)
currentIndex = 0;
LoadDicomFolder(savedDatasetFolders[currentIndex]);
}
SaveSavedList();
}
// ----------------------------------------------------------------
// フォルダコピー/削除のユーティリティ
// ----------------------------------------------------------------
private void CopyFolder(string sourceDir, string destDir)
{
if (!Directory.Exists(destDir))
{
Directory.CreateDirectory(destDir);
}
string[] files = Directory.GetFiles(sourceDir);
foreach (string file in files)
{
string name = Path.GetFileName(file);
string dest = Path.Combine(destDir, name);
File.Copy(file, dest, true);
}
string[] folders = Directory.GetDirectories(sourceDir);
foreach (string folder in folders)
{
string name = Path.GetFileName(folder);
string dest = Path.Combine(destDir, name);
CopyFolder(folder, dest);
}
}
private void RemoveFolder(string targetDir)
{
if (!Directory.Exists(targetDir)) return;
// 再帰的に削除
Directory.Delete(targetDir, true);
}
// ----------------------------------------------------------------
// 保存リストを JSON で読み書き
// ----------------------------------------------------------------
[Serializable]
private class SavedList
{
public List<string> folders;
public int currentIndex;
}
private void LoadSavedList()
{
if (!File.Exists(listJsonPath))
{
Debug.Log("[RuntimeDicomLoader] No existing DicomCacheList.json found.");
savedDatasetFolders.Clear();
currentIndex = -1;
return;
}
string json = File.ReadAllText(listJsonPath);
SavedList data = JsonUtility.FromJson<SavedList>(json);
if (data == null)
{
savedDatasetFolders.Clear();
currentIndex = -1;
Debug.LogWarning("[RuntimeDicomLoader] Failed to parse DicomCacheList.json");
return;
}
savedDatasetFolders = data.folders ?? new List<string>();
currentIndex = data.currentIndex;
// アプリ開始時に最後の状態を自動表示したいならここでロード
if (currentIndex >= 0 && currentIndex < savedDatasetFolders.Count)
{
LoadDicomFolder(savedDatasetFolders[currentIndex]);
}
else
{
currentIndex = -1;
}
Debug.Log($"[RuntimeDicomLoader] LoadSavedList done. {savedDatasetFolders.Count} items.");
}
private void SaveSavedList()
{
SavedList data = new SavedList
{
folders = savedDatasetFolders,
currentIndex = currentIndex
};
string json = JsonUtility.ToJson(data, true);
File.WriteAllText(listJsonPath, json);
Debug.Log("[RuntimeDicomLoader] SaveSavedList done.");
}
}スライスレンダラーを見れるようにする
スライスレンダラーについて
シーンを切り分けて、スライスレンダラーを見るモードにしているときは別のシーンへと遷移するようにする(現在のシーンをこれ以上ごちゃごちゃにしないようにするため)。
スライスレンダラーには3Dオブジェクトのplaneを作製する機能があるよう。
とりあえず作成したスクリプト
3D側のスクリプトとInputActionにも遷移用のコードを追加。
スライスレンダラー追加ver1
csharp
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
using UnityVolumeRendering;
public class Scene2DController : MonoBehaviour
{
[Header("Assign the VolumeRenderedObject (from 3D) in Inspector or search via code")]
public VolumeRenderedObject volumeObj;
private GamepadControls controls; // New Input System generated class
private SlicingPlane slicingPlane;
private void Awake()
{
controls = new GamepadControls();
}
private void OnEnable()
{
// 有効化されると 2D用のActionMapをEnable
controls.Player2D.Enable();
// Startボタン (長押し) → 3Dシーンへ戻る
controls.Player2D.SwitchScene.performed += OnSwitchScenePerformed;
}
private void OnDisable()
{
// Disable時に購読解除
controls.Player2D.SwitchScene.performed -= OnSwitchScenePerformed;
controls.Player2D.Disable();
}
private void Start()
{
// volumeObj が Inspector でセットされていない場合、シーン内で検索
if (volumeObj == null)
{
volumeObj = FindObjectOfType<VolumeRenderedObject>();
if (volumeObj == null)
{
Debug.LogError("[Scene2DController] No VolumeRenderedObject found in scene!");
return;
}
}
// 1) ここで SlicingPlane を生成
slicingPlane = volumeObj.CreateSlicingPlane();
// 2) XY 平面にする(Zが法線になる)
slicingPlane.transform.localRotation = Quaternion.identity;
// 3) ワールド空間のルートに置く(親なし)
slicingPlane.transform.SetParent(null, false);
slicingPlane.transform.localPosition = Vector3.zero;
slicingPlane.transform.localScale = Vector3.one;
// これで slicingPlane は XY 面のスライスを表示する。
// Plane を移動/回転しない限り、デフォルト位置の断面が見えるだけ。
}
// SwitchScene → Startボタン(長押し)で 3Dシーンへ戻る
private void OnSwitchScenePerformed(InputAction.CallbackContext ctx)
{
// シーン名は 3Dシーンを "Dicom3DViewer" と仮定
SceneManager.LoadScene("Dicom3DViewer");
}
}このスクリプトだと3D側で表示していたオブジェクトが見つからなくなる模様。 用改良。
わかりやすいDICOM解説
DICOMデータとはなにか|医療のためのPythonプログラミング
DICOMのタグについて
DICOMは画像データだけではなくほかにも患者の情報や撮影に関する情報が入っている。
それらはタグで管理しておりDICOMViewerなどを利用することで確認することができる。
(データがないこと・タグが形式によて異なることがあるようなので注意)
使用したDICOMViewer:
DICOMのデータ収集:
Welcome to The Cancer Imaging Archive - The Cancer Imaging Archive (TCIA)

権利的にも問題なさそう。
テストデータのDICOMタグ

タグの編集については
で行うことができます。 DICOMファイルの連番の一番最後のもののタグデータ: 08|20を日付:本来は撮影した日付、編集で好きなものに変更可
10|10を好きな名前に変更することで表示する名前を 変更できます。
Author: 水上 | Source:
水上\医療系:CT スキャンデータについて調査 0c74f6e4d4994d11ade00f425cbbc6cb.md