From 183f0ff3cc737cf56e1ea6441cf45432d1daab6a Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Wed, 4 Feb 2026 13:08:52 -0600 Subject: [PATCH 01/32] Fix generating random particles dataset Signed-off-by: Patrick Avery --- tomviz/PythonGeneratedDatasetReaction.cxx | 2 +- tomviz/python/RandomParticles.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tomviz/PythonGeneratedDatasetReaction.cxx b/tomviz/PythonGeneratedDatasetReaction.cxx index 54a3a79a9..d8b9da422 100644 --- a/tomviz/PythonGeneratedDatasetReaction.cxx +++ b/tomviz/PythonGeneratedDatasetReaction.cxx @@ -53,7 +53,7 @@ class PythonGeneratedDataSource : public QObject { tomviz::Python python; - m_operatorModule = python.import("tomviz.utils"); + m_operatorModule = python.import("tomviz.internal_utils"); if (!m_operatorModule.isValid()) { qCritical() << "Failed to import tomviz.utils module."; } diff --git a/tomviz/python/RandomParticles.py b/tomviz/python/RandomParticles.py index f499f840b..2fe406354 100644 --- a/tomviz/python/RandomParticles.py +++ b/tomviz/python/RandomParticles.py @@ -29,7 +29,7 @@ def generate_dataset(array, p_in=30.0, p_s=60.0, sparsity=0.20): f_shape = np.argsort(f_shape, axis=None) # Sort the shape image f_shape = f_shape.flatten() # Number of zero voxels - N_zero = np.int(np.round((array.size * (1 - sparsity)))) + N_zero = np.int64(np.round((array.size * (1 - sparsity)))) f_shape[N_zero:] = f_shape[N_zero] f_in[f_shape] = 0 From 716dada60e163147f6f9d8f90eab8ac592b11866 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Wed, 4 Feb 2026 14:07:53 -0600 Subject: [PATCH 02/32] Truncate error on external pipeline execution Otherwise, it is too long and you sometimes can't close it. Signed-off-by: Patrick Avery --- tomviz/PipelineExecutor.cxx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tomviz/PipelineExecutor.cxx b/tomviz/PipelineExecutor.cxx index d64d8e44c..e69c00ccd 100644 --- a/tomviz/PipelineExecutor.cxx +++ b/tomviz/PipelineExecutor.cxx @@ -211,8 +211,13 @@ ExternalPipelineExecutor::ExternalPipelineExecutor(Pipeline* pipeline) void ExternalPipelineExecutor::displayError(const QString& title, const QString& msg) { - QMessageBox::critical(tomviz::mainWidget(), title, msg); qCritical() << msg; + QMessageBox msgBox(tomviz::mainWidget()); + msgBox.setIcon(QMessageBox::Critical); + msgBox.setWindowTitle(title); + msgBox.setText("An error occurred during external pipeline execution"); + msgBox.setDetailedText(msg); + msgBox.exec(); } QString ExternalPipelineExecutor::workingDir() From bc4a29c695c1ff10791b4384bde4cb59c8cc9dd7 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Wed, 4 Feb 2026 14:50:16 -0600 Subject: [PATCH 03/32] Prevent divide-by-zero errors when normalizing Signed-off-by: Patrick Avery --- tomviz/python/NormalizeTiltSeries.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tomviz/python/NormalizeTiltSeries.py b/tomviz/python/NormalizeTiltSeries.py index e9babaf70..607c97029 100644 --- a/tomviz/python/NormalizeTiltSeries.py +++ b/tomviz/python/NormalizeTiltSeries.py @@ -21,6 +21,11 @@ def transform(dataset): for i in range(0, data.shape[2]): # Normalize each tilt image. - data[:, :, i] = data[:, :, i] / np.sum(data[:, :, i]) * intensity + img_sum = np.sum(data[:, :, i]) + if abs(img_sum) < 1e-8: + # Skip or we get a divide-by-zero error + continue + + data[:, :, i] = data[:, :, i] / img_sum * intensity dataset.active_scalars = data From 4049de03b196169043d1292e067cc66988132a72 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Fri, 13 Feb 2026 13:23:24 -0600 Subject: [PATCH 04/32] Automatically add `@apply_to_each_array` This adds the decorator `@apply_to_each_array` automatically to every transform function, unless explicitly instructed not to in the JSON description file via `"apply_to_each_array": false`. This is being done because nearly every operator in the codebase needs this decorator, and it is tedious to have to remember it every time. It is not added automatically if it is already present on an operator. Signed-off-by: Patrick Avery --- tomviz/CMakeLists.txt | 1 + tomviz/MainWindow.cxx | 4 +- .../AutoCenterOfMassTiltImageAlignment.json | 1 + ...utoCrossCorrelationTiltImageAlignment.json | 1 + .../python/AutoTiltAxisRotationAlignment.json | 3 ++ tomviz/python/AutoTiltAxisShiftAlignment.json | 1 + tomviz/python/PyStackRegImageAlignment.json | 1 + tomviz/python/Recon_DFT.json | 1 + tomviz/python/Recon_DFT_constraint.json | 1 + tomviz/python/ShiftTiltSeriesRandomly.json | 1 + tomviz/python/tomviz/_internal.py | 51 +++++++++++++++++++ tomviz/python/tomviz/executor.py | 5 +- 12 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 tomviz/python/AutoTiltAxisRotationAlignment.json diff --git a/tomviz/CMakeLists.txt b/tomviz/CMakeLists.txt index 0ecd815d4..1c39bc9ab 100644 --- a/tomviz/CMakeLists.txt +++ b/tomviz/CMakeLists.txt @@ -515,6 +515,7 @@ set(json_files AddPoissonNoise.json AutoCenterOfMassTiltImageAlignment.json AutoCrossCorrelationTiltImageAlignment.json + AutoTiltAxisRotationAlignment.json AutoTiltAxisShiftAlignment.json PyStackRegImageAlignment.json BinaryThreshold.json diff --git a/tomviz/MainWindow.cxx b/tomviz/MainWindow.cxx index 4f6da8816..a121d5731 100644 --- a/tomviz/MainWindow.cxx +++ b/tomviz/MainWindow.cxx @@ -411,10 +411,10 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) rotateAlignAction, "Tilt Axis Alignment (manual)", readInPythonScript("RotationAlign"), true, false, false, readInJSONDescription("RotationAlign")); - // new AddRotateAlignReaction(rotateAlignAction); new AddPythonTransformReaction( autoRotateAlignAction, "Auto Tilt Axis Align", - readInPythonScript("AutoTiltAxisRotationAlignment"), true); + readInPythonScript("AutoTiltAxisRotationAlignment"), true, false, false, + readInJSONDescription("AutoTiltAxisRotationAlignment")); new AddPythonTransformReaction( autoRotateAlignShiftAction, "Auto Tilt Axis Shift Align", readInPythonScript("AutoTiltAxisShiftAlignment"), true, false, false, diff --git a/tomviz/python/AutoCenterOfMassTiltImageAlignment.json b/tomviz/python/AutoCenterOfMassTiltImageAlignment.json index c6a791b39..ac468f324 100644 --- a/tomviz/python/AutoCenterOfMassTiltImageAlignment.json +++ b/tomviz/python/AutoCenterOfMassTiltImageAlignment.json @@ -1,5 +1,6 @@ { "externalCompatible": false, + "apply_to_each_array": false, "results" : [ { "name" : "alignments", diff --git a/tomviz/python/AutoCrossCorrelationTiltImageAlignment.json b/tomviz/python/AutoCrossCorrelationTiltImageAlignment.json index 91c39a39c..b56095078 100644 --- a/tomviz/python/AutoCrossCorrelationTiltImageAlignment.json +++ b/tomviz/python/AutoCrossCorrelationTiltImageAlignment.json @@ -2,6 +2,7 @@ "name" : "AutoCrossCorrelationTiltImageAlignment", "label" : "Auto Tilt Image Align (XCORR)", "description" : "Automatically align tilt images by cross-correlation", + "apply_to_each_array": false, "parameters" : [ { "name" : "transform_source", diff --git a/tomviz/python/AutoTiltAxisRotationAlignment.json b/tomviz/python/AutoTiltAxisRotationAlignment.json new file mode 100644 index 000000000..422ec6681 --- /dev/null +++ b/tomviz/python/AutoTiltAxisRotationAlignment.json @@ -0,0 +1,3 @@ +{ + "apply_to_each_array": false +} diff --git a/tomviz/python/AutoTiltAxisShiftAlignment.json b/tomviz/python/AutoTiltAxisShiftAlignment.json index 3d50d450a..e5c66ce6a 100644 --- a/tomviz/python/AutoTiltAxisShiftAlignment.json +++ b/tomviz/python/AutoTiltAxisShiftAlignment.json @@ -2,6 +2,7 @@ "name" : "AutoTiltAxisShiftAlignment", "label" : "Auto Tilt Axis Shift Align", "description" : "Automatically center images along the tilt axis", + "apply_to_each_array": false, "parameters" : [ { "name" : "transform_source", diff --git a/tomviz/python/PyStackRegImageAlignment.json b/tomviz/python/PyStackRegImageAlignment.json index 6eb65897e..2b872eb47 100644 --- a/tomviz/python/PyStackRegImageAlignment.json +++ b/tomviz/python/PyStackRegImageAlignment.json @@ -2,6 +2,7 @@ "name" : "PyStackReg", "label" : "Auto Tilt Image Align (PyStackReg)", "description" : "Perform image alignment using PyStackReg.", + "apply_to_each_array": false, "parameters" : [ { "name" : "transform_source", diff --git a/tomviz/python/Recon_DFT.json b/tomviz/python/Recon_DFT.json index 04f0547c2..d8a6e5883 100644 --- a/tomviz/python/Recon_DFT.json +++ b/tomviz/python/Recon_DFT.json @@ -3,6 +3,7 @@ "label" : "Direct Fourier Reconstruction", "description" : "Reconstruct a tilt series using Direct Fourier Method (DFM). The tilt axis must be parallel to the x-direction and centered in the y-direction. The size of reconstruction will be (Nx,Ny,Ny). Reconstrucing a 512x512x512 tomogram typically takes 30-40 seconds.", "externalCompatible": false, + "apply_to_each_array": false, "children": [ { "name": "reconstruction", diff --git a/tomviz/python/Recon_DFT_constraint.json b/tomviz/python/Recon_DFT_constraint.json index 12d4d740b..388949fe4 100644 --- a/tomviz/python/Recon_DFT_constraint.json +++ b/tomviz/python/Recon_DFT_constraint.json @@ -3,6 +3,7 @@ "label" : "Reconstruct (Constraint based Direct Fourier)", "description" : "Reconstruct a tilt series using constraint-based Direct Fourier method. The tilt axis must be parallel to the x-direction and centered in the y-direction. The size of reconstruction will be (Nx,Ny,Ny). Reconstructing a 512x512x512 tomogram typically takes xxxx mins.", "externalCompatible": false, + "apply_to_each_array": false, "children": [ { "name": "reconstruction", diff --git a/tomviz/python/ShiftTiltSeriesRandomly.json b/tomviz/python/ShiftTiltSeriesRandomly.json index 603d136cb..0f1649788 100644 --- a/tomviz/python/ShiftTiltSeriesRandomly.json +++ b/tomviz/python/ShiftTiltSeriesRandomly.json @@ -2,6 +2,7 @@ "name" : "ShiftTiltSeries", "label" : "Shift Tilt Series Randomly", "description" : "Apply random integer shifts to tilt series. The maximum shift can be specified betow.", + "apply_to_each_array": false, "parameters" : [ { "name" : "maxShift", diff --git a/tomviz/python/tomviz/_internal.py b/tomviz/python/tomviz/_internal.py index 2de288d9a..09513b253 100644 --- a/tomviz/python/tomviz/_internal.py +++ b/tomviz/python/tomviz/_internal.py @@ -4,6 +4,7 @@ # This source file is part of the Tomviz project, https://tomviz.org/. # It is released under the 3-Clause BSD License, see "LICENSE". ############################################################################### +from typing import Any import fnmatch import importlib.machinery import importlib.util @@ -130,6 +131,53 @@ def find_transform_function(transform_module, op=None): return transform_function +def has_decorator(func: Callable, decorator_marker: str = '_is_my_decorator') -> bool: + """Check if a function was already decorated with a decorator name""" + # Check the function itself + if getattr(func, decorator_marker, False): + return True + + # Traverse the __wrapped__ chain + current = func + while hasattr(current, '__wrapped__'): + current = current.__wrapped__ + if getattr(current, decorator_marker, False): + return True + + return False + + +def add_transform_decorators(transform_method: Callable, + operator_dict: dict[str, Any]) -> Callable: + """Optionally add any transform wrappers that we need to add + + Currently, this adds `@apply_to_each_array` automatically if + `"apply_to_each_array": false` is not set within the json + description, and if the decorator was not already applied. + """ + add_apply_to_each_array = True + operator_description = operator_dict.get('description') + if operator_description: + description_json = json.loads(operator_description) + if not description_json.get('apply_to_each_array', True): + # It was intentionally disabled in the json + add_apply_to_each_array = False + + if transform_method.__name__ == 'transform_scalars': + # This is an old transform function. We don't want to do any + # kind of automatic modifications to the old ones. + add_apply_to_each_array = False + + if add_apply_to_each_array: + # First, make sure it wasn't already decorated + if not has_decorator(transform_method, 'apply_to_each_array'): + # Decorate it! + from tomviz.utils import apply_to_each_array + transform_method = apply_to_each_array(transform_method) + + return transform_method + + def transform_method_wrapper(transform_method: Callable, operator_serialized: str, *args, **kwargs): # We take the serialized operator as input because we may need it @@ -139,6 +187,9 @@ def transform_method_wrapper(transform_method: Callable, operator_dict = json.loads(operator_serialized) tomviz_pipeline_env = None + # Add any transform decorators that we need + transform_method = add_transform_decorators(transform_method, operator_dict) + operator_description = operator_dict.get('description') if operator_description: description_json = json.loads(operator_description) diff --git a/tomviz/python/tomviz/executor.py b/tomviz/python/tomviz/executor.py index 6ed22dbb4..5748765c3 100644 --- a/tomviz/python/tomviz/executor.py +++ b/tomviz/python/tomviz/executor.py @@ -15,7 +15,7 @@ import numpy as np from tqdm import tqdm -from tomviz._internal import find_transform_function +from tomviz._internal import add_transform_decorators, find_transform_function from tomviz.external_dataset import Dataset @@ -699,6 +699,9 @@ def _load_transform_functions(operators): operator_module = _load_operator_module(operator_label, operator_script) transform = find_transform_function(operator_module) + # Add any transform decorators (like `@apply_to_each_array`) + transform = add_transform_decorators(transform, operator) + # partial apply the arguments arguments = {} if 'arguments' in operator: From 5fd4916aeccb09992904d3c0210cdc79d1961077 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Fri, 13 Feb 2026 13:41:14 -0600 Subject: [PATCH 05/32] Remove manual `@apply_to_each_array` decorators They are no longer needed, as it is applied automatically. Signed-off-by: Patrick Avery --- tomviz/python/AddConstant.py | 4 ---- tomviz/python/AddPoissonNoise.py | 2 -- tomviz/python/BinTiltSeriesByTwo.py | 4 ---- tomviz/python/BinVolumeByTwo.py | 4 ---- tomviz/python/CircleMask.py | 4 ---- tomviz/python/ClearVolume.py | 4 ---- tomviz/python/ClipEdges.py | 4 ---- tomviz/python/DeleteSlices.py | 4 ---- tomviz/python/FFT_AbsLog.py | 3 --- tomviz/python/GaussianFilter.py | 4 ---- tomviz/python/GaussianFilterTiltSeries.py | 4 ---- tomviz/python/GenerateTiltSeries.py | 2 -- tomviz/python/GradientMagnitude2D_Sobel.py | 4 ---- tomviz/python/GradientMagnitude_Sobel.py | 4 ---- tomviz/python/HannWindow3D.py | 4 ---- tomviz/python/InvertData.py | 2 -- tomviz/python/LaplaceFilter.py | 4 ---- tomviz/python/ManualManipulation.py | 3 --- tomviz/python/MedianFilter.py | 4 ---- tomviz/python/NormalizeTiltSeries.py | 4 ---- tomviz/python/Pad_Data.py | 4 ---- tomviz/python/PeronaMalikAnisotropicDiffusion.py | 2 -- tomviz/python/Recon_SIRT.py | 2 -- tomviz/python/Recon_WBP.py | 2 -- tomviz/python/Recon_tomopy_gridrec.py | 4 ---- tomviz/python/RemoveBadPixelsTiltSeries.py | 4 ---- tomviz/python/Resample.py | 4 ---- tomviz/python/Rotate3D.py | 1 - tomviz/python/RotationAlign.py | 4 ---- tomviz/python/SetNegativeVoxelsToZero.py | 4 ---- tomviz/python/Shift3D.py | 4 ---- tomviz/python/Shift_Stack_Uniformly.py | 5 ----- tomviz/python/Square_Root_Data.py | 2 -- tomviz/python/Subtract_TiltSer_Background.py | 4 ---- tomviz/python/Subtract_TiltSer_Background_Auto.py | 4 ---- tomviz/python/SwapAxes.py | 4 ---- tomviz/python/TV_Filter.py | 2 -- tomviz/python/UnsharpMask.py | 2 -- tomviz/python/WienerFilter.py | 4 ---- tomviz/python/ctf_correct.py | 3 --- 40 files changed, 137 deletions(-) diff --git a/tomviz/python/AddConstant.py b/tomviz/python/AddConstant.py index 80be964b1..382cb0d7b 100644 --- a/tomviz/python/AddConstant.py +++ b/tomviz/python/AddConstant.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, constant=0.0): """Add a constant to the data set""" diff --git a/tomviz/python/AddPoissonNoise.py b/tomviz/python/AddPoissonNoise.py index d36073f5d..499f70826 100644 --- a/tomviz/python/AddPoissonNoise.py +++ b/tomviz/python/AddPoissonNoise.py @@ -1,11 +1,9 @@ import numpy as np import tomviz.operators -from tomviz.utils import apply_to_each_array class AddPoissonNoiseOperator(tomviz.operators.CancelableOperator): - @apply_to_each_array def transform(self, dataset, N=25): """Add Poisson noise to tilt images""" self.progress.maximum = 1 diff --git a/tomviz/python/BinTiltSeriesByTwo.py b/tomviz/python/BinTiltSeriesByTwo.py index e8139da93..16aea837e 100644 --- a/tomviz/python/BinTiltSeriesByTwo.py +++ b/tomviz/python/BinTiltSeriesByTwo.py @@ -1,7 +1,3 @@ -from tomviz import utils - - -@utils.apply_to_each_array def transform(dataset): """Downsample tilt images by a factor of 2""" diff --git a/tomviz/python/BinVolumeByTwo.py b/tomviz/python/BinVolumeByTwo.py index f1020a5c7..3c213f07c 100644 --- a/tomviz/python/BinVolumeByTwo.py +++ b/tomviz/python/BinVolumeByTwo.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset): """Downsample volume by a factor of 2""" diff --git a/tomviz/python/CircleMask.py b/tomviz/python/CircleMask.py index ee16eeb6e..ac434285b 100644 --- a/tomviz/python/CircleMask.py +++ b/tomviz/python/CircleMask.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, axis, ratio, value): try: import tomopy diff --git a/tomviz/python/ClearVolume.py b/tomviz/python/ClearVolume.py index 2c8366a69..ead713510 100644 --- a/tomviz/python/ClearVolume.py +++ b/tomviz/python/ClearVolume.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, XRANGE=None, YRANGE=None, ZRANGE=None): """Define this method for Python operators that transform input scalars""" diff --git a/tomviz/python/ClipEdges.py b/tomviz/python/ClipEdges.py index ad661c9ca..8b5287a30 100644 --- a/tomviz/python/ClipEdges.py +++ b/tomviz/python/ClipEdges.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, clipNum=5): """Set values outside a cirular range to minimum(dataset) to remove reconstruction artifacts""" diff --git a/tomviz/python/DeleteSlices.py b/tomviz/python/DeleteSlices.py index 51be004a6..491dcf0ed 100644 --- a/tomviz/python/DeleteSlices.py +++ b/tomviz/python/DeleteSlices.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, firstSlice=None, lastSlice=None, axis=2): """Delete Slices in Dataset""" diff --git a/tomviz/python/FFT_AbsLog.py b/tomviz/python/FFT_AbsLog.py index 06d60cef2..bab146a2f 100644 --- a/tomviz/python/FFT_AbsLog.py +++ b/tomviz/python/FFT_AbsLog.py @@ -5,10 +5,7 @@ # # WARNING: Be patient! Large datasets may take a while. -from tomviz.utils import apply_to_each_array - -@apply_to_each_array def transform(dataset): import numpy as np diff --git a/tomviz/python/GaussianFilter.py b/tomviz/python/GaussianFilter.py index 7cf5fea49..6c3210b21 100644 --- a/tomviz/python/GaussianFilter.py +++ b/tomviz/python/GaussianFilter.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, sigma=2.0): """Apply a Gaussian filter to volume dataset.""" """Gaussian Filter blurs the image and reduces the noise and details.""" diff --git a/tomviz/python/GaussianFilterTiltSeries.py b/tomviz/python/GaussianFilterTiltSeries.py index 328ce537c..01548a757 100644 --- a/tomviz/python/GaussianFilterTiltSeries.py +++ b/tomviz/python/GaussianFilterTiltSeries.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, sigma=2.0): """Apply a Gaussian filter to tilt images.""" """Gaussian Filter blurs the image and reduces the noise and details.""" diff --git a/tomviz/python/GenerateTiltSeries.py b/tomviz/python/GenerateTiltSeries.py index 1a5dfcd25..dfad75b6c 100644 --- a/tomviz/python/GenerateTiltSeries.py +++ b/tomviz/python/GenerateTiltSeries.py @@ -1,13 +1,11 @@ import numpy as np import scipy.ndimage -from tomviz.utils import apply_to_each_array import tomviz.operators class GenerateTiltSeriesOperator(tomviz.operators.CancelableOperator): - @apply_to_each_array def transform(self, dataset, start_angle=-90.0, angle_increment=3.0, num_tilts=60): """Generate Tilt Series from Volume""" diff --git a/tomviz/python/GradientMagnitude2D_Sobel.py b/tomviz/python/GradientMagnitude2D_Sobel.py index 9370d2ec5..07c9110c7 100644 --- a/tomviz/python/GradientMagnitude2D_Sobel.py +++ b/tomviz/python/GradientMagnitude2D_Sobel.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset): """Calculate gradient magnitude of each tilt image using Sobel operator""" diff --git a/tomviz/python/GradientMagnitude_Sobel.py b/tomviz/python/GradientMagnitude_Sobel.py index 886ea742d..e3e2f6b7a 100644 --- a/tomviz/python/GradientMagnitude_Sobel.py +++ b/tomviz/python/GradientMagnitude_Sobel.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset): """Calculate 3D gradient magnitude using Sobel operator""" diff --git a/tomviz/python/HannWindow3D.py b/tomviz/python/HannWindow3D.py index 19a6a85c0..9d5ac6e22 100644 --- a/tomviz/python/HannWindow3D.py +++ b/tomviz/python/HannWindow3D.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset): import numpy as np diff --git a/tomviz/python/InvertData.py b/tomviz/python/InvertData.py index 1ae006aaf..b056d046b 100644 --- a/tomviz/python/InvertData.py +++ b/tomviz/python/InvertData.py @@ -1,4 +1,3 @@ -from tomviz.utils import apply_to_each_array import tomviz.operators @@ -7,7 +6,6 @@ class InvertOperator(tomviz.operators.CancelableOperator): - @apply_to_each_array def transform(self, dataset): import numpy as np self.progress.maximum = NUMBER_OF_CHUNKS diff --git a/tomviz/python/LaplaceFilter.py b/tomviz/python/LaplaceFilter.py index 3d6936767..c81e7b109 100644 --- a/tomviz/python/LaplaceFilter.py +++ b/tomviz/python/LaplaceFilter.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset): """Apply a Laplace filter to dataset.""" diff --git a/tomviz/python/ManualManipulation.py b/tomviz/python/ManualManipulation.py index 10867ccf2..d62ea3d38 100644 --- a/tomviz/python/ManualManipulation.py +++ b/tomviz/python/ManualManipulation.py @@ -2,8 +2,6 @@ from scipy.ndimage.interpolation import zoom from tomviz import utils -from tomviz.utils import apply_to_each_array - def apply_shift(array, shift): @@ -132,7 +130,6 @@ def apply_alignment(array, spacing, reference_spacing, reference_shape): return apply_resize(array, reference_shape) -@apply_to_each_array def transform(dataset, scaling=None, rotation=None, shift=None, align_with_reference=False, reference_spacing=None, reference_shape=None): diff --git a/tomviz/python/MedianFilter.py b/tomviz/python/MedianFilter.py index 02c754e03..9490fa1ee 100644 --- a/tomviz/python/MedianFilter.py +++ b/tomviz/python/MedianFilter.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, size=2): """Apply a Median filter to dataset.""" """ Median filter is a nonlinear filter used to reduce noise.""" diff --git a/tomviz/python/NormalizeTiltSeries.py b/tomviz/python/NormalizeTiltSeries.py index 607c97029..28aa3012b 100644 --- a/tomviz/python/NormalizeTiltSeries.py +++ b/tomviz/python/NormalizeTiltSeries.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset): """ Normalize tilt series so that each tilt image has the same total intensity. diff --git a/tomviz/python/Pad_Data.py b/tomviz/python/Pad_Data.py index 2649d16d3..4a1914caa 100644 --- a/tomviz/python/Pad_Data.py +++ b/tomviz/python/Pad_Data.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, pad_size_before=[0, 0, 0], pad_size_after=[0, 0, 0], pad_mode_index=0): """Pad dataset""" diff --git a/tomviz/python/PeronaMalikAnisotropicDiffusion.py b/tomviz/python/PeronaMalikAnisotropicDiffusion.py index 91fd9e376..4206b9741 100644 --- a/tomviz/python/PeronaMalikAnisotropicDiffusion.py +++ b/tomviz/python/PeronaMalikAnisotropicDiffusion.py @@ -1,10 +1,8 @@ -from tomviz.utils import apply_to_each_array import tomviz.operators class PeronaMalikAnisotropicDiffusion(tomviz.operators.CancelableOperator): - @apply_to_each_array def transform(self, dataset, conductance=1.0, iterations=100, timestep=0.0625): """This filter performs anisotropic diffusion on an image using diff --git a/tomviz/python/Recon_SIRT.py b/tomviz/python/Recon_SIRT.py index 91f7a47d7..ac2fe746e 100644 --- a/tomviz/python/Recon_SIRT.py +++ b/tomviz/python/Recon_SIRT.py @@ -1,13 +1,11 @@ import numpy as np import scipy.sparse as ss import tomviz.operators -from tomviz.utils import apply_to_each_array import time class ReconSirtOperator(tomviz.operators.CompletableOperator): - @apply_to_each_array def transform(self, dataset, Niter=10, stepSize=0.0001, updateMethodIndex=0, Nupdates=0): """ diff --git a/tomviz/python/Recon_WBP.py b/tomviz/python/Recon_WBP.py index 6bc728763..f9b259a8f 100644 --- a/tomviz/python/Recon_WBP.py +++ b/tomviz/python/Recon_WBP.py @@ -1,13 +1,11 @@ import numpy as np from scipy.interpolate import interp1d import tomviz.operators -from tomviz.utils import apply_to_each_array import time class ReconWBPOperator(tomviz.operators.CancelableOperator): - @apply_to_each_array def transform(self, dataset, Nrecon=None, filter=None, interp=None, Nupdates=None): """ diff --git a/tomviz/python/Recon_tomopy_gridrec.py b/tomviz/python/Recon_tomopy_gridrec.py index f09c36837..644ecad9f 100644 --- a/tomviz/python/Recon_tomopy_gridrec.py +++ b/tomviz/python/Recon_tomopy_gridrec.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, rot_center=0, tune_rot_center=True): """Reconstruct sinograms using the tomopy gridrec algorithm diff --git a/tomviz/python/RemoveBadPixelsTiltSeries.py b/tomviz/python/RemoveBadPixelsTiltSeries.py index bca96db04..2e15cb47d 100644 --- a/tomviz/python/RemoveBadPixelsTiltSeries.py +++ b/tomviz/python/RemoveBadPixelsTiltSeries.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, threshold=None): """Remove bad pixels in tilt series.""" diff --git a/tomviz/python/Resample.py b/tomviz/python/Resample.py index 0dff1de59..615ddf604 100644 --- a/tomviz/python/Resample.py +++ b/tomviz/python/Resample.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, resampling_factor=[1, 1, 1]): """Resample dataset""" diff --git a/tomviz/python/Rotate3D.py b/tomviz/python/Rotate3D.py index 9317c93da..7969890c5 100644 --- a/tomviz/python/Rotate3D.py +++ b/tomviz/python/Rotate3D.py @@ -1,7 +1,6 @@ from tomviz import utils -@utils.apply_to_each_array def transform(dataset, rotation_angle=90.0, rotation_axis=0): import numpy as np diff --git a/tomviz/python/RotationAlign.py b/tomviz/python/RotationAlign.py index ade67c686..fd51c03d4 100644 --- a/tomviz/python/RotationAlign.py +++ b/tomviz/python/RotationAlign.py @@ -1,10 +1,6 @@ # Perform alignment to the estimated rotation axis # # Developed as part of the tomviz project (www.tomviz.com). -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, SHIFT=None, rotation_angle=90.0, tilt_axis=0): from tomviz import utils from scipy import ndimage diff --git a/tomviz/python/SetNegativeVoxelsToZero.py b/tomviz/python/SetNegativeVoxelsToZero.py index be2787c23..2db321cdd 100644 --- a/tomviz/python/SetNegativeVoxelsToZero.py +++ b/tomviz/python/SetNegativeVoxelsToZero.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset): """Set negative voxels to zero""" diff --git a/tomviz/python/Shift3D.py b/tomviz/python/Shift3D.py index 39b2443ff..eac00fd7d 100644 --- a/tomviz/python/Shift3D.py +++ b/tomviz/python/Shift3D.py @@ -1,10 +1,6 @@ # Shift a 3D dataset using SciPy Interpolation libraries. # # Developed as part of the tomviz project (www.tomviz.com). -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, SHIFT=None): from scipy import ndimage diff --git a/tomviz/python/Shift_Stack_Uniformly.py b/tomviz/python/Shift_Stack_Uniformly.py index 5087fbfe0..aa4c1b023 100644 --- a/tomviz/python/Shift_Stack_Uniformly.py +++ b/tomviz/python/Shift_Stack_Uniformly.py @@ -1,11 +1,6 @@ # Shift all data uniformly (it is a rolling shift). # # Developed as part of the tomviz project (www.tomviz.com). - -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, shift=[0, 0, 0]): import numpy as np diff --git a/tomviz/python/Square_Root_Data.py b/tomviz/python/Square_Root_Data.py index 408b4f1b5..731d1ff49 100644 --- a/tomviz/python/Square_Root_Data.py +++ b/tomviz/python/Square_Root_Data.py @@ -1,4 +1,3 @@ -from tomviz.utils import apply_to_each_array import tomviz.operators NUMBER_OF_CHUNKS = 10 @@ -6,7 +5,6 @@ class SquareRootOperator(tomviz.operators.CancelableOperator): - @apply_to_each_array def transform(self, dataset): """Define this method for Python operators that transform input scalars""" diff --git a/tomviz/python/Subtract_TiltSer_Background.py b/tomviz/python/Subtract_TiltSer_Background.py index 1e298d7fc..610ee6864 100644 --- a/tomviz/python/Subtract_TiltSer_Background.py +++ b/tomviz/python/Subtract_TiltSer_Background.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, XRANGE=None, YRANGE=None, ZRANGE=None): '''For each tilt image, the method uses average pixel value of selected region as the background level and subtracts it from the image.''' diff --git a/tomviz/python/Subtract_TiltSer_Background_Auto.py b/tomviz/python/Subtract_TiltSer_Background_Auto.py index a96ce3aa6..b3366616a 100644 --- a/tomviz/python/Subtract_TiltSer_Background_Auto.py +++ b/tomviz/python/Subtract_TiltSer_Background_Auto.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset): """ For each tilt image, the method calculates its histogram diff --git a/tomviz/python/SwapAxes.py b/tomviz/python/SwapAxes.py index 6ee15acb8..370fb5ac6 100644 --- a/tomviz/python/SwapAxes.py +++ b/tomviz/python/SwapAxes.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, axis1, axis2): """Swap two axes in a dataset""" diff --git a/tomviz/python/TV_Filter.py b/tomviz/python/TV_Filter.py index 50d7778b6..88f9c85bf 100644 --- a/tomviz/python/TV_Filter.py +++ b/tomviz/python/TV_Filter.py @@ -1,4 +1,3 @@ -from tomviz.utils import apply_to_each_array import tomviz.operators import numpy as np @@ -8,7 +7,6 @@ class ArtifactsTVOperator(tomviz.operators.CancelableOperator): - @apply_to_each_array def transform(self, dataset, Niter=100, a=0.1, wedgeSize=5, kmin=5, theta=0): """ diff --git a/tomviz/python/UnsharpMask.py b/tomviz/python/UnsharpMask.py index 2f8a06560..497fc9842 100644 --- a/tomviz/python/UnsharpMask.py +++ b/tomviz/python/UnsharpMask.py @@ -1,10 +1,8 @@ -from tomviz.utils import apply_to_each_array import tomviz.operators class UnsharpMask(tomviz.operators.CancelableOperator): - @apply_to_each_array def transform(self, dataset, amount=0.5, threshold=0.0, sigma=1.0): """This filter performs anisotropic diffusion on an image using the classic Perona-Malik, gradient magnitude-based equation. diff --git a/tomviz/python/WienerFilter.py b/tomviz/python/WienerFilter.py index 3ec80686c..aa8a6d7b8 100644 --- a/tomviz/python/WienerFilter.py +++ b/tomviz/python/WienerFilter.py @@ -1,7 +1,3 @@ -from tomviz.utils import apply_to_each_array - - -@apply_to_each_array def transform(dataset, SX=0.5, SY=0.5, SZ=0.5, noise=15.0): """Deblur Images with a Weiner Filter.""" diff --git a/tomviz/python/ctf_correct.py b/tomviz/python/ctf_correct.py index 371010f6d..462f1092d 100755 --- a/tomviz/python/ctf_correct.py +++ b/tomviz/python/ctf_correct.py @@ -1,11 +1,8 @@ import numpy as np -from tomviz.utils import apply_to_each_array - # Given an dataset containing one or more 2D images, # apply CTF operations on them. -@apply_to_each_array def transform(dataset, apix=None, df1=None, df2=None, ast=None, ampcon=None, cs=None, kev=None, ctf_method=None, snr=None): From 9803e743707a871ef8e67195db43d6312a8cde90 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Fri, 13 Feb 2026 14:41:59 -0600 Subject: [PATCH 06/32] Add ability to save tilt angles to a .txt file Signed-off-by: Patrick Avery --- tomviz/DataPropertiesPanel.cxx | 44 ++++++++++++++++++++++++++++++++++ tomviz/DataPropertiesPanel.h | 1 + tomviz/DataPropertiesPanel.ui | 12 +++++++++- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/tomviz/DataPropertiesPanel.cxx b/tomviz/DataPropertiesPanel.cxx index 1bb91690e..ce5cf2deb 100644 --- a/tomviz/DataPropertiesPanel.cxx +++ b/tomviz/DataPropertiesPanel.cxx @@ -38,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -98,6 +99,7 @@ DataPropertiesPanel::DataPropertiesPanel(QWidget* parentObject) connect(&ActiveObjects::instance(), SIGNAL(viewChanged(vtkSMViewProxy*)), SLOT(updateAxesGridLabels())); connect(m_ui->SetTiltAnglesButton, SIGNAL(clicked()), SLOT(setTiltAngles())); + connect(m_ui->saveTiltAngles, SIGNAL(clicked()), SLOT(saveTiltAngles())); connect(m_ui->unitBox, SIGNAL(editingFinished()), SLOT(updateUnits())); connect(m_ui->xLengthBox, &QLineEdit::editingFinished, [this]() { this->updateLength(m_ui->xLengthBox, 0); }); @@ -402,6 +404,7 @@ void DataPropertiesPanel::updateData() m_tiltAnglesSeparator->show(); m_ui->SetTiltAnglesButton->show(); m_ui->TiltAnglesTable->show(); + m_ui->saveTiltAngles->show(); QVector tiltAngles = dsource->getTiltAngles(); m_ui->TiltAnglesTable->setRowCount(tiltAngles.size()); m_ui->TiltAnglesTable->setColumnCount(1); @@ -414,6 +417,7 @@ void DataPropertiesPanel::updateData() m_tiltAnglesSeparator->hide(); m_ui->SetTiltAnglesButton->hide(); m_ui->TiltAnglesTable->hide(); + m_ui->saveTiltAngles->show(); } connect(m_ui->TiltAnglesTable, SIGNAL(cellChanged(int, int)), SLOT(onTiltAnglesModified(int, int))); @@ -627,6 +631,45 @@ void DataPropertiesPanel::setTiltAngles() SetTiltAnglesReaction::showSetTiltAnglesUI(mainWindow, dsource); } +void DataPropertiesPanel::saveTiltAngles() +{ + DataSource* dsource = m_currentDataSource; + if (!dsource) { + return; + } + + // Prompt user to select a file for saving + QString fileName = QFileDialog::getSaveFileName( + nullptr, + "Save Tilt Angles", + QString(), // Default directory (or you can specify a path) + "TXT Files (*.txt);;All Files (*)" + ); + + // Check if user cancelled + if (fileName.isEmpty()) { + return; + } + + auto tiltAngles = dsource->getTiltAngles(); + + // Open file for writing + QFile file(fileName); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::warning(nullptr, "Error", + "Could not open file for writing: " + file.errorString()); + return; + } + + // Write tilt angles, one per line + QTextStream out(&file); + for (const double& angle : tiltAngles) { + out << angle << "\n"; + } + + file.close(); +} + void DataPropertiesPanel::scheduleUpdate() { m_updateNeeded = true; @@ -811,6 +854,7 @@ void DataPropertiesPanel::clear() m_ui->TiltAnglesTable->clear(); m_ui->TiltAnglesTable->setRowCount(0); m_ui->TiltAnglesTable->hide(); + m_ui->saveTiltAngles->hide(); } void DataPropertiesPanel::updateSpacing(int axis, double newLength) diff --git a/tomviz/DataPropertiesPanel.h b/tomviz/DataPropertiesPanel.h index 2453da6d3..78aa83327 100644 --- a/tomviz/DataPropertiesPanel.h +++ b/tomviz/DataPropertiesPanel.h @@ -55,6 +55,7 @@ private slots: void setDataSource(DataSource*); void onTiltAnglesModified(int row, int column); void setTiltAngles(); + void saveTiltAngles(); void scheduleUpdate(); void onDataPropertiesChanged(); void onDataPositionChanged(double, double, double); diff --git a/tomviz/DataPropertiesPanel.ui b/tomviz/DataPropertiesPanel.ui index 02e401d0a..b9c108ccd 100644 --- a/tomviz/DataPropertiesPanel.ui +++ b/tomviz/DataPropertiesPanel.ui @@ -7,7 +7,7 @@ 0 0 482 - 682 + 707 @@ -479,6 +479,16 @@ + + + + <html><head/><body><p>Save the tilt angles to an XY file.</p></body></html> + + + Save Tilt Angles + + + From 7634ac4a0e33e85283105ff84a78eede1a3e4bd7 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Fri, 13 Feb 2026 16:25:11 -0600 Subject: [PATCH 07/32] Fixes for new automatic decorating This fixes a few issues the tests identified with the automatic decoration. Signed-off-by: Patrick Avery --- tests/python/utils.py | 30 +++++++++++++++++++++++++++-- tomviz/python/BinTiltSeriesByTwo.py | 3 +++ tomviz/python/Recon_SIRT.json | 8 +------- tomviz/python/tomviz/_internal.py | 21 +++++++++++++++----- 4 files changed, 48 insertions(+), 14 deletions(-) diff --git a/tests/python/utils.py b/tests/python/utils.py index b7ba753da..880d5e6e2 100644 --- a/tests/python/utils.py +++ b/tests/python/utils.py @@ -1,22 +1,42 @@ +from pathlib import Path +from types import ModuleType +from typing import Callable import importlib.util import inspect -from pathlib import Path import shutil -from types import ModuleType import urllib.request import zipfile from tomviz.executor import OperatorWrapper from tomviz.operators import Operator +from tomviz._internal import add_transform_decorators OPERATOR_PATH = Path(__file__).parent.parent.parent / 'tomviz/python' +def add_decorators(func: Callable, operator_name: str) -> Callable: + # Automatically add the decorators which would normally be + # automatically added by Tomviz. + json_path = OPERATOR_PATH / f'{operator_name}.json' + op_dict = {} + if json_path.exists(): + with open(json_path, 'rb') as rf: + op_dict['description'] = rf.read() + + func = add_transform_decorators(func, op_dict) + return func + + def load_operator_module(operator_name: str) -> ModuleType: module_path = OPERATOR_PATH / f'{operator_name}.py' spec = importlib.util.spec_from_file_location(operator_name, module_path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) + + if hasattr(module, 'transform'): + # Add the decorators + module.transform = add_decorators(module.transform, operator_name) + return module @@ -24,9 +44,15 @@ def load_operator_class(operator_module: ModuleType) -> Operator | None: # Locate the operator class for v in operator_module.__dict__.values(): if inspect.isclass(v) and issubclass(v, Operator): + if hasattr(v, 'transform'): + # Decorate at the class level + name = operator_module.__name__ + v.transform = add_decorators(v.transform, name) + # Instantiate and set up wrapper operator = v() operator._operator_wrapper = OperatorWrapper() + return operator diff --git a/tomviz/python/BinTiltSeriesByTwo.py b/tomviz/python/BinTiltSeriesByTwo.py index 16aea837e..66dd1d508 100644 --- a/tomviz/python/BinTiltSeriesByTwo.py +++ b/tomviz/python/BinTiltSeriesByTwo.py @@ -1,3 +1,6 @@ +from tomviz import utils + + def transform(dataset): """Downsample tilt images by a factor of 2""" diff --git a/tomviz/python/Recon_SIRT.json b/tomviz/python/Recon_SIRT.json index 7866145f1..740fb4b70 100644 --- a/tomviz/python/Recon_SIRT.json +++ b/tomviz/python/Recon_SIRT.json @@ -1,13 +1,7 @@ { "name" : "ReconstructSIRT", "label" : "SIRT Reconstruction", - "description" : "Reconstruct a tilt series using Simultaneous Iterative Reconstruction Techniques Technique (SIRT) with a Positivity Constraint. - -The tilt series data should be aligned prior to reconstruction and the tilt axis must be parallel to the x-direction. - -The size of reconstruction will be (Nx,Ny,Ny). The number of iterations can be specified below. - -Reconstrucing a 256x256x256 tomogram typically with Landweber's Method takes about 2 mins for 10 iterations.", + "description" : "Reconstruct a tilt series using Simultaneous Iterative Reconstruction Techniques Technique (SIRT) with a Positivity Constraint.\nThe tilt series data should be aligned prior to reconstruction and the tilt axis must be parallel to the x-direction.\nThe size of reconstruction will be (Nx,Ny,Ny). The number of iterations can be specified below.\nReconstrucing a 256x256x256 tomogram typically with Landweber's Method takes about 2 mins for 10 iterations.", "children": [ { "name": "reconstruction", diff --git a/tomviz/python/tomviz/_internal.py b/tomviz/python/tomviz/_internal.py index 09513b253..cf0120b8e 100644 --- a/tomviz/python/tomviz/_internal.py +++ b/tomviz/python/tomviz/_internal.py @@ -4,7 +4,9 @@ # This source file is part of the Tomviz project, https://tomviz.org/. # It is released under the 3-Clause BSD License, see "LICENSE". ############################################################################### -from typing import Any +from pathlib import Path +from types import MethodType +from typing import Any, Callable import fnmatch import importlib.machinery import importlib.util @@ -16,9 +18,6 @@ import tempfile import traceback -from pathlib import Path -from typing import Callable - import tomviz import tomviz.operators @@ -147,6 +146,18 @@ def has_decorator(func: Callable, decorator_marker: str = '_is_my_decorator') -> return False +def apply_decorator(func: Callable, decorator: Callable) -> Callable: + # Apply the decorator, taking into account different behavior for MethodType + # callables + if isinstance(func, MethodType): + # It's a bound method + tmp_func = decorator(func.__func__) + return MethodType(tmp_func, func.__self__) + + # Unbound function + return decorator(func) + + def add_transform_decorators(transform_method: Callable, operator_dict: dict[str, Any]) -> Callable: """Optionally add any transform wrappers that we need to add @@ -173,7 +184,7 @@ def add_transform_decorators(transform_method: Callable, if not has_decorator(transform_method, 'apply_to_each_array'): # Decorate it! from tomviz.utils import apply_to_each_array - transform_method = apply_to_each_array(transform_method) + transform_method = apply_decorator(transform_method, apply_to_each_array) return transform_method From cb84038b0c18a201373fbee89fe53732c418a3fd Mon Sep 17 00:00:00 2001 From: Alessandro Genova Date: Tue, 27 Jan 2026 17:23:38 -0500 Subject: [PATCH 08/32] add ModulePlot and LineView to display operators Table results Signed-off-by: Alessandro Genova --- tomviz/CMakeLists.txt | 2 + tomviz/MainWindow.cxx | 8 +- tomviz/ViewFrameActions.cxx | 2 + tomviz/modules/ModuleFactory.cxx | 47 +++++- tomviz/modules/ModuleFactory.h | 3 + tomviz/modules/ModuleMenu.cxx | 24 ++- tomviz/modules/ModuleMolecule.cxx | 5 + tomviz/modules/ModuleMolecule.h | 2 + tomviz/modules/ModulePlot.cxx | 223 ++++++++++++++++++++++++++++ tomviz/modules/ModulePlot.h | 65 ++++++++ tomviz/operators/OperatorResult.cxx | 5 - 11 files changed, 364 insertions(+), 22 deletions(-) create mode 100644 tomviz/modules/ModulePlot.cxx create mode 100644 tomviz/modules/ModulePlot.h diff --git a/tomviz/CMakeLists.txt b/tomviz/CMakeLists.txt index 1c39bc9ab..fd6ec20db 100644 --- a/tomviz/CMakeLists.txt +++ b/tomviz/CMakeLists.txt @@ -308,6 +308,8 @@ list(APPEND SOURCES modules/ModuleMolecule.h modules/ModuleOutline.cxx modules/ModuleOutline.h + modules/ModulePlot.cxx + modules/ModulePlot.h modules/ModulePropertiesPanel.cxx modules/ModulePropertiesPanel.h modules/ModuleRuler.cxx diff --git a/tomviz/MainWindow.cxx b/tomviz/MainWindow.cxx index a121d5731..e46fd05f1 100644 --- a/tomviz/MainWindow.cxx +++ b/tomviz/MainWindow.cxx @@ -124,9 +124,11 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) // Update back light azimuth default on view. connect(pqApplicationCore::instance()->getServerManagerModel(), &pqServerManagerModel::viewAdded, [](pqView* view) { - vtkSMPropertyHelper helper(view->getProxy(), "BackLightAzimuth"); - // See https://github.com/OpenChemistry/tomviz/issues/1525 - helper.Set(60); + if (view && view->getProxy()->IsA("vtkSMRenderViewProxy")) { + vtkSMPropertyHelper helper(view->getProxy(), "BackLightAzimuth"); + // See https://github.com/OpenChemistry/tomviz/issues/1525 + helper.Set(60); + } }); // checkOpenGL(); diff --git a/tomviz/ViewFrameActions.cxx b/tomviz/ViewFrameActions.cxx index 509fca31d..f4e5c06c2 100644 --- a/tomviz/ViewFrameActions.cxx +++ b/tomviz/ViewFrameActions.cxx @@ -20,6 +20,8 @@ ViewFrameActions::availableViewTypes() views.push_back(viewType); else if (viewType.Name == "SpreadSheetView") views.push_back(viewType); + else if (viewType.Name == "XYChartView") + views.push_back(viewType); } return views; } diff --git a/tomviz/modules/ModuleFactory.cxx b/tomviz/modules/ModuleFactory.cxx index 0d6241b54..ae73f9907 100644 --- a/tomviz/modules/ModuleFactory.cxx +++ b/tomviz/modules/ModuleFactory.cxx @@ -8,6 +8,7 @@ #include "ModuleContour.h" #include "ModuleMolecule.h" #include "ModuleOutline.h" +#include "ModulePlot.h" #include "ModuleRuler.h" #include "ModuleScaleCube.h" #include "ModuleSegment.h" @@ -19,6 +20,10 @@ #include #include +#include +#include +#include +#include #include #include @@ -40,7 +45,8 @@ QList ModuleFactory::moduleTypes() << "Volume" << "Threshold" << "Molecule" - << "Clip"; + << "Clip" + << "Plot"; std::sort(reply.begin(), reply.end()); return reply; } @@ -49,11 +55,11 @@ bool ModuleFactory::moduleApplicable(const QString& moduleName, DataSource* dataSource, vtkSMViewProxy* view) { - if (moduleName == "Molecule") { + if (moduleName == "Molecule" || moduleName == "Plot") { return false; } - if (dataSource && view) { + if (dataSource && view && vtkSMRenderViewProxy::SafeDownCast(view)) { if (dataSource->getNumberOfComponents() > 1) { if (moduleName == "Contour" || moduleName == "Threshold") { return false; @@ -68,14 +74,37 @@ bool ModuleFactory::moduleApplicable(const QString& moduleName, MoleculeSource* moleculeSource, vtkSMViewProxy* view) { - if (moleculeSource && view) { - if (moduleName == "Molecule") { + if (moduleName == "Molecule") { + if (moleculeSource && view && vtkSMRenderViewProxy::SafeDownCast(view)) { return true; } } return false; } +bool ModuleFactory::moduleApplicable(const QString& moduleName, + OperatorResult* operatorResult, + vtkSMViewProxy* view) +{ + if (moduleName == "Plot") { + return ( + operatorResult && + view && + vtkTable::SafeDownCast(operatorResult->dataObject()) && + vtkSMContextViewProxy::SafeDownCast(view) + ); + } else if (moduleName == "Molecule") { + return ( + operatorResult && + view && + vtkMolecule::SafeDownCast(operatorResult->dataObject()) && + vtkSMRenderViewProxy::SafeDownCast(view) + ); + } + + return false; +} + Module* ModuleFactory::allocateModule(const QString& type) { Module* module = nullptr; @@ -100,6 +129,8 @@ Module* ModuleFactory::allocateModule(const QString& type) module = new ModuleMolecule(); } else if (type== "Clip") { module = new ModuleClip(); + } else if (type == "Plot") { + module = new ModulePlot(); } return module; } @@ -180,8 +211,7 @@ Module* ModuleFactory::createModule(const QString& type, OperatorResult* result, QIcon ModuleFactory::moduleIcon(const QString& type) { QIcon icon; - DataSource* d = nullptr; - Module* mdl = ModuleFactory::createModule(type, d, nullptr); + Module* mdl = ModuleFactory::allocateModule(type); if (mdl) { icon = mdl->icon(); delete mdl; @@ -220,6 +250,9 @@ const char* ModuleFactory::moduleType(const Module* module) if (qobject_cast(module)) { return "Molecule"; } + if (qobject_cast(module)) { + return "Plot"; + } if (qobject_cast(module)) { return "Clip"; } diff --git a/tomviz/modules/ModuleFactory.h b/tomviz/modules/ModuleFactory.h index 6fd8de73a..a28e3b6ce 100644 --- a/tomviz/modules/ModuleFactory.h +++ b/tomviz/modules/ModuleFactory.h @@ -31,6 +31,9 @@ class ModuleFactory static bool moduleApplicable(const QString& moduleName, MoleculeSource* moleculeSource, vtkSMViewProxy* view); + static bool moduleApplicable(const QString& moduleName, + OperatorResult* operatorResult, + vtkSMViewProxy* view); /// Creates a module of the given type to show the dataSource in the view. static Module* createModule(const QString& type, DataSource* dataSource, diff --git a/tomviz/modules/ModuleMenu.cxx b/tomviz/modules/ModuleMenu.cxx index 0e9d8ab23..1bc51842a 100644 --- a/tomviz/modules/ModuleMenu.cxx +++ b/tomviz/modules/ModuleMenu.cxx @@ -18,11 +18,10 @@ ModuleMenu::ModuleMenu(QToolBar* toolBar, QMenu* menu, QObject* parentObject) Q_ASSERT(menu); Q_ASSERT(toolBar); connect(menu, SIGNAL(triggered(QAction*)), SLOT(triggered(QAction*))); - connect(&ActiveObjects::instance(), SIGNAL(dataSourceChanged(DataSource*)), - SLOT(updateActions())); - connect(&ActiveObjects::instance(), - SIGNAL(moleculeSourceChanged(MoleculeSource*)), - SLOT(updateActions())); + connect(&ActiveObjects::instance(), QOverload::of(&ActiveObjects::dataSourceChanged), this, &ModuleMenu::updateActions); + connect(&ActiveObjects::instance(), &ActiveObjects::moleculeSourceChanged, this, &ModuleMenu::updateActions); + connect(&ActiveObjects::instance(), &ActiveObjects::resultChanged, this, &ModuleMenu::updateActions); + connect(&ActiveObjects::instance(), QOverload::of(&ActiveObjects::viewChanged), this, &ModuleMenu::updateActions); updateActions(); } @@ -40,6 +39,7 @@ void ModuleMenu::updateActions() auto activeDataSource = ActiveObjects::instance().activeDataSource(); auto activeMoleculeSource = ActiveObjects::instance().activeMoleculeSource(); + auto activeOperatorResult = ActiveObjects::instance().activeOperatorResult(); auto activeView = ActiveObjects::instance().activeView(); QList modules = ModuleFactory::moduleTypes(); @@ -48,7 +48,8 @@ void ModuleMenu::updateActions() auto actn = menu->addAction(ModuleFactory::moduleIcon(txt), txt); actn->setEnabled( ModuleFactory::moduleApplicable(txt, activeDataSource, activeView) || - ModuleFactory::moduleApplicable(txt, activeMoleculeSource, activeView)); + ModuleFactory::moduleApplicable(txt, activeMoleculeSource, activeView) || + ModuleFactory::moduleApplicable(txt, activeOperatorResult, activeView)); toolBar->addAction(actn); actn->setData(txt); } @@ -64,12 +65,21 @@ void ModuleMenu::triggered(QAction* maction) auto type = maction->data().toString(); auto dataSource = ActiveObjects::instance().activeDataSource(); auto moleculeSource = ActiveObjects::instance().activeMoleculeSource(); + auto operatorResult = ActiveObjects::instance().activeOperatorResult(); auto view = ActiveObjects::instance().activeView(); Module* module; if (type == "Molecule") { + if (operatorResult) { + module = + ModuleManager::instance().createAndAddModule(type, operatorResult, view); + } else { + module = + ModuleManager::instance().createAndAddModule(type, moleculeSource, view); + } + } else if (type == "Plot") { module = - ModuleManager::instance().createAndAddModule(type, moleculeSource, view); + ModuleManager::instance().createAndAddModule(type, operatorResult, view); } else { module = ModuleManager::instance().createAndAddModule(type, dataSource, view); diff --git a/tomviz/modules/ModuleMolecule.cxx b/tomviz/modules/ModuleMolecule.cxx index f686b1578..555bd64be 100644 --- a/tomviz/modules/ModuleMolecule.cxx +++ b/tomviz/modules/ModuleMolecule.cxx @@ -39,6 +39,11 @@ QIcon ModuleMolecule::icon() const return QIcon(":/pqWidgets/Icons/pqGroup.svg"); } +bool ModuleMolecule::initialize(DataSource* data, vtkSMViewProxy* vtkView) +{ + return false; +} + bool ModuleMolecule::initialize(OperatorResult* result, vtkSMViewProxy* view) { if (!Module::initialize(result, view)) { diff --git a/tomviz/modules/ModuleMolecule.h b/tomviz/modules/ModuleMolecule.h index 6b595e67d..451a8205e 100644 --- a/tomviz/modules/ModuleMolecule.h +++ b/tomviz/modules/ModuleMolecule.h @@ -30,6 +30,8 @@ class ModuleMolecule : public Module QString label() const override { return "Molecule"; } QIcon icon() const override; using Module::initialize; + bool initialize(DataSource* dataSource, + vtkSMViewProxy* view) override; bool initialize(MoleculeSource* moleculeSource, vtkSMViewProxy* view) override; bool initialize(OperatorResult* result, vtkSMViewProxy* view) override; diff --git a/tomviz/modules/ModulePlot.cxx b/tomviz/modules/ModulePlot.cxx new file mode 100644 index 000000000..593aabe3a --- /dev/null +++ b/tomviz/modules/ModulePlot.cxx @@ -0,0 +1,223 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "ModulePlot.h" + +#include "OperatorResult.h" +#include "Utilities.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace tomviz { + +ModulePlot::ModulePlot(QObject* parentObject) + : Module(parentObject) + , m_visible(true) + , m_view(nullptr) + , m_table(nullptr) + , m_chart(nullptr) + , m_producer(nullptr) +{ + m_result_modified_cb->SetCallback(&ModulePlot::onResultModified); + m_result_modified_cb->SetClientData(this); +} + +ModulePlot::~ModulePlot() +{ + finalize(); +} + +QIcon ModulePlot::icon() const +{ + return QIcon(":/pqWidgets/Icons/pqLineChart16.png"); +} + +bool ModulePlot::initialize(DataSource* data, vtkSMViewProxy* view) +{ + Q_UNUSED(data); + Q_UNUSED(view); + + return false; +} + +bool ModulePlot::initialize(MoleculeSource* data, vtkSMViewProxy* view) +{ + Q_UNUSED(data); + Q_UNUSED(view); + + return false; +} + +bool ModulePlot::initialize(OperatorResult* result, vtkSMViewProxy* view) +{ + Module::initialize(result, view); + + m_table = vtkTable::SafeDownCast(result->dataObject()); + m_producer = vtkTrivialProducer::SafeDownCast(result->producerProxy()->GetClientSideObject()); + m_view = vtkPVContextView::SafeDownCast(view->GetClientSideView()); + m_chart = nullptr; + + if (m_table == nullptr || m_producer == nullptr || m_view == nullptr) { + return false; + } + + auto context_view = m_view->GetContextView(); + + m_chart = vtkChartXY::SafeDownCast(context_view->GetScene()->GetItem(0)); + + if (m_chart == nullptr) { + return false; + } + + // Detect when the result dataobject changes, i.e. when the pipeline re-runs + m_producer->AddObserver(vtkCommand::ModifiedEvent, m_result_modified_cb); + + addAllPlots(); + + return true; +} + +bool ModulePlot::finalize() +{ + if (m_producer) { + m_producer->RemoveObserver(m_result_modified_cb); + } + + removeAllPlots(); + + m_plots.clear(); + + return true; +} + +void ModulePlot::addAllPlots() +{ + removeAllPlots(); + + if (m_table == nullptr || m_chart == nullptr) { + return; + } + + vtkIdType num_cols = m_table->GetNumberOfColumns(); + + for (vtkIdType col = 1; col < num_cols; col++) { + auto line = vtkSmartPointer::New(); + u_int8_t color[3] = {0, 0, 0}; + color[(col - 1) % 3] = 255; + line->SetInputData(m_table, 0, col); + line->SetColor(color[0], color[1], color[2], 255); + line->SetWidth(3.0); + m_chart->AddPlot(line); + m_plots.append(line); + } +} + +void ModulePlot::removeAllPlots() +{ + if (m_chart == nullptr) { + return; + } + + for (auto iter = m_plots.begin(); iter != m_plots.end(); iter++) { + m_chart->RemovePlotInstance(*iter); + } + + m_plots.clear(); +} + +bool ModulePlot::setVisibility(bool val) +{ + m_visible = val; + + if (val) { + addAllPlots(); + } else { + removeAllPlots(); + } + + Module::setVisibility(val); + + return true; +} + +bool ModulePlot::visibility() const +{ + return m_visible; +} + +void ModulePlot::addToPanel(QWidget* panel) +{ + if (panel->layout()) { + delete panel->layout(); + } + + QFormLayout* layout = new QFormLayout; + + panel->setLayout(layout); +} + +QJsonObject ModulePlot::serialize() const +{ + auto json = Module::serialize(); + auto props = json["properties"].toObject(); + + json["properties"] = props; + return json; +} + +bool ModulePlot::deserialize(const QJsonObject& json) +{ + if (!Module::deserialize(json)) { + return false; + } + if (json["properties"].isObject()) { + auto props = json["properties"].toObject(); + return true; + } + return false; +} + +void ModulePlot::dataSourceMoved(double, double, double) +{ +} + +void ModulePlot::dataSourceRotated(double, double, double) +{ +} + +vtkDataObject* ModulePlot::dataToExport() +{ + return nullptr; +} + +void ModulePlot::onResultModified(vtkObject* caller, long unsigned int eventId, void* clientData, void*callData) +{ + Q_UNUSED(caller); + Q_UNUSED(eventId); + Q_UNUSED(callData); + + auto self = reinterpret_cast(clientData); + auto result = self->operatorResult(); + self->m_table = vtkTable::SafeDownCast(result->dataObject()); + + self->removeAllPlots(); + self->m_plots.clear(); + + if (self->visibility()) { + self->addAllPlots(); + } +} + +} // namespace tomviz diff --git a/tomviz/modules/ModulePlot.h b/tomviz/modules/ModulePlot.h new file mode 100644 index 000000000..b246b2a96 --- /dev/null +++ b/tomviz/modules/ModulePlot.h @@ -0,0 +1,65 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#ifndef tomvizModulePlot_h +#define tomvizModulePlot_h + +#include "Module.h" + +class vtkCallbackCommand; +class vtkChartXY; +class vtkPlot; +class vtkPVContextView; +class vtkTable; +class vtkTrivialProducer; + + +namespace tomviz { + +class MoleculeSource; +class OperatorResult; + +class ModulePlot : public Module +{ + Q_OBJECT + +public: + ModulePlot(QObject* parent = nullptr); + ~ModulePlot() override; + + QString label() const override { return "Plot"; } + QIcon icon() const override; + using Module::initialize; + bool initialize(DataSource* data, vtkSMViewProxy* vtkView) override; + bool initialize(MoleculeSource* data, vtkSMViewProxy* vtkView) override; + bool initialize(OperatorResult* result, vtkSMViewProxy* view) override; + bool finalize() override; + bool setVisibility(bool val) override; + bool visibility() const override; + void addToPanel(QWidget*) override; + QJsonObject serialize() const override; + bool deserialize(const QJsonObject& json) override; + + QString exportDataTypeString() override { return ""; } + vtkDataObject* dataToExport() override; + + void dataSourceMoved(double newX, double newY, double newZ) override; + void dataSourceRotated(double newX, double newY, double newZ) override; + +private: + static void onResultModified(vtkObject* caller, long unsigned int eventId, void* clientData, void*callData); + void addAllPlots(); + void removeAllPlots(); + + Q_DISABLE_COPY(ModulePlot) + bool m_visible; + vtkWeakPointer m_view; + vtkNew m_result_modified_cb; + vtkWeakPointer m_table; + vtkWeakPointer m_chart; + vtkWeakPointer m_producer; + QList> m_plots; + +}; +} // namespace tomviz +#endif diff --git a/tomviz/operators/OperatorResult.cxx b/tomviz/operators/OperatorResult.cxx index bb13acb76..2efe5aae3 100644 --- a/tomviz/operators/OperatorResult.cxx +++ b/tomviz/operators/OperatorResult.cxx @@ -98,11 +98,6 @@ void OperatorResult::setDataObject(vtkDataObject* object) vtkTrivialProducer* producer = vtkTrivialProducer::SafeDownCast(clientSideObject); producer->SetOutput(object); - // If the result is a vtkMolecule, create a ModuleMolecule to display it - if (vtkMolecule::SafeDownCast(object)) { - auto view = ActiveObjects::instance().activeView(); - ModuleManager::instance().createAndAddModule("Molecule", this, view); - } } vtkSMSourceProxy* OperatorResult::producerProxy() From 868edde046c0896b60d962433c3c9846ad17ed8b Mon Sep 17 00:00:00 2001 From: Alessandro Genova Date: Wed, 28 Jan 2026 13:40:12 -0500 Subject: [PATCH 09/32] add options to ModulePlot to log scale x and y axes Signed-off-by: Alessandro Genova --- tomviz/modules/ModulePlot.cxx | 45 +++++++++++++++++++++++++++++++++++ tomviz/modules/ModulePlot.h | 10 ++++++++ 2 files changed, 55 insertions(+) diff --git a/tomviz/modules/ModulePlot.cxx b/tomviz/modules/ModulePlot.cxx index 593aabe3a..5a5ca4ef5 100644 --- a/tomviz/modules/ModulePlot.cxx +++ b/tomviz/modules/ModulePlot.cxx @@ -6,6 +6,7 @@ #include "OperatorResult.h" #include "Utilities.h" +#include #include #include #include @@ -16,6 +17,7 @@ #include #include +#include #include #include #include @@ -81,6 +83,11 @@ bool ModulePlot::initialize(OperatorResult* result, vtkSMViewProxy* view) return false; } + auto x_axis = m_chart->GetAxis(vtkAxis::BOTTOM); + auto y_axis = m_chart->GetAxis(vtkAxis::LEFT); + m_xLogScale = x_axis->GetLogScale(); + m_yLogScale = y_axis->GetLogScale(); + // Detect when the result dataobject changes, i.e. when the pipeline re-runs m_producer->AddObserver(vtkCommand::ModifiedEvent, m_result_modified_cb); @@ -165,6 +172,18 @@ void ModulePlot::addToPanel(QWidget* panel) QFormLayout* layout = new QFormLayout; + m_xLogCheckBox = new QCheckBox("X Log Scale"); + m_yLogCheckBox = new QCheckBox("Y Log Scale"); + m_xLogCheckBox->setChecked(m_xLogScale); + m_yLogCheckBox->setChecked(m_yLogScale); + layout->addRow(m_xLogCheckBox); + layout->addRow(m_yLogCheckBox); + + connect(m_xLogCheckBox, &QCheckBox::toggled, this, + &ModulePlot::onXLogScaleChanged); + connect(m_yLogCheckBox, &QCheckBox::toggled, this, + &ModulePlot::onYLogScaleChanged); + panel->setLayout(layout); } @@ -220,4 +239,30 @@ void ModulePlot::onResultModified(vtkObject* caller, long unsigned int eventId, } } +void ModulePlot::onXLogScaleChanged(bool logScale) +{ + if (m_chart == nullptr) { + return; + } + + m_xLogScale = logScale; + auto x_axis = m_chart->GetAxis(vtkAxis::BOTTOM); + x_axis->SetLogScale(logScale); + + m_view->Update(); +} + +void ModulePlot::onYLogScaleChanged(bool logScale) +{ + if (m_chart == nullptr) { + return; + } + + m_yLogScale = logScale; + auto y_axis = m_chart->GetAxis(vtkAxis::LEFT); + y_axis->SetLogScale(logScale); + + m_view->Update(); +} + } // namespace tomviz diff --git a/tomviz/modules/ModulePlot.h b/tomviz/modules/ModulePlot.h index b246b2a96..49a06c5e5 100644 --- a/tomviz/modules/ModulePlot.h +++ b/tomviz/modules/ModulePlot.h @@ -13,6 +13,8 @@ class vtkPVContextView; class vtkTable; class vtkTrivialProducer; +class QCheckBox; + namespace tomviz { @@ -46,6 +48,10 @@ class ModulePlot : public Module void dataSourceMoved(double newX, double newY, double newZ) override; void dataSourceRotated(double newX, double newY, double newZ) override; +private slots: + void onXLogScaleChanged(bool); + void onYLogScaleChanged(bool); + private: static void onResultModified(vtkObject* caller, long unsigned int eventId, void* clientData, void*callData); void addAllPlots(); @@ -53,12 +59,16 @@ class ModulePlot : public Module Q_DISABLE_COPY(ModulePlot) bool m_visible; + bool m_xLogScale; + bool m_yLogScale; vtkWeakPointer m_view; vtkNew m_result_modified_cb; vtkWeakPointer m_table; vtkWeakPointer m_chart; vtkWeakPointer m_producer; QList> m_plots; + QPointer m_xLogCheckBox; + QPointer m_yLogCheckBox; }; } // namespace tomviz From 829df71a5f7e3d5ac661d90c763c93c2c1e09769 Mon Sep 17 00:00:00 2001 From: Alessandro Genova Date: Tue, 17 Feb 2026 13:54:45 -0500 Subject: [PATCH 10/32] let operators provide xy axes labels and log scaling Signed-off-by: Alessandro Genova --- tomviz/modules/ModulePlot.cxx | 78 +++++++++++++++++++++++++++++++---- tomviz/modules/ModulePlot.h | 7 +++- tomviz/python/tomviz/utils.py | 24 ++++++++++- 3 files changed, 96 insertions(+), 13 deletions(-) diff --git a/tomviz/modules/ModulePlot.cxx b/tomviz/modules/ModulePlot.cxx index 5a5ca4ef5..d941b1305 100644 --- a/tomviz/modules/ModulePlot.cxx +++ b/tomviz/modules/ModulePlot.cxx @@ -10,15 +10,19 @@ #include #include #include +#include #include #include #include #include +#include +#include #include #include #include #include +#include #include #include @@ -83,11 +87,6 @@ bool ModulePlot::initialize(OperatorResult* result, vtkSMViewProxy* view) return false; } - auto x_axis = m_chart->GetAxis(vtkAxis::BOTTOM); - auto y_axis = m_chart->GetAxis(vtkAxis::LEFT); - m_xLogScale = x_axis->GetLogScale(); - m_yLogScale = y_axis->GetLogScale(); - // Detect when the result dataobject changes, i.e. when the pipeline re-runs m_producer->AddObserver(vtkCommand::ModifiedEvent, m_result_modified_cb); @@ -119,6 +118,25 @@ void ModulePlot::addAllPlots() vtkIdType num_cols = m_table->GetNumberOfColumns(); + auto fieldData = m_table->GetFieldData(); + auto labelsArray = vtkStringArray::SafeDownCast( + fieldData->GetAbstractArray("axes_labels")); + if (labelsArray && labelsArray->GetNumberOfTuples() >= 2) { + auto x_axis = m_chart->GetAxis(vtkAxis::BOTTOM); + auto y_axis = m_chart->GetAxis(vtkAxis::LEFT); + x_axis->SetTitle(labelsArray->GetValue(0)); + y_axis->SetTitle(labelsArray->GetValue(1)); + } + + auto logScaleArray = vtkUnsignedCharArray::SafeDownCast( + fieldData->GetAbstractArray("axes_log_scale")); + if (logScaleArray && logScaleArray->GetNumberOfTuples() >= 2) { + auto x_axis = m_chart->GetAxis(vtkAxis::BOTTOM); + auto y_axis = m_chart->GetAxis(vtkAxis::LEFT); + x_axis->SetLogScale(logScaleArray->GetValue(0) != 0); + y_axis->SetLogScale(logScaleArray->GetValue(1) != 0); + } + for (vtkIdType col = 1; col < num_cols; col++) { auto line = vtkSmartPointer::New(); u_int8_t color[3] = {0, 0, 0}; @@ -172,13 +190,33 @@ void ModulePlot::addToPanel(QWidget* panel) QFormLayout* layout = new QFormLayout; + QString xLabel, yLabel; + bool xLogScale = false; + bool yLogScale = false; + + if (m_chart) { + xLabel = m_chart->GetAxis(vtkAxis::BOTTOM)->GetTitle().c_str(); + yLabel = m_chart->GetAxis(vtkAxis::LEFT)->GetTitle().c_str(); + xLogScale = m_chart->GetAxis(vtkAxis::BOTTOM)->GetLogScale(); + yLogScale = m_chart->GetAxis(vtkAxis::LEFT)->GetLogScale(); + } + + m_xLabelEdit = new QLineEdit(xLabel); + m_yLabelEdit = new QLineEdit(yLabel); + layout->addRow("X Label", m_xLabelEdit); + layout->addRow("Y Label", m_yLabelEdit); + m_xLogCheckBox = new QCheckBox("X Log Scale"); m_yLogCheckBox = new QCheckBox("Y Log Scale"); - m_xLogCheckBox->setChecked(m_xLogScale); - m_yLogCheckBox->setChecked(m_yLogScale); + m_xLogCheckBox->setChecked(xLogScale); + m_yLogCheckBox->setChecked(yLogScale); layout->addRow(m_xLogCheckBox); layout->addRow(m_yLogCheckBox); + connect(m_xLabelEdit, &QLineEdit::textChanged, this, + &ModulePlot::onXLabelChanged); + connect(m_yLabelEdit, &QLineEdit::textChanged, this, + &ModulePlot::onYLabelChanged); connect(m_xLogCheckBox, &QCheckBox::toggled, this, &ModulePlot::onXLogScaleChanged); connect(m_yLogCheckBox, &QCheckBox::toggled, this, @@ -245,7 +283,6 @@ void ModulePlot::onXLogScaleChanged(bool logScale) return; } - m_xLogScale = logScale; auto x_axis = m_chart->GetAxis(vtkAxis::BOTTOM); x_axis->SetLogScale(logScale); @@ -258,11 +295,34 @@ void ModulePlot::onYLogScaleChanged(bool logScale) return; } - m_yLogScale = logScale; auto y_axis = m_chart->GetAxis(vtkAxis::LEFT); y_axis->SetLogScale(logScale); m_view->Update(); } +void ModulePlot::onXLabelChanged(const QString& label) +{ + if (m_chart == nullptr) { + return; + } + + auto x_axis = m_chart->GetAxis(vtkAxis::BOTTOM); + x_axis->SetTitle(label.toStdString()); + + m_view->Update(); +} + +void ModulePlot::onYLabelChanged(const QString& label) +{ + if (m_chart == nullptr) { + return; + } + + auto y_axis = m_chart->GetAxis(vtkAxis::LEFT); + y_axis->SetTitle(label.toStdString()); + + m_view->Update(); +} + } // namespace tomviz diff --git a/tomviz/modules/ModulePlot.h b/tomviz/modules/ModulePlot.h index 49a06c5e5..7dd29f212 100644 --- a/tomviz/modules/ModulePlot.h +++ b/tomviz/modules/ModulePlot.h @@ -14,6 +14,7 @@ class vtkTable; class vtkTrivialProducer; class QCheckBox; +class QLineEdit; namespace tomviz { @@ -51,6 +52,8 @@ class ModulePlot : public Module private slots: void onXLogScaleChanged(bool); void onYLogScaleChanged(bool); + void onXLabelChanged(const QString& label); + void onYLabelChanged(const QString& label); private: static void onResultModified(vtkObject* caller, long unsigned int eventId, void* clientData, void*callData); @@ -59,8 +62,6 @@ private slots: Q_DISABLE_COPY(ModulePlot) bool m_visible; - bool m_xLogScale; - bool m_yLogScale; vtkWeakPointer m_view; vtkNew m_result_modified_cb; vtkWeakPointer m_table; @@ -69,6 +70,8 @@ private slots: QList> m_plots; QPointer m_xLogCheckBox; QPointer m_yLogCheckBox; + QPointer m_xLabelEdit; + QPointer m_yLabelEdit; }; } // namespace tomviz diff --git a/tomviz/python/tomviz/utils.py b/tomviz/python/tomviz/utils.py index 84b53c1d8..9a3a92d45 100644 --- a/tomviz/python/tomviz/utils.py +++ b/tomviz/python/tomviz/utils.py @@ -257,7 +257,9 @@ def depad_array(array: np.ndarray, padding: int, tilt_axis: int) -> np.ndarray: return array[tuple(slice_list)] -def make_spreadsheet(column_names: list[str], table: np.ndarray) -> 'vtkTable': +def make_spreadsheet(column_names: list[str], table: np.ndarray, + axes_labels: tuple[str, str] = None, + axes_log_scale: tuple[bool, bool] = None) -> 'vtkTable': """Make a spreadsheet object to use within Tomviz If returned from an operator, this will ultimately appear within the @@ -287,7 +289,7 @@ def make_spreadsheet(column_names: list[str], table: np.ndarray) -> 'vtkTable': 'column names') return - from vtk import vtkTable, vtkFloatArray + from vtk import vtkTable, vtkFloatArray, vtkStringArray, vtkUnsignedCharArray vtk_table = vtkTable() for (column, name) in enumerate(column_names): array = vtkFloatArray() @@ -299,4 +301,22 @@ def make_spreadsheet(column_names: list[str], table: np.ndarray) -> 'vtkTable': for row in range(0, rows): array.InsertValue(row, table[row, column]) + if axes_labels is not None: + label_array = vtkStringArray() + label_array.SetName('axes_labels') + label_array.SetNumberOfComponents(1) + label_array.SetNumberOfTuples(2) + label_array.SetValue(0, axes_labels[0]) + label_array.SetValue(1, axes_labels[1]) + vtk_table.GetFieldData().AddArray(label_array) + + if axes_log_scale is not None: + log_array = vtkUnsignedCharArray() + log_array.SetName('axes_log_scale') + log_array.SetNumberOfComponents(1) + log_array.SetNumberOfTuples(2) + log_array.SetValue(0, int(axes_log_scale[0])) + log_array.SetValue(1, int(axes_log_scale[1])) + vtk_table.GetFieldData().AddArray(log_array) + return vtk_table From 6bf0f4cba8bf120061f6ce3b6cff1307570cd6dc Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Tue, 17 Feb 2026 12:28:45 -0600 Subject: [PATCH 11/32] Add support for Scan IDs to Tomviz This adds support for scan IDs in the following places: 1. The DataSource class 2. The DataPropertiesPanel (if present, they are displayed with the tilt angles) 3. EMD files (they are written in `/data/tomography/scan_ids`) 4. In operators (`dataset.scan_ids`) The scan IDs are automatically read from the PyXRF and Ptycho workflows and saved on the Data Source. Signed-off-by: Patrick Avery --- tomviz/DataPropertiesPanel.cxx | 37 ++++++++++-- tomviz/DataPropertiesPanel.h | 1 + tomviz/DataSource.cxx | 75 ++++++++++++++++++++++++ tomviz/DataSource.h | 17 ++++++ tomviz/EmdFormat.cxx | 22 +++++++ tomviz/PtychoRunner.cxx | 10 ++++ tomviz/PyXRFProcessDialog.cxx | 18 ++++++ tomviz/PyXRFProcessDialog.h | 1 + tomviz/PyXRFRunner.cxx | 11 ++++ tomviz/python/tomviz/dataset.py | 18 ++++++ tomviz/python/tomviz/executor.py | 12 ++++ tomviz/python/tomviz/external_dataset.py | 9 +++ tomviz/python/tomviz/internal_dataset.py | 8 +++ tomviz/python/tomviz/internal_utils.py | 28 +++++++++ 14 files changed, 262 insertions(+), 5 deletions(-) diff --git a/tomviz/DataPropertiesPanel.cxx b/tomviz/DataPropertiesPanel.cxx index ce5cf2deb..ddbf37423 100644 --- a/tomviz/DataPropertiesPanel.cxx +++ b/tomviz/DataPropertiesPanel.cxx @@ -406,13 +406,31 @@ void DataPropertiesPanel::updateData() m_ui->TiltAnglesTable->show(); m_ui->saveTiltAngles->show(); QVector tiltAngles = dsource->getTiltAngles(); + QVector scanIDs = dsource->getScanIDs(); + m_hasScanIDs = scanIDs.size() == tiltAngles.size() && !scanIDs.isEmpty(); m_ui->TiltAnglesTable->setRowCount(tiltAngles.size()); - m_ui->TiltAnglesTable->setColumnCount(1); + int numCols = m_hasScanIDs ? 2 : 1; + m_ui->TiltAnglesTable->setColumnCount(numCols); + int tiltCol = m_hasScanIDs ? 1 : 0; for (int i = 0; i < tiltAngles.size(); ++i) { + if (m_hasScanIDs) { + QTableWidgetItem* scanItem = new QTableWidgetItem(); + scanItem->setData(Qt::DisplayRole, QString::number(scanIDs[i])); + scanItem->setFlags(scanItem->flags() & ~Qt::ItemIsEditable); + m_ui->TiltAnglesTable->setItem(i, 0, scanItem); + } QTableWidgetItem* item = new QTableWidgetItem(); item->setData(Qt::DisplayRole, QString::number(tiltAngles[i])); - m_ui->TiltAnglesTable->setItem(i, 0, item); + m_ui->TiltAnglesTable->setItem(i, tiltCol, item); + } + // Set column headers + QStringList headers; + if (m_hasScanIDs) { + headers << "Scan ID"; } + headers << "Tilt Angle"; + m_ui->TiltAnglesTable->setHorizontalHeaderLabels(headers); + m_ui->TiltAnglesTable->horizontalHeader()->setStretchLastSection(true); } else { m_tiltAnglesSeparator->hide(); m_ui->SetTiltAnglesButton->hide(); @@ -519,6 +537,11 @@ void DataPropertiesPanel::onTiltAnglesModified(int row, int column) // The table shouldn't be shown if this is not true, so this slot shouldn't be // called Q_ASSERT(dsource->type() == DataSource::TiltSeries); + // Tilt angles are in column 1 when scan IDs are present, column 0 otherwise + int tiltCol = m_hasScanIDs ? 1 : 0; + if (column != tiltCol) { + return; + } QTableWidgetItem* item = m_ui->TiltAnglesTable->item(row, column); auto ok = false; auto value = item->data(Qt::DisplayRole).toDouble(&ok); @@ -652,6 +675,7 @@ void DataPropertiesPanel::saveTiltAngles() } auto tiltAngles = dsource->getTiltAngles(); + auto scanIDs = dsource->getScanIDs(); // Open file for writing QFile file(fileName); @@ -661,10 +685,13 @@ void DataPropertiesPanel::saveTiltAngles() return; } - // Write tilt angles, one per line + // Write scan IDs (if available) and tilt angles, one per line QTextStream out(&file); - for (const double& angle : tiltAngles) { - out << angle << "\n"; + for (int i = 0; i < tiltAngles.size(); ++i) { + if (m_hasScanIDs) { + out << scanIDs[i] << " "; + } + out << tiltAngles[i] << "\n"; } file.close(); diff --git a/tomviz/DataPropertiesPanel.h b/tomviz/DataPropertiesPanel.h index 78aa83327..0fb4463f8 100644 --- a/tomviz/DataPropertiesPanel.h +++ b/tomviz/DataPropertiesPanel.h @@ -87,6 +87,7 @@ private slots: // Hold the order (the indexes into the field data), so we can preserve // the order during a rename. QList m_scalarIndexes; + bool m_hasScanIDs = false; void clear(); void updateSpacing(int axis, double newLength); diff --git a/tomviz/DataSource.cxx b/tomviz/DataSource.cxx index c2fd8dcc5..5f02af848 100644 --- a/tomviz/DataSource.cxx +++ b/tomviz/DataSource.cxx @@ -688,6 +688,10 @@ DataSource* DataSource::clone() const newClone->setTiltAngles(getTiltAngles()); } + if (hasScanIDs(this->dataObject())) { + newClone->setScanIDs(getScanIDs()); + } + QList newTimeSteps; for (auto& timeStep : this->Internals->timeSeriesSteps) { newTimeSteps.append(timeStep.clone()); @@ -1727,6 +1731,77 @@ void DataSource::clearTiltAngles(vtkDataObject* image) } } +bool DataSource::hasScanIDs(vtkDataObject* image) +{ + if (!image) + return false; + + return image->GetFieldData()->HasArray("scan_ids"); +} + +QVector DataSource::getScanIDs(vtkDataObject* image) +{ + QVector result; + if (!image) + return result; + + auto fd = image->GetFieldData(); + if (fd->HasArray("scan_ids")) { + auto scanIds = fd->GetArray("scan_ids"); + result.resize(scanIds->GetNumberOfTuples()); + for (int i = 0; i < result.size(); ++i) { + result[i] = static_cast(scanIds->GetTuple1(i)); + } + } + return result; +} + +void DataSource::setScanIDs(vtkDataObject* image, + const QVector& scanIDs) +{ + if (!image) + return; + + auto fd = image->GetFieldData(); + int numTuples = scanIDs.size(); + std::vector data(numTuples); + for (int i = 0; i < numTuples; ++i) { + data[i] = scanIDs[i]; + } + setFieldDataArray(fd, "scan_ids", numTuples, data.data()); +} + +void DataSource::clearScanIDs(vtkDataObject* image) +{ + if (!image) + return; + + auto fd = image->GetFieldData(); + if (fd->HasArray("scan_ids")) { + fd->RemoveArray("scan_ids"); + } +} + +bool DataSource::hasScanIDs() +{ + return hasScanIDs(dataObject()); +} + +QVector DataSource::getScanIDs() const +{ + return getScanIDs(dataObject()); +} + +void DataSource::setScanIDs(const QVector& scanIDs) +{ + setScanIDs(dataObject(), scanIDs); +} + +void DataSource::clearScanIDs() +{ + clearScanIDs(dataObject()); +} + bool DataSource::wasSubsampled(vtkDataObject* image) { bool ret = false; diff --git a/tomviz/DataSource.h b/tomviz/DataSource.h index fa426ac05..b3b7f1aca 100644 --- a/tomviz/DataSource.h +++ b/tomviz/DataSource.h @@ -271,6 +271,18 @@ class DataSource : public QObject /// Remove the tilt angles from the data source void clearTiltAngles(); + /// Returns true if the dataset has scan IDs + bool hasScanIDs(); + + /// Get scan IDs (if available - otherwise an empty vector is returned) + QVector getScanIDs() const; + + /// Set the scan IDs + void setScanIDs(const QVector& scanIDs); + + /// Remove scan IDs + void clearScanIDs(); + /// Moves the displayPosition of the DataSource by deltaPosition void translate(const double deltaPosition[3]); @@ -372,6 +384,11 @@ class DataSource : public QObject const QVector& angles); static void clearTiltAngles(vtkDataObject* image); + static bool hasScanIDs(vtkDataObject* image); + static QVector getScanIDs(vtkDataObject* image); + static void setScanIDs(vtkDataObject* image, const QVector& scanIDs); + static void clearScanIDs(vtkDataObject* image); + /// Check to see if the data was subsampled while reading static bool wasSubsampled(vtkDataObject* image); diff --git a/tomviz/EmdFormat.cxx b/tomviz/EmdFormat.cxx index 4b58d9fd3..8ffd22cab 100644 --- a/tomviz/EmdFormat.cxx +++ b/tomviz/EmdFormat.cxx @@ -159,6 +159,20 @@ bool EmdFormat::readNode(h5::H5ReadWrite& reader, const std::string& emdNode, DataSource::setType(image, DataSource::TiltSeries); } + // Read scan IDs if present + std::string scanIdsPath = emdNode + "/scan_ids"; + if (reader.isDataSet(scanIdsPath)) { + auto scanIdsData = reader.readData(scanIdsPath); + if (!scanIdsData.empty()) { + QVector scanIDs; + scanIDs.reserve(scanIdsData.size()); + for (auto& id : scanIdsData) { + scanIDs.push_back(id); + } + DataSource::setScanIDs(image, scanIDs); + } + } + return true; } @@ -270,6 +284,14 @@ bool EmdFormat::writeNode(h5::H5ReadWrite& writer, const std::string& path, // Write any extra scalars we might have writeExtraScalars(writer, path, permutedImage); + // Write scan IDs if present + if (DataSource::hasScanIDs(image)) { + auto scanIDs = DataSource::getScanIDs(image); + std::vector scanIdsVec(scanIDs.begin(), scanIDs.end()); + std::vector dims(1, static_cast(scanIdsVec.size())); + writer.writeData(path, "scan_ids", dims, scanIdsVec); + } + return true; } diff --git a/tomviz/PtychoRunner.cxx b/tomviz/PtychoRunner.cxx index 79d65d3c5..752b1a612 100644 --- a/tomviz/PtychoRunner.cxx +++ b/tomviz/PtychoRunner.cxx @@ -278,12 +278,22 @@ class PtychoRunner::Internal : public QObject void loadOutputFiles() { + // Convert sidList to QVector for scan IDs + QVector scanIDs; + scanIDs.reserve(sidList.size()); + for (auto& sid : sidList) { + scanIDs.push_back(static_cast(sid)); + } + for (auto& filePath: outputFiles) { auto* dataSource = LoadDataReaction::loadData(filePath); if (!dataSource || !dataSource->imageData()) { qCritical() << "Failed to load file:" << filePath; return; } + if (!scanIDs.isEmpty()) { + dataSource->setScanIDs(scanIDs); + } } QString title = "Loading ptycho data complete"; diff --git a/tomviz/PyXRFProcessDialog.cxx b/tomviz/PyXRFProcessDialog.cxx index a02cc8c34..7532e3e4b 100644 --- a/tomviz/PyXRFProcessDialog.cxx +++ b/tomviz/PyXRFProcessDialog.cxx @@ -849,4 +849,22 @@ bool PyXRFProcessDialog::rotateDatasets() const return m_internal->rotateDatasets(); } +QVector PyXRFProcessDialog::selectedScanIDs() const +{ + QVector result; + for (const auto& sid : m_internal->filteredSidList) { + auto row = m_internal->sidToRow[sid]; + auto use = m_internal->logFileValue(row, "Use"); + if (use == "x" || use == "1") { + bool ok; + int id = sid.toInt(&ok); + if (!ok) { + id = -1; + } + result.append(id); + } + } + return result; +} + } // namespace tomviz diff --git a/tomviz/PyXRFProcessDialog.h b/tomviz/PyXRFProcessDialog.h index ddb70cee3..b1d713aa4 100644 --- a/tomviz/PyXRFProcessDialog.h +++ b/tomviz/PyXRFProcessDialog.h @@ -29,6 +29,7 @@ class PyXRFProcessDialog : public QDialog double pixelSizeY() const; bool skipProcessed() const; bool rotateDatasets() const; + QVector selectedScanIDs() const; private: class Internal; diff --git a/tomviz/PyXRFRunner.cxx b/tomviz/PyXRFRunner.cxx index 37f1ccf3e..727200d7a 100644 --- a/tomviz/PyXRFRunner.cxx +++ b/tomviz/PyXRFRunner.cxx @@ -88,6 +88,9 @@ class PyXRFRunner::Internal : public QObject // Recon options QStringList selectedElements; + // Scan IDs from the process dialog + QVector scanIDs; + bool autoLoadFinalData = true; Internal(PyXRFRunner* p) : parent(p) @@ -411,6 +414,9 @@ class PyXRFRunner::Internal : public QObject skipProcessed = processDialog->skipProcessed(); rotateDatasets = processDialog->rotateDatasets(); + // Store the selected scan IDs + scanIDs = processDialog->selectedScanIDs(); + // Make sure the output directory exists QDir().mkpath(outputDirectory); @@ -683,6 +689,11 @@ class PyXRFRunner::Internal : public QObject dataSource->setActiveScalars(firstName.toStdString().c_str()); dataSource->setLabel("Extracted Elements"); + + if (!scanIDs.isEmpty()) { + dataSource->setScanIDs(scanIDs); + } + dataSource->dataModified(); // Write this to an EMD format diff --git a/tomviz/python/tomviz/dataset.py b/tomviz/python/tomviz/dataset.py index f844f85ca..927633a81 100644 --- a/tomviz/python/tomviz/dataset.py +++ b/tomviz/python/tomviz/dataset.py @@ -134,6 +134,24 @@ def tilt_axis(self) -> int | None: """ pass + @property + @abstractmethod + def scan_ids(self) -> np.ndarray | None: + """Array of scan IDs associated with each projection in a tilt series. + + Returns None if scan IDs have not been set. + """ + pass + + @scan_ids.setter + @abstractmethod + def scan_ids(self, v: np.ndarray | None): + """Set the scan IDs for projections in a tilt series. + + Provide None to clear scan IDs. + """ + pass + @property @abstractmethod def dark(self) -> np.ndarray | None: diff --git a/tomviz/python/tomviz/executor.py b/tomviz/python/tomviz/executor.py index 5748765c3..90816d7cc 100644 --- a/tomviz/python/tomviz/executor.py +++ b/tomviz/python/tomviz/executor.py @@ -464,6 +464,10 @@ def is_hard_link(name): if dims is not None and dims[-1].name in ('angles', b'angles'): output['tilt_angles'] = dims[-1].values[:].astype(np.float64) + # Read scan IDs if present + if 'scan_ids' in tomography: + output['scan_ids'] = tomography['scan_ids'][:].astype(np.int32) + return output @@ -575,6 +579,12 @@ def _write_emd(path, dataset, dims=None): active_name = dataset.active_name tomviz_scalars[active_name] = h5py.SoftLink('/data/tomography/data') + # Write scan IDs if present + if dataset.scan_ids is not None: + tomography_group.create_dataset( + 'scan_ids', data=np.asarray(dataset.scan_ids, dtype=np.int32) + ) + def _read_data_exchange(path: Path, options: dict | None = None): with h5py.File(path, 'r') as f: @@ -767,6 +777,8 @@ def load_dataset(data_file_path, read_options=None): data.tilt_angles = output['tilt_angles'] if 'tilt_axis' in output: data.tilt_axis = output['tilt_axis'] + if 'scan_ids' in output: + data.scan_ids = output['scan_ids'] if dims is not None: # Convert to native type, as is required by itk data.spacing = [float(d.values[1] - d.values[0]) for d in dims] diff --git a/tomviz/python/tomviz/external_dataset.py b/tomviz/python/tomviz/external_dataset.py index 9e285e710..0016eff1f 100644 --- a/tomviz/python/tomviz/external_dataset.py +++ b/tomviz/python/tomviz/external_dataset.py @@ -14,6 +14,7 @@ def __init__(self, arrays, active=None): self.arrays = arrays self.tilt_angles = None self.tilt_axis = None + self.scan_ids = None # The currently active scalar self.active_name = active # If we weren't given the active array, set the first as the active @@ -124,6 +125,14 @@ def white(self) -> np.ndarray | None: def white(self, v: np.ndarray | None): self._white = v + @property + def scan_ids(self) -> np.ndarray | None: + return self._scan_ids + + @scan_ids.setter + def scan_ids(self, v: np.ndarray | None): + self._scan_ids = v + def create_child_dataset(self): child = copy.deepcopy(self) # Set tilt angles to None to be consistent with internal dataset diff --git a/tomviz/python/tomviz/internal_dataset.py b/tomviz/python/tomviz/internal_dataset.py index 24aef298f..6929be7ef 100644 --- a/tomviz/python/tomviz/internal_dataset.py +++ b/tomviz/python/tomviz/internal_dataset.py @@ -59,6 +59,14 @@ def tilt_axis(self): def tilt_axis(self, v): self._tilt_axis = v + @property + def scan_ids(self): + return internal_utils.get_scan_ids(self._data_object) + + @scan_ids.setter + def scan_ids(self, v): + internal_utils.set_scan_ids(self._data_object, v) + @property def dark(self): if not self._data_source.dark_data: diff --git a/tomviz/python/tomviz/internal_utils.py b/tomviz/python/tomviz/internal_utils.py index 7875cb969..228cbd133 100644 --- a/tomviz/python/tomviz/internal_utils.py +++ b/tomviz/python/tomviz/internal_utils.py @@ -177,6 +177,34 @@ def set_tilt_angles(dataobject, newarray): do.FieldData.AddArray(vtkarray) +@with_vtk_dataobject +def get_scan_ids(dataobject): + # Get the scan IDs array + do = dsa.WrapDataObject(dataobject) + rawarray = do.FieldData.GetArray('scan_ids') + if isinstance(rawarray, dsa.VTKNoneArray): + return None + vtkarray = dsa.vtkDataArrayToVTKArray(rawarray, do) + vtkarray.Association = dsa.ArrayAssociation.FIELD + return vtkarray + + +@with_vtk_dataobject +def set_scan_ids(dataobject, newarray): + # replace the scan IDs with the new array + from vtkmodules.util.vtkConstants import VTK_INT + if newarray is None: + do = dsa.WrapDataObject(dataobject) + do.FieldData.RemoveArray('scan_ids') + return + vtkarray = np_s.numpy_to_vtk(newarray, deep=1, array_type=VTK_INT) + vtkarray.Association = dsa.ArrayAssociation.FIELD + vtkarray.SetName('scan_ids') + do = dsa.WrapDataObject(dataobject) + do.FieldData.RemoveArray('scan_ids') + do.FieldData.AddArray(vtkarray) + + @with_vtk_dataobject def get_coordinate_arrays(dataobject): """Returns a triple of Numpy arrays containing x, y, and z coordinates for From f3c83c6648957a5f4260b1c92d7d02f68c5e3409 Mon Sep 17 00:00:00 2001 From: Alessandro Genova Date: Tue, 17 Feb 2026 15:05:26 -0500 Subject: [PATCH 12/32] add ability to insert operators before the selected operator Signed-off-by: Alessandro Genova --- tomviz/ActiveObjects.h | 3 +++ tomviz/DataSource.cxx | 13 +++++++++++-- tomviz/Pipeline.cxx | 6 ++++-- tomviz/PipelineModel.cxx | 16 +++++++++++++++- tomviz/PipelineView.cxx | 4 ++-- tomviz/operators/OperatorPropertiesPanel.cxx | 2 +- 6 files changed, 36 insertions(+), 8 deletions(-) diff --git a/tomviz/ActiveObjects.h b/tomviz/ActiveObjects.h index 56167ad4f..3e756679d 100644 --- a/tomviz/ActiveObjects.h +++ b/tomviz/ActiveObjects.h @@ -49,6 +49,9 @@ class ActiveObjects : public QObject /// Returns the selected data source, nullptr if no data source is selected. DataSource* selectedDataSource() const { return m_selectedDataSource; } + /// Returns the active operator. + Operator* activeOperator() const { return m_activeOperator; } + /// Returns the active data source. MoleculeSource* activeMoleculeSource() const { diff --git a/tomviz/DataSource.cxx b/tomviz/DataSource.cxx index 5f02af848..415131128 100644 --- a/tomviz/DataSource.cxx +++ b/tomviz/DataSource.cxx @@ -1006,8 +1006,17 @@ void DataSource::setUnits(const QString& units, bool markModified) int DataSource::addOperator(Operator* op) { op->setParent(this); - int index = this->Internals->Operators.count(); - this->Internals->Operators.push_back(op); + int index = -1; + auto activeOp = ActiveObjects::instance().activeOperator(); + if (activeOp && activeOp->dataSource() == this) { + index = this->Internals->Operators.indexOf(activeOp); + } + if (index >= 0) { + this->Internals->Operators.insert(index, op); + } else { + index = this->Internals->Operators.count(); + this->Internals->Operators.push_back(op); + } emit operatorAdded(op); return index; diff --git a/tomviz/Pipeline.cxx b/tomviz/Pipeline.cxx index 141aa3e05..9a898524b 100644 --- a/tomviz/Pipeline.cxx +++ b/tomviz/Pipeline.cxx @@ -488,9 +488,11 @@ void Pipeline::addDataSource(DataSource* dataSource) &Operator::newChildDataSource), [this](DataSource* ds) { addDataSource(ds); }); - // We need to ensure we move add datasource to the end of the branch + // We need to ensure we move add datasource to the end of the branch, + // but only if the new operator is the last one (appended). For mid-chain + // insertions, the child DataSource should stay where it is. auto operators = op->dataSource()->operators(); - if (operators.size() > 1) { + if (operators.size() > 1 && op == operators.last()) { auto transformedDataSourceOp = findTransformedDataSourceOperator(op->dataSource()); if (transformedDataSourceOp != nullptr) { diff --git a/tomviz/PipelineModel.cxx b/tomviz/PipelineModel.cxx index 9ad99f51d..7a6f27825 100644 --- a/tomviz/PipelineModel.cxx +++ b/tomviz/PipelineModel.cxx @@ -864,8 +864,22 @@ void PipelineModel::operatorAdded(Operator* op, auto index = dataSourceIndex(dataSource); auto dataSourceItem = treeItem(index); - // Operators are just append as last child. + // Find the correct insertion row based on the operator's position in the + // DataSource's operator list. int insertionRow = dataSourceItem->childCount(); + auto operators = dataSource->operators(); + int opIndex = operators.indexOf(op); + if (opIndex >= 0 && opIndex < operators.size() - 1) { + // Mid-chain insertion: find the tree item of the next operator and insert + // before it. + auto nextOp = operators[opIndex + 1]; + for (int i = 0; i < dataSourceItem->childCount(); ++i) { + if (dataSourceItem->child(i)->op() == nextOp) { + insertionRow = i; + break; + } + } + } beginInsertRows(index, insertionRow, insertionRow); dataSourceItem->insertChild(insertionRow, PipelineModel::Item(op)); endInsertRows(); diff --git a/tomviz/PipelineView.cxx b/tomviz/PipelineView.cxx index f13ed358e..10c639a4c 100644 --- a/tomviz/PipelineView.cxx +++ b/tomviz/PipelineView.cxx @@ -548,9 +548,9 @@ void PipelineView::currentChanged(const QModelIndex& current, auto pipelineModel = qobject_cast(model()); Q_ASSERT(pipelineModel); - // First set the selected data source to nullptr, in case the new selection - // is not a data source. + // Clear stale active state before setting new selection. ActiveObjects::instance().setSelectedDataSource(nullptr); + ActiveObjects::instance().setActiveOperator(nullptr); if (auto dataSource = pipelineModel->dataSource(current)) { ActiveObjects::instance().setSelectedDataSource(dataSource); } else if (auto module = pipelineModel->module(current)) { diff --git a/tomviz/operators/OperatorPropertiesPanel.cxx b/tomviz/operators/OperatorPropertiesPanel.cxx index af10dc559..93046cfcb 100644 --- a/tomviz/operators/OperatorPropertiesPanel.cxx +++ b/tomviz/operators/OperatorPropertiesPanel.cxx @@ -37,7 +37,7 @@ OperatorPropertiesPanel::~OperatorPropertiesPanel() = default; void OperatorPropertiesPanel::setOperator(Operator* op) { if (m_activeOperator) { - disconnect(op, SIGNAL(labelModified())); + disconnect(m_activeOperator, SIGNAL(labelModified())); } deleteLayoutContents(m_layout); m_operatorWidget = nullptr; From 36a23c4f5b58fe2690b9fe8f839512e24926bb12 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Tue, 17 Feb 2026 16:59:01 -0600 Subject: [PATCH 13/32] Add alternative pixel size name support for ptycho The pixel size name changed recently. We need to support both the old version and the new version. Signed-off-by: Patrick Avery --- tomviz/python/tomviz/ptycho/ptycho.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/tomviz/python/tomviz/ptycho/ptycho.py b/tomviz/python/tomviz/ptycho/ptycho.py index 9bf5a4fb9..ac3ec742d 100644 --- a/tomviz/python/tomviz/ptycho/ptycho.py +++ b/tomviz/python/tomviz/ptycho/ptycho.py @@ -444,10 +444,15 @@ def fetch_angle_from_ptycho_hyan_file(filepath: PathLike) -> float | None: def fetch_pixel_sizes_from_ptycho_hyan_file( filepath: PathLike, ) -> tuple[float, float] | None: - print(f'Obtaining pixel sizes from config file: {filepath})') + print(f'Obtaining pixel sizes from config file: {filepath}') vars_required = [ - 'lambda_nm', 'z_m', 'x_arr_size', 'y_arr_size', 'ccd_pixel_um' + 'lambda_nm', 'z_m', 'nx', 'ny', 'ccd_pixel_um' ] + alternatives = { + 'nx': 'x_arr_size', + 'ny': 'y_arr_size', + } + vars_requested = vars_required + list(alternatives.values()) results = {} try: with open(filepath, 'r') as rf: @@ -456,13 +461,22 @@ def fetch_pixel_sizes_from_ptycho_hyan_file( continue lhs = line.split('=')[0].strip() - if lhs in vars_required: - value = float(line.split('=')[1].strip()) + if lhs in vars_requested: + value = float(line.split('=', 1)[1].strip()) results[lhs] = value except Exception as e: print('Failed to fetch pixel sizes with error:', e, file=sys.stderr) return None + # Add alternatives if they are present + for name in vars_required: + if name not in results and name in alternatives: + # Check the alt_name + alt_name = alternatives[name] + if alt_name in results: + # Convert it + results[name] = results.pop(alt_name) + missing = [x for x in vars_required if x not in results] if missing: print( @@ -476,7 +490,7 @@ def fetch_pixel_sizes_from_ptycho_hyan_file( results['lambda_nm'] * results['z_m'] * 1e6 / results['ccd_pixel_um'] ) - x_pixel_size = numerator / results['x_arr_size'] - y_pixel_size = numerator / results['y_arr_size'] + x_pixel_size = numerator / results['nx'] + y_pixel_size = numerator / results['ny'] return x_pixel_size, y_pixel_size From af9943c78a34d03e8823af0dcce1e9e1e9c49638 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Wed, 18 Feb 2026 10:38:10 -0600 Subject: [PATCH 14/32] Automatically find and validate `pyxrf-utils` exec This gets the PyXRF dialogs to automatically locate `pyxrf-utils` executables in some standard paths, starting with the last one that the user selected. It will set it automatically if it exists. If it does not exist, the user will be forced to pick one. The validation error is significantly easier to read than the previous error that would be encountered. Signed-off-by: Patrick Avery --- tomviz/PyXRFMakeHDF5Dialog.cxx | 64 +++++++++++++++++++++++++++++++++- tomviz/PyXRFProcessDialog.cxx | 61 +++++++++++++++++++++++++++++++- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/tomviz/PyXRFMakeHDF5Dialog.cxx b/tomviz/PyXRFMakeHDF5Dialog.cxx index 245597d2b..b07e1c904 100644 --- a/tomviz/PyXRFMakeHDF5Dialog.cxx +++ b/tomviz/PyXRFMakeHDF5Dialog.cxx @@ -11,8 +11,57 @@ #include #include +#include #include #include +#include + +namespace { + +bool executableExists(const QString& command) +{ + if (command.isEmpty()) { + return false; + } + // If the command contains a path separator, treat it as a file path + if (command.contains('/') || command.contains(QDir::separator())) { + QFileInfo info(command); + return info.isFile() && info.isExecutable(); + } + // Otherwise check whether it can be found in $PATH + return !QStandardPaths::findExecutable(command).isEmpty(); +} + +// Returns the best available pyxrf-utils command by checking, in order: +// 1. The previously saved command (if it still exists) +// 2. "run-pyxrf-utils" in $PATH +// 3. "pyxrf-utils" in $PATH +// 4. An absolute fallback path +// 5. Empty string (not found) +QString findPyxrfUtilsCommand(const QString& savedCommand) +{ + if (executableExists(savedCommand)) { + return savedCommand; + } + + const QStringList candidates = { "run-pyxrf-utils", "pyxrf-utils" }; + for (const auto& candidate : candidates) { + if (executableExists(candidate)) { + return candidate; + } + } + + const QString absoluteFallback = + "/nsls2/data2/hxn/legacy/Hiran/tomviz/conda_envs/" + "tomviz-latest-wip/bin/run-pyxrf-utils"; + if (executableExists(absoluteFallback)) { + return absoluteFallback; + } + + return ""; +} + +} // anonymous namespace namespace tomviz { @@ -225,6 +274,18 @@ class PyXRFMakeHDF5Dialog::Internal : public QObject return false; } + // Check that the executable exists when it will actually be used + if (!useAlreadyExistingData() || remakeCsvFile()) { + auto cmd = command(); + if (!executableExists(cmd)) { + reason = + QString("The pyxrf-utils executable \"%1\" was not found. " + "Please specify a valid path to the executable.") + .arg(cmd.isEmpty() ? QString("(empty)") : cmd); + return false; + } + } + return true; } @@ -243,7 +304,8 @@ class PyXRFMakeHDF5Dialog::Internal : public QObject settings->beginGroup("pyxrf"); // Do this in the general pyxrf settings - setCommand(settings->value("pyxrfUtilsCommand", "pyxrf-utils").toString()); + auto savedCommand = settings->value("pyxrfUtilsCommand", "").toString(); + setCommand(findPyxrfUtilsCommand(savedCommand)); settings->beginGroup("makeHDF5"); setMethod(settings->value("method", "New").toString()); diff --git a/tomviz/PyXRFProcessDialog.cxx b/tomviz/PyXRFProcessDialog.cxx index 7532e3e4b..73c887fa1 100644 --- a/tomviz/PyXRFProcessDialog.cxx +++ b/tomviz/PyXRFProcessDialog.cxx @@ -20,8 +20,56 @@ #include #include #include +#include #include +namespace { + +bool executableExists(const QString& command) +{ + if (command.isEmpty()) { + return false; + } + // If the command contains a path separator, treat it as a file path + if (command.contains('/') || command.contains(QDir::separator())) { + QFileInfo info(command); + return info.isFile() && info.isExecutable(); + } + // Otherwise check whether it can be found in $PATH + return !QStandardPaths::findExecutable(command).isEmpty(); +} + +// Returns the best available pyxrf-utils command by checking, in order: +// 1. The previously saved command (if it still exists) +// 2. "run-pyxrf-utils" in $PATH +// 3. "pyxrf-utils" in $PATH +// 4. An absolute fallback path +// 5. Empty string (not found) +QString findPyxrfUtilsCommand(const QString& savedCommand) +{ + if (executableExists(savedCommand)) { + return savedCommand; + } + + const QStringList candidates = { "run-pyxrf-utils", "pyxrf-utils" }; + for (const auto& candidate : candidates) { + if (executableExists(candidate)) { + return candidate; + } + } + + const QString absoluteFallback = + "/nsls2/data2/hxn/legacy/Hiran/tomviz/conda_envs/" + "tomviz-latest-wip/bin/run-pyxrf-utils"; + if (executableExists(absoluteFallback)) { + return absoluteFallback; + } + + return ""; +} + +} // anonymous namespace + namespace tomviz { class PyXRFProcessDialog::Internal : public QObject @@ -208,6 +256,16 @@ class PyXRFProcessDialog::Internal : public QObject } } + // Check that the executable exists before attempting to run it + auto cmd = command(); + if (!executableExists(cmd)) { + reason = + QString("The pyxrf-utils executable \"%1\" was not found. " + "Please specify a valid path to the executable.") + .arg(cmd.isEmpty() ? QString("(empty)") : cmd); + return false; + } + return true; } @@ -575,7 +633,8 @@ class PyXRFProcessDialog::Internal : public QObject auto settings = pqApplicationCore::instance()->settings(); settings->beginGroup("pyxrf"); - setCommand(settings->value("pyxrfUtilsCommand", "pyxrf-utils").toString()); + auto savedCommand = settings->value("pyxrfUtilsCommand", "").toString(); + setCommand(findPyxrfUtilsCommand(savedCommand)); settings->beginGroup("process"); // Only load these settings if we are re-using the same previous From 88eb8a6b9c6f51c023a4c39fc98af09ae998f652 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Wed, 18 Feb 2026 11:32:14 -0600 Subject: [PATCH 15/32] Omit unused parameter name to fix compile warning Signed-off-by: Patrick Avery --- tomviz/modules/ModuleMolecule.cxx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tomviz/modules/ModuleMolecule.cxx b/tomviz/modules/ModuleMolecule.cxx index 555bd64be..bfeb4fa0f 100644 --- a/tomviz/modules/ModuleMolecule.cxx +++ b/tomviz/modules/ModuleMolecule.cxx @@ -39,7 +39,7 @@ QIcon ModuleMolecule::icon() const return QIcon(":/pqWidgets/Icons/pqGroup.svg"); } -bool ModuleMolecule::initialize(DataSource* data, vtkSMViewProxy* vtkView) +bool ModuleMolecule::initialize(DataSource*, vtkSMViewProxy*) { return false; } From fff843f262e8b759240dfbd4a18a76df22602ad2 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Tue, 17 Feb 2026 14:48:18 -0600 Subject: [PATCH 16/32] Refactor FxiWorkflowWidget to ShiftRotationCenter This is now a generic operator for shifting the rotation center. You generate a set of reconstructions from a single slice with a set of test rotation centerss and are able to view each one, and determine which rotation center produces the best reconstruction. Signed-off-by: Patrick Avery --- tomviz/Behaviors.cxx | 6 +- tomviz/CMakeLists.txt | 8 +- tomviz/MainWindow.cxx | 11 +- tomviz/RotateAlignWidget.cxx | 4 +- ...dget.cxx => ShiftRotationCenterWidget.cxx} | 530 ++++++------------ ...owWidget.h => ShiftRotationCenterWidget.h} | 19 +- ...Widget.ui => ShiftRotationCenterWidget.ui} | 421 +++++--------- tomviz/python/Recon_tomopy_fxi.json | 59 -- tomviz/python/Recon_tomopy_fxi.py | 426 -------------- tomviz/python/ShiftRotationCenter_tomopy.json | 17 + tomviz/python/ShiftRotationCenter_tomopy.py | 112 ++++ 11 files changed, 476 insertions(+), 1137 deletions(-) rename tomviz/{FxiWorkflowWidget.cxx => ShiftRotationCenterWidget.cxx} (53%) rename tomviz/{FxiWorkflowWidget.h => ShiftRotationCenterWidget.h} (64%) rename tomviz/{FxiWorkflowWidget.ui => ShiftRotationCenterWidget.ui} (58%) delete mode 100644 tomviz/python/Recon_tomopy_fxi.json delete mode 100644 tomviz/python/Recon_tomopy_fxi.py create mode 100644 tomviz/python/ShiftRotationCenter_tomopy.json create mode 100644 tomviz/python/ShiftRotationCenter_tomopy.py diff --git a/tomviz/Behaviors.cxx b/tomviz/Behaviors.cxx index a1f4c1be3..e0f7db090 100644 --- a/tomviz/Behaviors.cxx +++ b/tomviz/Behaviors.cxx @@ -5,7 +5,7 @@ #include "ActiveObjects.h" #include "AddRenderViewContextMenuBehavior.h" -#include "FxiWorkflowWidget.h" +#include "ShiftRotationCenterWidget.h" #include "ManualManipulationWidget.h" #include "MoveActiveObject.h" #include "OperatorPython.h" @@ -102,8 +102,8 @@ Behaviors::Behaviors(QMainWindow* mainWindow) : QObject(mainWindow) void Behaviors::registerCustomOperatorUIs() { - OperatorPython::registerCustomWidget("FxiWorkflowWidget", true, - FxiWorkflowWidget::New); + OperatorPython::registerCustomWidget("ShiftRotationCenterWidget", true, + ShiftRotationCenterWidget::New); OperatorPython::registerCustomWidget("RotationAlignWidget", true, RotateAlignWidget::New); OperatorPython::registerCustomWidget("ManualManipulationWidget", true, diff --git a/tomviz/CMakeLists.txt b/tomviz/CMakeLists.txt index fd6ec20db..6e9afb3ad 100644 --- a/tomviz/CMakeLists.txt +++ b/tomviz/CMakeLists.txt @@ -98,8 +98,8 @@ set(SOURCES FileFormatManager.h FxiFormat.cxx FxiFormat.h - FxiWorkflowWidget.cxx - FxiWorkflowWidget.h + ShiftRotationCenterWidget.cxx + ShiftRotationCenterWidget.h GenericHDF5Format.cxx GenericHDF5Format.h GradientOpacityWidget.h @@ -465,7 +465,7 @@ set(python_files Recon_SIRT.py Recon_TV_minimization.py Recon_tomopy_gridrec.py - Recon_tomopy_fxi.py + ShiftRotationCenter_tomopy.py FFT_AbsLog.py ManualManipulation.py Shift_Stack_Uniformly.py @@ -551,7 +551,7 @@ set(json_files Recon_DFT_constraint.json Recon_TV_minimization.json Recon_tomopy_gridrec.json - Recon_tomopy_fxi.json + ShiftRotationCenter_tomopy.json Recon_SIRT.json Recon_WBP.json Shift3D.json diff --git a/tomviz/MainWindow.cxx b/tomviz/MainWindow.cxx index e46fd05f1..00f247f1f 100644 --- a/tomviz/MainWindow.cxx +++ b/tomviz/MainWindow.cxx @@ -334,6 +334,8 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) m_ui->menuTomography->addAction("Tilt Axis Shift Alignment (Auto)"); QAction* rotateAlignAction = m_ui->menuTomography->addAction("Tilt Axis Alignment (Manual)"); + QAction* shiftRotationCenterAction = + m_ui->menuTomography->addAction("Shift Rotation Center (Manual)"); m_ui->menuTomography->addSeparator(); QAction* reconLabel = m_ui->menuTomography->addAction("Reconstruction:"); @@ -354,7 +356,6 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) m_ui->menuTomography->addAction("TV Minimization Method"); QAction* reconTomoPyGridRecAction = m_ui->menuTomography->addAction("TomoPy Gridrec Method"); - QAction* fxiWorkflowAction = m_ui->menuTomography->addAction("FXI Workflow"); m_ui->menuTomography->addSeparator(); QAction* simulationLabel = @@ -434,6 +435,10 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) autoAlignPyStackRegAction, "Auto Tilt Image Align (PyStackReg)", readInPythonScript("PyStackRegImageAlignment"), false, false, false, readInJSONDescription("PyStackRegImageAlignment")); + new AddPythonTransformReaction( + shiftRotationCenterAction, "Shift Rotation Center", + readInPythonScript("ShiftRotationCenter_tomopy"), true, false, false, + readInJSONDescription("ShiftRotationCenter_tomopy")); new AddPythonTransformReaction(reconDFMAction, "Reconstruct (Direct Fourier)", readInPythonScript("Recon_DFT"), true, false, @@ -460,10 +465,6 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) reconTomoPyGridRecAction, "Reconstruct (TomoPy Gridrec)", readInPythonScript("Recon_tomopy_gridrec"), true, false, false, readInJSONDescription("Recon_tomopy_gridrec")); - new AddPythonTransformReaction( - fxiWorkflowAction, "Reconstruct (FXI Workflow)", - readInPythonScript("Recon_tomopy_fxi"), true, false, false, - readInJSONDescription("Recon_tomopy_fxi")); new ReconstructionReaction(reconWBP_CAction); diff --git a/tomviz/RotateAlignWidget.cxx b/tomviz/RotateAlignWidget.cxx index b61cfca7c..5f9475153 100644 --- a/tomviz/RotateAlignWidget.cxx +++ b/tomviz/RotateAlignWidget.cxx @@ -109,7 +109,8 @@ class RotateAlignWidget::RAWInternal void setupCameras() { tomviz::setupRenderer(this->mainRenderer, this->mainSliceMapper, - this->axesActor); + nullptr); + this->mainRenderer->ResetCameraClippingRange(); tomviz::setupRenderer(this->reconRenderer[0], this->reconSliceMapper[0]); tomviz::setupRenderer(this->reconRenderer[1], @@ -473,7 +474,6 @@ RotateAlignWidget::RotateAlignWidget(Operator* op, interatorStyle2); this->Internals->Ui.sliceView_3->interactor()->SetInteractorStyle( interatorStyle3); - this->Internals->setupCameras(); this->Internals->rotationAxis->SetPoint1(0, 0, 0); this->Internals->rotationAxis->SetPoint1(1, 1, 1); diff --git a/tomviz/FxiWorkflowWidget.cxx b/tomviz/ShiftRotationCenterWidget.cxx similarity index 53% rename from tomviz/FxiWorkflowWidget.cxx rename to tomviz/ShiftRotationCenterWidget.cxx index b72485947..b9e2d7cd1 100644 --- a/tomviz/FxiWorkflowWidget.cxx +++ b/tomviz/ShiftRotationCenterWidget.cxx @@ -1,15 +1,13 @@ /* This source file is part of the Tomviz project, https://tomviz.org/. It is released under the 3-Clause BSD License, see "LICENSE". */ -#include "FxiWorkflowWidget.h" -#include "ui_FxiWorkflowWidget.h" +#include "ShiftRotationCenterWidget.h" +#include "ui_ShiftRotationCenterWidget.h" #include "ActiveObjects.h" #include "ColorMap.h" #include "DataSource.h" -#include "InterfaceBuilder.h" #include "InternalPythonHelper.h" -#include "OperatorPython.h" #include "PresetDialog.h" #include "Utilities.h" @@ -19,7 +17,9 @@ #include #include +#include #include +#include #include #include #include @@ -27,8 +27,11 @@ #include #include #include +#include #include #include +#include +#include #include #include #include @@ -108,22 +111,12 @@ class InteractorStyle : public vtkInteractorStyleImage vtkStandardNewMacro(InteractorStyle) - template - QMap unite(const QMap& map1, const QMap& map2) -{ - auto ret = map1; - for (const auto& k : map2.keys()) { - ret[k] = map2[k]; - } - return ret; -} - -class FxiWorkflowWidget::Internal : public QObject +class ShiftRotationCenterWidget::Internal : public QObject { Q_OBJECT public: - Ui::FxiWorkflowWidget ui; + Ui::ShiftRotationCenterWidget ui; QPointer op; vtkSmartPointer image; vtkSmartPointer rotationImages; @@ -134,20 +127,25 @@ class FxiWorkflowWidget::Internal : public QObject vtkNew mapper; vtkNew renderer; vtkNew axesActor; + + // Projection view (top-left) with center line overlay + vtkNew projSlice; + vtkNew projMapper; + vtkNew projRenderer; + vtkNew centerLine; + vtkNew centerLineActor; QString script; InternalPythonHelper pythonHelper; - QPointer parent; + QPointer parent; QPointer dataSource; - QPointer interfaceBuilder; - QVariantMap customReconSettings; - QVariantMap customTestRotationSettings; int sliceNumber = 0; QScopedPointer progressDialog; QFutureWatcher futureWatcher; bool testRotationsSuccess = false; QString testRotationsErrorMessage; - Internal(Operator* o, vtkSmartPointer img, FxiWorkflowWidget* p) + Internal(Operator* o, vtkSmartPointer img, + ShiftRotationCenterWidget* p) : op(o), image(img) { // Must call setupUi() before using p in any way @@ -155,11 +153,7 @@ class FxiWorkflowWidget::Internal : public QObject setParent(p); parent = p; - readSettings(); - - // Keep the axes invisible until the data is displayed - axesActor->SetVisibility(false); - + renderer->SetBackground(1, 1, 1); mapper->SetOrientation(0); slice->SetMapper(mapper); renderer->AddViewProp(slice); @@ -178,19 +172,64 @@ class FxiWorkflowWidget::Internal : public QObject dataSource = ActiveObjects::instance().activeDataSource(); } + // Set up the projection view showing one projection image (Z-axis slice). + // This matches the orientation used by the main slice view in + // RotateAlignWidget: XY plane, camera looking from +Z. + projMapper->SetInputData(image); + projMapper->SetSliceNumber(image->GetDimensions()[2] / 2); + projMapper->Update(); + projSlice->SetMapper(projMapper); + + // Use the data source's color map for the projection view + auto* dsLut = vtkScalarsToColors::SafeDownCast( + dataSource->colorMap()->GetClientSideObject()); + if (dsLut) { + projSlice->GetProperty()->SetLookupTable(dsLut); + } + + projRenderer->AddViewProp(projSlice); + + // Set up the red center line overlay + vtkNew lineMapper; + lineMapper->SetInputConnection(centerLine->GetOutputPort()); + centerLineActor->SetMapper(lineMapper); + centerLineActor->GetProperty()->SetColor(1, 1, 0); + centerLineActor->GetProperty()->SetLineWidth(2.0); + projRenderer->AddActor(centerLineActor); + + ui.projectionView->renderWindow()->AddRenderer(projRenderer); + vtkNew projInteractorStyle; + ui.projectionView->interactor()->SetInteractorStyle(projInteractorStyle); + + tomviz::setupRenderer(projRenderer, projMapper, nullptr); + projRenderer->GetActiveCamera()->SetViewUp(1, 0, 0); + projRenderer->ResetCameraClippingRange(); + updateCenterLine(); + static unsigned int colorMapCounter = 0; ++colorMapCounter; auto pxm = ActiveObjects::instance().proxyManager(); vtkNew tfmgr; colorMap = - tfmgr->GetColorTransferFunction(QString("FxiWorkflowWidgetColorMap%1") - .arg(colorMapCounter) - .toLatin1() - .data(), - pxm); - - setColorMapToGrayscale(); + tfmgr->GetColorTransferFunction( + QString("ShiftRotationCenterWidgetColorMap%1") + .arg(colorMapCounter) + .toLatin1() + .data(), + pxm); + + // Default to the same colormap as the data source (projection view / + // main render window). Fall back to grayscale if unavailable. + auto* dsLutVtk = vtkScalarsToColors::SafeDownCast( + dataSource->colorMap()->GetClientSideObject()); + auto* colorMapVtk = + vtkScalarsToColors::SafeDownCast(colorMap->GetClientSideObject()); + if (dsLutVtk && colorMapVtk) { + colorMapVtk->DeepCopy(dsLutVtk); + } else { + setColorMapToGrayscale(); + } for (auto* w : inputWidgets()) { w->installEventFilter(this); @@ -200,29 +239,25 @@ class FxiWorkflowWidget::Internal : public QObject ui.colorPresetButton->setIcon(QIcon(":/pqWidgets/Icons/pqFavorites.svg")); auto* dims = image->GetDimensions(); - ui.slice->setMaximum(dims[1] - 1); - ui.sliceStart->setMaximum(dims[1] - 1); - ui.sliceStop->setMaximum(dims[1]); - // Get the slice start to default to 0, and the slice stop - // to default to dims[1], despite whatever settings they read in. - ui.sliceStart->setValue(0); - ui.sliceStop->setValue(dims[1]); + // All center-related values are offsets from the image midpoint. + // 0 means the rotation center is exactly at the midpoint. + setRotationCenter(0); - // Set the default start and stop values around the predicted - // center of rotation. - auto center = dims[0] / 2.0; - auto delta = std::min(40.0, center); - ui.start->setValue(center - delta); - ui.stop->setValue(center + delta); + // Default start/stop to +/- 10% of the detector width + auto delta = dims[0] * 0.1; + ui.start->setValue(-delta); + ui.stop->setValue(delta); - // Indicate what the max is via a tooltip. - auto toolTip = "Max: " + QString::number(dims[1]); - ui.sliceStop->setToolTip(toolTip); + // Default slice to the middle slice + ui.slice->setMaximum(dims[1] - 1); + ui.slice->setValue(dims[1] / 2); + + // Load saved settings for steps, algorithm, numIterations only + readSettings(); - // Hide the additional parameters label unless the user adds some - ui.reconExtraParamsLayoutWidget->hide(); - ui.testRotationsExtraParamsLayoutWidget->hide(); + // Hide iterations by default (only shown for iterative algorithms) + updateAlgorithmUI(); progressDialog.reset(new InternalProgressDialog(parent)); @@ -246,282 +281,98 @@ class FxiWorkflowWidget::Internal : public QObject &Internal::onPreviewRangeEdited); connect(ui.previewMax, &DoubleSliderWidget::valueEdited, this, &Internal::onPreviewRangeEdited); + connect(ui.algorithm, QOverload::of(&QComboBox::currentIndexChanged), + this, &Internal::updateAlgorithmUI); + connect(ui.slice, QOverload::of(&QSpinBox::valueChanged), this, + &Internal::onSliceChanged); + connect(ui.rotationCenter, + QOverload::of(&QDoubleSpinBox::valueChanged), this, + &Internal::updateCenterLine); } - void setupUI(OperatorPython* pythonOp) - { - if (!pythonOp) { - return; - } - - // If the user added extra parameters, add them here - auto json = QJsonDocument::fromJson(pythonOp->JSONDescription().toLatin1()); - if (json.isNull() || !json.isObject()) { - return; - } - - DataSource* ds = nullptr; - if (pythonOp->hasChildDataSource()) { - ds = pythonOp->childDataSource(); - } else { - ds = qobject_cast(pythonOp->parent()); - } - - if (!ds) { - ds = ActiveObjects::instance().activeDataSource(); - } - - QJsonObject root = json.object(); - - // Get the parameters for the operator - QJsonValueRef parametersNode = root["parameters"]; - if (parametersNode.isUndefined() || !parametersNode.isArray()) { - return; - } - auto parameters = parametersNode.toArray(); - - // Set up the interface builder - if (interfaceBuilder) { - interfaceBuilder->deleteLater(); - interfaceBuilder = nullptr; - } - - interfaceBuilder = new InterfaceBuilder(this, ds); - interfaceBuilder->setParameterValues(pythonOp->arguments()); - - // Add any extra parameter widgets - addReconExtraParamWidgets(parameters); - addTestRotationExtraParamWidgets(parameters); - - // Modify the extra param widgets with any saved settings - setReconExtraParamValues(customReconSettings); - setTestRotationExtraParamValues(customTestRotationSettings); - } - - void addReconExtraParamWidgets(QJsonArray parameters) + void onSliceChanged(int val) { - // Here is the list of parameters for which we already have widgets - QStringList knownParameters = { - "denoise_flag", "denoise_level", "dark_scale", - "rotation_center", "slice_start", "slice_stop", - }; - - int i = 0; - while (i < parameters.size()) { - QJsonValueRef parameterNode = parameters[i]; - QJsonObject parameterObject = parameterNode.toObject(); - QJsonValueRef nameValue = parameterObject["name"]; - auto tagValue = parameterObject["tag"]; - if (knownParameters.contains(nameValue.toString())) { - // This parameter is already known. Remove it. - parameters.removeAt(i); - } else if (tagValue.toString("") != "") { - // Not the right tag. Remove it. - parameters.removeAt(i); - } else { - i += 1; - } - } - - if (parameters.isEmpty()) { - return; - } - - // If we get to this point, we have some extra parameters. - // Show the additional parameters label, and add the parameters. - ui.reconExtraParamsLayoutWidget->show(); - auto layout = ui.reconExtraParamsLayout; - interfaceBuilder->buildParameterInterface(layout, parameters); - } + // Update the projection view to show the selected slice + projMapper->SetSliceNumber(val); + projMapper->Update(); + updateCenterLine(); - void addTestRotationExtraParamWidgets(QJsonArray parameters) - { - QString tag = "test_rotations"; - bool show = false; - - for (auto node : parameters) { - auto obj = node.toObject(); - if (obj["tag"].toString("") == tag) { - show = true; - break; - } - } - - if (!show) { - // Nothing to show - return; - } - - // If we get to this point, we have some extra parameters. - // Show the additional parameters label, and add the parameters. - ui.testRotationsExtraParamsLayoutWidget->show(); - auto layout = ui.testRotationsExtraParamsLayout; - interfaceBuilder->buildParameterInterface(layout, parameters, - "test_rotations"); - } - - void setExtraParamValues(QVariantMap values) - { - setReconExtraParamValues(values); - setTestRotationExtraParamValues(values); - } - - void setReconExtraParamValues(QVariantMap values) - { - if (!interfaceBuilder) { - return; - } - - auto parentWidget = ui.reconExtraParamsLayoutWidget; - interfaceBuilder->setParameterValues(values); - interfaceBuilder->updateWidgetValues(parentWidget); + // The existing test rotation results are no longer valid for this slice + setRotationData(vtkImageData::New()); + rotations.clear(); + updateImageViewSlider(); + render(); } - void setTestRotationExtraParamValues(QVariantMap values) + void updateCenterLine() { - if (!interfaceBuilder) { + if (!image) { return; } - auto parentWidget = ui.testRotationsExtraParamsLayoutWidget; - interfaceBuilder->setParameterValues(values); - interfaceBuilder->updateWidgetValues(parentWidget); - } + double bounds[6]; + image->GetBounds(bounds); + double centerX = (bounds[0] + bounds[1]) / 2.0; + double lineX = centerX + rotationCenter() * image->GetSpacing()[0]; - QVariantMap extraParamValues() - { - return unite(reconExtraParamValues(), testRotationsExtraParamValues()); - } - - QVariantMap reconExtraParamValues() - { - if (!interfaceBuilder) { - return QVariantMap(); - } + // Vertical line spanning the full Y (detector row) range, placed just in + // front of the current Z slice (toward the camera, which looks from +Z). + double p1[3] = { lineX, bounds[2], bounds[5] + 1 }; + double p2[3] = { lineX, bounds[3], bounds[5] + 1 }; + centerLine->SetPoint1(p1); + centerLine->SetPoint2(p2); + centerLine->Update(); + centerLineActor->GetMapper()->Update(); - auto parentWidget = ui.reconExtraParamsLayoutWidget; - return interfaceBuilder->parameterValues(parentWidget); + projRenderer->ResetCameraClippingRange(); + ui.projectionView->renderWindow()->Render(); } - QVariantMap testRotationsExtraParamValues() + void setupRenderer() { - if (!interfaceBuilder) { - return QVariantMap(); - } - - auto parentWidget = ui.testRotationsExtraParamsLayoutWidget; - return interfaceBuilder->parameterValues(parentWidget); + // Pass nullptr for the axes actor to avoid vtkVectorText + // "Text is not set" errors caused by degenerate bounds in the + // slice axis dimension. + tomviz::setupRenderer(renderer, mapper, nullptr); } - void setupRenderer() { tomviz::setupRenderer(renderer, mapper, axesActor); } - void render() { ui.sliceView->renderWindow()->Render(); } void readSettings() - { - readGeneralSettings(); - readReconSettings(); - readTestSettings(); - } - - void readGeneralSettings() { auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("FxiWorkflowWidget"); - settings->beginGroup("General"); - setDenoiseFlag(settings->value("denoiseFlag", false).toBool()); - setDenoiseLevel(settings->value("denoiseLevel", 9).toInt()); - setDarkScale(settings->value("darkScale", 1).toDouble()); - settings->endGroup(); - settings->endGroup(); - } - - void readReconSettings() - { - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("FxiWorkflowWidget"); - settings->beginGroup("Recon"); - setRotationCenter(settings->value("rotationCenter", 600).toDouble()); - setSliceStart(settings->value("sliceStart", 0).toInt()); - setSliceStop(settings->value("sliceStop", 1).toInt()); - customReconSettings = settings->value("extraParams").toMap(); - settings->endGroup(); - settings->endGroup(); - } - - void readTestSettings() - { - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("FxiWorkflowWidget"); - settings->beginGroup("TestSettings"); + settings->beginGroup("ShiftRotationCenterWidget"); ui.steps->setValue(settings->value("steps", 26).toInt()); - ui.slice->setValue(settings->value("sli", 0).toInt()); - customTestRotationSettings = settings->value("extraParams").toMap(); - settings->endGroup(); + setAlgorithm(settings->value("algorithm", "mlem").toString()); + ui.numIterations->setValue(settings->value("numIterations", 15).toInt()); settings->endGroup(); } void writeSettings() - { - writeGeneralSettings(); - writeReconSettings(); - writeTestSettings(); - } - - void writeGeneralSettings() { auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("FxiWorkflowWidget"); - settings->beginGroup("General"); - settings->setValue("denoiseFlag", denoiseFlag()); - settings->setValue("denoiseLevel", denoiseLevel()); - settings->setValue("darkScale", darkScale()); - settings->endGroup(); - settings->endGroup(); - } - - void writeReconSettings() - { - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("FxiWorkflowWidget"); - settings->beginGroup("Recon"); - settings->setValue("rotationCenter", rotationCenter()); - settings->setValue("sliceStart", sliceStart()); - settings->setValue("sliceStop", sliceStop()); - settings->setValue("extraParams", reconExtraParamValues()); - settings->endGroup(); - settings->endGroup(); - } - - void writeTestSettings() - { - auto settings = pqApplicationCore::instance()->settings(); - settings->beginGroup("FxiWorkflowWidget"); - settings->beginGroup("TestSettings"); + settings->beginGroup("ShiftRotationCenterWidget"); settings->setValue("steps", ui.steps->value()); - settings->setValue("sli", ui.slice->value()); - settings->setValue("extraParams", testRotationsExtraParamValues()); - settings->endGroup(); + settings->setValue("algorithm", algorithm()); + settings->setValue("numIterations", ui.numIterations->value()); settings->endGroup(); } QList inputWidgets() { - return { ui.denoiseFlag, ui.denoiseLevel, ui.darkScale, ui.start, - ui.stop, ui.steps, ui.slice, ui.rotationCenter, - ui.sliceStart, ui.sliceStop }; + return { ui.start, ui.stop, ui.steps, ui.slice, ui.rotationCenter }; } void startGeneratingTestImages() { progressDialog->show(); - auto future = QtConcurrent::run(std::bind(&Internal::generateTestImages, this)); + auto future = + QtConcurrent::run(std::bind(&Internal::generateTestImages, this)); futureWatcher.setFuture(future); } void testImagesGenerated() { - updateImageViewSlider(); if (!testRotationsSuccess) { auto msg = testRotationsErrorMessage; qCritical() << msg; @@ -529,6 +380,14 @@ class FxiWorkflowWidget::Internal : public QObject return; } + // Re-update the mapper on the main thread so bounds are current, + // then configure the camera. This must happen here (not in + // setRotationData) because that runs on a background thread. + mapper->Update(); + setupRenderer(); + renderer->ResetCameraClippingRange(); + updateImageViewSlider(); + if (rotationDataValid()) { resetColorRange(); render(); @@ -557,21 +416,17 @@ class FxiWorkflowWidget::Internal : public QObject Python::Object data = Python::createDataset(image, *dataSource); + // Convert offsets to absolute values for Python + auto imgCenter = image->GetDimensions()[0] / 2.0; + Python::Dict kwargs; kwargs.set("dataset", data); - kwargs.set("start", ui.start->value()); - kwargs.set("stop", ui.stop->value()); + kwargs.set("start", imgCenter + ui.start->value()); + kwargs.set("stop", imgCenter + ui.stop->value()); kwargs.set("steps", ui.steps->value()); kwargs.set("sli", ui.slice->value()); - kwargs.set("denoise_flag", denoiseFlag()); - kwargs.set("denoise_level", denoiseLevel()); - kwargs.set("dark_scale", darkScale()); - - // Add extra parameters - auto extraParams = testRotationsExtraParamValues(); - for (const auto& k : extraParams.keys()) { - kwargs.set(k, toVariant(extraParams[k])); - } + kwargs.set("algorithm", algorithm()); + kwargs.set("num_iter", ui.numIterations->value()); auto ret = func.call(kwargs); auto result = ret.toDict(); @@ -604,17 +459,15 @@ class FxiWorkflowWidget::Internal : public QObject } for (int i = 0; i < pyRotations.length(); ++i) { - rotations.append(pyRotations[i].toDouble()); + // Convert absolute centers to offsets from the image midpoint + rotations.append(pyRotations[i].toDouble() - imgCenter); } setRotationData(imageData); } // If we made it this far, it was a success - // Make the axes visible. - axesActor->SetVisibility(true); - // Save these settings in case the user wants to use them again... - writeTestSettings(); + writeSettings(); testRotationsSuccess = true; } @@ -624,7 +477,6 @@ class FxiWorkflowWidget::Internal : public QObject mapper->SetInputData(rotationImages); mapper->SetSliceNumber(0); mapper->Update(); - setupRenderer(); } void resetColorRange() @@ -745,7 +597,7 @@ class FxiWorkflowWidget::Internal : public QObject if (sliceNumber < rotations.size()) { ui.currentRotation->setValue(rotations[sliceNumber]); - // For convenience, also set the rotation center for reconstruction + // For convenience, also set the rotation center ui.rotationCenter->setValue(rotations[sliceNumber]); } else { qCritical() << sliceNumber @@ -812,86 +664,62 @@ class FxiWorkflowWidget::Internal : public QObject dialog.exec(); } - void setDenoiseFlag(bool b) { ui.denoiseFlag->setChecked(b); } - bool denoiseFlag() const { return ui.denoiseFlag->isChecked(); } - - void setDenoiseLevel(int i) { ui.denoiseLevel->setValue(i); } - int denoiseLevel() const { return ui.denoiseLevel->value(); } - - void setDarkScale(double x) { ui.darkScale->setValue(x); } - double darkScale() const { return ui.darkScale->value(); } - void setRotationCenter(double center) { ui.rotationCenter->setValue(center); } double rotationCenter() const { return ui.rotationCenter->value(); } - void setSliceStart(int i) { ui.sliceStart->setValue(i); } - int sliceStart() const { return ui.sliceStart->value(); } + QString algorithm() const { return ui.algorithm->currentText(); } + void setAlgorithm(const QString& alg) + { + int index = ui.algorithm->findText(alg); + if (index >= 0) { + ui.algorithm->setCurrentIndex(index); + } + } - void setSliceStop(int i) { ui.sliceStop->setValue(i); } - int sliceStop() const { return ui.sliceStop->value(); } + void updateAlgorithmUI() + { + bool iterative = (ui.algorithm->currentText() != "gridrec"); + ui.numIterationsLabel->setVisible(iterative); + ui.numIterations->setVisible(iterative); + } }; -#include "FxiWorkflowWidget.moc" +#include "ShiftRotationCenterWidget.moc" -FxiWorkflowWidget::FxiWorkflowWidget(Operator* op, - vtkSmartPointer image, - QWidget* p) +ShiftRotationCenterWidget::ShiftRotationCenterWidget( + Operator* op, vtkSmartPointer image, QWidget* p) : CustomPythonOperatorWidget(p) { m_internal.reset(new Internal(op, image, this)); } -FxiWorkflowWidget::~FxiWorkflowWidget() = default; +ShiftRotationCenterWidget::~ShiftRotationCenterWidget() = default; -void FxiWorkflowWidget::getValues(QVariantMap& map) +void ShiftRotationCenterWidget::getValues(QVariantMap& map) { - map.insert("denoise_flag", m_internal->denoiseFlag()); - map.insert("denoise_level", m_internal->denoiseLevel()); - map.insert("dark_scale", m_internal->darkScale()); map.insert("rotation_center", m_internal->rotationCenter()); - map.insert("slice_start", m_internal->sliceStart()); - map.insert("slice_stop", m_internal->sliceStop()); - - map = unite(map, m_internal->reconExtraParamValues()); } -void FxiWorkflowWidget::setValues(const QVariantMap& map) +void ShiftRotationCenterWidget::setValues(const QVariantMap& map) { - if (map.contains("denoise_flag")) { - m_internal->setDenoiseFlag(map["denoise_flag"].toBool()); - } - if (map.contains("denoise_level")) { - m_internal->setDenoiseLevel(map["denoise_level"].toInt()); - } - if (map.contains("dark_scale")) { - m_internal->setDarkScale(map["dark_scale"].toDouble()); - } if (map.contains("rotation_center")) { m_internal->setRotationCenter(map["rotation_center"].toDouble()); } - if (map.contains("slice_start")) { - m_internal->setSliceStart(map["slice_start"].toInt()); + if (map.contains("algorithm")) { + m_internal->setAlgorithm(map["algorithm"].toString()); } - if (map.contains("slice_stop")) { - m_internal->setSliceStop(map["slice_stop"].toInt()); + if (map.contains("num_iter")) { + m_internal->ui.numIterations->setValue(map["num_iter"].toInt()); } - - m_internal->setReconExtraParamValues(map); } -void FxiWorkflowWidget::setScript(const QString& script) +void ShiftRotationCenterWidget::setScript(const QString& script) { Superclass::setScript(script); m_internal->script = script; } -void FxiWorkflowWidget::setupUI(OperatorPython* op) -{ - Superclass::setupUI(op); - m_internal->setupUI(op); -} - -void FxiWorkflowWidget::writeSettings() +void ShiftRotationCenterWidget::writeSettings() { Superclass::writeSettings(); m_internal->writeSettings(); diff --git a/tomviz/FxiWorkflowWidget.h b/tomviz/ShiftRotationCenterWidget.h similarity index 64% rename from tomviz/FxiWorkflowWidget.h rename to tomviz/ShiftRotationCenterWidget.h index 165a2a6de..2e6496dc4 100644 --- a/tomviz/FxiWorkflowWidget.h +++ b/tomviz/ShiftRotationCenterWidget.h @@ -1,8 +1,8 @@ /* This source file is part of the Tomviz project, https://tomviz.org/. It is released under the 3-Clause BSD License, see "LICENSE". */ -#ifndef tomvizFxiWorkflowWidget_h -#define tomvizFxiWorkflowWidget_h +#ifndef tomvizShiftRotationCenterWidget_h +#define tomvizShiftRotationCenterWidget_h #include "CustomPythonOperatorWidget.h" @@ -15,15 +15,15 @@ class vtkImageData; namespace tomviz { class Operator; -class FxiWorkflowWidget : public CustomPythonOperatorWidget +class ShiftRotationCenterWidget : public CustomPythonOperatorWidget { Q_OBJECT typedef CustomPythonOperatorWidget Superclass; public: - FxiWorkflowWidget(Operator* op, vtkSmartPointer image, - QWidget* parent = NULL); - ~FxiWorkflowWidget(); + ShiftRotationCenterWidget(Operator* op, vtkSmartPointer image, + QWidget* parent = NULL); + ~ShiftRotationCenterWidget(); static CustomPythonOperatorWidget* New(QWidget* p, Operator* op, vtkSmartPointer data); @@ -32,21 +32,20 @@ class FxiWorkflowWidget : public CustomPythonOperatorWidget void setValues(const QMap& map) override; void setScript(const QString& script) override; - void setupUI(OperatorPython* op) override; void writeSettings() override; private: - Q_DISABLE_COPY(FxiWorkflowWidget) + Q_DISABLE_COPY(ShiftRotationCenterWidget) class Internal; QScopedPointer m_internal; }; -inline CustomPythonOperatorWidget* FxiWorkflowWidget::New( +inline CustomPythonOperatorWidget* ShiftRotationCenterWidget::New( QWidget* p, Operator* op, vtkSmartPointer data) { - return new FxiWorkflowWidget(op, data, p); + return new ShiftRotationCenterWidget(op, data, p); } } // namespace tomviz diff --git a/tomviz/FxiWorkflowWidget.ui b/tomviz/ShiftRotationCenterWidget.ui similarity index 58% rename from tomviz/FxiWorkflowWidget.ui rename to tomviz/ShiftRotationCenterWidget.ui index 4e2cd3c5a..574a6ba2a 100644 --- a/tomviz/FxiWorkflowWidget.ui +++ b/tomviz/ShiftRotationCenterWidget.ui @@ -1,7 +1,7 @@ - FxiWorkflowWidget - + ShiftRotationCenterWidget + 0 @@ -30,11 +30,44 @@ 0 - - - + + + + + + + + 1 + 2 + + + + + 300 + 300 + + + + + + + + + 1 + 1 + + + + + 300 + 300 + + + + + - + 2 @@ -94,6 +127,9 @@ 3 + + -100000.000000000000000 + 100000.000000000000000 @@ -117,6 +153,9 @@ 3 + + -100000.000000000000000 + 100000.000000000000000 @@ -165,29 +204,61 @@ - - - - - - - 450 - 0 - - - - true - + + + + + Algorithm: + + + algorithm + + + + + + - <html><head/><body><p><span style=" font-size:large; font-weight:600;">Additional Parameters</span></p></body></html> + gridrec - - true + + + + mlem - - - - + + + + + + + <html><head/><body><p>Number of iterations to use for iterative algorithms like `mlem`. Default is 15 for speed. </p><p><br/></p><p>For greater accuracy, the suggested range is between 50 and 200 iterations.</p></body></html> + + + Iterations: + + + numIterations + + + + + + + <html><head/><body><p>Number of iterations to use for iterative algorithms like `mlem`. Default is 15 for speed. </p><p><br/></p><p>For greater accuracy, the suggested range is between 50 and 200 iterations.</p></body></html> + + + 1 + + + 1000 + + + 20 + + + + @@ -235,6 +306,9 @@ 3 + + -1000000.000000000000000 + 1000000.000000000000000 @@ -281,7 +355,7 @@ - Rotation: + Offset: currentRotation @@ -308,23 +382,7 @@ - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 40 - - - - - - + QGroupBox{padding-top:15px; margin-top:-15px} @@ -332,256 +390,68 @@ - - - - - - Slice Start: - - - sliceStart - - - - - - - Slice Stop: - - - sliceStop - - - - - - - - 450 - 0 - - - - true - - - <html><head/><body><p><span style=" font-size:large; font-weight:600;">Reconstruction</span></p></body></html> - - - true - - - - - - - Rotation Center: - - - rotationCenter - - - - - - - false - - - 10000 - - - - - - - false - - - 10000 - - - - - - - false - - - 3 - - - 0.000000000000000 - - - 100000.000000000000000 - - - 0.500000000000000 - - - - - - - - - - - 450 - 0 - - - - true - - - <html><head/><body><p><span style=" font-size:large; font-weight:600;">Additional Parameters</span></p></body></html> - - - true - - - - - - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - 1 - 1 - - - - - 300 - 300 - - - - - - - - QGroupBox{padding-top:15px; margin-top:-15px} - - - - - - - - - - - <html><head/><body><p>Whether to apply Wiener denoise</p></body></html> - - - Denoise Flag - - - - - - - <html><head/><body><p>The level to apply to tomopy.prep.stripe.remove_stripe_fw</p></body></html> + + + + + 450 + 0 + - Denoise Level: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + <html><head/><body><h3 style=" margin-top:4px; margin-bottom:4px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:large; font-weight:600;">Parameters</span></h3></body></html> - - denoiseLevel - - - - - - - <html><head/><body><p>The level to apply to tomopy.prep.stripe.remove_stripe_fw</p></body></html> - - - 0 - - - 1000 + + true - - - - <html><head/><body><p>The scaling that should be applied to the dark image</p></body></html> - + + - Dark Scale: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + Center Offset: - darkScale + rotationCenter - - - - <html><head/><body><p>The scaling that should be applied to the dark image</p></body></html> + + + + false - 6 + 3 - -10000.000000000000000 + -100000.000000000000000 - 10000.000000000000000 + 100000.000000000000000 - - 1.000000000000000 + + 0.500000000000000 - - - - - <html><head/><body><p><span style=" font-size:large; font-weight:600;">General Parameters</span></p><p>Parameters used in both &quot;Test Rotation Centers&quot; and &quot;Reconstruction&quot;.</p></body></html> - - - false - - - - - + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + @@ -613,19 +483,16 @@ - denoiseFlag - denoiseLevel - darkScale start stop steps slice + algorithm + numIterations testRotations currentRotation - colorPresetButton rotationCenter - sliceStart - sliceStop + colorPresetButton diff --git a/tomviz/python/Recon_tomopy_fxi.json b/tomviz/python/Recon_tomopy_fxi.json deleted file mode 100644 index 2dbf506ff..000000000 --- a/tomviz/python/Recon_tomopy_fxi.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name" : "FXI TomoPy Reconstruction", - "label" : "FXI TomoPy Reconstruction", - "description" : "Run FXI TomoPy Reconstruction on a dataset", - "widget": "FxiWorkflowWidget", - "externalCompatible": false, - "parameters" : [ - { - "name" : "rotation_center", - "label" : "Rotation Center", - "description" : "The center of rotation of the dataset", - "type" : "double", - "default" : 0.0, - "precision" : 3 - }, - { - "name" : "slice_start", - "label" : "Slice Start", - "description" : "The first slice to use for reconstruction", - "type" : "int", - "default" : 0 - }, - { - "name" : "slice_stop", - "label" : "Slice Stop", - "description" : "The last slice to use for reconstruction", - "type" : "int", - "default" : 0 - }, - { - "name" : "denoise_flag", - "label" : "Denoise Flag", - "description" : "Whether to apply Wiener denoise", - "type" : "bool", - "default" : false - }, - { - "name" : "denoise_level", - "label" : "Denoise Level", - "description" : "The level to apply to tomopy.prep.stripe.remove_stripe_fw", - "type" : "int", - "default" : 9 - }, - { - "name" : "dark_scale", - "label" : "Dark Scale", - "description" : "The scaling that should be applied to the dark image", - "type" : "double", - "default" : 1 - } - ], - "children": [ - { - "name": "reconstruction", - "label": "Reconstruction", - "type": "reconstruction" - } - ] -} diff --git a/tomviz/python/Recon_tomopy_fxi.py b/tomviz/python/Recon_tomopy_fxi.py deleted file mode 100644 index 83f5818f7..000000000 --- a/tomviz/python/Recon_tomopy_fxi.py +++ /dev/null @@ -1,426 +0,0 @@ -import numpy as np - - -def transform(dataset, rotation_center=0, slice_start=0, slice_stop=1, - denoise_flag=0, denoise_level=9, dark_scale=1): - - # Get the current volume as a numpy array. - array = dataset.active_scalars - - dark = dataset.dark - white = dataset.white - angles = dataset.tilt_angles - tilt_axis = dataset.tilt_axis - - # TomoPy wants the tilt axis to be zero, so ensure that is true - if tilt_axis == 2: - order = [2, 1, 0] - array = np.transpose(array, order) - - if dark is not None: - dark = np.transpose(dark, order) - - if white is not None: - white = np.transpose(white, order) - - if angles is None: - raise Exception('No angles found') - - # FIXME: Are these right? - recon_input = { - 'img_tomo': array, - 'angle': angles, - } - - if dark is not None: - recon_input['img_dark_avg'] = dark - - if white is not None: - recon_input['img_bkg_avg'] = white - - kwargs = { - 'f': recon_input, - 'rot_cen': rotation_center, - 'sli': [slice_start, slice_stop], - 'denoise_flag': denoise_flag, - 'denoise_level': denoise_level, - 'dark_scale': dark_scale, - } - - # Perform the reconstruction - output = recon(**kwargs) - - # Set the transformed array - child = dataset.create_child_dataset() - child.active_scalars = output - - return_values = {} - return_values['reconstruction'] = child - return return_values - - -def test_rotations(dataset, start=None, stop=None, steps=None, sli=0, - denoise_flag=0, denoise_level=9, dark_scale=1): - # Get the current volume as a numpy array. - array = dataset.active_scalars - - dark = dataset.dark - white = dataset.white - angles = dataset.tilt_angles - tilt_axis = dataset.tilt_axis - - # TomoPy wants the tilt axis to be zero, so ensure that is true - if tilt_axis == 2: - order = [2, 1, 0] - array = np.transpose(array, order) - - if dark is not None: - dark = np.transpose(dark, order) - - if white is not None: - white = np.transpose(white, order) - - if angles is None: - raise Exception('No angles found') - - recon_input = { - 'img_tomo': array, - 'angle': angles, - } - - if dark is not None: - recon_input['img_dark_avg'] = dark - - if white is not None: - recon_input['img_bkg_avg'] = white - - kwargs = { - 'f': recon_input, - 'start': start, - 'stop': stop, - 'steps': steps, - 'sli': sli, - 'denoise_flag': denoise_flag, - 'denoise_level': denoise_level, - 'dark_scale': dark_scale, - } - - if dark is None or white is None: - kwargs['txm_normed_flag'] = True - - # Perform the reconstruction - images, centers = rotcen_test(**kwargs) - - child = dataset.create_child_dataset() - child.active_scalars = images - - return_values = {} - return_values['images'] = child - return_values['centers'] = centers.astype(float).tolist() - return return_values - - -def find_nearest(data, value): - data = np.array(data) - return np.abs(data - value).argmin() - - -def recon(f, rot_cen, sli=[], binning=None, zero_flag=0, block_list=[], - bkg_level=0, txm_normed_flag=0, read_full_memory=0, denoise_flag=0, - denoise_level=9, dark_scale=1): - ''' - reconstruct 3D tomography - Inputs: - -------- - f: dict - input dictionary of scan - rot_cen: float - rotation center - sli: list - a range of slice to recontruct, e.g. [100:300] - bingning: int - binning the reconstruted 3D tomographic image - zero_flag: bool - if 1: set negative pixel value to 0 - if 0: keep negative pixel value - block_list: list - a list of index for the projections that will not be considered in - reconstruction - - ''' - import tomopy - - tmp = np.array(f['img_tomo'][0]) - s = [1, tmp.shape[0], tmp.shape[1]] - - if len(sli) == 0: - sli = [0, s[1]] - elif len(sli) == 1 and sli[0] >= 0 and sli[0] <= s[1]: - sli = [sli[0], sli[0]+1] - elif len(sli) == 2 and sli[0] >= 0 and sli[1] <= s[1]: - pass - else: - print('non valid slice id, will take reconstruction for the whole', - 'object') - ''' - if len(col) == 0: - col = [0, s[2]] - elif len(col) == 1 and col[0] >=0 and col[0] <= s[2]: - col = [col[0], col[0]+1] - elif len(col) == 2 and col[0] >=0 and col[1] <= s[2]: - col_info = '_col_{}_{}'.format(col[0], col[1]) - else: - col = [0, s[2]] - print('invalid col id, will take reconstruction for the whole object') - ''' - # rot_cen = rot_cen - col[0] - theta = np.array(f['angle']) / 180.0 * np.pi - pos = find_nearest(theta, theta[0]+np.pi) - block_list = list(block_list) + list(np.arange(pos+1, len(theta))) - allow_list = list(set(np.arange(len(theta))) - set(block_list)) - theta = theta[allow_list] - tmp = np.squeeze(np.array(f['img_tomo'][0])) - s = tmp.shape - - sli_step = 40 - sli_total = np.arange(sli[0], sli[1]) - binning = binning if binning else 1 - - n_steps = int(len(sli_total) / sli_step) - rot_cen = rot_cen * 1.0 / binning - - if read_full_memory: - sli_step = sli[1] - sli[0] - n_steps = 1 - - if denoise_flag: - add_slice = min(sli_step // 2, 20) - wiener_param = {} - psf = 2 - wiener_param['psf'] = np.ones([psf, psf])/(psf**2) - wiener_param['reg'] = None - wiener_param['balance'] = 0.3 - wiener_param['is_real'] = True - wiener_param['clip'] = True - else: - add_slice = 0 - wiener_param = [] - - try: - rec = np.zeros([s[0] // binning, s[1] // binning, s[1] // binning], - dtype=np.float32) - except Exception: - print('Cannot allocate memory') - - ''' - # first sli_step slices: will not do any denoising - prj_norm = proj_normalize(f, [0, sli_step], txm_normed_flag, binning, - allow_list, bkg_level) - prj_norm = wiener_denoise(prj_norm, wiener_param, denoise_flag) - rec_sub = tomopy.recon(prj_norm, theta, center=rot_cen, - algorithm='gridrec') - rec[0 : rec_sub.shape[0]] = rec_sub - ''' - # following slices - for i in range(n_steps): - if i == n_steps-1: - sli_sub = [i*sli_step+sli_total[0], len(sli_total)+sli[0]] - current_sli = sli_sub - else: - sli_sub = [i*sli_step+sli_total[0], (i+1)*sli_step+sli_total[0]] - current_sli = [sli_sub[0]-add_slice, sli_sub[1]+add_slice] - print(f'recon {i+1}/{n_steps}: sli = [{sli_sub[0]},', - f'{sli_sub[1]}] ... ') - prj_norm = proj_normalize(f, current_sli, txm_normed_flag, binning, - allow_list, bkg_level, denoise_level, - dark_scale) - prj_norm = wiener_denoise(prj_norm, wiener_param, denoise_flag) - if i != 0 and i != n_steps - 1: - start = add_slice // binning - stop = sli_step // binning + start - prj_norm = prj_norm[:, start:stop] - rec_sub = tomopy.recon(prj_norm, theta, center=rot_cen, - algorithm='gridrec') - start = i * sli_step // binning - rec[start: start + rec_sub.shape[0]] = rec_sub - - if zero_flag: - rec[rec < 0] = 0 - - return rec - - -def wiener_denoise(prj_norm, wiener_param, denoise_flag): - import skimage.restoration as skr - if not denoise_flag or not len(wiener_param): - return prj_norm - - ss = prj_norm.shape - psf = wiener_param['psf'] - reg = wiener_param['reg'] - balance = wiener_param['balance'] - is_real = wiener_param['is_real'] - clip = wiener_param['clip'] - for j in range(ss[0]): - prj_norm[j] = skr.wiener(prj_norm[j], psf=psf, reg=reg, - balance=balance, is_real=is_real, clip=clip) - return prj_norm - - -def proj_normalize(f, sli, txm_normed_flag, binning, allow_list=[], - bkg_level=0, denoise_level=9, dark_scale=1): - import tomopy - - img_tomo = np.array(f['img_tomo'][:, sli[0]:sli[1], :]) - try: - img_bkg = np.array(f['img_bkg_avg'][:, sli[0]:sli[1]]) - except Exception: - img_bkg = [] - try: - img_dark = np.array(f['img_dark_avg'][:, sli[0]:sli[1]]) - except Exception: - img_dark = [] - if len(img_dark) == 0 or len(img_bkg) == 0 or txm_normed_flag == 1: - prj = img_tomo - else: - prj = ((img_tomo - img_dark / dark_scale) / - (img_bkg - img_dark / dark_scale)) - - s = prj.shape - prj = bin_ndarray(prj, (s[0], int(s[1] / binning), int(s[2] / binning)), - 'mean') - prj_norm = -np.log(prj) - prj_norm[np.isnan(prj_norm)] = 0 - prj_norm[np.isinf(prj_norm)] = 0 - prj_norm[prj_norm < 0] = 0 - prj_norm = prj_norm[allow_list] - prj_norm = tomopy.prep.stripe.remove_stripe_fw(prj_norm, - level=denoise_level, - wname='db5', sigma=1, - pad=True) - prj_norm -= bkg_level - return prj_norm - - -def bin_ndarray(ndarray, new_shape=None, operation='mean'): - """ - Bins an ndarray in all axes based on the target shape, by summing or - averaging. - - Number of output dimensions must match number of input dimensions and - new axes must divide old ones. - - Example - ------- - >>> m = np.arange(0,100,1).reshape((10,10)) - >>> n = bin_ndarray(m, new_shape=(5,5), operation='sum') - >>> print(n) - - [[ 22 30 38 46 54] - [102 110 118 126 134] - [182 190 198 206 214] - [262 270 278 286 294] - [342 350 358 366 374]] - - """ - if new_shape is None: - s = np.array(ndarray.shape) - s1 = np.int32(s / 2) - new_shape = tuple(s1) - operation = operation.lower() - if operation not in ['sum', 'mean']: - raise ValueError("Operation not supported.") - if ndarray.ndim != len(new_shape): - raise ValueError("Shape mismatch: {} -> {}".format(ndarray.shape, - new_shape)) - compression_pairs = [(d, c // d) for d, c in zip(new_shape, - ndarray.shape)] - flattened = [x for p in compression_pairs for x in p] - ndarray = ndarray.reshape(flattened) - for i in range(len(new_shape)): - op = getattr(ndarray, operation) - ndarray = op(-1*(i+1)) - return ndarray - - -def rotcen_test(f, start=None, stop=None, steps=None, sli=0, block_list=[], - print_flag=1, bkg_level=0, txm_normed_flag=0, denoise_flag=0, - denoise_level=9, dark_scale=1): - - import tomopy - - tmp = np.array(f['img_tomo'][0]) - s = [1, tmp.shape[0], tmp.shape[1]] - - if denoise_flag: - import skimage.restoration as skr - addition_slice = 100 - psf = 2 - psf = np.ones([psf, psf])/(psf**2) - reg = None - balance = 0.3 - is_real = True - clip = True - else: - addition_slice = 0 - - if sli == 0: - sli = int(s[1] / 2) - - sli_exp = [np.max([0, sli - addition_slice // 2]), - np.min([sli + addition_slice // 2 + 1, s[1]])] - - theta = np.array(f['angle']) / 180.0 * np.pi - - img_tomo = np.array(f['img_tomo'][:, sli_exp[0]:sli_exp[1], :]) - - if txm_normed_flag: - prj = img_tomo - else: - img_bkg = np.array(f['img_bkg_avg'][:, sli_exp[0]:sli_exp[1], :]) - img_dark = np.array(f['img_dark_avg'][:, sli_exp[0]:sli_exp[1], :]) - prj = ((img_tomo - img_dark / dark_scale) / - (img_bkg - img_dark / dark_scale)) - - prj_norm = -np.log(prj) - prj_norm[np.isnan(prj_norm)] = 0 - prj_norm[np.isinf(prj_norm)] = 0 - prj_norm[prj_norm < 0] = 0 - - prj_norm -= bkg_level - - prj_norm = tomopy.prep.stripe.remove_stripe_fw(prj_norm, - level=denoise_level, - wname='db5', sigma=1, - pad=True) - if denoise_flag: # denoise using wiener filter - ss = prj_norm.shape - for i in range(ss[0]): - prj_norm[i] = skr.wiener(prj_norm[i], psf=psf, reg=reg, - balance=balance, is_real=is_real, - clip=clip) - - s = prj_norm.shape - if len(s) == 2: - prj_norm = prj_norm.reshape(s[0], 1, s[1]) - s = prj_norm.shape - - pos = find_nearest(theta, theta[0]+np.pi) - block_list = list(block_list) + list(np.arange(pos+1, len(theta))) - if len(block_list): - allow_list = list(set(np.arange(len(prj_norm))) - set(block_list)) - prj_norm = prj_norm[allow_list] - theta = theta[allow_list] - if start is None or stop is None or steps is None: - start = int(s[2]/2-50) - stop = int(s[2]/2+50) - steps = 26 - cen = np.linspace(start, stop, steps) - img = np.zeros([len(cen), s[2], s[2]]) - for i in range(len(cen)): - if print_flag: - print('{}: rotcen {}'.format(i+1, cen[i])) - img[i] = tomopy.recon(prj_norm[:, addition_slice:addition_slice + 1], - theta, center=cen[i], algorithm='gridrec') - img = tomopy.circ_mask(img, axis=0, ratio=0.8) - return img, cen diff --git a/tomviz/python/ShiftRotationCenter_tomopy.json b/tomviz/python/ShiftRotationCenter_tomopy.json new file mode 100644 index 000000000..cfea97e55 --- /dev/null +++ b/tomviz/python/ShiftRotationCenter_tomopy.json @@ -0,0 +1,17 @@ +{ + "name" : "Shift Rotation Center", + "label" : "Shift Rotation Center", + "description" : "Shift projections so the rotation center is at the image midpoint", + "widget": "ShiftRotationCenterWidget", + "externalCompatible": false, + "parameters" : [ + { + "name" : "rotation_center", + "label" : "Center Offset", + "description" : "Offset of the rotation center from the image midpoint (0 = centered)", + "type" : "double", + "default" : 0.0, + "precision" : 3 + } + ] +} diff --git a/tomviz/python/ShiftRotationCenter_tomopy.py b/tomviz/python/ShiftRotationCenter_tomopy.py new file mode 100644 index 000000000..42a0cafaf --- /dev/null +++ b/tomviz/python/ShiftRotationCenter_tomopy.py @@ -0,0 +1,112 @@ +import numpy as np + + +def transform(dataset, rotation_center=0): + from scipy.ndimage import shift as ndshift + + array = dataset.active_scalars + tilt_axis = dataset.tilt_axis + + # rotation_center is an offset from the image midpoint. + # A positive offset means the rotation center is right of center, + # so we shift left (negative) to bring it to center. + pixel_shift = -rotation_center + + # The detector horizontal axis depends on the tilt axis convention. + # tilt_axis == 0: array is (proj, Y, X) -> detector horizontal is axis 2 + # tilt_axis == 2: array is (X, Y, proj) -> detector horizontal is axis 0 + if tilt_axis == 2: + det_axis = 0 + else: + det_axis = 2 + + # Shift the entire volume along the detector horizontal axis + shift_vec = [0.0, 0.0, 0.0] + shift_vec[det_axis] = pixel_shift + array = ndshift(array, shift_vec, mode='constant') + + dataset.active_scalars = array + + +def test_rotations(dataset, start=None, stop=None, steps=None, sli=0, + algorithm='gridrec', num_iter=15): + # Get the current volume as a numpy array. + array = dataset.active_scalars + + angles = dataset.tilt_angles + tilt_axis = dataset.tilt_axis + + # TomoPy wants the tilt axis to be zero, so ensure that is true + if tilt_axis == 2: + array = np.transpose(array, [2, 0, 1]) + + if angles is None: + raise Exception('No angles found') + + recon_input = { + 'img_tomo': array, + 'angle': angles, + } + + kwargs = { + 'f': recon_input, + 'start': start, + 'stop': stop, + 'steps': steps, + 'sli': sli, + 'algorithm': algorithm, + 'num_iter': num_iter, + } + + # Perform the test rotations + images, centers = rotcen_test(**kwargs) + + child = dataset.create_child_dataset() + child.active_scalars = images + + return_values = {} + return_values['images'] = child + return_values['centers'] = centers.astype(float).tolist() + return return_values + + +def rotcen_test(f, start=None, stop=None, steps=None, sli=0, + algorithm='gridrec', num_iter=15): + + import tomopy + + tmp = np.array(f['img_tomo'][0]) + s = [1, tmp.shape[0], tmp.shape[1]] + + if sli == 0: + sli = int(s[1] / 2) + + theta = np.array(f['angle']) / 180.0 * np.pi + + img_tomo = np.array(f['img_tomo'][:, sli:sli + 1, :]) + + img_tomo[np.isnan(img_tomo)] = 0 + img_tomo[np.isinf(img_tomo)] = 0 + + s = img_tomo.shape + if len(s) == 2: + img_tomo = img_tomo.reshape(s[0], 1, s[1]) + s = img_tomo.shape + + if start is None or stop is None or steps is None: + start = int(s[2] / 2 - 50) + stop = int(s[2] / 2 + 50) + steps = 26 + cen = np.linspace(start, stop, steps) + img = np.zeros([len(cen), s[2], s[2]]) + + recon_kwargs = {} + if algorithm != 'gridrec': + recon_kwargs['num_iter'] = num_iter + + for i in range(len(cen)): + print(f'{i + 1}: rotcen {cen[i]}') + img[i] = tomopy.recon(img_tomo, theta, center=cen[i], + algorithm=algorithm, **recon_kwargs) + img = tomopy.circ_mask(img, axis=0, ratio=0.8) + return img, cen From 7ac7725256e90d7385f50a509ed8bf931c57a9ca Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Wed, 18 Feb 2026 12:29:41 -0600 Subject: [PATCH 17/32] Ensure operator dialog appears above main window This prevents the operator dialog from just appearing randomly on other monitors or in random places on the desktop window. Signed-off-by: Patrick Avery --- tomviz/operators/EditOperatorDialog.cxx | 18 ++++++++++++++++++ tomviz/operators/EditOperatorDialog.h | 3 +++ 2 files changed, 21 insertions(+) diff --git a/tomviz/operators/EditOperatorDialog.cxx b/tomviz/operators/EditOperatorDialog.cxx index 5457d8478..b7efc93a6 100644 --- a/tomviz/operators/EditOperatorDialog.cxx +++ b/tomviz/operators/EditOperatorDialog.cxx @@ -20,6 +20,7 @@ #include #include #include +#include #include #include @@ -123,6 +124,23 @@ EditOperatorDialog::EditOperatorDialog(Operator* op, DataSource* dataSource, EditOperatorDialog::~EditOperatorDialog() {} +void EditOperatorDialog::showEvent(QShowEvent* event) +{ + Superclass::showEvent(event); + + // Always center on the main window, overriding any restored geometry or + // window manager placement. + auto* mainWin = tomviz::mainWidget(); + if (!mainWin) { + return; + } + + auto mainCenter = mainWin->frameGeometry().center(); + auto dlgSize = frameGeometry().size(); + move(mainCenter.x() - dlgSize.width() / 2, + mainCenter.y() - dlgSize.height() / 2); +} + void EditOperatorDialog::setViewMode(const QString& mode) { if (this->Internals->Widget) { diff --git a/tomviz/operators/EditOperatorDialog.h b/tomviz/operators/EditOperatorDialog.h index 119755c1d..08686c71f 100644 --- a/tomviz/operators/EditOperatorDialog.h +++ b/tomviz/operators/EditOperatorDialog.h @@ -40,6 +40,9 @@ class EditOperatorDialog : public QDialog // dialog already, that dialog is set to the requested mode and given focus. static void showDialogForOperator(Operator* op, const QString& viewMode = ""); +protected: + void showEvent(QShowEvent* event) override; + private slots: void onApply(); void onCancel(); From 0658733f98ab08cf01fa62b8f9621d1ba48c3e0e Mon Sep 17 00:00:00 2001 From: Alessandro Genova Date: Wed, 18 Feb 2026 12:33:49 -0500 Subject: [PATCH 18/32] add optional breakpoints to the pipeline at each operator Signed-off-by: Alessandro Genova --- tomviz/Pipeline.cxx | 71 ++++++++++++++- tomviz/Pipeline.h | 3 + tomviz/PipelineModel.cxx | 23 ++++- tomviz/PipelineView.cxx | 153 +++++++++++++++++++++++++++++++-- tomviz/PipelineView.h | 12 +++ tomviz/icons/breakpoint.png | Bin 0 -> 134 bytes tomviz/icons/breakpoint@2x.png | Bin 0 -> 202 bytes tomviz/icons/play.png | Bin 0 -> 141 bytes tomviz/icons/play@2x.png | Bin 0 -> 194 bytes tomviz/operators/Operator.cxx | 15 +++- tomviz/operators/Operator.h | 8 ++ tomviz/resources.qrc | 4 + 12 files changed, 275 insertions(+), 14 deletions(-) create mode 100644 tomviz/icons/breakpoint.png create mode 100644 tomviz/icons/breakpoint@2x.png create mode 100644 tomviz/icons/play.png create mode 100644 tomviz/icons/play@2x.png diff --git a/tomviz/Pipeline.cxx b/tomviz/Pipeline.cxx index 9a898524b..a86c944cd 100644 --- a/tomviz/Pipeline.cxx +++ b/tomviz/Pipeline.cxx @@ -188,11 +188,63 @@ Pipeline::Future* Pipeline::execute(DataSource* dataSource) return emptyFuture(); } + // Find the first breakpoint operator at or after the start + int startIdx = operators.indexOf(firstModifiedOperator); + Operator* breakpointOp = nullptr; + for (int i = startIdx; i < operators.size(); ++i) { + if (operators[i]->hasBreakpoint()) { + breakpointOp = operators[i]; + break; + } + } + + if (breakpointOp) { + auto future = execute(dataSource, firstModifiedOperator, breakpointOp); + connect(future, &Pipeline::Future::finished, this, + [this, breakpointOp, dataSource]() { + // Reset operators from the breakpoint onwards to Queued so they + // reflect the fact that they haven't run with the current data. + auto ops = dataSource->operators(); + int bpIdx = ops.indexOf(breakpointOp); + for (int i = bpIdx; i < ops.size(); ++i) { + ops[i]->resetState(); + } + emit breakpointReached(breakpointOp); + }); + return future; + } + return execute(dataSource, firstModifiedOperator); } Pipeline::Future* Pipeline::execute(DataSource* ds, Operator* start) { + // Check for breakpoints between start and end of pipeline + auto operators = ds->operators(); + int startIdx = operators.indexOf(start); + Operator* breakpointOp = nullptr; + for (int i = startIdx; i < operators.size(); ++i) { + if (operators[i]->hasBreakpoint()) { + breakpointOp = operators[i]; + break; + } + } + + if (breakpointOp) { + auto future = execute(ds, start, breakpointOp); + connect(future, &Pipeline::Future::finished, this, + [this, breakpointOp, ds]() { + // Reset operators from the breakpoint onwards to Queued. + auto ops = ds->operators(); + int bpIdx = ops.indexOf(breakpointOp); + for (int i = bpIdx; i < ops.size(); ++i) { + ops[i]->resetState(); + } + emit breakpointReached(breakpointOp); + }); + return future; + } + return execute(ds, start, nullptr); } @@ -220,7 +272,6 @@ Pipeline::Future* Pipeline::execute(DataSource* ds, Operator* start, return future; } int startIndex = 0; - // We currently only support running the last operator or the entire pipeline. if (start == nullptr) { start = operators.first(); } @@ -243,6 +294,15 @@ Pipeline::Future* Pipeline::execute(DataSource* ds, Operator* start, ds = transformedDataSource(ds); } } + // If start is not the first operator and we haven't already adjusted + // startIndex (e.g. when resuming from a breakpoint), start from the + // correct position using the already-transformed intermediate data. + else if (start != operators.first() && startIndex == 0) { + startIndex = operators.indexOf(start); + if (startIndex > 0) { + ds = transformedDataSource(ds); + } + } // If we have been asked to run until the new operator we can just return // the transformed data. @@ -362,8 +422,13 @@ void Pipeline::branchFinished() // hasChildDataSource is true. auto lastOp = start->operators().last(); if (!lastOp->isCompleted()) { - // Cannot continue - return; + // The DataSource's last operator hasn't completed. This can happen when + // execution stopped early (e.g. at a breakpoint). If the last actually + // executed operator completed, update the visualization with the + // intermediate result; otherwise bail out. + if (operators.isEmpty() || !operators.last()->isCompleted()) { + return; + } } if (!lastOp->hasChildDataSource()) { diff --git a/tomviz/Pipeline.h b/tomviz/Pipeline.h index 7fe1c1b70..a83d2cb98 100644 --- a/tomviz/Pipeline.h +++ b/tomviz/Pipeline.h @@ -122,6 +122,9 @@ public slots: /// This signal is fired the execution of the pipeline finishes. void finished(); + /// This signal is fired when execution stops at a breakpoint operator. + void breakpointReached(Operator* op); + /// This signal is fired when an operator is added. The second argument /// is the datasource that should be moved to become its output in the /// pipeline view (or null if there isn't one). diff --git a/tomviz/PipelineModel.cxx b/tomviz/PipelineModel.cxx index 7a6f27825..dfbfa34f7 100644 --- a/tomviz/PipelineModel.cxx +++ b/tomviz/PipelineModel.cxx @@ -787,6 +787,22 @@ void PipelineModel::dataSourceAdded(DataSource* dataSource) emit dataSourceModified(transformed); }); + // Refresh all operator rows when a breakpoint is reached: the breakpoint + // operator's label column shows the play icon, and operators from the + // breakpoint onwards have their state column updated (Complete → Queued). + connect(pipeline, &Pipeline::breakpointReached, [this](Operator* op) { + auto ds = op->dataSource(); + if (!ds) + return; + auto ops = ds->operators(); + int bpIdx = ops.indexOf(op); + for (int i = bpIdx; i < ops.size(); ++i) { + auto idx = operatorIndex(ops[i]); + auto stateIdx = index(idx.row(), Column::state, idx.parent()); + emit dataChanged(idx, stateIdx); + } + }); + // When restoring a data source from a state file it will have its operators // before we can listen to the signal above. Display those operators. foreach (auto op, dataSource->operators()) { @@ -856,9 +872,14 @@ void PipelineModel::operatorAdded(Operator* op, // Make sure dataChange signal is emitted when operator is complete connect(op, &Operator::transformingDone, [this, op]() { auto opIndex = operatorIndex(op); - auto statusIndex = index(opIndex.row(), 1, opIndex.parent()); + auto statusIndex = index(opIndex.row(), Column::state, opIndex.parent()); emit dataChanged(statusIndex, statusIndex); }); + // Refresh label column when breakpoint state changes (delegate paints it) + connect(op, &Operator::breakpointChanged, [this, op]() { + auto opIndex = operatorIndex(op); + emit dataChanged(opIndex, opIndex); + }); connect(op, &Operator::dataSourceMoved, this, &PipelineModel::dataSourceMoved); diff --git a/tomviz/PipelineView.cxx b/tomviz/PipelineView.cxx index 10c639a4c..b9c92fa10 100644 --- a/tomviz/PipelineView.cxx +++ b/tomviz/PipelineView.cxx @@ -40,12 +40,35 @@ #include #include #include +#include #include #include #include namespace tomviz { +namespace { + +/// Returns true if the operator's breakpoint has been reached: the operator +/// has a breakpoint, is still Queued, and all preceding operators are Complete. +bool isBreakpointReached(Operator* op) +{ + if (!op || !op->hasBreakpoint() || !op->isQueued()) + return false; + auto ds = op->dataSource(); + if (!ds) + return false; + for (auto o : ds->operators()) { + if (o == op) + break; + if (!o->isCompleted()) + return false; + } + return true; +} + +} // namespace + class OperatorRunningDelegate : public QItemDelegate { @@ -77,11 +100,53 @@ void OperatorRunningDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const { - auto pipelineModel = qobject_cast(m_view->model()); auto op = pipelineModel->op(index); + if (op && index.column() == Column::label) { + // Reserve space on the left for the breakpoint indicator, then let the + // base class paint the label content in the remaining area. + int bpWidth = PipelineView::breakpointAreaWidth(); + QRect bpRect(option.rect.left(), option.rect.top(), bpWidth, + option.rect.height()); + + // Draw the breakpoint / play / hover indicator + QPixmap pixmap; + qreal opacity = 1.0; + if (isBreakpointReached(op)) { + pixmap = QPixmap(":/icons/play.png"); + } else if (op->hasBreakpoint()) { + pixmap = QPixmap(":/icons/breakpoint.png"); + } else { + // Show semi-transparent breakpoint icon on hover + auto hoverIdx = m_view->hoverIndex(); + if (hoverIdx.isValid() && hoverIdx.row() == index.row() && + hoverIdx.parent() == index.parent()) { + pixmap = QPixmap(":/icons/breakpoint.png"); + opacity = 0.3; + } + } + + if (!pixmap.isNull()) { + painter->save(); + painter->setOpacity(opacity); + int iconSize = qMin(bpRect.width(), bpRect.height()) - 4; + QRect iconRect(bpRect.left() + (bpWidth - iconSize) / 2, + bpRect.top() + (bpRect.height() - iconSize) / 2, + iconSize, iconSize); + painter->drawPixmap(iconRect, pixmap); + painter->restore(); + } + + // Paint the rest of the label content shifted to the right + QStyleOptionViewItem shiftedOption = option; + shiftedOption.rect.setLeft(option.rect.left() + bpWidth); + QItemDelegate::paint(painter, shiftedOption, index); + return; + } + QItemDelegate::paint(painter, option, index); + if (op && index.column() == Column::state) { if (op->state() == OperatorState::Running) { QPixmap pixmap(":/icons/spinner.png"); @@ -123,6 +188,7 @@ PipelineView::PipelineView(QWidget* p) : QTreeView(p) setIndentation(20); setRootIsDecorated(false); setItemsExpandable(false); + setMouseTracking(true); QString customStyle = "QTreeView::branch { background-color: white; }"; setStyleSheet(customStyle); @@ -493,15 +559,52 @@ void PipelineView::deleteItems(const QModelIndexList& idxs) void PipelineView::rowActivated(const QModelIndex& idx) { - if (idx.isValid() && idx.column() == Column::state) { - auto pipelineModel = qobject_cast(model()); - if (pipelineModel) { - if (auto module = pipelineModel->module(idx)) { - module->setVisibility(!module->visibility()); - emit model()->dataChanged(idx, idx); - if (pqView* view = tomviz::convert(module->view())) { - view->render(); + if (!idx.isValid()) + return; + + auto pipelineModel = qobject_cast(model()); + if (!pipelineModel) + return; + + if (idx.column() == Column::label) { + if (auto op = pipelineModel->op(idx)) { + // Check if the click landed in the breakpoint area (left side of the + // label column). + auto cursorPos = viewport()->mapFromGlobal(QCursor::pos()); + auto cellRect = visualRect(idx); + int clickX = cursorPos.x() - cellRect.left(); + if (clickX >= 0 && clickX < breakpointAreaWidth()) { + auto ds = op->dataSource(); + auto pipeline = ds->pipeline(); + // Don't allow breakpoint changes while the pipeline is running. + if (pipeline && pipeline->isRunning()) { + return; } + if (isBreakpointReached(op)) { + // Resume execution from this operator + auto operators = ds->operators(); + Operator* nextBp = nullptr; + int bpIdx = operators.indexOf(op); + for (int i = bpIdx + 1; i < operators.size(); ++i) { + if (operators[i]->hasBreakpoint()) { + nextBp = operators[i]; + break; + } + } + pipeline->execute(ds, op, nextBp)->deleteWhenFinished(); + } else { + // Toggle breakpoint + op->setBreakpoint(!op->hasBreakpoint()); + } + return; + } + } + } else if (idx.column() == Column::state) { + if (auto module = pipelineModel->module(idx)) { + module->setVisibility(!module->visibility()); + emit model()->dataChanged(idx, idx); + if (pqView* view = tomviz::convert(module->view())) { + view->render(); } } } @@ -669,6 +772,38 @@ void PipelineView::setModuleVisibility(const QModelIndexList& idxs, } } +void PipelineView::mouseMoveEvent(QMouseEvent* event) +{ + auto idx = indexAt(event->pos()); + if (idx != m_hoverIndex) { + auto oldIndex = m_hoverIndex; + m_hoverIndex = idx; + // Repaint old and new rows so the hover breakpoint indicator updates + if (oldIndex.isValid()) { + auto labelIdx = model()->index(oldIndex.row(), Column::label, + oldIndex.parent()); + update(labelIdx); + } + if (idx.isValid()) { + auto labelIdx = model()->index(idx.row(), Column::label, idx.parent()); + update(labelIdx); + } + } + QTreeView::mouseMoveEvent(event); +} + +void PipelineView::leaveEvent(QEvent* event) +{ + if (m_hoverIndex.isValid()) { + auto oldIndex = m_hoverIndex; + m_hoverIndex = QModelIndex(); + auto labelIdx = model()->index(oldIndex.row(), Column::label, + oldIndex.parent()); + update(labelIdx); + } + QTreeView::leaveEvent(event); +} + void PipelineView::initLayout() { header()->setStretchLastSection(false); diff --git a/tomviz/PipelineView.h b/tomviz/PipelineView.h index d58bfd8b4..e78cfddba 100644 --- a/tomviz/PipelineView.h +++ b/tomviz/PipelineView.h @@ -29,6 +29,13 @@ class PipelineView : public QTreeView void setModel(QAbstractItemModel*) override; void initLayout(); + /// Returns the model index currently hovered by the mouse, if any. + QModelIndex hoverIndex() const { return m_hoverIndex; } + + /// Width in pixels reserved for the breakpoint indicator area in the label + /// column. + static constexpr int breakpointAreaWidth() { return 20; } + protected: void keyPressEvent(QKeyEvent*) override; void contextMenuEvent(QContextMenuEvent*) override; @@ -36,6 +43,11 @@ class PipelineView : public QTreeView const QModelIndex& previous) override; void deleteItems(const QModelIndexList& idxs); bool enableDeleteItems(const QModelIndexList& idxs); + void mouseMoveEvent(QMouseEvent* event) override; + void leaveEvent(QEvent* event) override; + +private: + QModelIndex m_hoverIndex; private slots: void rowActivated(const QModelIndex& idx); diff --git a/tomviz/icons/breakpoint.png b/tomviz/icons/breakpoint.png new file mode 100644 index 0000000000000000000000000000000000000000..6c7abaf5be261557626380248d6074c56f32e5a9 GIT binary patch literal 134 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`zMd|QAr*6y6C^SYba+4Nn|QH4 zW#6ft#SvGNI2L8Qu)kn4oVZAGwv<{7S0`%$bH_&6*-@`{COqIhAbS1=!=|WLRgDti h3sx_*(B!vaU=Xd85tG}uNgQY}gQu&X%Q~loCIF3OE8+kE literal 0 HcmV?d00001 diff --git a/tomviz/icons/breakpoint@2x.png b/tomviz/icons/breakpoint@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0114dfadf790830af9595c708bee8fb55a087169 GIT binary patch literal 202 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJiJmTwAr*7pPCLzez<|T~^0`Z# zoBs36T(El!_Xk6kkR^|jRc`yUXi6^rS}rSh+4i~f-1zL7pA(qFPjR+d8lTdY6Fm@| z_pyt?`e_Cmw?JTD%0z~VPovbfw9H(^KEbd~bN4+KsRfKS{#sgEK&BT%N(}S#lYR<9 zPN#&{D=;~o4k%AxXZq;m{i4yqc7FZo=(XZUWo`8O9F;YJj$`n2^>bP0l+XkK3o1t% literal 0 HcmV?d00001 diff --git a/tomviz/icons/play.png b/tomviz/icons/play.png new file mode 100644 index 0000000000000000000000000000000000000000..5c801f27d54d2781763eda1a568c15356366b3fb GIT binary patch literal 141 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`A)YRdAr*6y6C_LyWOQGwFZyD* zP}*sgqt6W%PeYE?j7ub1t}`U@D4k-cVVju3VAdRQ!tlYI^OkNBvsIWUm`(x;^}0wl m_$$XTcUR(RXwEg;8yRV_&&Ph74N-|BEpz6SA$T&klyn&IEal!-< z2F6APBLNKsriBbP0$K-{CNhLg@aD`YxBfMo#emD3VYPv10%tJ8JOdE{PG^Rh1zZgr r?F=V2a3A29&2Z*J$*cVd@4weJ%=Ml(tvNdY=qLtHS3j3^P6(this)); + if (m_breakpoint) { + json["breakpoint"] = true; + } return json; } bool Operator::deserialize(const QJsonObject& json) { - Q_UNUSED(json); + if (json.contains("breakpoint")) { + m_breakpoint = json["breakpoint"].toBool(); + } return true; } @@ -212,6 +217,14 @@ void Operator::createNewChildDataSource( } } +void Operator::setBreakpoint(bool enabled) +{ + if (m_breakpoint != enabled) { + m_breakpoint = enabled; + emit breakpointChanged(); + } +} + void Operator::cancelTransform() { m_state = OperatorState::Canceled; diff --git a/tomviz/operators/Operator.h b/tomviz/operators/Operator.h index 5deac7cd7..883b338e2 100644 --- a/tomviz/operators/Operator.h +++ b/tomviz/operators/Operator.h @@ -189,6 +189,10 @@ class Operator : public QObject /// Set the operator state, this is needed for external execution. void setState(OperatorState state) { m_state = state; } + /// Get/set whether a breakpoint is set on this operator. + bool hasBreakpoint() const { return m_breakpoint; } + void setBreakpoint(bool enabled); + /// Get the operator's help url QString helpUrl() const { return m_helpUrl; } void setHelpUrl(const QString& s) { m_helpUrl = s; } @@ -247,6 +251,9 @@ class Operator : public QObject // operator to run. void transformCompleted(); + // Emitted when the breakpoint state changes. + void breakpointChanged(); + public slots: /// Called when the 'Cancel' button is pressed on the progress dialog. /// Subclasses overriding this method should call the base implementation @@ -314,6 +321,7 @@ protected slots: int m_progressStep = 0; QString m_progressMessage; QString m_helpUrl; + bool m_breakpoint = false; std::atomic m_state{ OperatorState::Queued }; QPointer m_customDialog; }; diff --git a/tomviz/resources.qrc b/tomviz/resources.qrc index 4d2716918..f6bc7bab6 100644 --- a/tomviz/resources.qrc +++ b/tomviz/resources.qrc @@ -33,5 +33,9 @@ icons/pqLock@2x.png icons/greybar.png icons/greybar@2x.png + icons/breakpoint.png + icons/breakpoint@2x.png + icons/play.png + icons/play@2x.png From 7c51f80e65195a5842e2f5fb6b24e5e483dcae0b Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Wed, 18 Feb 2026 13:28:31 -0600 Subject: [PATCH 19/32] Only set UseColorPaletteForBackground if available Verify it is available to set before using it. Otherwise, we get a warning. Signed-off-by: Patrick Avery --- tomviz/AddRenderViewContextMenuBehavior.cxx | 4 +++- tomviz/modules/ModuleManager.cxx | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/tomviz/AddRenderViewContextMenuBehavior.cxx b/tomviz/AddRenderViewContextMenuBehavior.cxx index d536cb26f..427a5ad91 100644 --- a/tomviz/AddRenderViewContextMenuBehavior.cxx +++ b/tomviz/AddRenderViewContextMenuBehavior.cxx @@ -78,7 +78,9 @@ void AddRenderViewContextMenuBehavior::onSetBackgroundColor() // Must set this to zero so that the render view will use its own // background color rather than the global palette. - vtkSMPropertyHelper(proxy, "UseColorPaletteForBackground").Set(0); + if (proxy->GetProperty("UseColorPaletteForBackground")) { + vtkSMPropertyHelper(proxy, "UseColorPaletteForBackground").Set(0); + } proxy->UpdateVTKObjects(); view->render(); diff --git a/tomviz/modules/ModuleManager.cxx b/tomviz/modules/ModuleManager.cxx index 72733fd27..756184e4b 100644 --- a/tomviz/modules/ModuleManager.cxx +++ b/tomviz/modules/ModuleManager.cxx @@ -759,8 +759,10 @@ bool ModuleManager::serialize(QJsonObject& doc, const QDir& stateDir, jView["active"] = true; } - jView["useColorPaletteForBackground"] = - vtkSMPropertyHelper(view, "UseColorPaletteForBackground").GetAsInt(); + if (view->GetProperty("UseColorPaletteForBackground")) { + jView["useColorPaletteForBackground"] = + vtkSMPropertyHelper(view, "UseColorPaletteForBackground").GetAsInt(); + } // Now to get some more specific information about the view! pugi::xml_document document; From 015dc37cd0c1c1e5a87005b113e46c88e7732a7e Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Wed, 18 Feb 2026 14:46:53 -0600 Subject: [PATCH 20/32] Fix tomopy operator and generalize it It can now perform other reconstruction operations, such as mlem. Signed-off-by: Patrick Avery --- tomviz/CMakeLists.txt | 4 +- tomviz/MainWindow.cxx | 8 +-- tomviz/python/Recon_tomopy.json | 38 +++++++++++++ tomviz/python/Recon_tomopy.py | 37 +++++++++++++ tomviz/python/Recon_tomopy_gridrec.json | 32 ----------- tomviz/python/Recon_tomopy_gridrec.py | 73 ------------------------- 6 files changed, 81 insertions(+), 111 deletions(-) create mode 100644 tomviz/python/Recon_tomopy.json create mode 100644 tomviz/python/Recon_tomopy.py delete mode 100644 tomviz/python/Recon_tomopy_gridrec.json delete mode 100644 tomviz/python/Recon_tomopy_gridrec.py diff --git a/tomviz/CMakeLists.txt b/tomviz/CMakeLists.txt index 6e9afb3ad..92e0184c6 100644 --- a/tomviz/CMakeLists.txt +++ b/tomviz/CMakeLists.txt @@ -464,7 +464,7 @@ set(python_files Recon_ART.py Recon_SIRT.py Recon_TV_minimization.py - Recon_tomopy_gridrec.py + Recon_tomopy.py ShiftRotationCenter_tomopy.py FFT_AbsLog.py ManualManipulation.py @@ -550,7 +550,7 @@ set(json_files Recon_DFT.json Recon_DFT_constraint.json Recon_TV_minimization.json - Recon_tomopy_gridrec.json + Recon_tomopy.json ShiftRotationCenter_tomopy.json Recon_SIRT.json Recon_WBP.json diff --git a/tomviz/MainWindow.cxx b/tomviz/MainWindow.cxx index 00f247f1f..a30ae651e 100644 --- a/tomviz/MainWindow.cxx +++ b/tomviz/MainWindow.cxx @@ -355,7 +355,7 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) QAction* reconTVMinimizationAction = m_ui->menuTomography->addAction("TV Minimization Method"); QAction* reconTomoPyGridRecAction = - m_ui->menuTomography->addAction("TomoPy Gridrec Method"); + m_ui->menuTomography->addAction("TomoPy Reconstruction"); m_ui->menuTomography->addSeparator(); QAction* simulationLabel = @@ -462,9 +462,9 @@ MainWindow::MainWindow(QWidget* parent, Qt::WindowFlags flags) readInPythonScript("Recon_TV_minimization"), true, false, false, readInJSONDescription("Recon_TV_minimization")); new AddPythonTransformReaction( - reconTomoPyGridRecAction, "Reconstruct (TomoPy Gridrec)", - readInPythonScript("Recon_tomopy_gridrec"), true, false, false, - readInJSONDescription("Recon_tomopy_gridrec")); + reconTomoPyGridRecAction, "Reconstruct (TomoPy)", + readInPythonScript("Recon_tomopy"), true, false, false, + readInJSONDescription("Recon_tomopy")); new ReconstructionReaction(reconWBP_CAction); diff --git a/tomviz/python/Recon_tomopy.json b/tomviz/python/Recon_tomopy.json new file mode 100644 index 000000000..80ebaa515 --- /dev/null +++ b/tomviz/python/Recon_tomopy.json @@ -0,0 +1,38 @@ +{ + "name" : "TomoPy", + "label" : "TomoPy Reconstruction", + "description" : "Run TomoPy reconstruction on a dataset", + "parameters" : [ + { + "name" : "algorithm", + "label" : "Algorithm", + "description" : "Reconstruction algorithm to use", + "type" : "enumeration", + "default" : 0, + "options" : [ + {"Gridrec" : "gridrec"}, + {"MLEM" : "mlem"} + ] + }, + { + "name" : "num_iter", + "label" : "Number of Iterations", + "description" : "Number of iterations (MLEM only)", + "type" : "int", + "default" : 5, + "minimum" : 1, + "maximum" : 1000, + "visible_if" : "algorithm == 'mlem'" + } + ], + "children": [ + { + "name": "reconstruction", + "label": "Reconstruction", + "type": "reconstruction" + } + ], + "help" : { + "url": "reconstruction/#tomopy" + } +} diff --git a/tomviz/python/Recon_tomopy.py b/tomviz/python/Recon_tomopy.py new file mode 100644 index 000000000..a90190a15 --- /dev/null +++ b/tomviz/python/Recon_tomopy.py @@ -0,0 +1,37 @@ +import numpy as np +import tomopy + + +def transform(dataset, algorithm='gridrec', num_iter=5): + data = dataset.active_scalars + tilt_axis = dataset.tilt_axis + + # TomoPy wants the tilt axis to be zero, so ensure that is true + if tilt_axis == 2: + data = np.transpose(data, (2, 1, 0)) + + # Normalize to [0, 1] + data = data.astype(np.float32) + data = (data - data.min()) / (data.max() - data.min()) + + angles_rad = np.deg2rad(dataset.tilt_angles) + center = data.shape[2] // 2 + + # Reconstruct + recon_kwargs = {} + if algorithm == 'mlem': + recon_kwargs['num_iter'] = num_iter + + rec = tomopy.recon(data, angles_rad, center=center, algorithm=algorithm, + **recon_kwargs) + + # Apply circular mask + rec = tomopy.circ_mask(rec, axis=0, ratio=0.95, val=0.0) + + # Transpose back to expected Tomviz format + rec = np.transpose(rec, (2, 0, 1)) + + child = dataset.create_child_dataset() + child.active_scalars = rec + + return {'reconstruction': child} diff --git a/tomviz/python/Recon_tomopy_gridrec.json b/tomviz/python/Recon_tomopy_gridrec.json deleted file mode 100644 index 6a143233e..000000000 --- a/tomviz/python/Recon_tomopy_gridrec.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name" : "TomoPy Gridrec Reconstruction", - "label" : "TomoPy Gridrec Reconstruction", - "description" : "Run TomoPy Gridrec Reconstruction on a dataset", - "parameters" : [ - { - "name" : "rot_center", - "label" : "Rotation Center", - "description" : "The center of rotation of the dataset", - "type" : "double", - "default" : 0.0, - "precision" : 3 - }, - { - "name" : "tune_rot_center", - "label" : "Tune Rotation Center", - "description" : "Allow tomopy to tune the center of rotation", - "type" : "bool", - "default" : true - } - ], - "children": [ - { - "name": "reconstruction", - "label": "Reconstruction", - "type": "reconstruction" - } - ], - "help" : { - "url": "reconstruction/#tomopy" - } -} diff --git a/tomviz/python/Recon_tomopy_gridrec.py b/tomviz/python/Recon_tomopy_gridrec.py deleted file mode 100644 index 644ecad9f..000000000 --- a/tomviz/python/Recon_tomopy_gridrec.py +++ /dev/null @@ -1,73 +0,0 @@ -def transform(dataset, rot_center=0, tune_rot_center=True): - """Reconstruct sinograms using the tomopy gridrec algorithm - - Typically, a data exchange file would be loaded for this - reconstruction. This operation will attempt to perform - flat-field correction of the raw data using the dark and - white background data found in the data exchange file. - - This operator also requires either the tomviz/tomopy-pipeline - docker image, or a python environment with tomopy installed. - """ - - import numpy as np - import tomopy - - # Get the current volume as a numpy array. - array = dataset.active_scalars - - dark = dataset.dark - white = dataset.white - angles = dataset.tilt_angles - tilt_axis = dataset.tilt_axis - - # TomoPy wants the tilt axis to be zero, so ensure that is true - if tilt_axis == 2: - order = [2, 1, 0] - array = np.transpose(array, order) - if dark is not None and white is not None: - dark = np.transpose(dark, order) - white = np.transpose(white, order) - - if angles is not None: - # tomopy wants radians - theta = np.radians(angles) - else: - # Assume it is equally spaced between 0 and 180 degrees - theta = tomopy.angles(array.shape[0]) - - # Perform flat-field correction of raw data - if white is not None and dark is not None: - array = tomopy.normalize(array, white, dark, cutoff=1.4) - - if rot_center == 0: - # Try to find it automatically - init = array.shape[2] / 2.0 - rot_center = tomopy.find_center(array, theta, init=init, ind=0, - tol=0.5) - elif tune_rot_center: - # Tune the center - rot_center = tomopy.find_center(array, theta, init=rot_center, ind=0, - tol=0.5) - - # Calculate -log(array) - array = tomopy.minus_log(array) - - # Remove nan, neg, and inf values - array = tomopy.remove_nan(array, val=0.0) - array = tomopy.remove_neg(array, val=0.00) - array[np.where(array == np.inf)] = 0.00 - - # Perform the reconstruction - array = tomopy.recon(array, theta, center=rot_center, algorithm='gridrec') - - # Mask each reconstructed slice with a circle. - array = tomopy.circ_mask(array, axis=0, ratio=0.95) - - # Set the transformed array - child = dataset.create_child_dataset() - child.active_scalars = array - - return_values = {} - return_values['reconstruction'] = child - return return_values From df52da1650a443df911ee8d5d385961c093162d2 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Thu, 19 Feb 2026 06:58:09 -0600 Subject: [PATCH 21/32] Add several safety checks to prevent seg faults These are not necessarily places we have encountered seg faults, but places we *could* potentially encounter them (and it's possible they have caused segmentation faults in the past). Signed-off-by: Patrick Avery --- tomviz/AddPythonTransformReaction.cxx | 27 ++++++++++++++++++++++++--- tomviz/CentralWidget.cxx | 2 +- tomviz/DataSource.cxx | 6 +++++- tomviz/DoubleSliderWidget.cxx | 3 +++ tomviz/IntSliderWidget.cxx | 3 +++ tomviz/modules/ModuleRuler.cxx | 21 ++++++++++++++++----- tomviz/modules/ModuleScaleCube.cxx | 13 ++++++++++--- 7 files changed, 62 insertions(+), 13 deletions(-) diff --git a/tomviz/AddPythonTransformReaction.cxx b/tomviz/AddPythonTransformReaction.cxx index 4e8fb887e..1ceaebe8c 100644 --- a/tomviz/AddPythonTransformReaction.cxx +++ b/tomviz/AddPythonTransformReaction.cxx @@ -36,7 +36,6 @@ #include #include -#include namespace tomviz { @@ -153,6 +152,9 @@ OperatorPython* AddPythonTransformReaction::addExpression(DataSource* source) } else if (scriptLabel == "Shift Volume") { auto t = source->producer(); auto data = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); + if (!data) { + return nullptr; + } int* extent = data->GetExtent(); QDialog dialog(tomviz::mainWidget()); @@ -230,6 +232,9 @@ OperatorPython* AddPythonTransformReaction::addExpression(DataSource* source) } else if (scriptLabel == "Crop") { auto t = source->producer(); auto data = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); + if (!data) { + return nullptr; + } int* extent = data->GetExtent(); QDialog dialog(tomviz::mainWidget()); @@ -301,6 +306,9 @@ OperatorPython* AddPythonTransformReaction::addExpression(DataSource* source) int extent[6]; auto t = source->producer(); vtkImageData* image = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); + if (!image) { + return nullptr; + } image->GetOrigin(origin); image->GetSpacing(spacing); image->GetExtent(extent); @@ -335,6 +343,9 @@ OperatorPython* AddPythonTransformReaction::addExpression(DataSource* source) auto t = source->producer(); auto image = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); + if (!image) { + return nullptr; + } image->GetOrigin(origin); image->GetSpacing(spacing); image->GetExtent(extent); @@ -429,13 +440,18 @@ void AddPythonTransformReaction::addExpressionFromNonModalDialog() } } - assert(volumeWidget); + if (!volumeWidget) { + return; + } int selection_extent[6]; volumeWidget->getExtentOfSelection(selection_extent); int image_extent[6]; auto t = source->producer(); auto image = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); + if (!image) { + return; + } image->GetExtent(image_extent); // The image extent is not necessarily zero-based. The numpy array is. @@ -466,13 +482,18 @@ void AddPythonTransformReaction::addExpressionFromNonModalDialog() } } - assert(volumeWidget); + if (!volumeWidget) { + return; + } int selection_extent[6]; volumeWidget->getExtentOfSelection(selection_extent); int image_extent[6]; auto t = source->producer(); auto image = vtkImageData::SafeDownCast(t->GetOutputDataObject(0)); + if (!image) { + return; + } image->GetExtent(image_extent); int indices[6]; indices[0] = selection_extent[0] - image_extent[0]; diff --git a/tomviz/CentralWidget.cxx b/tomviz/CentralWidget.cxx index ef7695d9a..bfb070a96 100644 --- a/tomviz/CentralWidget.cxx +++ b/tomviz/CentralWidget.cxx @@ -235,7 +235,7 @@ void CentralWidget::setColorMapDataSource(DataSource* source) // Get the actual data source, build a histogram out of it. auto image = vtkImageData::SafeDownCast(source->dataObject()); - if (image->GetPointData()->GetScalars() == nullptr) { + if (!image || image->GetPointData()->GetScalars() == nullptr) { return; } diff --git a/tomviz/DataSource.cxx b/tomviz/DataSource.cxx index 415131128..9b8f0dcca 100644 --- a/tomviz/DataSource.cxx +++ b/tomviz/DataSource.cxx @@ -57,7 +57,11 @@ namespace { void createOrResizeTiltAnglesArray(vtkDataObject* data) { auto fd = data->GetFieldData(); - int* extent = vtkImageData::SafeDownCast(data)->GetExtent(); + auto* imageData = vtkImageData::SafeDownCast(data); + if (!imageData) { + return; + } + int* extent = imageData->GetExtent(); int numTiltAngles = extent[5] - extent[4] + 1; if (!fd->HasArray("tilt_angles")) { vtkNew array; diff --git a/tomviz/DoubleSliderWidget.cxx b/tomviz/DoubleSliderWidget.cxx index 25d0340a7..dd775869d 100644 --- a/tomviz/DoubleSliderWidget.cxx +++ b/tomviz/DoubleSliderWidget.cxx @@ -169,6 +169,9 @@ bool DoubleSliderWidget::strictRange() const } const QDoubleValidator* dv = qobject_cast(this->LineEdit->validator()); + if (!dv) { + return false; + } return dv->bottom() == this->minimum() && dv->top() == this->maximum(); } diff --git a/tomviz/IntSliderWidget.cxx b/tomviz/IntSliderWidget.cxx index 184ba2ad5..48e8a6226 100644 --- a/tomviz/IntSliderWidget.cxx +++ b/tomviz/IntSliderWidget.cxx @@ -136,6 +136,9 @@ bool IntSliderWidget::strictRange() const } const QIntValidator* dv = qobject_cast(this->LineEdit->validator()); + if (!dv) { + return false; + } return dv->bottom() == this->minimum() && dv->top() == this->maximum(); } diff --git a/tomviz/modules/ModuleRuler.cxx b/tomviz/modules/ModuleRuler.cxx index 8723f7a4e..528c690ae 100644 --- a/tomviz/modules/ModuleRuler.cxx +++ b/tomviz/modules/ModuleRuler.cxx @@ -222,13 +222,24 @@ void ModuleRuler::endPointsUpdated() vtkSMPropertyHelper(m_rulerSource, "Point1").Get(point1, 3); vtkSMPropertyHelper(m_rulerSource, "Point2").Get(point2, 3); DataSource* source = dataSource(); - vtkImageData* img = vtkImageData::SafeDownCast( - vtkAlgorithm::SafeDownCast(source->proxy()->GetClientSideObject()) - ->GetOutputDataObject(0)); + auto* algo = + vtkAlgorithm::SafeDownCast(source->proxy()->GetClientSideObject()); + if (!algo) { + return; + } + vtkImageData* img = + vtkImageData::SafeDownCast(algo->GetOutputDataObject(0)); + if (!img) { + return; + } + vtkDataArray* scalars = img->GetPointData()->GetScalars(); + if (!scalars) { + return; + } vtkIdType p1 = img->FindPoint(point1); vtkIdType p2 = img->FindPoint(point2); - double v1 = img->GetPointData()->GetScalars()->GetTuple1(p1); - double v2 = img->GetPointData()->GetScalars()->GetTuple1(p2); + double v1 = scalars->GetTuple1(p1); + double v2 = scalars->GetTuple1(p2); emit newEndpointData(v1, v2); renderNeeded(); } diff --git a/tomviz/modules/ModuleScaleCube.cxx b/tomviz/modules/ModuleScaleCube.cxx index 4b22f5392..390200d51 100644 --- a/tomviz/modules/ModuleScaleCube.cxx +++ b/tomviz/modules/ModuleScaleCube.cxx @@ -280,15 +280,22 @@ void ModuleScaleCube::setAnnotation(const bool val) void ModuleScaleCube::setLengthUnit() { - QString s = qobject_cast(sender())->getUnits(); + DataSource* data = qobject_cast(sender()); + if (!data) { + return; + } + QString s = data->getUnits(); m_cubeRep->SetLengthUnit(s.toStdString().c_str()); emit onLengthUnitChanged(s); } void ModuleScaleCube::setPositionUnit() { - QString s = qobject_cast(sender())->getUnits(); - emit onLengthUnitChanged(s); + DataSource* data = qobject_cast(sender()); + if (!data) { + return; + } + emit onLengthUnitChanged(data->getUnits()); } void ModuleScaleCube::dataPropertiesChanged() From 3f4fdb32c9444882085fe7c25e0e6b9474b8f68a Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Thu, 19 Feb 2026 09:14:14 -0600 Subject: [PATCH 22/32] Add checks that ptycho directory exists When we tried to operate on a non-existing ptycho directory, it would cause major issues, including a crash. Make sure the ptycho directory exists before we do anything else. Signed-off-by: Patrick Avery --- tomviz/PtychoDialog.cxx | 16 +++++++++++++++- tomviz/python/tomviz/ptycho/ptycho.py | 8 ++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/tomviz/PtychoDialog.cxx b/tomviz/PtychoDialog.cxx index add48be46..ce7630e69 100644 --- a/tomviz/PtychoDialog.cxx +++ b/tomviz/PtychoDialog.cxx @@ -375,7 +375,11 @@ class PtychoDialog::Internal : public QObject setPtychoGUICommand( settings->value("ptychoGUICommand", "run-ptycho").toString()); - setPtychoDirectory(settings->value("ptychoDirectory", "").toString()); + auto savedPtychoDir = settings->value("ptychoDirectory", "").toString(); + if (!savedPtychoDir.isEmpty() && !QDir(savedPtychoDir).exists()) { + savedPtychoDir = ""; + } + setPtychoDirectory(savedPtychoDir); setCsvFile(settings->value("loadFromCSVFile", "").toString()); setFilterSIDsString(settings->value("filterSIDsString", "").toString()); @@ -552,6 +556,16 @@ class PtychoDialog::Internal : public QObject void ptychoDirEdited() { + auto dir = ptychoDirectory(); + if (!dir.isEmpty() && !QDir(dir).exists()) { + QMessageBox::critical(parent.data(), "Directory Not Found", + "Ptycho directory does not exist: " + dir); + setPtychoDirectory(""); + setCsvFile(""); + setFilterSIDsString(""); + return; + } + // Whenever this is called, make sure we clear the CSV file and SID filters setCsvFile(""); setFilterSIDsString(""); diff --git a/tomviz/python/tomviz/ptycho/ptycho.py b/tomviz/python/tomviz/ptycho/ptycho.py index ac3ec742d..ff1b7169b 100644 --- a/tomviz/python/tomviz/ptycho/ptycho.py +++ b/tomviz/python/tomviz/ptycho/ptycho.py @@ -15,6 +15,14 @@ def gather_ptycho_info(ptycho_dir: PathLike) -> dict: ptycho_dir = Path(ptycho_dir) + if not ptycho_dir.is_dir(): + # It either doesn't exist or it's not a directory + return { + 'sid_list': [], + 'version_list': [], + 'angle_list': [], + 'error_list': [], + } sid_list = sorted([int(x.name[1:]) for x in ptycho_dir.iterdir() if x.is_dir() and x.name.startswith('S')]) From c9cadf85946e27f36d4f169faf47e4360b5dc2ce Mon Sep 17 00:00:00 2001 From: Alessandro Genova Date: Thu, 19 Feb 2026 12:12:28 -0500 Subject: [PATCH 23/32] fix pipeline execution with breakpoint when modifying operators Signed-off-by: Alessandro Genova --- tomviz/Pipeline.cxx | 42 ++++++++++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/tomviz/Pipeline.cxx b/tomviz/Pipeline.cxx index a86c944cd..e285c658f 100644 --- a/tomviz/Pipeline.cxx +++ b/tomviz/Pipeline.cxx @@ -199,16 +199,17 @@ Pipeline::Future* Pipeline::execute(DataSource* dataSource) } if (breakpointOp) { + // Reset operators from the breakpoint onwards to Queued so they + // reflect the fact that they haven't run with the current data. + auto ops = dataSource->operators(); + int bpIdx = ops.indexOf(breakpointOp); + for (int i = bpIdx; i < ops.size(); ++i) { + ops[i]->resetState(); + } + auto future = execute(dataSource, firstModifiedOperator, breakpointOp); connect(future, &Pipeline::Future::finished, this, - [this, breakpointOp, dataSource]() { - // Reset operators from the breakpoint onwards to Queued so they - // reflect the fact that they haven't run with the current data. - auto ops = dataSource->operators(); - int bpIdx = ops.indexOf(breakpointOp); - for (int i = bpIdx; i < ops.size(); ++i) { - ops[i]->resetState(); - } + [this, breakpointOp]() { emit breakpointReached(breakpointOp); }); return future; @@ -231,15 +232,16 @@ Pipeline::Future* Pipeline::execute(DataSource* ds, Operator* start) } if (breakpointOp) { + // Reset operators from the breakpoint onwards to Queued. + auto ops = ds->operators(); + int bpIdx = ops.indexOf(breakpointOp); + for (int i = bpIdx; i < ops.size(); ++i) { + ops[i]->resetState(); + } + auto future = execute(ds, start, breakpointOp); connect(future, &Pipeline::Future::finished, this, - [this, breakpointOp, ds]() { - // Reset operators from the breakpoint onwards to Queued. - auto ops = ds->operators(); - int bpIdx = ops.indexOf(breakpointOp); - for (int i = bpIdx; i < ops.size(); ++i) { - ops[i]->resetState(); - } + [this, breakpointOp]() { emit breakpointReached(breakpointOp); }); return future; @@ -297,10 +299,18 @@ Pipeline::Future* Pipeline::execute(DataSource* ds, Operator* start, // If start is not the first operator and we haven't already adjusted // startIndex (e.g. when resuming from a breakpoint), start from the // correct position using the already-transformed intermediate data. + // but can only use the already transformed intermediate data if the + // previous operator is the one that created it, otherwise operators + // could be applied multiple times to already transformed data. else if (start != operators.first() && startIndex == 0) { startIndex = operators.indexOf(start); if (startIndex > 0) { - ds = transformedDataSource(ds); + auto prevOp = operators[startIndex - 1]; + if (prevOp->isCompleted() && start->isQueued()) { + ds = transformedDataSource(ds); + } else { + startIndex = 0; + } } } From 56e3f876b003e6215162d3a92b44b92e1862254e Mon Sep 17 00:00:00 2001 From: Alessandro Genova Date: Thu, 19 Feb 2026 13:55:38 -0500 Subject: [PATCH 24/32] add ability to export table results as csv Signed-off-by: Alessandro Genova --- tomviz/PipelineView.cxx | 10 +++++++++ tomviz/PipelineView.h | 1 + tomviz/Utilities.cxx | 49 +++++++++++++++++++++++++++++++++++++++++ tomviz/Utilities.h | 4 ++++ 4 files changed, 64 insertions(+) diff --git a/tomviz/PipelineView.cxx b/tomviz/PipelineView.cxx index b9c92fa10..44d5c2265 100644 --- a/tomviz/PipelineView.cxx +++ b/tomviz/PipelineView.cxx @@ -296,6 +296,7 @@ void PipelineView::contextMenuEvent(QContextMenuEvent* e) QAction* snapshotAction = nullptr; QAction* showInterfaceAction = nullptr; QAction* exportTableResultAction = nullptr; + QAction* exportTableCsvAction = nullptr; QAction* reloadAndResampleAction = nullptr; bool allowReExecute = false; CloneDataReaction* cloneReaction; @@ -303,6 +304,7 @@ void PipelineView::contextMenuEvent(QContextMenuEvent* e) if (result && qobject_cast(result->parent())) { if (vtkTable::SafeDownCast(result->dataObject())) { exportTableResultAction = contextMenu.addAction("Save as JSON"); + exportTableCsvAction = contextMenu.addAction("Save as CSV"); } else { return; } @@ -479,6 +481,8 @@ void PipelineView::contextMenuEvent(QContextMenuEvent* e) } } else if (selectedItem == exportTableResultAction) { exportTableAsJson(vtkTable::SafeDownCast(result->dataObject())); + } else if (selectedItem == exportTableCsvAction) { + exportTableAsCsv(vtkTable::SafeDownCast(result->dataObject())); } else if (selectedItem == reloadAndResampleAction) { dataSource->reloadAndResample(); } @@ -490,6 +494,12 @@ void PipelineView::exportTableAsJson(vtkTable* table) jsonToFile(json); } +void PipelineView::exportTableAsCsv(vtkTable* table) +{ + auto csv = tableToCsv(table); + csvToFile(csv); +} + void PipelineView::deleteItems(const QModelIndexList& idxs) { auto pipelineModel = qobject_cast(model()); diff --git a/tomviz/PipelineView.h b/tomviz/PipelineView.h index e78cfddba..47de946e4 100644 --- a/tomviz/PipelineView.h +++ b/tomviz/PipelineView.h @@ -60,6 +60,7 @@ private slots: void deleteItemsConfirm(const QModelIndexList& idxs); void setModuleVisibility(const QModelIndexList& idxs, bool visible); void exportTableAsJson(vtkTable*); + void exportTableAsCsv(vtkTable*); }; } // namespace tomviz diff --git a/tomviz/Utilities.cxx b/tomviz/Utilities.cxx index d8866fe1d..37f4092a1 100644 --- a/tomviz/Utilities.cxx +++ b/tomviz/Utilities.cxx @@ -1124,6 +1124,55 @@ QJsonDocument tableToJson(vtkTable* table) return QJsonDocument(rows); } +QString tableToCsv(vtkTable* table) +{ + QStringList lines; + QStringList headers; + for (vtkIdType j = 0; j < table->GetNumberOfColumns(); ++j) { + headers << QString(table->GetColumnName(j)); + } + lines << headers.join(","); + for (vtkIdType i = 0; i < table->GetNumberOfRows(); ++i) { + auto row = table->GetRow(i); + QStringList fields; + for (vtkIdType j = 0; j < row->GetSize(); ++j) { + auto value = row->GetValue(j); + if (value.IsNumeric()) { + fields << QString::number(value.ToDouble()); + } else { + fields << QString(); + } + } + lines << fields.join(","); + } + return lines.join("\n"); +} + +bool csvToFile(const QString& csv) +{ + QStringList filters; + filters << "CSV Files (*.csv)"; + QFileDialog dialog; + dialog.setFileMode(QFileDialog::AnyFile); + dialog.setNameFilters(filters); + dialog.setAcceptMode(QFileDialog::AcceptSave); + QString fileName = dialogToFileName(&dialog); + if (fileName.isEmpty()) { + return false; + } + if (!fileName.endsWith(".csv")) { + fileName = QString("%1.csv").arg(fileName); + } + QFile file(fileName); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + qCritical() << QString("Error opening file for writing: %1").arg(fileName); + return false; + } + file.write(csv.toUtf8()); + file.close(); + return true; +} + QJsonDocument vectorToJson(const QVector vector) { QJsonArray rows; diff --git a/tomviz/Utilities.h b/tomviz/Utilities.h index ed33c48d3..b342369dc 100644 --- a/tomviz/Utilities.h +++ b/tomviz/Utilities.h @@ -237,6 +237,10 @@ bool jsonToFile(const QJsonDocument& json); QJsonDocument tableToJson(vtkTable* table); QJsonDocument vectorToJson(const QVector vector); +/// Write a vtkTable to csv file +QString tableToCsv(vtkTable* table); +bool csvToFile(const QString& csv); + /// Write a vtkMolecule to json file bool moleculeToFile(vtkMolecule* molecule); extern double offWhite[3]; From 8eb0f571a31cb667d19a930e7dd082408a36ea99 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Thu, 19 Feb 2026 14:10:25 -0600 Subject: [PATCH 25/32] Allow "and" and "or" for conditionals in json file This allows "and" and "or" to be used in json description files, like this: ```json "visible_if": "algorithm == 'mlem' or algorithm == 'ospml_hybrid'" ``` Signed-off-by: Patrick Avery --- tomviz/InterfaceBuilder.cxx | 268 +++++++++++++++++++++++------------- 1 file changed, 173 insertions(+), 95 deletions(-) diff --git a/tomviz/InterfaceBuilder.cxx b/tomviz/InterfaceBuilder.cxx index 166b06b01..5336d11ce 100644 --- a/tomviz/InterfaceBuilder.cxx +++ b/tomviz/InterfaceBuilder.cxx @@ -22,9 +22,12 @@ #include #include #include +#include #include #include +#include + using tomviz::DataSource; Q_DECLARE_METATYPE(DataSource*) @@ -589,11 +592,6 @@ static const QStringList PATH_TYPES = { "file", "save_file", "directory" }; namespace tomviz { -bool setupEnableTriggerAbstract(QWidget* refWidget, QWidget* widget, - const QString& comparator, - const QVariant& compareValue, - bool visibility); - InterfaceBuilder::InterfaceBuilder(QObject* parentObject, DataSource* ds) : QObject(parentObject), m_dataSource(ds) {} @@ -687,64 +685,6 @@ void InterfaceBuilder::setupEnableAndVisibleStates( setupEnableStates(parent, parameters, false); } -void InterfaceBuilder::setupEnableStates(const QObject* parent, - QJsonArray& parameters, - bool visible) const -{ - static const QStringList validComparators = { - "==", "!=", ">", ">=", "<", "<=" - }; - - QJsonObject::size_type numParameters = parameters.size(); - for (QJsonObject::size_type i = 0; i < numParameters; ++i) { - QJsonValueRef parameterNode = parameters[i]; - QJsonObject parameterObject = parameterNode.toObject(); - - QString text = visible ? "visible_if" : "enable_if"; - QString enableIfValue = parameterObject[text].toString(""); - if (enableIfValue.isEmpty()) { - continue; - } - - QString widgetName = parameterObject["name"].toString(""); - if (widgetName.isEmpty()) { - qCritical() << text << "parameters must have a name. Ignoring..."; - continue; - } - auto* widget = parent->findChild(widgetName); - if (!widget) { - qCritical() << "Failed to find widget with name:" << widgetName; - continue; - } - - auto split = enableIfValue.simplified().split(" "); - if (split.size() != 3) { - qCritical() << "Invalid" << text << "string:" << enableIfValue; - continue; - } - - auto refWidgetName = split[0]; - auto comparator = split[1]; - auto compareValue = split[2]; - auto* refWidget = parent->findChild(refWidgetName); - - if (!refWidget) { - qCritical() << "Invalid widget name in" << text << "string:" << enableIfValue; - continue; - } - - if (!validComparators.contains(comparator)) { - qCritical() << "Invalid comparator in" << text << "string:" << enableIfValue; - continue; - } - - if (!setupEnableTriggerAbstract(refWidget, widget, comparator, - compareValue, visible)) { - qCritical() << "Failed to set up" << text << "trigger for" << widgetName; - } - } -} - QLayout* InterfaceBuilder::buildInterface() const { QWidget* widget = new QWidget; @@ -1047,51 +987,189 @@ bool compare(const T* widget, const QVariant& compareValue, return false; } -template -bool setupEnableTrigger(T* refWidget, QWidget* widget, - const QString& comparator, const QVariant& compareValue, - const char* property) +// Represents a single condition clause like "algorithm == 'mlem'" +struct EnableCondition { - // Set up the callback function - auto func = [=](){ - auto result = compare(refWidget, compareValue, comparator); - setWidgetProperty(widget, property, result); - }; - // Make the connection - widget->connect(refWidget, changedSignal(), widget, func); + QWidget* refWidget = nullptr; + QString comparator; + QVariant compareValue; +}; - // Trigger the update one time, since defaults are already set. - func(); +// Evaluate a single condition by delegating to the typed compare() function +static bool evaluateCondition(const EnableCondition& cond) +{ + auto* w = cond.refWidget; + if (isWidgetType(w)) { + return compare(qobject_cast(w), cond.compareValue, cond.comparator); + } else if (isWidgetType(w)) { + return compare(qobject_cast(w), cond.compareValue, cond.comparator); + } else if (isWidgetType(w)) { + return compare(qobject_cast(w), cond.compareValue, cond.comparator); + } else if (isWidgetType(w)) { + return compare(qobject_cast(w), cond.compareValue, cond.comparator); + } else if (isWidgetType(w)) { + return compare(qobject_cast(w), cond.compareValue, cond.comparator); + } + return false; +} - return true; +// Evaluate a compound expression: list of condition groups joined by "or", +// where each group is a list of conditions joined by "and". +// Result = (g0[0] && g0[1] && ...) || (g1[0] && g1[1] && ...) || ... +static bool evaluateCompound( + const QList>& orGroups) +{ + for (auto& andGroup : orGroups) { + bool groupResult = true; + for (auto& cond : andGroup) { + if (!evaluateCondition(cond)) { + groupResult = false; + break; + } + } + if (groupResult) { + return true; + } + } + return false; } -bool setupEnableTriggerAbstract(QWidget* refWidget, QWidget* widget, - const QString& comparator, - const QVariant& compareValue, - bool visibility) +static void connectWidgetChanged(QWidget* refWidget, QWidget* target, + std::function func) { - const char* property = visibility ? "visible" : "enabled"; if (isWidgetType(refWidget)) { - return setupEnableTrigger(qobject_cast(refWidget), widget, - comparator, compareValue, property); + target->connect(qobject_cast(refWidget), + changedSignal(), target, func); } else if (isWidgetType(refWidget)) { - return setupEnableTrigger(qobject_cast(refWidget), widget, - comparator, compareValue, property); + target->connect(qobject_cast(refWidget), + changedSignal(), target, func); } else if (isWidgetType(refWidget)) { - return setupEnableTrigger(qobject_cast(refWidget), widget, - comparator, compareValue, property); + target->connect(qobject_cast(refWidget), + changedSignal(), target, func); } else if (isWidgetType(refWidget)) { - return setupEnableTrigger(qobject_cast(refWidget), widget, - comparator, compareValue, property); + target->connect(qobject_cast(refWidget), + changedSignal(), target, func); } else if (isWidgetType(refWidget)) { - return setupEnableTrigger(qobject_cast(refWidget), widget, - comparator, compareValue, property); + target->connect(qobject_cast(refWidget), + changedSignal(), target, func); + } else { + qCritical() << "Unhandled widget type for enable/visible trigger:" + << refWidget->objectName(); } +} - qCritical() << "Unhandled widget type for object: " - << refWidget->objectName(); - return false; +void InterfaceBuilder::setupEnableStates(const QObject* parent, + QJsonArray& parameters, + bool visible) const +{ + static const QStringList validComparators = { + "==", "!=", ">", ">=", "<", "<=" + }; + + QJsonObject::size_type numParameters = parameters.size(); + for (QJsonObject::size_type i = 0; i < numParameters; ++i) { + QJsonValueRef parameterNode = parameters[i]; + QJsonObject parameterObject = parameterNode.toObject(); + + QString text = visible ? "visible_if" : "enable_if"; + QString enableIfValue = parameterObject[text].toString(""); + if (enableIfValue.isEmpty()) { + continue; + } + + QString widgetName = parameterObject["name"].toString(""); + if (widgetName.isEmpty()) { + qCritical() << text << "parameters must have a name. Ignoring..."; + continue; + } + auto* widget = parent->findChild(widgetName); + if (!widget) { + qCritical() << "Failed to find widget with name:" << widgetName; + continue; + } + + // Split on " or " first, then each piece on " and ". + // Precedence: "and" binds tighter than "or". + auto orParts = enableIfValue.simplified().split(" or ", + Qt::KeepEmptyParts, + Qt::CaseInsensitive); + + QList> orGroups; + bool parseError = false; + + for (auto& orPart : orParts) { + auto andParts = orPart.simplified().split(" and ", + Qt::KeepEmptyParts, + Qt::CaseInsensitive); + QList andGroup; + for (auto& clause : andParts) { + auto tokens = clause.simplified().split(" "); + if (tokens.size() != 3) { + qCritical() << "Invalid" << text << "clause:" << clause + << "in expression:" << enableIfValue; + parseError = true; + break; + } + + auto refWidgetName = tokens[0]; + auto comparator = tokens[1]; + auto compareValue = tokens[2]; + + auto* refWidget = parent->findChild(refWidgetName); + if (!refWidget) { + qCritical() << "Invalid widget name" << refWidgetName << "in" + << text << "string:" << enableIfValue; + parseError = true; + break; + } + + if (!validComparators.contains(comparator)) { + qCritical() << "Invalid comparator" << comparator << "in" + << text << "string:" << enableIfValue; + parseError = true; + break; + } + + EnableCondition cond; + cond.refWidget = refWidget; + cond.comparator = comparator; + cond.compareValue = compareValue; + andGroup.append(cond); + } + + if (parseError) { + break; + } + orGroups.append(andGroup); + } + + if (parseError) { + continue; + } + + const char* property = visible ? "visible" : "enabled"; + + // Build the evaluation callback + auto evalFunc = [orGroups, widget, property]() { + bool result = evaluateCompound(orGroups); + setWidgetProperty(widget, property, result); + }; + + // Connect every referenced widget's changed signal to re-evaluate + QSet connectedWidgets; + for (auto& andGroup : orGroups) { + for (auto& cond : andGroup) { + if (connectedWidgets.contains(cond.refWidget)) { + continue; + } + connectedWidgets.insert(cond.refWidget); + connectWidgetChanged(cond.refWidget, widget, evalFunc); + } + } + + // Evaluate once for the initial state + evalFunc(); + } } } // namespace tomviz From 445e628820fe456b6eed5fcae48b556a72757212 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Thu, 19 Feb 2026 14:14:09 -0600 Subject: [PATCH 26/32] Fix tomopy reconstruction This also adds a few more tomopy methods. The available methods are currently: gridrec, fbp, mlem, and ospml_hybrid. The mlem and ospml_hybrid ones are iterative methods and require the `num_iter` argument. Signed-off-by: Patrick Avery --- tomviz/python/Recon_tomopy.json | 8 +++++--- tomviz/python/Recon_tomopy.py | 6 +++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/tomviz/python/Recon_tomopy.json b/tomviz/python/Recon_tomopy.json index 80ebaa515..cb5d501aa 100644 --- a/tomviz/python/Recon_tomopy.json +++ b/tomviz/python/Recon_tomopy.json @@ -11,18 +11,20 @@ "default" : 0, "options" : [ {"Gridrec" : "gridrec"}, - {"MLEM" : "mlem"} + {"FBP" : "fbp"}, + {"MLEM" : "mlem"}, + {"OSPML Hybrid" : "ospml_hybrid"} ] }, { "name" : "num_iter", "label" : "Number of Iterations", - "description" : "Number of iterations (MLEM only)", + "description" : "Number of iterations (iterative methods only)", "type" : "int", "default" : 5, "minimum" : 1, "maximum" : 1000, - "visible_if" : "algorithm == 'mlem'" + "visible_if" : "algorithm == 'mlem' or algorithm == 'ospml_hybrid'" } ], "children": [ diff --git a/tomviz/python/Recon_tomopy.py b/tomviz/python/Recon_tomopy.py index a90190a15..304ac050d 100644 --- a/tomviz/python/Recon_tomopy.py +++ b/tomviz/python/Recon_tomopy.py @@ -8,7 +8,7 @@ def transform(dataset, algorithm='gridrec', num_iter=5): # TomoPy wants the tilt axis to be zero, so ensure that is true if tilt_axis == 2: - data = np.transpose(data, (2, 1, 0)) + data = np.transpose(data, (2, 0, 1)) # Normalize to [0, 1] data = data.astype(np.float32) @@ -19,7 +19,7 @@ def transform(dataset, algorithm='gridrec', num_iter=5): # Reconstruct recon_kwargs = {} - if algorithm == 'mlem': + if algorithm in ('mlem', 'ospml_hybrid'): recon_kwargs['num_iter'] = num_iter rec = tomopy.recon(data, angles_rad, center=center, algorithm=algorithm, @@ -29,7 +29,7 @@ def transform(dataset, algorithm='gridrec', num_iter=5): rec = tomopy.circ_mask(rec, axis=0, ratio=0.95, val=0.0) # Transpose back to expected Tomviz format - rec = np.transpose(rec, (2, 0, 1)) + rec = np.transpose(rec, (2, 1, 0)) child = dataset.create_child_dataset() child.active_scalars = rec From 1a5f31362e4aa8e6b4f8c130dc74987c40047418 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Thu, 19 Feb 2026 14:53:28 -0600 Subject: [PATCH 27/32] Ensure operator dialog appears above main window This additional step prevents it from being on a different monitor. Signed-off-by: Patrick Avery --- tomviz/operators/EditOperatorDialog.cxx | 26 +++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/tomviz/operators/EditOperatorDialog.cxx b/tomviz/operators/EditOperatorDialog.cxx index b7efc93a6..3e637fd0d 100644 --- a/tomviz/operators/EditOperatorDialog.cxx +++ b/tomviz/operators/EditOperatorDialog.cxx @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -128,17 +129,34 @@ void EditOperatorDialog::showEvent(QShowEvent* event) { Superclass::showEvent(event); - // Always center on the main window, overriding any restored geometry or - // window manager placement. + // Always center on the main window's screen, overriding any restored + // geometry or window manager placement that may put the dialog on a + // different monitor. auto* mainWin = tomviz::mainWidget(); if (!mainWin) { return; } + auto* screen = mainWin->screen(); + auto screenGeom = screen ? screen->availableGeometry() + : QRect(0, 0, 1920, 1080); + auto mainCenter = mainWin->frameGeometry().center(); auto dlgSize = frameGeometry().size(); - move(mainCenter.x() - dlgSize.width() / 2, - mainCenter.y() - dlgSize.height() / 2); + + // Center on the main window + int x = mainCenter.x() - dlgSize.width() / 2; + int y = mainCenter.y() - dlgSize.height() / 2; + + // Clamp to the main window's screen so we never spill onto another monitor + x = qBound(screenGeom.left(), x, + screenGeom.right() - dlgSize.width()); + y = qBound(screenGeom.top(), y, + screenGeom.bottom() - dlgSize.height()); + + move(x, y); + raise(); + activateWindow(); } void EditOperatorDialog::setViewMode(const QString& mode) From 2035cba11b8e18aeee2b723f312af27b0d258f9f Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Fri, 20 Feb 2026 12:55:43 -0600 Subject: [PATCH 28/32] Fixes to ShiftRotationCenterWidget This splits up the slice into the proper "Projection No" and "slice" values. It also fixes all the logic for determining and applying the rotation center. Signed-off-by: Patrick Avery --- tomviz/ShiftRotationCenterWidget.cxx | 102 +++++++++++++++----- tomviz/ShiftRotationCenterWidget.ui | 21 ++++ tomviz/python/ShiftRotationCenter_tomopy.py | 22 ++--- 3 files changed, 109 insertions(+), 36 deletions(-) diff --git a/tomviz/ShiftRotationCenterWidget.cxx b/tomviz/ShiftRotationCenterWidget.cxx index b9e2d7cd1..a42c69a49 100644 --- a/tomviz/ShiftRotationCenterWidget.cxx +++ b/tomviz/ShiftRotationCenterWidget.cxx @@ -128,12 +128,14 @@ class ShiftRotationCenterWidget::Internal : public QObject vtkNew renderer; vtkNew axesActor; - // Projection view (top-left) with center line overlay + // Projection view (top-left) with center line and slice line overlay vtkNew projSlice; vtkNew projMapper; vtkNew projRenderer; vtkNew centerLine; vtkNew centerLineActor; + vtkNew sliceLine; + vtkNew sliceLineActor; QString script; InternalPythonHelper pythonHelper; QPointer parent; @@ -189,7 +191,7 @@ class ShiftRotationCenterWidget::Internal : public QObject projRenderer->AddViewProp(projSlice); - // Set up the red center line overlay + // Set up the yellow center line overlay (vertical) vtkNew lineMapper; lineMapper->SetInputConnection(centerLine->GetOutputPort()); centerLineActor->SetMapper(lineMapper); @@ -197,14 +199,30 @@ class ShiftRotationCenterWidget::Internal : public QObject centerLineActor->GetProperty()->SetLineWidth(2.0); projRenderer->AddActor(centerLineActor); + // Set up the red slice line overlay (horizontal) + vtkNew sliceLineMapper; + sliceLineMapper->SetInputConnection(sliceLine->GetOutputPort()); + sliceLineActor->SetMapper(sliceLineMapper); + sliceLineActor->GetProperty()->SetColor(1, 0, 0); + sliceLineActor->GetProperty()->SetLineWidth(2.0); + projRenderer->AddActor(sliceLineActor); + ui.projectionView->renderWindow()->AddRenderer(projRenderer); vtkNew projInteractorStyle; ui.projectionView->interactor()->SetInteractorStyle(projInteractorStyle); tomviz::setupRenderer(projRenderer, projMapper, nullptr); projRenderer->GetActiveCamera()->SetViewUp(1, 0, 0); + + // Mirror the image left-to-right by placing the camera on the -Z side. + auto* cam = projRenderer->GetActiveCamera(); + double* pos = cam->GetPosition(); + double* fp = cam->GetFocalPoint(); + cam->SetPosition(pos[0], pos[1], fp[2] - (pos[2] - fp[2])); + projRenderer->ResetCameraClippingRange(); updateCenterLine(); + updateSliceLine(); static unsigned int colorMapCounter = 0; ++colorMapCounter; @@ -249,9 +267,13 @@ class ShiftRotationCenterWidget::Internal : public QObject ui.start->setValue(-delta); ui.stop->setValue(delta); - // Default slice to the middle slice - ui.slice->setMaximum(dims[1] - 1); - ui.slice->setValue(dims[1] / 2); + // Default projection number to the middle projection + ui.projectionNo->setMaximum(dims[2] - 1); + ui.projectionNo->setValue(dims[2] / 2); + + // Default slice to the middle slice (bounded by image height) + ui.slice->setMaximum(dims[0] - 1); + ui.slice->setValue(dims[0] / 2); // Load saved settings for steps, algorithm, numIterations only readSettings(); @@ -263,6 +285,10 @@ class ShiftRotationCenterWidget::Internal : public QObject updateControls(); setupConnections(); + + // Update line positions now that all values are set + updateCenterLine(); + updateSliceLine(); } void setupConnections() @@ -283,6 +309,8 @@ class ShiftRotationCenterWidget::Internal : public QObject &Internal::onPreviewRangeEdited); connect(ui.algorithm, QOverload::of(&QComboBox::currentIndexChanged), this, &Internal::updateAlgorithmUI); + connect(ui.projectionNo, QOverload::of(&QSpinBox::valueChanged), this, + &Internal::onProjectionChanged); connect(ui.slice, QOverload::of(&QSpinBox::valueChanged), this, &Internal::onSliceChanged); connect(ui.rotationCenter, @@ -290,12 +318,20 @@ class ShiftRotationCenterWidget::Internal : public QObject &Internal::updateCenterLine); } - void onSliceChanged(int val) + void onProjectionChanged(int val) { - // Update the projection view to show the selected slice + // Update the projection view to show the selected projection projMapper->SetSliceNumber(val); projMapper->Update(); updateCenterLine(); + updateSliceLine(); + + ui.projectionView->renderWindow()->Render(); + } + + void onSliceChanged(int) + { + updateSliceLine(); // The existing test rotation results are no longer valid for this slice setRotationData(vtkImageData::New()); @@ -312,13 +348,14 @@ class ShiftRotationCenterWidget::Internal : public QObject double bounds[6]; image->GetBounds(bounds); - double centerX = (bounds[0] + bounds[1]) / 2.0; - double lineX = centerX + rotationCenter() * image->GetSpacing()[0]; - - // Vertical line spanning the full Y (detector row) range, placed just in - // front of the current Z slice (toward the camera, which looks from +Z). - double p1[3] = { lineX, bounds[2], bounds[5] + 1 }; - double p2[3] = { lineX, bounds[3], bounds[5] + 1 }; + double centerY = (bounds[2] + bounds[3]) / 2.0; + double lineY = centerY + rotationCenter() * image->GetSpacing()[0]; + + // Vertical line in the view (constant Y, spanning X), placed just in + // front of the current Z slice (toward the camera, which looks from -Z). + double z = bounds[4] - 1; + double p1[3] = { bounds[0], lineY, z }; + double p2[3] = { bounds[1], lineY, z }; centerLine->SetPoint1(p1); centerLine->SetPoint2(p2); centerLine->Update(); @@ -328,6 +365,30 @@ class ShiftRotationCenterWidget::Internal : public QObject ui.projectionView->renderWindow()->Render(); } + void updateSliceLine() + { + if (!image) { + return; + } + + double bounds[6]; + image->GetBounds(bounds); + double lineX = bounds[0] + ui.slice->value() * image->GetSpacing()[0]; + + // Horizontal red line in the view (constant X, spanning Y), placed just in + // front of the current Z slice (toward the camera, which looks from -Z). + double z = bounds[4] - 1; + double p1[3] = { lineX, bounds[2], z }; + double p2[3] = { lineX, bounds[3], z }; + sliceLine->SetPoint1(p1); + sliceLine->SetPoint2(p2); + sliceLine->Update(); + sliceLineActor->GetMapper()->Update(); + + projRenderer->ResetCameraClippingRange(); + ui.projectionView->renderWindow()->Render(); + } + void setupRenderer() { // Pass nullptr for the axes actor to avoid vtkVectorText @@ -360,7 +421,8 @@ class ShiftRotationCenterWidget::Internal : public QObject QList inputWidgets() { - return { ui.start, ui.stop, ui.steps, ui.slice, ui.rotationCenter }; + return { ui.start, ui.stop, ui.steps, ui.projectionNo, ui.slice, + ui.rotationCenter }; } void startGeneratingTestImages() @@ -416,13 +478,10 @@ class ShiftRotationCenterWidget::Internal : public QObject Python::Object data = Python::createDataset(image, *dataSource); - // Convert offsets to absolute values for Python - auto imgCenter = image->GetDimensions()[0] / 2.0; - Python::Dict kwargs; kwargs.set("dataset", data); - kwargs.set("start", imgCenter + ui.start->value()); - kwargs.set("stop", imgCenter + ui.stop->value()); + kwargs.set("start", ui.start->value()); + kwargs.set("stop", ui.stop->value()); kwargs.set("steps", ui.steps->value()); kwargs.set("sli", ui.slice->value()); kwargs.set("algorithm", algorithm()); @@ -459,8 +518,7 @@ class ShiftRotationCenterWidget::Internal : public QObject } for (int i = 0; i < pyRotations.length(); ++i) { - // Convert absolute centers to offsets from the image midpoint - rotations.append(pyRotations[i].toDouble() - imgCenter); + rotations.append(pyRotations[i].toDouble()); } setRotationData(imageData); } diff --git a/tomviz/ShiftRotationCenterWidget.ui b/tomviz/ShiftRotationCenterWidget.ui index 574a6ba2a..b9fe2c0a0 100644 --- a/tomviz/ShiftRotationCenterWidget.ui +++ b/tomviz/ShiftRotationCenterWidget.ui @@ -181,6 +181,26 @@ + + + + Projection No.: + + + projectionNo + + + + + + + false + + + 100000 + + + @@ -486,6 +506,7 @@ start stop steps + projectionNo slice algorithm numIterations diff --git a/tomviz/python/ShiftRotationCenter_tomopy.py b/tomviz/python/ShiftRotationCenter_tomopy.py index 42a0cafaf..867484348 100644 --- a/tomviz/python/ShiftRotationCenter_tomopy.py +++ b/tomviz/python/ShiftRotationCenter_tomopy.py @@ -12,17 +12,9 @@ def transform(dataset, rotation_center=0): # so we shift left (negative) to bring it to center. pixel_shift = -rotation_center - # The detector horizontal axis depends on the tilt axis convention. - # tilt_axis == 0: array is (proj, Y, X) -> detector horizontal is axis 2 - # tilt_axis == 2: array is (X, Y, proj) -> detector horizontal is axis 0 - if tilt_axis == 2: - det_axis = 0 - else: - det_axis = 2 - # Shift the entire volume along the detector horizontal axis shift_vec = [0.0, 0.0, 0.0] - shift_vec[det_axis] = pixel_shift + shift_vec[1] = pixel_shift array = ndshift(array, shift_vec, mode='constant') dataset.active_scalars = array @@ -93,11 +85,10 @@ def rotcen_test(f, start=None, stop=None, steps=None, sli=0, img_tomo = img_tomo.reshape(s[0], 1, s[1]) s = img_tomo.shape - if start is None or stop is None or steps is None: - start = int(s[2] / 2 - 50) - stop = int(s[2] / 2 + 50) - steps = 26 - cen = np.linspace(start, stop, steps) + # Convert to absolute + start_abs = int(round(s[2] / 2 + start)) + stop_abs = int(round(s[2] / 2 + stop)) + cen = np.linspace(start_abs, stop_abs, steps) img = np.zeros([len(cen), s[2], s[2]]) recon_kwargs = {} @@ -109,4 +100,7 @@ def rotcen_test(f, start=None, stop=None, steps=None, sli=0, img[i] = tomopy.recon(img_tomo, theta, center=cen[i], algorithm=algorithm, **recon_kwargs) img = tomopy.circ_mask(img, axis=0, ratio=0.8) + + # Convert back to relative to the center + cen -= s[2] / 2 return img, cen From 9d05b7b264ddd55f08a3c4a4ab8429e5bfdf7819 Mon Sep 17 00:00:00 2001 From: Alessandro Genova Date: Fri, 20 Feb 2026 15:27:38 -0500 Subject: [PATCH 29/32] add PSD and FSC 1D metric operators --- tomviz/CMakeLists.txt | 4 + tomviz/DataTransformMenu.cxx | 12 ++ tomviz/python/FourierShellCorrelation.json | 15 +++ tomviz/python/FourierShellCorrelation.py | 127 +++++++++++++++++++++ tomviz/python/PowerSpectrumDensity.json | 15 +++ tomviz/python/PowerSpectrumDensity.py | 66 +++++++++++ 6 files changed, 239 insertions(+) create mode 100644 tomviz/python/FourierShellCorrelation.json create mode 100644 tomviz/python/FourierShellCorrelation.py create mode 100644 tomviz/python/PowerSpectrumDensity.json create mode 100644 tomviz/python/PowerSpectrumDensity.py diff --git a/tomviz/CMakeLists.txt b/tomviz/CMakeLists.txt index 92e0184c6..0f1afcc4b 100644 --- a/tomviz/CMakeLists.txt +++ b/tomviz/CMakeLists.txt @@ -510,6 +510,8 @@ set(python_files TV_Filter.py PoreSizeDistribution.py Tortuosity.py + PowerSpectrumDensity.py + FourierShellCorrelation.py Recon_real_time_tomography.py ) @@ -567,6 +569,8 @@ set(json_files TV_Filter.json PoreSizeDistribution.json Tortuosity.json + PowerSpectrumDensity.json + FourierShellCorrelation.json Recon_real_time_tomography.json ) diff --git a/tomviz/DataTransformMenu.cxx b/tomviz/DataTransformMenu.cxx index ee8b7dc24..8e7dbae41 100644 --- a/tomviz/DataTransformMenu.cxx +++ b/tomviz/DataTransformMenu.cxx @@ -79,6 +79,9 @@ void DataTransformMenu::buildTransforms() auto tortuosityAction = menu->addAction("Tortuosity"); auto poreSizeAction = menu->addAction("Pore Size Distribution"); menu->addSeparator(); + auto psdAction = menu->addAction("Power Spectrum Density"); + auto fscAction = menu->addAction("Fourier Shell Correlation"); + menu->addSeparator(); auto cloneAction = menu->addAction("Clone"); auto deleteDataAction = menu->addAction( QIcon(":/QtWidgets/Icons/pqDelete.svg"), "Delete Data and Modules"); @@ -183,6 +186,15 @@ void DataTransformMenu::buildTransforms() readInPythonScript("PoreSizeDistribution"), false, false, false, readInJSONDescription("PoreSizeDistribution")); + new AddPythonTransformReaction( + psdAction, "Power Spectrum Density", + readInPythonScript("PowerSpectrumDensity"), false, false, false, + readInJSONDescription("PowerSpectrumDensity")); + new AddPythonTransformReaction( + fscAction, "Fourier Shell Correlation", + readInPythonScript("FourierShellCorrelation"), false, false, false, + readInJSONDescription("FourierShellCorrelation")); + new CloneDataReaction(cloneAction); new DeleteDataReaction(deleteDataAction); diff --git a/tomviz/python/FourierShellCorrelation.json b/tomviz/python/FourierShellCorrelation.json new file mode 100644 index 000000000..9f2a00c4c --- /dev/null +++ b/tomviz/python/FourierShellCorrelation.json @@ -0,0 +1,15 @@ +{ + "name": "FourierShellCorrelation", + "label": "Fourier Shell Correlation", + "description": "", + "externalCompatible": false, + "apply_to_each_array": false, + "parameters": [], + "results": [ + { + "name": "plot", + "label": "FSC", + "type": "table" + } + ] +} diff --git a/tomviz/python/FourierShellCorrelation.py b/tomviz/python/FourierShellCorrelation.py new file mode 100644 index 000000000..046fff6d5 --- /dev/null +++ b/tomviz/python/FourierShellCorrelation.py @@ -0,0 +1,127 @@ +import tomviz.operators +import tomviz.utils + +import numpy as np +import scipy.stats as stats + +# Fourier Shell correlation, Xiaozing's Code. + +def cal_dist(shape): + if np.size(shape) == 2: + nx,ny = shape + dist_map = np.zeros((nx,ny)) + for i in range(nx): + for j in range(ny): + dist_map[i,j] = np.sqrt((i-nx/2)**2+(j-ny/2)**2) + + elif np.size(shape) == 3: + nx,ny,nz = shape + dist_map = np.zeros((nx,ny,nz)) + for i in range(nx): + for j in range(ny): + for k in range(nz): + dist_map[i,j,k] = np.sqrt((i-nx/2)**2+(j-ny/2)**2+(k-nz/2)**2) + + else: + raise ValueError("Wrong image dimensions.") + + return dist_map + + +def cal_fsc(image1,image2,pixel_size_nm,phase_flag=False, save=False, title=None): + + if np.ndim(image1) == 2: + if phase_flag: + image1 = np.angle(image1) + image2 = np.angle(image2) + nx,ny = np.shape(image1) + image1_fft = np.fft.fftshift(np.fft.fftn(np.fft.fftshift(image1))) / np.sqrt(nx*ny*1.) + image2_fft = np.fft.fftshift(np.fft.fftn(np.fft.fftshift(image2))) / np.sqrt(nx*ny*1.) + #r_max = int(np.sqrt(nx**2/4.+ny**2/4.)) + r_max = int(np.max((nx,ny))/2) + fsc = np.zeros(r_max) + noise_onebit = np.zeros(r_max) + noise_halfbit = np.zeros(r_max) + x = np.arange(nx/2)/(nx*pixel_size_nm) + #x = np.arange(r_max)/(nx*pixel_size_nm) + dist_map = cal_dist((nx,ny)) + #np.save('dist_map.np',dist_map) + for i in range(r_max): + index = np.where((i <= dist_map) & (dist_map < (i+1))) + fsc[i] = np.abs(np.sum(image1_fft[index] * np.conj(image2_fft[index])) / + np.sqrt(np.sum(np.abs(image1_fft[index])**2)*np.sum(np.abs(image2_fft[index])**2))) + n_point = np.size(index) / (2) + if n_point > 0: + noise_onebit[i] = (0.5+2.4142/np.sqrt(n_point)) / (1.5+1.4142/np.sqrt(n_point)) + noise_halfbit[i] = (0.2071+1.9102/np.sqrt(n_point)) / (1.2071+0.9102/np.sqrt(n_point)) + + elif np.ndim(image1) == 3: + if phase_flag: + image1 = np.angle(image1) + image2 = np.angle(image2) + nx,ny,nz = np.shape(image1) + image1_fft = np.fft.fftshift(np.fft.fftn(np.fft.fftshift(image1))) / np.sqrt(nx*ny*nz*1.) + image2_fft = np.fft.fftshift(np.fft.fftn(np.fft.fftshift(image2))) / np.sqrt(nx*ny*nz*1.) + #r_max = int(np.sqrt(nx**2/4+ny**2/4+nz**2/4)) + r_max = int(np.max((nx,ny,nz))/2) + fsc = np.zeros(r_max) + noise_onebit = np.zeros(r_max) + noise_halfbit = np.zeros(r_max) + x = np.arange(nx/2)/(nx*pixel_size_nm) + dist_map = cal_dist((nx,ny,nz)) + for i in range(r_max): + index = np.where((i <= dist_map) & (dist_map < (i+1))) + fsc[i] = np.abs(np.sum(image1_fft[index] * np.conj(image2_fft[index])) / np.sqrt(np.sum(np.abs(image1_fft[index])**2)*np.sum(np.abs(image2_fft[index])**2))) + n_point = np.size(index) / (3) + if n_point > 0: + noise_onebit[i] = (0.5+2.4142/np.sqrt(n_point)) / (1.5+1.4142/np.sqrt(n_point)) + noise_halfbit[i] = (0.2071+1.9102/np.sqrt(n_point)) / (1.2071+0.9102/np.sqrt(n_point)) + + else: + raise ValueError("Wrong image dimensions.") + + return x, fsc, noise_onebit, noise_halfbit + + +def checkerboard_split(image): + shape = image.shape + odd_index = list(np.arange(1, shape[i], 2) for i in range(len(shape))) + even_index = list(np.arange(0, shape[i], 2) for i in range(len(shape))) + image1 = image[even_index[0], :, :][:, odd_index[1], :][:, :, odd_index[2]] + \ + image[odd_index[0], :, :][:, odd_index[1], :][:, :, odd_index[2]] + + image2 = image[even_index[0], :, :][:, even_index[1], :][:, :, even_index[2]] + \ + image[odd_index[0], :, :][:, even_index[1], :][:, :, even_index[2]] + + return image1, image2 + + +class FourierShellCorrelation(tomviz.operators.CancelableOperator): + def transform(self, dataset): + scalars = dataset.active_scalars + pixel_spacing = dataset.spacing[0] + + if scalars is None: + raise RuntimeError("No scalars found!") + + image1, image2 = checkerboard_split(scalars) + x, fsc, noise_onebit, noise_halfbit = cal_fsc(image1, image2, pixel_spacing) + + column_names = ["x", "FSC", "One bit noise", "Half bit noise"] + + n = len(x) + + table_data = np.empty(shape=(n, 4)) + + table_data[:, 0] = x + table_data[:, 1] = fsc + table_data[:, 2] = noise_onebit + table_data[:, 3] = noise_halfbit + + table = tomviz.utils.make_spreadsheet(column_names, table_data, ("Spatial Frequency", "Fourier Shell Correlation"), (False, False)) + + return_values = { + "plot": table + } + + return return_values diff --git a/tomviz/python/PowerSpectrumDensity.json b/tomviz/python/PowerSpectrumDensity.json new file mode 100644 index 000000000..a5e8dd346 --- /dev/null +++ b/tomviz/python/PowerSpectrumDensity.json @@ -0,0 +1,15 @@ +{ + "name": "PowerSpectrumDensity", + "label": "Power Spectrum Density", + "description": "", + "externalCompatible": false, + "apply_to_each_array": false, + "parameters": [], + "results": [ + { + "name": "plot", + "label": "PSD", + "type": "table" + } + ] +} diff --git a/tomviz/python/PowerSpectrumDensity.py b/tomviz/python/PowerSpectrumDensity.py new file mode 100644 index 000000000..b0f1e11cb --- /dev/null +++ b/tomviz/python/PowerSpectrumDensity.py @@ -0,0 +1,66 @@ +import tomviz.operators +import tomviz.utils + +import numpy as np +import scipy.stats as stats + +def pad_to_cubic(arr): + """ + Pads a 3D numpy array to make it cubic (all dimensions equal to the largest dimension). + The padding is added to the end of each axis. + + Parameters: + arr (np.ndarray): Input 3D array. + + Returns: + np.ndarray: Cubic padded array. + """ + max_dim = max(arr.shape) + pad_widths = [(0, max_dim - s) for s in arr.shape] + return np.pad(arr, pad_widths, mode='constant', constant_values=0) + +def psd3D(image, pixel_size): + #again likes square images, you might need to pad the image to make it work. + fourier_image = np.fft.fftn(image) + fourier_amplitudes = np.abs(fourier_image)**2 + npix = image.shape[0] + kfreq = np.fft.fftfreq(npix) * npix + kfreq3D = np.meshgrid(kfreq, kfreq, kfreq) + knrm = np.sqrt(kfreq3D[0]**2 + kfreq3D[1]**2 + kfreq3D[2]**2) + + knrm = knrm.flatten() + fourier_amplitudes = fourier_amplitudes.flatten() + + kbins = np.arange(0.5, npix//2+1, 1.) + kvals = 0.5 * (kbins[1:] + kbins[:-1]) + Abins, _, _ = stats.binned_statistic(knrm, fourier_amplitudes, + statistic = "mean", + bins = kbins) + Abins *= np.pi * (kbins[1:]**2 - kbins[:-1]**2) + x = kvals/(npix*pixel_size) + return x, Abins + + +class PoreSizeDistribution(tomviz.operators.CancelableOperator): + def transform(self, dataset): + scalars = dataset.active_scalars + + if scalars is None: + raise RuntimeError("No scalars found!") + + return_values = {} + + column_names = ["x", "PSD"] + x, bins = psd3D(scalars, dataset.spacing[0]) + + n = len(x) + + table_data = np.empty(shape=(n, 2)) + + table_data[:, 0] = x + table_data[:, 1] = bins + + table = tomviz.utils.make_spreadsheet(column_names, table_data, ("Spatial Frequency", "Power Spectrum Density"), (False, True)) + return_values["plot"] = table + + return return_values From 29d3496bb2fbc0fa17962b1d949132b58c673427 Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Fri, 20 Feb 2026 14:37:29 -0600 Subject: [PATCH 30/32] Always pad PSD to cubic This is needed to prevent some errors. Signed-off-by: Patrick Avery --- tomviz/python/PowerSpectrumDensity.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tomviz/python/PowerSpectrumDensity.py b/tomviz/python/PowerSpectrumDensity.py index b0f1e11cb..5707bd87e 100644 --- a/tomviz/python/PowerSpectrumDensity.py +++ b/tomviz/python/PowerSpectrumDensity.py @@ -20,7 +20,7 @@ def pad_to_cubic(arr): return np.pad(arr, pad_widths, mode='constant', constant_values=0) def psd3D(image, pixel_size): - #again likes square images, you might need to pad the image to make it work. + #again likes square images, you might need to pad the image to make it work. fourier_image = np.fft.fftn(image) fourier_amplitudes = np.abs(fourier_image)**2 npix = image.shape[0] @@ -47,7 +47,9 @@ def transform(self, dataset): if scalars is None: raise RuntimeError("No scalars found!") - + + scalars = pad_to_cubic(scalars) + return_values = {} column_names = ["x", "PSD"] From b974dcb28f6a97bf2a7bea7dd2364cfa943e37ef Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Fri, 20 Feb 2026 14:44:00 -0600 Subject: [PATCH 31/32] Plot QiA and QN in ShiftRotationCenterWidget These show quality metrics that help you align the center. Signed-off-by: Patrick Avery --- tomviz/ShiftRotationCenterWidget.cxx | 183 +++++++++++++++++++- tomviz/ShiftRotationCenterWidget.ui | 77 +++++--- tomviz/python/ShiftRotationCenter_tomopy.py | 45 ++++- 3 files changed, 280 insertions(+), 25 deletions(-) diff --git a/tomviz/ShiftRotationCenterWidget.cxx b/tomviz/ShiftRotationCenterWidget.cxx index a42c69a49..1e361c6f4 100644 --- a/tomviz/ShiftRotationCenterWidget.cxx +++ b/tomviz/ShiftRotationCenterWidget.cxx @@ -18,10 +18,16 @@ #include #include +#include #include #include +#include #include +#include +#include #include +#include +#include #include #include #include @@ -36,6 +42,7 @@ #include #include #include +#include #include #include @@ -136,6 +143,17 @@ class ShiftRotationCenterWidget::Internal : public QObject vtkNew centerLineActor; vtkNew sliceLine; vtkNew sliceLineActor; + + // Quality metric line plots (bottom-right, side by side) + vtkNew chartViewQia; + vtkNew chartQia; + vtkNew chartViewQn; + vtkNew chartQn; + vtkNew indicatorTableQia; + vtkNew indicatorTableQn; + QList qiaValues; + QList qnValues; + QString script; InternalPythonHelper pythonHelper; QPointer parent; @@ -211,6 +229,22 @@ class ShiftRotationCenterWidget::Internal : public QObject vtkNew projInteractorStyle; ui.projectionView->interactor()->SetInteractorStyle(projInteractorStyle); + // Set up the Qia quality metric line plot + chartViewQia->SetRenderWindow(ui.plotViewQia->renderWindow()); + chartViewQia->SetInteractor(ui.plotViewQia->interactor()); + chartViewQia->GetScene()->AddItem(chartQia); + chartQia->SetTitle("Qia"); + chartQia->GetAxis(vtkAxis::BOTTOM)->SetTitle("Center"); + chartQia->GetAxis(vtkAxis::LEFT)->SetTitle(""); + + // Set up the Qn quality metric line plot + chartViewQn->SetRenderWindow(ui.plotViewQn->renderWindow()); + chartViewQn->SetInteractor(ui.plotViewQn->interactor()); + chartViewQn->GetScene()->AddItem(chartQn); + chartQn->SetTitle("Qn"); + chartQn->GetAxis(vtkAxis::BOTTOM)->SetTitle("Center"); + chartQn->GetAxis(vtkAxis::LEFT)->SetTitle(""); + tomviz::setupRenderer(projRenderer, projMapper, nullptr); projRenderer->GetActiveCamera()->SetViewUp(1, 0, 0); @@ -309,6 +343,16 @@ class ShiftRotationCenterWidget::Internal : public QObject &Internal::onPreviewRangeEdited); connect(ui.algorithm, QOverload::of(&QComboBox::currentIndexChanged), this, &Internal::updateAlgorithmUI); + connect(ui.algorithm, QOverload::of(&QComboBox::currentIndexChanged), + this, &Internal::clearTestResults); + connect(ui.start, QOverload::of(&QDoubleSpinBox::valueChanged), + this, &Internal::clearTestResults); + connect(ui.stop, QOverload::of(&QDoubleSpinBox::valueChanged), + this, &Internal::clearTestResults); + connect(ui.steps, QOverload::of(&QSpinBox::valueChanged), this, + &Internal::clearTestResults); + connect(ui.numIterations, QOverload::of(&QSpinBox::valueChanged), this, + &Internal::clearTestResults); connect(ui.projectionNo, QOverload::of(&QSpinBox::valueChanged), this, &Internal::onProjectionChanged); connect(ui.slice, QOverload::of(&QSpinBox::valueChanged), this, @@ -316,6 +360,9 @@ class ShiftRotationCenterWidget::Internal : public QObject connect(ui.rotationCenter, QOverload::of(&QDoubleSpinBox::valueChanged), this, &Internal::updateCenterLine); + connect(ui.rotationCenter, + QOverload::of(&QDoubleSpinBox::valueChanged), this, + &Internal::updateChartIndicator); } void onProjectionChanged(int val) @@ -332,11 +379,17 @@ class ShiftRotationCenterWidget::Internal : public QObject void onSliceChanged(int) { updateSliceLine(); + clearTestResults(); + } - // The existing test rotation results are no longer valid for this slice + void clearTestResults() + { setRotationData(vtkImageData::New()); rotations.clear(); + qiaValues.clear(); + qnValues.clear(); updateImageViewSlider(); + updateChart(); render(); } @@ -454,6 +507,8 @@ class ShiftRotationCenterWidget::Internal : public QObject resetColorRange(); render(); } + + updateChart(); } void generateTestImages() @@ -520,6 +575,26 @@ class ShiftRotationCenterWidget::Internal : public QObject for (int i = 0; i < pyRotations.length(); ++i) { rotations.append(pyRotations[i].toDouble()); } + + qiaValues.clear(); + qnValues.clear(); + + auto pyQia = result["qia"]; + auto qiaList = pyQia.toList(); + if (qiaList.isValid()) { + for (int i = 0; i < qiaList.length(); ++i) { + qiaValues.append(qiaList[i].toDouble()); + } + } + + auto pyQn = result["qn"]; + auto qnList = pyQn.toList(); + if (qnList.isValid()) { + for (int i = 0; i < qnList.length(); ++i) { + qnValues.append(qnList[i].toDouble()); + } + } + setRotationData(imageData); } @@ -736,9 +811,113 @@ class ShiftRotationCenterWidget::Internal : public QObject void updateAlgorithmUI() { - bool iterative = (ui.algorithm->currentText() != "gridrec"); + auto alg = ui.algorithm->currentText(); + bool iterative = (alg == "mlem" || alg == "ospml_hybrid"); ui.numIterationsLabel->setVisible(iterative); ui.numIterations->setVisible(iterative); + + // Qn is only meaningful for non-iterative algorithms (gridrec, fbp) + // that can produce negative values in the reconstruction. + ui.plotViewQn->setVisible(!iterative); + } + + void populateChart(vtkChartXY* targetChart, + QVTKGLWidget* view, const QList& values, + unsigned char r, unsigned char g, unsigned char b) + { + targetChart->ClearPlots(); + + if (rotations.isEmpty() || values.isEmpty()) { + view->renderWindow()->Render(); + return; + } + + int n = std::min(rotations.size(), values.size()); + + vtkNew xArr; + xArr->SetName("Center"); + xArr->SetNumberOfValues(n); + + vtkNew yArr; + yArr->SetName("Value"); + yArr->SetNumberOfValues(n); + + for (int i = 0; i < n; ++i) { + xArr->SetValue(i, rotations[i]); + yArr->SetValue(i, values[i]); + } + + vtkNew table; + table->AddColumn(xArr); + table->AddColumn(yArr); + table->SetNumberOfRows(n); + + auto* line = targetChart->AddPlot(vtkChart::LINE); + line->SetInputData(table, 0, 1); + line->SetColor(r, g, b, 255); + line->SetWidth(2.0); + } + + void addIndicator(vtkChartXY* targetChart, vtkTable* indTable, + QVTKGLWidget* view, const QList& values) + { + if (rotations.isEmpty() || values.isEmpty()) { + return; + } + + // Remove old indicator (keep only the data plot at index 0) + while (targetChart->GetNumberOfPlots() > 1) { + targetChart->RemovePlot(targetChart->GetNumberOfPlots() - 1); + } + + double center = rotationCenter(); + + double yMin = values[0]; + double yMax = values[0]; + for (auto v : values) { + if (v < yMin) + yMin = v; + if (v > yMax) + yMax = v; + } + double yPadding = (yMax - yMin) * 0.05; + + vtkNew indX; + indX->SetName("X"); + indX->SetNumberOfValues(2); + indX->SetValue(0, center); + indX->SetValue(1, center); + + vtkNew indY; + indY->SetName("Y"); + indY->SetNumberOfValues(2); + indY->SetValue(0, yMin - yPadding); + indY->SetValue(1, yMax + yPadding); + + indTable->Initialize(); + indTable->AddColumn(indX); + indTable->AddColumn(indY); + indTable->SetNumberOfRows(2); + + auto* indLine = targetChart->AddPlot(vtkChart::LINE); + indLine->SetInputData(indTable, 0, 1); + indLine->SetColor(255, 255, 0, 255); + indLine->SetWidth(2.0); + + view->renderWindow()->Render(); + } + + void updateChart() + { + populateChart(chartQia, ui.plotViewQia, qiaValues, 0, 114, 189); + populateChart(chartQn, ui.plotViewQn, qnValues, 217, 83, 25); + updateChartIndicator(); + } + + void updateChartIndicator() + { + addIndicator(chartQia, indicatorTableQia, ui.plotViewQia, qiaValues); + addIndicator(chartQn, indicatorTableQn, ui.plotViewQn, qnValues); } }; diff --git a/tomviz/ShiftRotationCenterWidget.ui b/tomviz/ShiftRotationCenterWidget.ui index b9fe2c0a0..b56293629 100644 --- a/tomviz/ShiftRotationCenterWidget.ui +++ b/tomviz/ShiftRotationCenterWidget.ui @@ -34,7 +34,7 @@ - + 1 @@ -50,20 +50,40 @@ - - - - 1 - 1 - - - - - 300 - 300 - - - + + + + + + 1 + 1 + + + + + 100 + 150 + + + + + + + + + 1 + 1 + + + + + 100 + 150 + + + + + @@ -242,11 +262,21 @@ gridrec + + + fbp + + mlem + + + ospml_hybrid + + @@ -459,17 +489,20 @@ - - - Qt::Vertical + + + + 1 + 1 + - + - 20 - 40 + 200 + 200 - + diff --git a/tomviz/python/ShiftRotationCenter_tomopy.py b/tomviz/python/ShiftRotationCenter_tomopy.py index 867484348..f729e0c34 100644 --- a/tomviz/python/ShiftRotationCenter_tomopy.py +++ b/tomviz/python/ShiftRotationCenter_tomopy.py @@ -20,6 +20,43 @@ def transform(dataset, rotation_center=0): dataset.active_scalars = array +def Qia(rec, opt='max'): + """Integral of absolute value quality metric.""" + qlist = [] + mavg = [] + for i in range(rec.shape[0]): + m = rec[i].sum() + mavg.append(m) + mavg = np.mean(mavg) + + for i in range(rec.shape[0]): + t = np.abs(rec[i]).sum() + qlist.append(t / mavg) + if opt == 'max': + num = qlist.index(max(qlist)) + else: + num = qlist.index(min(qlist)) + return qlist, num + + +def Qn(rec): + """Integral of negativity quality metric.""" + qlist = [] + mavg = [] + for i in range(rec.shape[0]): + m = rec[i].sum() + mavg.append(m) + mavg = np.mean(mavg) + + for i in range(rec.shape[0]): + table = -1 * rec[i] > 0 + testtable = rec[i] * table + t = testtable.sum() + qlist.append(-1 * t / mavg) + num = qlist.index(max(qlist)) + return qlist, num + + def test_rotations(dataset, start=None, stop=None, steps=None, sli=0, algorithm='gridrec', num_iter=15): # Get the current volume as a numpy array. @@ -53,12 +90,18 @@ def test_rotations(dataset, start=None, stop=None, steps=None, sli=0, # Perform the test rotations images, centers = rotcen_test(**kwargs) + # Compute quality metrics + qia_values, qia_best = Qia(images) + qn_values, qn_best = Qn(images) + child = dataset.create_child_dataset() child.active_scalars = images return_values = {} return_values['images'] = child return_values['centers'] = centers.astype(float).tolist() + return_values['qia'] = qia_values + return_values['qn'] = qn_values return return_values @@ -92,7 +135,7 @@ def rotcen_test(f, start=None, stop=None, steps=None, sli=0, img = np.zeros([len(cen), s[2], s[2]]) recon_kwargs = {} - if algorithm != 'gridrec': + if algorithm not in ('gridrec', 'fbp'): recon_kwargs['num_iter'] = num_iter for i in range(len(cen)): From bbdcff9b76938cf3ac6f4e6143417b2d62edb88b Mon Sep 17 00:00:00 2001 From: Patrick Avery Date: Sat, 21 Feb 2026 17:50:35 -0600 Subject: [PATCH 32/32] Change to physical units in ShiftRotationCenter This also moves some of the widgets around. Signed-off-by: Patrick Avery --- tomviz/ShiftRotationCenterWidget.cxx | 27 +++- tomviz/ShiftRotationCenterWidget.ui | 156 +++++++++++++------- tomviz/python/ShiftRotationCenter_tomopy.py | 97 ++++++------ 3 files changed, 178 insertions(+), 102 deletions(-) diff --git a/tomviz/ShiftRotationCenterWidget.cxx b/tomviz/ShiftRotationCenterWidget.cxx index 1e361c6f4..05a3c2560 100644 --- a/tomviz/ShiftRotationCenterWidget.cxx +++ b/tomviz/ShiftRotationCenterWidget.cxx @@ -234,7 +234,11 @@ class ShiftRotationCenterWidget::Internal : public QObject chartViewQia->SetInteractor(ui.plotViewQia->interactor()); chartViewQia->GetScene()->AddItem(chartQia); chartQia->SetTitle("Qia"); - chartQia->GetAxis(vtkAxis::BOTTOM)->SetTitle("Center"); + auto units = dataSource->getUnits(); + auto unitSuffix = QString(" %1").arg(units); + auto centerAxisTitle = + QString("Center (%1)").arg(units).toStdString(); + chartQia->GetAxis(vtkAxis::BOTTOM)->SetTitle(centerAxisTitle); chartQia->GetAxis(vtkAxis::LEFT)->SetTitle(""); // Set up the Qn quality metric line plot @@ -242,9 +246,15 @@ class ShiftRotationCenterWidget::Internal : public QObject chartViewQn->SetInteractor(ui.plotViewQn->interactor()); chartViewQn->GetScene()->AddItem(chartQn); chartQn->SetTitle("Qn"); - chartQn->GetAxis(vtkAxis::BOTTOM)->SetTitle("Center"); + chartQn->GetAxis(vtkAxis::BOTTOM)->SetTitle(centerAxisTitle); chartQn->GetAxis(vtkAxis::LEFT)->SetTitle(""); + // Add unit suffixes to spin boxes that display physical values + ui.start->setSuffix(unitSuffix); + ui.stop->setSuffix(unitSuffix); + ui.currentRotation->setSuffix(unitSuffix); + ui.rotationCenter->setSuffix(unitSuffix); + tomviz::setupRenderer(projRenderer, projMapper, nullptr); projRenderer->GetActiveCamera()->SetViewUp(1, 0, 0); @@ -291,13 +301,14 @@ class ShiftRotationCenterWidget::Internal : public QObject ui.colorPresetButton->setIcon(QIcon(":/pqWidgets/Icons/pqFavorites.svg")); auto* dims = image->GetDimensions(); + auto* spacing = image->GetSpacing(); // All center-related values are offsets from the image midpoint. // 0 means the rotation center is exactly at the midpoint. setRotationCenter(0); - // Default start/stop to +/- 10% of the detector width - auto delta = dims[0] * 0.1; + // Default start/stop to +/- 10% of the detector width (in physical units) + auto delta = dims[1] * 0.1 * spacing[1]; ui.start->setValue(-delta); ui.stop->setValue(delta); @@ -353,6 +364,9 @@ class ShiftRotationCenterWidget::Internal : public QObject &Internal::clearTestResults); connect(ui.numIterations, QOverload::of(&QSpinBox::valueChanged), this, &Internal::clearTestResults); + connect(ui.circMaskRatio, + QOverload::of(&QDoubleSpinBox::valueChanged), this, + &Internal::clearTestResults); connect(ui.projectionNo, QOverload::of(&QSpinBox::valueChanged), this, &Internal::onProjectionChanged); connect(ui.slice, QOverload::of(&QSpinBox::valueChanged), this, @@ -402,7 +416,7 @@ class ShiftRotationCenterWidget::Internal : public QObject double bounds[6]; image->GetBounds(bounds); double centerY = (bounds[2] + bounds[3]) / 2.0; - double lineY = centerY + rotationCenter() * image->GetSpacing()[0]; + double lineY = centerY + rotationCenter(); // Vertical line in the view (constant Y, spanning X), placed just in // front of the current Z slice (toward the camera, which looks from -Z). @@ -459,6 +473,7 @@ class ShiftRotationCenterWidget::Internal : public QObject ui.steps->setValue(settings->value("steps", 26).toInt()); setAlgorithm(settings->value("algorithm", "mlem").toString()); ui.numIterations->setValue(settings->value("numIterations", 15).toInt()); + ui.circMaskRatio->setValue(settings->value("circMaskRatio", 0.8).toDouble()); settings->endGroup(); } @@ -469,6 +484,7 @@ class ShiftRotationCenterWidget::Internal : public QObject settings->setValue("steps", ui.steps->value()); settings->setValue("algorithm", algorithm()); settings->setValue("numIterations", ui.numIterations->value()); + settings->setValue("circMaskRatio", ui.circMaskRatio->value()); settings->endGroup(); } @@ -541,6 +557,7 @@ class ShiftRotationCenterWidget::Internal : public QObject kwargs.set("sli", ui.slice->value()); kwargs.set("algorithm", algorithm()); kwargs.set("num_iter", ui.numIterations->value()); + kwargs.set("circ_mask_ratio", ui.circMaskRatio->value()); auto ret = func.call(kwargs); auto result = ret.toDict(); diff --git a/tomviz/ShiftRotationCenterWidget.ui b/tomviz/ShiftRotationCenterWidget.ui index b56293629..c9aeb39ea 100644 --- a/tomviz/ShiftRotationCenterWidget.ui +++ b/tomviz/ShiftRotationCenterWidget.ui @@ -30,7 +30,7 @@ 0 - + @@ -49,42 +49,6 @@ - - - - - - - 1 - 1 - - - - - 100 - 150 - - - - - - - - - 1 - 1 - - - - - 100 - 150 - - - - - - @@ -104,7 +68,7 @@ - + Test Rotations @@ -243,7 +207,7 @@ - + @@ -310,6 +274,71 @@ + + + + + + Circle Mask Ratio: + + + circMaskRatio + + + + + + + false + + + 2 + + + 0.000000000000000 + + + 1.000000000000000 + + + 0.050000000000000 + + + 0.800000000000000 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + 1 + 1 + + + + + 200 + 200 + + + + @@ -489,20 +518,40 @@ - - - - 1 - 1 - - - - - 200 - 200 - - - + + + + + + 1 + 1 + + + + + 100 + 150 + + + + + + + + + 1 + 1 + + + + + 100 + 150 + + + + + @@ -543,6 +592,7 @@ slice algorithm numIterations + circMaskRatio testRotations currentRotation rotationCenter diff --git a/tomviz/python/ShiftRotationCenter_tomopy.py b/tomviz/python/ShiftRotationCenter_tomopy.py index f729e0c34..8878bbcda 100644 --- a/tomviz/python/ShiftRotationCenter_tomopy.py +++ b/tomviz/python/ShiftRotationCenter_tomopy.py @@ -7,10 +7,10 @@ def transform(dataset, rotation_center=0): array = dataset.active_scalars tilt_axis = dataset.tilt_axis - # rotation_center is an offset from the image midpoint. + # rotation_center is an offset from the image midpoint in physical units. # A positive offset means the rotation center is right of center, # so we shift left (negative) to bring it to center. - pixel_shift = -rotation_center + pixel_shift = -rotation_center / dataset.spacing[1] # Shift the entire volume along the detector horizontal axis shift_vec = [0.0, 0.0, 0.0] @@ -20,45 +20,8 @@ def transform(dataset, rotation_center=0): dataset.active_scalars = array -def Qia(rec, opt='max'): - """Integral of absolute value quality metric.""" - qlist = [] - mavg = [] - for i in range(rec.shape[0]): - m = rec[i].sum() - mavg.append(m) - mavg = np.mean(mavg) - - for i in range(rec.shape[0]): - t = np.abs(rec[i]).sum() - qlist.append(t / mavg) - if opt == 'max': - num = qlist.index(max(qlist)) - else: - num = qlist.index(min(qlist)) - return qlist, num - - -def Qn(rec): - """Integral of negativity quality metric.""" - qlist = [] - mavg = [] - for i in range(rec.shape[0]): - m = rec[i].sum() - mavg.append(m) - mavg = np.mean(mavg) - - for i in range(rec.shape[0]): - table = -1 * rec[i] > 0 - testtable = rec[i] * table - t = testtable.sum() - qlist.append(-1 * t / mavg) - num = qlist.index(max(qlist)) - return qlist, num - - def test_rotations(dataset, start=None, stop=None, steps=None, sli=0, - algorithm='gridrec', num_iter=15): + algorithm='gridrec', num_iter=15, circ_mask_ratio=0.8): # Get the current volume as a numpy array. array = dataset.active_scalars @@ -72,6 +35,11 @@ def test_rotations(dataset, start=None, stop=None, steps=None, sli=0, if angles is None: raise Exception('No angles found') + # start/stop are in physical units; convert to pixel offsets for tomopy + spacing_y = dataset.spacing[1] + start_px = start / spacing_y + stop_px = stop / spacing_y + recon_input = { 'img_tomo': array, 'angle': angles, @@ -79,17 +47,21 @@ def test_rotations(dataset, start=None, stop=None, steps=None, sli=0, kwargs = { 'f': recon_input, - 'start': start, - 'stop': stop, + 'start': start_px, + 'stop': stop_px, 'steps': steps, 'sli': sli, 'algorithm': algorithm, 'num_iter': num_iter, + 'circ_mask_ratio': circ_mask_ratio, } # Perform the test rotations images, centers = rotcen_test(**kwargs) + # Convert centers from pixel offsets to physical units + centers = centers * spacing_y + # Compute quality metrics qia_values, qia_best = Qia(images) qn_values, qn_best = Qn(images) @@ -106,7 +78,7 @@ def test_rotations(dataset, start=None, stop=None, steps=None, sli=0, def rotcen_test(f, start=None, stop=None, steps=None, sli=0, - algorithm='gridrec', num_iter=15): + algorithm='gridrec', num_iter=15, circ_mask_ratio=0.8): import tomopy @@ -142,8 +114,45 @@ def rotcen_test(f, start=None, stop=None, steps=None, sli=0, print(f'{i + 1}: rotcen {cen[i]}') img[i] = tomopy.recon(img_tomo, theta, center=cen[i], algorithm=algorithm, **recon_kwargs) - img = tomopy.circ_mask(img, axis=0, ratio=0.8) + img = tomopy.circ_mask(img, axis=0, ratio=circ_mask_ratio) # Convert back to relative to the center cen -= s[2] / 2 return img, cen + + +def Qia(rec, opt='max'): + """Integral of absolute value quality metric.""" + qlist = [] + mavg = [] + for i in range(rec.shape[0]): + m = rec[i].sum() + mavg.append(m) + mavg = np.mean(mavg) + + for i in range(rec.shape[0]): + t = np.abs(rec[i]).sum() + qlist.append(t / mavg) + if opt == 'max': + num = qlist.index(max(qlist)) + else: + num = qlist.index(min(qlist)) + return qlist, num + + +def Qn(rec): + """Integral of negativity quality metric.""" + qlist = [] + mavg = [] + for i in range(rec.shape[0]): + m = rec[i].sum() + mavg.append(m) + mavg = np.mean(mavg) + + for i in range(rec.shape[0]): + table = -1 * rec[i] > 0 + testtable = rec[i] * table + t = testtable.sum() + qlist.append(-1 * t / mavg) + num = qlist.index(max(qlist)) + return qlist, num