Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
183f0ff
Fix generating random particles dataset
psavery Feb 4, 2026
716dada
Truncate error on external pipeline execution
psavery Feb 4, 2026
bc4a29c
Prevent divide-by-zero errors when normalizing
psavery Feb 4, 2026
4049de0
Automatically add `@apply_to_each_array`
psavery Feb 13, 2026
5fd4916
Remove manual `@apply_to_each_array` decorators
psavery Feb 13, 2026
9803e74
Add ability to save tilt angles to a .txt file
psavery Feb 13, 2026
7634ac4
Fixes for new automatic decorating
psavery Feb 13, 2026
cb84038
add ModulePlot and LineView to display operators Table results
alesgenova Jan 27, 2026
868edde
add options to ModulePlot to log scale x and y axes
alesgenova Jan 28, 2026
829df71
let operators provide xy axes labels and log scaling
alesgenova Feb 17, 2026
6bf0f4c
Add support for Scan IDs to Tomviz
psavery Feb 17, 2026
f3c83c6
add ability to insert operators before the selected operator
alesgenova Feb 17, 2026
36a23c4
Add alternative pixel size name support for ptycho
psavery Feb 17, 2026
af9943c
Automatically find and validate `pyxrf-utils` exec
psavery Feb 18, 2026
88eb8a6
Omit unused parameter name to fix compile warning
psavery Feb 18, 2026
fff843f
Refactor FxiWorkflowWidget to ShiftRotationCenter
psavery Feb 17, 2026
7ac7725
Ensure operator dialog appears above main window
psavery Feb 18, 2026
0658733
add optional breakpoints to the pipeline at each operator
alesgenova Feb 18, 2026
7c51f80
Only set UseColorPaletteForBackground if available
psavery Feb 18, 2026
015dc37
Fix tomopy operator and generalize it
psavery Feb 18, 2026
df52da1
Add several safety checks to prevent seg faults
psavery Feb 19, 2026
3f4fdb3
Add checks that ptycho directory exists
psavery Feb 19, 2026
c9cadf8
fix pipeline execution with breakpoint when modifying operators
alesgenova Feb 19, 2026
eeb31eb
Merge pull request #16 from alesgenova/fix-breakpoint
psavery Feb 19, 2026
56e3f87
add ability to export table results as csv
alesgenova Feb 19, 2026
8eb0f57
Allow "and" and "or" for conditionals in json file
psavery Feb 19, 2026
445e628
Fix tomopy reconstruction
psavery Feb 19, 2026
fce079a
Merge pull request #17 from alesgenova/export-csv-table
psavery Feb 19, 2026
1a5f313
Ensure operator dialog appears above main window
psavery Feb 19, 2026
2035cba
Fixes to ShiftRotationCenterWidget
psavery Feb 20, 2026
9d05b7b
add PSD and FSC 1D metric operators
alesgenova Feb 20, 2026
8e9df07
Merge pull request #18 from alesgenova/nsls-operators
psavery Feb 20, 2026
29d3496
Always pad PSD to cubic
psavery Feb 20, 2026
b974dcb
Plot QiA and QN in ShiftRotationCenterWidget
psavery Feb 20, 2026
bbdcff9
Change to physical units in ShiftRotationCenter
psavery Feb 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions tests/python/utils.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,58 @@
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


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


Expand Down
3 changes: 3 additions & 0 deletions tomviz/ActiveObjects.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
27 changes: 24 additions & 3 deletions tomviz/AddPythonTransformReaction.cxx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
#include <QVBoxLayout>
#include <QtDebug>

#include <cassert>

namespace tomviz {

Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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];
Expand Down
4 changes: 3 additions & 1 deletion tomviz/AddRenderViewContextMenuBehavior.cxx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
6 changes: 3 additions & 3 deletions tomviz/Behaviors.cxx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 13 additions & 6 deletions tomviz/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -462,8 +464,8 @@ set(python_files
Recon_ART.py
Recon_SIRT.py
Recon_TV_minimization.py
Recon_tomopy_gridrec.py
Recon_tomopy_fxi.py
Recon_tomopy.py
ShiftRotationCenter_tomopy.py
FFT_AbsLog.py
ManualManipulation.py
Shift_Stack_Uniformly.py
Expand Down Expand Up @@ -508,13 +510,16 @@ set(python_files
TV_Filter.py
PoreSizeDistribution.py
Tortuosity.py
PowerSpectrumDensity.py
FourierShellCorrelation.py
Recon_real_time_tomography.py
)

set(json_files
AddPoissonNoise.json
AutoCenterOfMassTiltImageAlignment.json
AutoCrossCorrelationTiltImageAlignment.json
AutoTiltAxisRotationAlignment.json
AutoTiltAxisShiftAlignment.json
PyStackRegImageAlignment.json
BinaryThreshold.json
Expand Down Expand Up @@ -547,8 +552,8 @@ set(json_files
Recon_DFT.json
Recon_DFT_constraint.json
Recon_TV_minimization.json
Recon_tomopy_gridrec.json
Recon_tomopy_fxi.json
Recon_tomopy.json
ShiftRotationCenter_tomopy.json
Recon_SIRT.json
Recon_WBP.json
Shift3D.json
Expand All @@ -564,6 +569,8 @@ set(json_files
TV_Filter.json
PoreSizeDistribution.json
Tortuosity.json
PowerSpectrumDensity.json
FourierShellCorrelation.json
Recon_real_time_tomography.json
)

Expand Down
2 changes: 1 addition & 1 deletion tomviz/CentralWidget.cxx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
75 changes: 73 additions & 2 deletions tomviz/DataPropertiesPanel.cxx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
#include <QClipboard>
#include <QDebug>
#include <QDoubleValidator>
#include <QFileDialog>
#include <QKeyEvent>
#include <QMainWindow>
#include <QMessageBox>
Expand Down Expand Up @@ -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); });
Expand Down Expand Up @@ -402,18 +404,38 @@ void DataPropertiesPanel::updateData()
m_tiltAnglesSeparator->show();
m_ui->SetTiltAnglesButton->show();
m_ui->TiltAnglesTable->show();
m_ui->saveTiltAngles->show();
QVector<double> tiltAngles = dsource->getTiltAngles();
QVector<int> 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();
m_ui->TiltAnglesTable->hide();
m_ui->saveTiltAngles->show();
}
connect(m_ui->TiltAnglesTable, SIGNAL(cellChanged(int, int)),
SLOT(onTiltAnglesModified(int, int)));
Expand Down Expand Up @@ -515,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);
Expand Down Expand Up @@ -627,6 +654,49 @@ 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();
auto scanIDs = dsource->getScanIDs();

// 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 scan IDs (if available) and tilt angles, one per line
QTextStream out(&file);
for (int i = 0; i < tiltAngles.size(); ++i) {
if (m_hasScanIDs) {
out << scanIDs[i] << " ";
}
out << tiltAngles[i] << "\n";
}

file.close();
}

void DataPropertiesPanel::scheduleUpdate()
{
m_updateNeeded = true;
Expand Down Expand Up @@ -811,6 +881,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)
Expand Down
Loading