diff --git a/Assets/3rdparty/Nifti.NET/Nifti.cs b/Assets/3rdparty/Nifti.NET/Nifti.cs index 47cc2ff5..3d7a8371 100644 --- a/Assets/3rdparty/Nifti.NET/Nifti.cs +++ b/Assets/3rdparty/Nifti.NET/Nifti.cs @@ -135,6 +135,8 @@ public float[] ToSingleArray() return Array.ConvertAll(this.Data as short[], Convert.ToSingle); else if(type == typeof(ushort)) return Array.ConvertAll(this.Data as ushort[], Convert.ToSingle); + else if (type == typeof(byte)) + return Array.ConvertAll(this.Data as byte[], Convert.ToSingle); else return null; } diff --git a/Assets/Editor/TransferFunctionEditorWindow.cs b/Assets/Editor/TransferFunctionEditorWindow.cs index e1bd274e..d35be9db 100644 --- a/Assets/Editor/TransferFunctionEditorWindow.cs +++ b/Assets/Editor/TransferFunctionEditorWindow.cs @@ -11,6 +11,8 @@ public class TransferFunctionEditorWindow : EditorWindow private TransferFunctionEditor tfEditor = new TransferFunctionEditor(); + private bool keepTf = false; + public static void ShowWindow(VolumeRenderedObject volRendObj) { // Close all (if any) 2D TF editor windows @@ -25,6 +27,21 @@ public static void ShowWindow(VolumeRenderedObject volRendObj) wnd.SetInitialPosition(); } + public static void ShowWindow(VolumeRenderedObject volRendObj, TransferFunction transferFunction) + { + // Close all (if any) 2D TF editor windows + TransferFunction2DEditorWindow[] tf2dWnds = Resources.FindObjectsOfTypeAll(); + foreach (TransferFunction2DEditorWindow tf2dWnd in tf2dWnds) + tf2dWnd.Close(); + + TransferFunctionEditorWindow wnd = (TransferFunctionEditorWindow)EditorWindow.GetWindow(typeof(TransferFunctionEditorWindow)); + wnd.volRendObject = volRendObj; + wnd.tf = transferFunction; + wnd.keepTf = true; + wnd.Show(); + wnd.SetInitialPosition(); + } + private void SetInitialPosition() { Rect rect = this.position; @@ -48,8 +65,9 @@ private void OnGUI() if (volRendObject == null) return; - - tf = volRendObject.transferFunction; + + if (!keepTf) + tf = volRendObject.transferFunction; Event currentEvent = new Event(Event.current); @@ -62,7 +80,7 @@ private void OnGUI() Rect outerRect = new Rect(0.0f, 0.0f, contentWidth, contentHeight); Rect tfEditorRect = new Rect(outerRect.x + 20.0f, outerRect.y + 20.0f, outerRect.width - 40.0f, outerRect.height - 50.0f); - tfEditor.SetVolumeObject(volRendObject); + tfEditor.SetTarget(volRendObject.dataset, tf); tfEditor.DrawOnGUI(tfEditorRect); // Draw horizontal zoom slider @@ -99,8 +117,9 @@ private void OnGUI() TransferFunction newTF = TransferFunctionDatabase.LoadTransferFunction(filepath); if(newTF != null) { - tf = newTF; - volRendObject.SetTransferFunction(tf); + tf.alphaControlPoints = newTF.alphaControlPoints; + tf.colourControlPoints = newTF.colourControlPoints; + tf.GenerateTexture(); tfEditor.ClearSelection(); } } @@ -108,11 +127,12 @@ private void OnGUI() // Clear TF if(GUI.Button(new Rect(tfEditorRect.x + 150.0f, tfEditorRect.y + tfEditorRect.height + 20.0f, 70.0f, 30.0f), "Clear")) { - tf = ScriptableObject.CreateInstance(); + tf.alphaControlPoints.Clear(); + tf.colourControlPoints.Clear(); tf.alphaControlPoints.Add(new TFAlphaControlPoint(0.2f, 0.0f)); tf.alphaControlPoints.Add(new TFAlphaControlPoint(0.8f, 1.0f)); tf.colourControlPoints.Add(new TFColourControlPoint(0.5f, new Color(0.469f, 0.354f, 0.223f, 1.0f))); - volRendObject.SetTransferFunction(tf); + tf.GenerateTexture(); tfEditor.ClearSelection(); } diff --git a/Assets/Editor/Utils/EditorDatasetImportUtils.cs b/Assets/Editor/Utils/EditorDatasetImportUtils.cs new file mode 100644 index 00000000..81a085f1 --- /dev/null +++ b/Assets/Editor/Utils/EditorDatasetImportUtils.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System; +using System.Linq; +using UnityEditor; +using UnityEngine; + +namespace UnityVolumeRendering +{ + public class EditorDatasetImportUtils + { + public static async Task ImportDicomDirectoryAsync(string dir, ProgressHandler progressHandler) + { + Debug.Log("Async dataset load. Hold on."); + + List importedDatasets = new List(); + bool recursive = true; + + // Read all files + IEnumerable fileCandidates = Directory.EnumerateFiles(dir, "*.*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly) + .Where(p => p.EndsWith(".dcm", StringComparison.InvariantCultureIgnoreCase) || p.EndsWith(".dicom", StringComparison.InvariantCultureIgnoreCase) || p.EndsWith(".dicm", StringComparison.InvariantCultureIgnoreCase)); + + if (!fileCandidates.Any()) + { + if (UnityEditor.EditorUtility.DisplayDialog("Could not find any DICOM files", + $"Failed to find any files with DICOM file extension.{Environment.NewLine}Do you want to include files without DICOM file extension?", "Yes", "No")) + { + fileCandidates = Directory.EnumerateFiles(dir, "*.*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); + } + } + + if (fileCandidates.Any()) + { + progressHandler.StartStage(0.2f, "Loading DICOM series"); + + IImageSequenceImporter importer = ImporterFactory.CreateImageSequenceImporter(ImageSequenceFormat.DICOM); + IEnumerable seriesList = await importer.LoadSeriesAsync(fileCandidates, new ImageSequenceImportSettings { progressHandler = progressHandler }); + + progressHandler.EndStage(); + progressHandler.StartStage(0.8f); + + int seriesIndex = 0, numSeries = seriesList.Count(); + foreach (IImageSequenceSeries series in seriesList) + { + progressHandler.StartStage(1.0f / numSeries, $"Importing series {seriesIndex + 1} of {numSeries}"); + VolumeDataset dataset = await importer.ImportSeriesAsync(series, new ImageSequenceImportSettings { progressHandler = progressHandler }); + if (dataset != null) + { + await OptionallyDownscale(dataset); + importedDatasets.Add(dataset); + } + seriesIndex++; + progressHandler.EndStage(); + } + + progressHandler.EndStage(); + } + else + Debug.LogError("Could not find any DICOM files to import."); + + return importedDatasets.ToArray(); + } + + public static async Task OptionallyDownscale(VolumeDataset dataset) + { + if (EditorPrefs.GetBool("DownscaleDatasetPrompt")) + { + if (EditorUtility.DisplayDialog("Optional DownScaling", + $"Do you want to downscale the dataset? The dataset's dimension is: {dataset.dimX} x {dataset.dimY} x {dataset.dimZ}", "Yes", "No")) + { + Debug.Log("Async dataset downscale. Hold on."); + await Task.Run(() => dataset.DownScaleData()); + } + } + } + } +} diff --git a/Assets/Editor/VolumeRenderedObjectCustomInspector.cs b/Assets/Editor/VolumeRenderedObjectCustomInspector.cs index bd343c52..5ccb1e6e 100644 --- a/Assets/Editor/VolumeRenderedObjectCustomInspector.cs +++ b/Assets/Editor/VolumeRenderedObjectCustomInspector.cs @@ -2,6 +2,8 @@ using UnityEditor; using System.Collections.Generic; using System.Threading.Tasks; +using System.IO; +using UnityEngine.Events; namespace UnityVolumeRendering { @@ -11,6 +13,8 @@ public class VolumeRenderedObjectCustomInspector : Editor, IProgressView private bool tfSettings = true; private bool lightSettings = true; private bool otherSettings = true; + private bool overlayVolumeSettings = false; + private bool segmentationSettings = false; private float currentProgress = 1.0f; private string currentProgressDescrition = ""; private bool progressDirty = false; @@ -137,6 +141,101 @@ public override void OnInspectorGUI() } } + // Overlay volume + overlayVolumeSettings = EditorGUILayout.Foldout(overlayVolumeSettings, "PET/overlay volume"); + if (overlayVolumeSettings) + { + OverlayType overlayType = volrendObj.GetOverlayType(); + TransferFunction secondaryTransferFunction = volrendObj.GetSecondaryTransferFunction(); + if (overlayType != OverlayType.Overlay) + { + if (GUILayout.Button("Load PET (NRRD, NIFTI)")) + { + ImportImageFileDataset(volrendObj, (VolumeDataset dataset) => + { + TransferFunction secondaryTransferFunction = ScriptableObject.CreateInstance(); + secondaryTransferFunction.colourControlPoints = new List() { new TFColourControlPoint(0.0f, Color.red), new TFColourControlPoint(1.0f, Color.red) }; + secondaryTransferFunction.GenerateTexture(); + volrendObj.SetOverlayDataset(dataset); + volrendObj.SetSecondaryTransferFunction(secondaryTransferFunction); + }); + } + if (GUILayout.Button("Load PET (DICOM)")) + { + ImportDicomDataset(volrendObj, (VolumeDataset dataset) => + { + TransferFunction secondaryTransferFunction = ScriptableObject.CreateInstance(); + secondaryTransferFunction.colourControlPoints = new List() { new TFColourControlPoint(0.0f, Color.red), new TFColourControlPoint(1.0f, Color.red) }; + secondaryTransferFunction.GenerateTexture(); + volrendObj.SetOverlayDataset(dataset); + volrendObj.SetSecondaryTransferFunction(secondaryTransferFunction); + }); + } + } + else + { + if (GUILayout.Button("Edit overlay transfer function")) + { + TransferFunctionEditorWindow.ShowWindow(volrendObj, secondaryTransferFunction); + } + + if (GUILayout.Button("Remove secondary volume")) + { + volrendObj.SetOverlayDataset(null); + } + } + } + + // Segmentations + segmentationSettings = EditorGUILayout.Foldout(segmentationSettings, "Segmentations"); + if (segmentationSettings) + { + List segmentationLabels = volrendObj.GetSegmentationLabels(); + if (segmentationLabels != null && segmentationLabels.Count > 0) + { + for (int i = 0; i < segmentationLabels.Count; i++) + { + EditorGUILayout.BeginHorizontal(); + SegmentationLabel segmentationlabel = segmentationLabels[i]; + EditorGUI.BeginChangeCheck(); + segmentationlabel.name = EditorGUILayout.TextField(segmentationlabel.name); + segmentationlabel.colour = EditorGUILayout.ColorField(segmentationlabel.colour); + bool changed = EditorGUI.EndChangeCheck(); + segmentationLabels[i] = segmentationlabel; + if (GUILayout.Button("delete")) + { + volrendObj.RemoveSegmentation(segmentationlabel.id); + } + EditorGUILayout.EndHorizontal(); + if (changed) + { + volrendObj.UpdateSegmentationLabels(); + } + } + + SegmentationRenderMode segmentationRendreMode = (SegmentationRenderMode)EditorGUILayout.EnumPopup("Render mode", volrendObj.GetSegmentationRenderMode()); + volrendObj.SetSegmentationRenderMode(segmentationRendreMode); + } + if (GUILayout.Button("Add segmentation (NRRD, NIFTI)")) + { + ImportImageFileDataset(volrendObj, (VolumeDataset dataset) => + { + volrendObj.AddSegmentation(dataset); + }); + } + if (GUILayout.Button("Add segmentation (DICOM)")) + { + ImportDicomDataset(volrendObj, (VolumeDataset dataset) => + { + volrendObj.AddSegmentation(dataset); + }); + } + if (GUILayout.Button("Clear segmentations")) + { + volrendObj.ClearSegmentations(); + } + } + // Other settings GUILayout.Space(10); otherSettings = EditorGUILayout.Foldout(otherSettings, "Other Settings"); @@ -152,5 +251,54 @@ public override void OnInspectorGUI() volrendObj.SetSamplingRateMultiplier(EditorGUILayout.Slider("Sampling rate multiplier", volrendObj.GetSamplingRateMultiplier(), 0.2f, 2.0f)); } } + private static async void ImportImageFileDataset(VolumeRenderedObject targetObject, UnityAction onLoad) + { + string filePath = EditorUtility.OpenFilePanel("Select a folder to load", "", ""); + ImageFileFormat imageFileFormat = DatasetFormatUtilities.GetImageFileFormat(filePath); + if (!File.Exists(filePath)) + { + Debug.LogError($"File doesn't exist: {filePath}"); + return; + } + if (imageFileFormat == ImageFileFormat.Unknown) + { + Debug.LogError($"Invalid file format: {Path.GetExtension(filePath)}"); + return; + } + + using (ProgressHandler progressHandler = new ProgressHandler(new EditorProgressView())) + { + progressHandler.StartStage(1.0f, "Importing dataset"); + IImageFileImporter importer = ImporterFactory.CreateImageFileImporter(imageFileFormat); + Task importTask = importer.ImportAsync(filePath); + await importTask; + progressHandler.EndStage(); + + if (importTask.Result != null) + { + onLoad.Invoke(importTask.Result); + } + } + } + + private static async void ImportDicomDataset(VolumeRenderedObject targetObject, UnityAction onLoad) + { + string dir = EditorUtility.OpenFolderPanel("Select a folder to load", "", ""); + if (Directory.Exists(dir)) + { + using (ProgressHandler progressHandler = new ProgressHandler(new EditorProgressView())) + { + progressHandler.StartStage(1.0f, "Importing dataset"); + Task importTask = EditorDatasetImportUtils.ImportDicomDirectoryAsync(dir, progressHandler); + await importTask; + progressHandler.EndStage(); + + if (importTask.Result.Length > 0) + { + onLoad.Invoke(importTask.Result[0]); + } + } + } + } } } diff --git a/Assets/Editor/VolumeRendererEditorFunctions.cs b/Assets/Editor/VolumeRendererEditorFunctions.cs index 90396a3f..cd564580 100644 --- a/Assets/Editor/VolumeRendererEditorFunctions.cs +++ b/Assets/Editor/VolumeRendererEditorFunctions.cs @@ -51,7 +51,7 @@ private static async void DicomImportAsync(bool spawnInScene) using (ProgressHandler progressHandler = new ProgressHandler(new EditorProgressView())) { progressHandler.StartStage(0.7f, "Importing dataset"); - Task importTask = DicomImportDirectoryAsync(dir, progressHandler); + Task importTask = EditorDatasetImportUtils.ImportDicomDirectoryAsync(dir, progressHandler); await importTask; progressHandler.EndStage(); progressHandler.StartStage(0.3f, "Spawning dataset"); @@ -79,58 +79,6 @@ private static async void DicomImportAsync(bool spawnInScene) } } - private static async Task DicomImportDirectoryAsync(string dir, ProgressHandler progressHandler) - { - Debug.Log("Async dataset load. Hold on."); - - List importedDatasets = new List(); - bool recursive = true; - - // Read all files - IEnumerable fileCandidates = Directory.EnumerateFiles(dir, "*.*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly) - .Where(p => p.EndsWith(".dcm", StringComparison.InvariantCultureIgnoreCase) || p.EndsWith(".dicom", StringComparison.InvariantCultureIgnoreCase) || p.EndsWith(".dicm", StringComparison.InvariantCultureIgnoreCase)); - - if (!fileCandidates.Any()) - { - if (UnityEditor.EditorUtility.DisplayDialog("Could not find any DICOM files", - $"Failed to find any files with DICOM file extension.{Environment.NewLine}Do you want to include files without DICOM file extension?", "Yes", "No")) - { - fileCandidates = Directory.EnumerateFiles(dir, "*.*", recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); - } - } - - if (fileCandidates.Any()) - { - progressHandler.StartStage(0.2f, "Loading DICOM series"); - - IImageSequenceImporter importer = ImporterFactory.CreateImageSequenceImporter(ImageSequenceFormat.DICOM); - IEnumerable seriesList = await importer.LoadSeriesAsync(fileCandidates, new ImageSequenceImportSettings { progressHandler = progressHandler }); - - progressHandler.EndStage(); - progressHandler.StartStage(0.8f); - - int seriesIndex = 0, numSeries = seriesList.Count(); - foreach (IImageSequenceSeries series in seriesList) - { - progressHandler.StartStage(1.0f / numSeries, $"Importing series {seriesIndex + 1} of {numSeries}"); - VolumeDataset dataset = await importer.ImportSeriesAsync(series, new ImageSequenceImportSettings { progressHandler = progressHandler }); - if (dataset != null) - { - await OptionallyDownscale(dataset); - importedDatasets.Add(dataset); - } - seriesIndex++; - progressHandler.EndStage(); - } - - progressHandler.EndStage(); - } - else - Debug.LogError("Could not find any DICOM files to import."); - - return importedDatasets.ToArray(); - } - [MenuItem("Volume Rendering/Load dataset/Load NRRD dataset")] private static void ShowNRRDDatasetImporter() { @@ -169,7 +117,7 @@ private static async void ImportNRRDDatasetAsync(bool spawnInScene) progressHandler.ReportProgress(0.8f, "Creating object"); if (dataset != null) { - await OptionallyDownscale(dataset); + await EditorDatasetImportUtils.OptionallyDownscale(dataset); if (spawnInScene) { await VolumeObjectFactory.CreateObjectAsync(dataset); @@ -221,7 +169,7 @@ private static async void ImportNIFTIDatasetAsync(bool spawnInScene) if (dataset != null) { - await OptionallyDownscale(dataset); + await EditorDatasetImportUtils.OptionallyDownscale(dataset); if (spawnInScene) { await VolumeObjectFactory.CreateObjectAsync(dataset); @@ -273,7 +221,7 @@ private static async void ImporImageFileDatasetAsync(bool spawnInScene) if (dataset != null) { - await OptionallyDownscale(dataset); + await EditorDatasetImportUtils.OptionallyDownscale(dataset); if (spawnInScene) { await VolumeObjectFactory.CreateObjectAsync(dataset); @@ -325,7 +273,7 @@ private static async void ImportParDatasetAsync(bool spawnInScene) if (dataset != null) { - await OptionallyDownscale(dataset); + await EditorDatasetImportUtils.OptionallyDownscale(dataset); if (spawnInScene) { await VolumeObjectFactory.CreateObjectAsync(dataset); @@ -377,7 +325,7 @@ private static async void ImportSequenceAsync() VolumeDataset dataset = await importer.ImportSeriesAsync(series); if (dataset != null) { - await OptionallyDownscale(dataset); + await EditorDatasetImportUtils.OptionallyDownscale(dataset); await VolumeObjectFactory.CreateObjectAsync(dataset); } } @@ -388,19 +336,6 @@ private static async void ImportSequenceAsync() } } - private static async Task OptionallyDownscale(VolumeDataset dataset) - { - if (EditorPrefs.GetBool("DownscaleDatasetPrompt")) - { - if (EditorUtility.DisplayDialog("Optional DownScaling", - $"Do you want to downscale the dataset? The dataset's dimension is: {dataset.dimX} x {dataset.dimY} x {dataset.dimZ}", "Yes", "No")) - { - Debug.Log("Async dataset downscale. Hold on."); - await Task.Run(() => dataset.DownScaleData()); - } - } - } - [MenuItem("Volume Rendering/Cross section/Cross section plane")] private static void OnMenuItemClick() { diff --git a/Assets/Scripts/GUI/IMGUI/TransferFunctionEditor.cs b/Assets/Scripts/GUI/IMGUI/TransferFunctionEditor.cs index 9ca12f53..44f9418c 100644 --- a/Assets/Scripts/GUI/IMGUI/TransferFunctionEditor.cs +++ b/Assets/Scripts/GUI/IMGUI/TransferFunctionEditor.cs @@ -1,3 +1,4 @@ +using System; using UnityEngine; namespace UnityVolumeRendering @@ -8,7 +9,9 @@ public class TransferFunctionEditor private int movingAlphaPointIndex = -1; private int selectedColPointIndex = -1; - private VolumeRenderedObject volRendObject = null; + private VolumeRenderedObject targetObject = null; + private VolumeDataset dataset = null; + private TransferFunction transferFunction = null; private Texture2D histTex = null; private Material tfGUIMat = null; @@ -28,19 +31,42 @@ public void Initialise() tfPaletteGUIMat = Resources.Load("TransferFunctionPaletteGUIMat"); } + [Obsolete("Use SetTarget instead")] public void SetVolumeObject(VolumeRenderedObject volRendObject) { - this.volRendObject = volRendObject; + SetTarget(volRendObject); + } + + public void SetTarget(VolumeRenderedObject volRendObject) + { + this.targetObject = volRendObject; + this.dataset = volRendObject.dataset; + this.transferFunction = volRendObject.transferFunction; + } + + public void SetTarget(VolumeDataset dataset, TransferFunction transferFunction) + { + this.targetObject = null; + this.dataset = dataset; + this.transferFunction = transferFunction; } public void DrawOnGUI(Rect rect) { GUI.skin.button.alignment = TextAnchor.MiddleCenter; - if (volRendObject == null) + if (targetObject != null) + { + dataset = targetObject.dataset; + transferFunction = targetObject.transferFunction; + } + + if (dataset == null || transferFunction == null) + { return; + } - TransferFunction tf = volRendObject.transferFunction; + TransferFunction tf = this.transferFunction; Event currentEvent = Event.current; @@ -67,9 +93,9 @@ public void DrawOnGUI(Rect rect) if(histTex == null) { if(SystemInfo.supportsComputeShaders) - histTex = HistogramTextureGenerator.GenerateHistogramTextureOnGPU(volRendObject.dataset); + histTex = HistogramTextureGenerator.GenerateHistogramTextureOnGPU(dataset); else - histTex = HistogramTextureGenerator.GenerateHistogramTexture(volRendObject.dataset); + histTex = HistogramTextureGenerator.GenerateHistogramTexture(dataset); } // Draw histogram @@ -209,7 +235,7 @@ public void DrawOnGUI(Rect rect) } else { - float hue = Random.Range(0.0f, 1.0f); + float hue = UnityEngine.Random.Range(0.0f, 1.0f); Color newColour = Color.HSVToRGB(hue, 1.0f, 1.0f); tf.colourControlPoints.Add(new TFColourControlPoint(Mathf.Clamp(mousePos.x, 0.0f, 1.0f), newColour)); } @@ -232,7 +258,7 @@ public void ClearSelection() public Color? GetSelectedColour() { if (selectedColPointIndex != -1) - return volRendObject.transferFunction.colourControlPoints[selectedColPointIndex].colourValue; + return transferFunction.colourControlPoints[selectedColPointIndex].colourValue; else return null; } @@ -241,9 +267,9 @@ public void SetSelectedColour(Color colour) { if (selectedColPointIndex != -1) { - TFColourControlPoint colPoint = volRendObject.transferFunction.colourControlPoints[selectedColPointIndex]; + TFColourControlPoint colPoint = transferFunction.colourControlPoints[selectedColPointIndex]; colPoint.colourValue = colour; - volRendObject.transferFunction.colourControlPoints[selectedColPointIndex] = colPoint; + transferFunction.colourControlPoints[selectedColPointIndex] = colPoint; } } @@ -251,7 +277,7 @@ public void RemoveSelectedColour() { if (selectedColPointIndex != -1) { - volRendObject.transferFunction.colourControlPoints.RemoveAt(selectedColPointIndex); + transferFunction.colourControlPoints.RemoveAt(selectedColPointIndex); selectedColPointIndex = -1; } } @@ -289,7 +315,7 @@ private void HandleZoom(float zoomDelta, Vector2 zoomTarget) /// Threshold for maximum distance. Points further away than this won't get picked. private int PickColourControlPoint(float position, float maxDistance = 0.03f) { - TransferFunction tf = volRendObject.transferFunction; + TransferFunction tf = transferFunction; int nearestPointIndex = -1; float nearestDist = 1000.0f; for (int i = 0; i < tf.colourControlPoints.Count; i++) @@ -312,7 +338,7 @@ private int PickColourControlPoint(float position, float maxDistance = 0.03f) private int PickAlphaControlPoint(Vector2 position, float maxDistance = 0.05f) { Vector2 distMultiplier = new Vector2(1.0f / zoomRect.width, 1.0f / zoomRect.height); - TransferFunction tf = volRendObject.transferFunction; + TransferFunction tf = transferFunction; int nearestPointIndex = -1; float nearestDist = 1000.0f; for (int i = 0; i < tf.alphaControlPoints.Count; i++) diff --git a/Assets/Scripts/Importing/ImageFileImporter/Nifti.NET/NiftiImporter.cs b/Assets/Scripts/Importing/ImageFileImporter/Nifti.NET/NiftiImporter.cs index 3cee3776..3bbff7f0 100644 --- a/Assets/Scripts/Importing/ImageFileImporter/Nifti.NET/NiftiImporter.cs +++ b/Assets/Scripts/Importing/ImageFileImporter/Nifti.NET/NiftiImporter.cs @@ -30,7 +30,10 @@ public VolumeDataset Import(string filePath) // Create dataset VolumeDataset volumeDataset = ScriptableObject.CreateInstance(); - ImportInternal(volumeDataset, niftiFile, filePath); + bool succeeded = ImportInternal(volumeDataset, niftiFile, filePath); + + if (!succeeded) + volumeDataset = null; return volumeDataset; } @@ -55,17 +58,26 @@ public async Task ImportAsync(string filePath) return null; } - await Task.Run(() => ImportInternal(volumeDataset,niftiFile,filePath)); + bool succeeded = await Task.Run(() => ImportInternal(volumeDataset,niftiFile,filePath)); + + if (!succeeded) + volumeDataset = null; return volumeDataset; } - private void ImportInternal(VolumeDataset volumeDataset,Nifti.NET.Nifti niftiFile,string filePath) + private bool ImportInternal(VolumeDataset volumeDataset,Nifti.NET.Nifti niftiFile,string filePath) { int dimX = niftiFile.Header.dim[1]; int dimY = niftiFile.Header.dim[2]; int dimZ = niftiFile.Header.dim[3]; float[] pixelData = niftiFile.ToSingleArray(); + if (pixelData == null) + { + Debug.LogError($"Failed to read data, of type: {niftiFile.Data?.GetType()}"); + return false; + } + Vector3 pixdim = new Vector3(niftiFile.Header.pixdim[1], niftiFile.Header.pixdim[2], niftiFile.Header.pixdim[3]); Vector3 size = new Vector3(dimX * pixdim.x, dimY * pixdim.y, dimZ * pixdim.z); @@ -80,6 +92,8 @@ private void ImportInternal(VolumeDataset volumeDataset,Nifti.NET.Nifti niftiFil volumeDataset.FixDimensions(); volumeDataset.rotation = Quaternion.Euler(90.0f, 0.0f, 0.0f); + + return true; } } } diff --git a/Assets/Scripts/Importing/Utilities/DatasetFormatUtilities.cs b/Assets/Scripts/Importing/Utilities/DatasetFormatUtilities.cs new file mode 100644 index 00000000..2c1f8c1e --- /dev/null +++ b/Assets/Scripts/Importing/Utilities/DatasetFormatUtilities.cs @@ -0,0 +1,22 @@ +namespace UnityVolumeRendering +{ + public class DatasetFormatUtilities + { + public static ImageFileFormat GetImageFileFormat(string filePath) + { + string extension = System.IO.Path.GetExtension(filePath); + switch (extension) + { + case ".nrrd": + return ImageFileFormat.NRRD; + case ".vasp": + return ImageFileFormat.VASP; + case ".nii": + case ".gz": + return filePath.ToLower().EndsWith(".nii.gz") ? ImageFileFormat.NIFTI : ImageFileFormat.Unknown; + default: + return ImageFileFormat.Unknown; + } + } + } +} diff --git a/Assets/Scripts/Segmentation/OverlayType.cs b/Assets/Scripts/Segmentation/OverlayType.cs new file mode 100644 index 00000000..77c20850 --- /dev/null +++ b/Assets/Scripts/Segmentation/OverlayType.cs @@ -0,0 +1,9 @@ +namespace UnityVolumeRendering +{ + public enum OverlayType + { + None, + Overlay, + Segmentation + } +} diff --git a/Assets/Scripts/Segmentation/SegmentationLabel.cs b/Assets/Scripts/Segmentation/SegmentationLabel.cs new file mode 100644 index 00000000..bc5f0363 --- /dev/null +++ b/Assets/Scripts/Segmentation/SegmentationLabel.cs @@ -0,0 +1,12 @@ +using UnityEngine; + +namespace UnityVolumeRendering +{ + [System.Serializable] + public struct SegmentationLabel + { + public int id; + public string name; + public Color colour; + } +} diff --git a/Assets/Scripts/Segmentation/SegmentationRenderMode.cs b/Assets/Scripts/Segmentation/SegmentationRenderMode.cs new file mode 100644 index 00000000..d59068cf --- /dev/null +++ b/Assets/Scripts/Segmentation/SegmentationRenderMode.cs @@ -0,0 +1,9 @@ +namespace UnityVolumeRendering +{ + [System.Serializable] + public enum SegmentationRenderMode + { + OverlayColour, + Isolate + } +} diff --git a/Assets/Scripts/VolumeData/VolumeDataset.cs b/Assets/Scripts/VolumeData/VolumeDataset.cs index 8b9a8fcb..9fcbc2bb 100644 --- a/Assets/Scripts/VolumeData/VolumeDataset.cs +++ b/Assets/Scripts/VolumeData/VolumeDataset.cs @@ -74,6 +74,11 @@ public Texture3D GetDataTexture() } } + public void RecreateDataTexture() + { + dataTexture = AsyncHelper.RunSync(() => CreateTextureInternalAsync(NullProgressHandler.instance)); + } + /// /// Gets the 3D data texture, containing the density values of the dataset. /// Will create the data texture if it does not exist, without blocking the main thread. @@ -156,6 +161,11 @@ public float GetMaxDataValue() return maxDataValue; } + public void RecalculateBounds() + { + CalculateValueBounds(new NullProgressHandler()); + } + /// /// Ensures that the dataset is not too large. /// This is automatically called during import, diff --git a/Assets/Scripts/VolumeObject/VolumeRenderedObject.cs b/Assets/Scripts/VolumeObject/VolumeRenderedObject.cs index 707f0b48..2890064e 100644 --- a/Assets/Scripts/VolumeObject/VolumeRenderedObject.cs +++ b/Assets/Scripts/VolumeObject/VolumeRenderedObject.cs @@ -1,4 +1,7 @@ -using System.Threading; +using openDicom.Encoding; +using System.Collections.Generic; +using System.Linq; +using System.Threading; using System.Threading.Tasks; using UnityEngine; @@ -34,6 +37,21 @@ public class VolumeRenderedObject : MonoBehaviour [SerializeField, HideInInspector] private LightSource lightSource; + [SerializeField, HideInInspector] + private VolumeDataset secondaryDataset; + + [SerializeField, HideInInspector] + private TransferFunction secondaryTransferFunction; + + [SerializeField, HideInInspector] + private List segmentationLabels = new List(); + + [SerializeField, HideInInspector] + private OverlayType overlayType = OverlayType.None; + + [SerializeField, HideInInspector] + private SegmentationRenderMode segmentationRenderMode = SegmentationRenderMode.OverlayColour; + // Minimum and maximum gradient threshold for lighting contribution. Values below min will be unlit, and between min and max will be partly shaded. [SerializeField, HideInInspector] private Vector2 gradientLightingThreshold = new Vector2(0.02f, 0.15f); @@ -82,6 +100,158 @@ public SlicingPlane CreateSlicingPlane() return slicingPlaneComp; } + public OverlayType GetOverlayType() + { + return this.overlayType; + } + + public TransferFunction GetSecondaryTransferFunction() + { + return this.secondaryTransferFunction; + } + + public void SetSecondaryTransferFunction(TransferFunction tf) + { + this.secondaryTransferFunction = tf; + UpdateMaterialProperties(); + } + + public void SetOverlayDataset(VolumeDataset dataset) + { + if (dataset != null) + { + this.overlayType = OverlayType.Overlay; + } + else if(this.overlayType == OverlayType.Overlay) + { + this.overlayType = OverlayType.None; + } + this.secondaryDataset = dataset; + UpdateMaterialProperties(); + } + + public SegmentationRenderMode GetSegmentationRenderMode() + { + return segmentationRenderMode; + } + + public void SetSegmentationRenderMode(SegmentationRenderMode mode) + { + if (mode != segmentationRenderMode) + { + segmentationRenderMode = mode; + UpdateMaterialProperties(); + } + } + + public List GetSegmentationLabels() + { + return segmentationLabels; + } + + public void AddSegmentation(VolumeDataset dataset) + { + if (secondaryDataset != null && dataset.data.Length != secondaryDataset.data.Length) + { + Debug.LogError("Can't add segmentation with different dimension than original dataset."); + return; + } + + overlayType = OverlayType.Segmentation; + + int segmentationId = segmentationLabels.Count > 0 ? segmentationLabels.Max(l => l.id) + 1 : 1; + + if (segmentationLabels.Count == 0) + { + secondaryDataset = dataset; + } + else + { + for (int i = 0; i < secondaryDataset.data.Length; i++) + { + secondaryDataset.data[i] = dataset.data[i] > 0.0f ? (float)segmentationId : secondaryDataset.data[i]; + } + secondaryDataset.RecalculateBounds(); + secondaryDataset.RecreateDataTexture(); + secondaryDataset.GetDataTexture().filterMode = FilterMode.Point; + } + SegmentationLabel segmentationLabel = new SegmentationLabel(); + segmentationLabel.id = segmentationId; + segmentationLabel.name = dataset.name; + segmentationLabel.colour = Random.ColorHSV(); + segmentationLabels.Add(segmentationLabel); + UpdateSegmentationLabels(); + } + + public void RemoveSegmentation(int id) + { + int segmentationIndex = segmentationLabels.FindIndex(s => s.id == id); + if (segmentationIndex != -1) + { + segmentationLabels.RemoveAt(segmentationIndex); + } + else + { + Debug.LogError($"Segmentation not found: {id}"); + } + for (int i = 0; i < secondaryDataset.data.Length; i++) + { + secondaryDataset.data[i] = secondaryDataset.data[i] == id ? 0 : secondaryDataset.data[i]; + } + secondaryDataset.RecalculateBounds(); + secondaryDataset.RecreateDataTexture(); + secondaryDataset.GetDataTexture().filterMode = FilterMode.Point; + UpdateSegmentationLabels(); + } + + public void ClearSegmentations() + { + if (overlayType == OverlayType.Segmentation) + { + secondaryDataset = null; + secondaryTransferFunction = null; + overlayType = OverlayType.None; + } + segmentationLabels.Clear(); + UpdateMaterialProperties(); + } + + public void UpdateSegmentationLabels() + { + if (segmentationLabels.Count == 0) + { + UpdateMaterialProperties(); + return; + } + + segmentationLabels.OrderBy(l => l.id); + if (secondaryTransferFunction == null) + { + secondaryTransferFunction = ScriptableObject.CreateInstance(); + } + secondaryTransferFunction.alphaControlPoints.Clear(); + secondaryTransferFunction.colourControlPoints.Clear(); + int maxSegmentationId = segmentationLabels[segmentationLabels.Count - 1].id; + float minDataValue = secondaryDataset.GetMinDataValue(); + float maxDataValue = secondaryDataset.GetMaxDataValue(); + secondaryTransferFunction.alphaControlPoints.Add(new TFAlphaControlPoint(0.0f, 0.0f)); + secondaryTransferFunction.alphaControlPoints.Add(new TFAlphaControlPoint(1.0f, 1.0f)); + for (int i = 0; i < segmentationLabels.Count; i++) + { + SegmentationLabel segmentationLabel = segmentationLabels[i]; + float t = segmentationLabel.id / maxDataValue; + secondaryTransferFunction.colourControlPoints.Add(new TFColourControlPoint(t, segmentationLabel.colour)); + if (i == 0) + { + secondaryTransferFunction.alphaControlPoints.Add(new TFAlphaControlPoint(t - 0.01f, 0.0f)); + secondaryTransferFunction.alphaControlPoints.Add(new TFAlphaControlPoint(t, 1.0f)); + } + } + secondaryTransferFunction.GenerateTexture(); + secondaryTransferFunction.GetTexture().filterMode = FilterMode.Point; + UpdateMaterialProperties(); + } + public void SetRenderMode(RenderMode mode) { Task task = SetRenderModeAsync(mode); @@ -317,11 +487,10 @@ private async Task UpdateMaterialPropertiesAsync(IProgressHandler progressHandle try { bool useGradientTexture = tfRenderMode == TFRenderMode.TF2D || renderMode == RenderMode.IsosurfaceRendering || lightingEnabled; - Texture3D gradientTexture = useGradientTexture ? await dataset.GetGradientTextureAsync(progressHandler) : null; Texture3D dataTexture = await dataset.GetDataTextureAsync(progressHandler); - meshRenderer.sharedMaterial.SetTexture("_DataTex", dataTexture); - meshRenderer.sharedMaterial.SetTexture("_GradientTex", gradientTexture); - UpdateMatInternal(); + Texture3D gradientTexture = useGradientTexture ? await dataset.GetGradientTextureAsync(progressHandler) : null; + Texture3D secondaryDataTexture = secondaryDataset ? await secondaryDataset?.GetDataTextureAsync(progressHandler) : null; + UpdateMatInternal(dataTexture, gradientTexture, secondaryDataTexture); } finally { @@ -329,11 +498,38 @@ private async Task UpdateMaterialPropertiesAsync(IProgressHandler progressHandle } } - private void UpdateMatInternal() + private void UpdateMatInternal(Texture3D dataTexture, Texture3D gradientTexture, Texture3D secondaryDataTexture) { - if (meshRenderer.sharedMaterial.GetTexture("_DataTex") == null) + if (dataTexture != null) { - meshRenderer.sharedMaterial.SetTexture("_DataTex", dataset.GetDataTexture()); + meshRenderer.sharedMaterial.SetTexture("_DataTex", dataTexture); + } + + if (gradientTexture != null) + { + meshRenderer.sharedMaterial.SetTexture("_GradientTex", gradientTexture); + } + + if (overlayType != OverlayType.None && secondaryDataTexture != null) + { + Texture2D secondaryTF = secondaryTransferFunction.GetTexture(); + meshRenderer.sharedMaterial.SetTexture("_SecondaryDataTex", secondaryDataTexture); + meshRenderer.sharedMaterial.SetTexture("_SecondaryTFTex", secondaryTF); + if (overlayType == OverlayType.Segmentation && segmentationRenderMode == SegmentationRenderMode.Isolate) + { + meshRenderer.sharedMaterial.EnableKeyword("MULTIVOLUME_ISOLATE"); + meshRenderer.sharedMaterial.DisableKeyword("MULTIVOLUME_OVERLAY"); + } + else + { + meshRenderer.sharedMaterial.EnableKeyword("MULTIVOLUME_OVERLAY"); + meshRenderer.sharedMaterial.DisableKeyword("MULTIVOLUME_ISOLATE"); + } + } + else + { + meshRenderer.sharedMaterial.DisableKeyword("MULTIVOLUME_OVERLAY"); + meshRenderer.sharedMaterial.DisableKeyword("MULTIVOLUME_ISOLATE"); } if (meshRenderer.sharedMaterial.GetTexture("_NoiseTex") == null) diff --git a/Assets/Shaders/DirectVolumeRenderingShader.shader b/Assets/Shaders/DirectVolumeRenderingShader.shader index 37413038..279b2b20 100644 --- a/Assets/Shaders/DirectVolumeRenderingShader.shader +++ b/Assets/Shaders/DirectVolumeRenderingShader.shader @@ -13,6 +13,8 @@ _MinGradient("Gradient visibility threshold", Range(0.0, 1.0)) = 0.0 _LightingGradientThresholdStart("Gradient threshold for lighting (end)", Range(0.0, 1.0)) = 0.0 _LightingGradientThresholdEnd("Gradient threshold for lighting (start)", Range(0.0, 1.0)) = 0.0 + _SecondaryDataTex ("Secondary Data Texture (Generated)", 3D) = "" {} + _SecondaryTFTex("Transfer Function Texture for secondary volume", 2D) = "" {} [HideInInspector] _ShadowVolumeTextureSize("Shadow volume dimensions", Vector) = (1, 1, 1) [HideInInspector] _TextureSize("Dataset dimensions", Vector) = (1, 1, 1) } @@ -37,6 +39,8 @@ #pragma multi_compile __ RAY_TERMINATE_ON #pragma multi_compile __ USE_MAIN_LIGHT #pragma multi_compile __ CUBIC_INTERPOLATION_ON + #pragma multi_compile __ SECONDARY_VOLUME_ON + #pragma multi_compile MULTIVOLUME_NONE MULTIVOLUME_OVERLAY MULTIVOLUME_ISOLATE #pragma vertex vert #pragma fragment frag @@ -76,6 +80,8 @@ sampler2D _NoiseTex; sampler2D _TFTex; sampler3D _ShadowVolume; + sampler3D _SecondaryDataTex; + sampler2D _SecondaryTFTex; float _MinVal; float _MaxVal; @@ -191,6 +197,12 @@ return tex2Dlod(_TFTex, float4(density, gradientMagnitude, 0.0f, 0.0f)); } + // Gets the colour from a secondary 1D Transfer Function (x = density) + float4 getSecondaryTF1DColour(float density) + { + return tex2Dlod(_SecondaryTFTex, float4(density, 0.0f, 0.0f, 0.0f)); + } + // Gets the density at the specified position float getDensity(float3 pos) { @@ -201,6 +213,12 @@ #endif } + // Gets the density of the secondary volume at the specified position + float getSecondaryDensity(float3 pos) + { + return tex3Dlod(_SecondaryDataTex, float4(pos.x, pos.y, pos.z, 0.0f)); + } + // Gets the density at the specified position, without tricubic interpolation float getDensityNoTricubic(float3 pos) { @@ -323,6 +341,16 @@ continue; #endif +#if defined(MULTIVOLUME_OVERLAY) || defined(MULTIVOLUME_ISOLATE) + const float secondaryDensity = getSecondaryDensity(currPos); + float4 secondaryColour = getSecondaryTF1DColour(secondaryDensity); +#if MULTIVOLUME_OVERLAY + src = secondaryColour.a > 0.0 ? secondaryColour : src; +#elif MULTIVOLUME_ISOLATE + src.a = secondaryColour.a > 0.0 ? src.a : 0.0; +#endif +#endif + // Calculate gradient (needed for lighting and 2D transfer functions) #if defined(TF2D_ON) || defined(LIGHTING_ON) float3 gradient = getGradient(currPos); @@ -440,6 +468,23 @@ #endif const float density = getDensity(currPos); +#if MULTIVOLUME_ISOLATE + const float secondaryDensity = getSecondaryDensity(currPos); + if (secondaryDensity <= 0.0) + continue; +#elif MULTIVOLUME_OVERLAY + const float secondaryDensity = getSecondaryDensity(currPos); + if (secondaryDensity > 0.0) + { + col = getSecondaryTF1DColour(secondaryDensity); + float3 gradient = getGradient(currPos); + float gradMag = length(gradient); + float3 normal = gradient / gradMag; + col.rgb = calculateLighting(col.rgb, normal, getLightDirection(-ray.direction), -ray.direction, 0.15); + col.a = 1.0; + break; + } +#endif if (density > _MinVal && density < _MaxVal) { float3 gradient = getGradient(currPos); diff --git a/CREDITS.md b/CREDITS.md index 56a11822..20c6ae7c 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -18,5 +18,7 @@ Texture downscaling, optimisation. Async loading - [Riccardo Lops](https://github.com/riccardolops) Modified shader to handle stereo rendering. +- [Juan Pablo Montoya](https://github.com/JuanPabloMontoya271) +Overlay Segmentations - initial implementation Feel free to add yourself to this list when contributing to this project.