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/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/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/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/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 0ecd815d4..0f1afcc4b 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 @@ -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 @@ -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 @@ -508,6 +510,8 @@ set(python_files TV_Filter.py PoreSizeDistribution.py Tortuosity.py + PowerSpectrumDensity.py + FourierShellCorrelation.py Recon_real_time_tomography.py ) @@ -515,6 +519,7 @@ set(json_files AddPoissonNoise.json AutoCenterOfMassTiltImageAlignment.json AutoCrossCorrelationTiltImageAlignment.json + AutoTiltAxisRotationAlignment.json AutoTiltAxisShiftAlignment.json PyStackRegImageAlignment.json BinaryThreshold.json @@ -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 @@ -564,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/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/DataPropertiesPanel.cxx b/tomviz/DataPropertiesPanel.cxx index 1bb91690e..ddbf37423 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,18 +404,38 @@ void DataPropertiesPanel::updateData() m_tiltAnglesSeparator->show(); m_ui->SetTiltAnglesButton->show(); 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(); m_ui->TiltAnglesTable->hide(); + m_ui->saveTiltAngles->show(); } connect(m_ui->TiltAnglesTable, SIGNAL(cellChanged(int, int)), SLOT(onTiltAnglesModified(int, int))); @@ -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); @@ -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; @@ -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) diff --git a/tomviz/DataPropertiesPanel.h b/tomviz/DataPropertiesPanel.h index 2453da6d3..0fb4463f8 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); @@ -86,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/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 + + + diff --git a/tomviz/DataSource.cxx b/tomviz/DataSource.cxx index c2fd8dcc5..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; @@ -688,6 +692,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()); @@ -1002,8 +1010,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; @@ -1727,6 +1744,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/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/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/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/FxiWorkflowWidget.cxx b/tomviz/FxiWorkflowWidget.cxx deleted file mode 100644 index b72485947..000000000 --- a/tomviz/FxiWorkflowWidget.cxx +++ /dev/null @@ -1,900 +0,0 @@ -/* 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 "ActiveObjects.h" -#include "ColorMap.h" -#include "DataSource.h" -#include "InterfaceBuilder.h" -#include "InternalPythonHelper.h" -#include "OperatorPython.h" -#include "PresetDialog.h" -#include "Utilities.h" - -#include - -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include - -#include - -namespace tomviz { - -class InternalProgressDialog : public QProgressDialog -{ -public: - InternalProgressDialog(QWidget* parent = nullptr) : QProgressDialog(parent) - { - setWindowTitle("Tomviz"); - setLabelText("Generating test images..."); - setMinimum(0); - setMaximum(0); - setWindowModality(Qt::WindowModal); - - // No cancel button - setCancelButton(nullptr); - - // No close button in the corner - setWindowFlags((windowFlags() | Qt::CustomizeWindowHint) & - ~Qt::WindowCloseButtonHint); - - reset(); - } - - void keyPressEvent(QKeyEvent* e) override - { - // Do not let the user close the dialog by pressing escape - if (e->key() == Qt::Key_Escape) { - return; - } - - QProgressDialog::keyPressEvent(e); - } -}; - -class InteractorStyle : public vtkInteractorStyleImage -{ - // Our customized 2D interactor style class -public: - static InteractorStyle* New(); - - InteractorStyle() { this->SetInteractionModeToImage2D(); } - - void OnLeftButtonDown() override - { - // Override this to not do window level events, and instead do panning. - int x = this->Interactor->GetEventPosition()[0]; - int y = this->Interactor->GetEventPosition()[1]; - - this->FindPokedRenderer(x, y); - if (this->CurrentRenderer == nullptr) { - return; - } - - this->GrabFocus(this->EventCallbackCommand); - if (!this->Interactor->GetShiftKey() && - !this->Interactor->GetControlKey()) { - this->StartPan(); - } else { - this->Superclass::OnLeftButtonDown(); - } - } -}; - -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 -{ - Q_OBJECT - -public: - Ui::FxiWorkflowWidget ui; - QPointer op; - vtkSmartPointer image; - vtkSmartPointer rotationImages; - vtkSmartPointer colorMap; - vtkSmartPointer lut; - QList rotations; - vtkNew slice; - vtkNew mapper; - vtkNew renderer; - vtkNew axesActor; - QString script; - InternalPythonHelper pythonHelper; - 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) - : op(o), image(img) - { - // Must call setupUi() before using p in any way - ui.setupUi(p); - setParent(p); - parent = p; - - readSettings(); - - // Keep the axes invisible until the data is displayed - axesActor->SetVisibility(false); - - mapper->SetOrientation(0); - slice->SetMapper(mapper); - renderer->AddViewProp(slice); - ui.sliceView->renderWindow()->AddRenderer(renderer); - - vtkNew interactorStyle; - ui.sliceView->interactor()->SetInteractorStyle(interactorStyle); - setRotationData(vtkImageData::New()); - - // Use a child data source if one is available so the color map will match - if (op->childDataSource()) { - dataSource = op->childDataSource(); - } else if (op->dataSource()) { - dataSource = op->dataSource(); - } else { - dataSource = ActiveObjects::instance().activeDataSource(); - } - - 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(); - - for (auto* w : inputWidgets()) { - w->installEventFilter(this); - } - - // This isn't always working in Qt designer, so set it here as well - 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]); - - // 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); - - // Indicate what the max is via a tooltip. - auto toolTip = "Max: " + QString::number(dims[1]); - ui.sliceStop->setToolTip(toolTip); - - // Hide the additional parameters label unless the user adds some - ui.reconExtraParamsLayoutWidget->hide(); - ui.testRotationsExtraParamsLayoutWidget->hide(); - - progressDialog.reset(new InternalProgressDialog(parent)); - - updateControls(); - setupConnections(); - } - - void setupConnections() - { - connect(ui.testRotations, &QPushButton::pressed, this, - &Internal::startGeneratingTestImages); - connect(ui.imageViewSlider, &IntSliderWidget::valueEdited, this, - &Internal::sliderEdited); - connect(&futureWatcher, &QFutureWatcher::finished, this, - &Internal::testImagesGenerated); - connect(&futureWatcher, &QFutureWatcher::finished, - progressDialog.data(), &QProgressDialog::accept); - connect(ui.colorPresetButton, &QToolButton::clicked, this, - &Internal::onColorPresetClicked); - connect(ui.previewMin, &DoubleSliderWidget::valueEdited, this, - &Internal::onPreviewRangeEdited); - connect(ui.previewMax, &DoubleSliderWidget::valueEdited, this, - &Internal::onPreviewRangeEdited); - } - - 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) - { - // 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); - } - - 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); - } - - void setTestRotationExtraParamValues(QVariantMap values) - { - if (!interfaceBuilder) { - return; - } - - auto parentWidget = ui.testRotationsExtraParamsLayoutWidget; - interfaceBuilder->setParameterValues(values); - interfaceBuilder->updateWidgetValues(parentWidget); - } - - QVariantMap extraParamValues() - { - return unite(reconExtraParamValues(), testRotationsExtraParamValues()); - } - - QVariantMap reconExtraParamValues() - { - if (!interfaceBuilder) { - return QVariantMap(); - } - - auto parentWidget = ui.reconExtraParamsLayoutWidget; - return interfaceBuilder->parameterValues(parentWidget); - } - - QVariantMap testRotationsExtraParamValues() - { - if (!interfaceBuilder) { - return QVariantMap(); - } - - auto parentWidget = ui.testRotationsExtraParamsLayoutWidget; - return interfaceBuilder->parameterValues(parentWidget); - } - - 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"); - ui.steps->setValue(settings->value("steps", 26).toInt()); - ui.slice->setValue(settings->value("sli", 0).toInt()); - customTestRotationSettings = settings->value("extraParams").toMap(); - settings->endGroup(); - 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->setValue("steps", ui.steps->value()); - settings->setValue("sli", ui.slice->value()); - settings->setValue("extraParams", testRotationsExtraParamValues()); - settings->endGroup(); - 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 }; - } - - void startGeneratingTestImages() - { - progressDialog->show(); - auto future = QtConcurrent::run(std::bind(&Internal::generateTestImages, this)); - futureWatcher.setFuture(future); - } - - void testImagesGenerated() - { - updateImageViewSlider(); - if (!testRotationsSuccess) { - auto msg = testRotationsErrorMessage; - qCritical() << msg; - QMessageBox::critical(parent, "Tomviz", msg); - return; - } - - if (rotationDataValid()) { - resetColorRange(); - render(); - } - } - - void generateTestImages() - { - testRotationsSuccess = false; - rotations.clear(); - - { - Python python; - auto module = pythonHelper.loadModule(script); - if (!module.isValid()) { - testRotationsErrorMessage = "Failed to load script"; - return; - } - - auto func = module.findFunction("test_rotations"); - if (!func.isValid()) { - testRotationsErrorMessage = - "Failed to find function \"test_rotations\""; - return; - } - - Python::Object data = Python::createDataset(image, *dataSource); - - Python::Dict kwargs; - kwargs.set("dataset", data); - 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("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])); - } - - auto ret = func.call(kwargs); - auto result = ret.toDict(); - if (!result.isValid()) { - testRotationsErrorMessage = "Failed to execute test_rotations()"; - return; - } - - auto pyImages = result["images"]; - auto* object = Python::VTK::convertToDataObject(pyImages); - if (!object) { - testRotationsErrorMessage = - "No image data was returned from test_rotations()"; - return; - } - - auto* imageData = vtkImageData::SafeDownCast(object); - if (!imageData) { - testRotationsErrorMessage = - "No image data was returned from test_rotations()"; - return; - } - - auto centers = result["centers"]; - auto pyRotations = centers.toList(); - if (!pyRotations.isValid() || pyRotations.length() <= 0) { - testRotationsErrorMessage = - "No rotations returned from test_rotations()"; - return; - } - - for (int i = 0; i < pyRotations.length(); ++i) { - rotations.append(pyRotations[i].toDouble()); - } - 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(); - testRotationsSuccess = true; - } - - void setRotationData(vtkImageData* data) - { - rotationImages = data; - mapper->SetInputData(rotationImages); - mapper->SetSliceNumber(0); - mapper->Update(); - setupRenderer(); - } - - void resetColorRange() - { - if (!rotationDataValid()) { - return; - } - - auto* range = rotationImages->GetScalarRange(); - - auto blocked1 = QSignalBlocker(ui.previewMin); - auto blocked2 = QSignalBlocker(ui.previewMax); - ui.previewMin->setMinimum(range[0]); - ui.previewMin->setMaximum(range[1]); - ui.previewMin->setValue(range[0]); - ui.previewMax->setMinimum(range[0]); - ui.previewMax->setMaximum(range[1]); - ui.previewMax->setValue(range[1]); - - rescaleColors(range); - } - - void rescaleColors(double* range) - { - // Always perform a deep copy of the original color map - // If we always modify the control points of the same LUT, - // the control points will often change and we will end up - // with a very different LUT than we had originally. - resetLut(); - if (!lut) { - return; - } - - auto* tf = vtkColorTransferFunction::SafeDownCast(lut); - if (!tf) { - return; - } - - rescaleLut(tf, range[0], range[1]); - } - - void onPreviewRangeEdited() - { - if (!rotationDataValid() || !lut) { - return; - } - - auto* maxRange = rotationImages->GetScalarRange(); - - double range[2]; - range[0] = ui.previewMin->value(); - range[1] = ui.previewMax->value(); - - auto minDiff = (maxRange[1] - maxRange[0]) / 1000; - if (range[1] - range[0] < minDiff) { - if (sender() == ui.previewMin) { - // Move the max - range[1] = range[0] + minDiff; - auto blocked = QSignalBlocker(ui.previewMax); - ui.previewMax->setValue(range[1]); - } else { - // Move the min - range[0] = range[1] - minDiff; - auto blocked = QSignalBlocker(ui.previewMin); - ui.previewMin->setValue(range[0]); - } - } - - rescaleColors(range); - render(); - } - - void updateControls() - { - std::vector blockers; - for (auto w : inputWidgets()) { - blockers.emplace_back(w); - } - - updateImageViewSlider(); - } - - bool rotationDataValid() - { - if (!rotationImages.GetPointer()) { - return false; - } - - if (rotations.isEmpty()) { - return false; - } - - return true; - } - - void updateImageViewSlider() - { - auto blocked = QSignalBlocker(ui.imageViewSlider); - - bool enable = rotationDataValid(); - ui.testRotationsSettingsGroup->setVisible(enable); - if (!enable) { - return; - } - - auto* dims = rotationImages->GetDimensions(); - ui.imageViewSlider->setMaximum(dims[0] - 1); - - sliceNumber = dims[0] / 2; - ui.imageViewSlider->setValue(sliceNumber); - - sliderEdited(); - } - - void sliderEdited() - { - sliceNumber = ui.imageViewSlider->value(); - if (sliceNumber < rotations.size()) { - ui.currentRotation->setValue(rotations[sliceNumber]); - - // For convenience, also set the rotation center for reconstruction - ui.rotationCenter->setValue(rotations[sliceNumber]); - } else { - qCritical() << sliceNumber - << "is greater than the rotations size:" << rotations.size(); - } - - mapper->SetSliceNumber(sliceNumber); - mapper->Update(); - render(); - } - - bool eventFilter(QObject* o, QEvent* e) override - { - if (inputWidgets().contains(qobject_cast(o))) { - if (e->type() == QEvent::KeyPress) { - QKeyEvent* keyEvent = static_cast(e); - if (keyEvent->key() == Qt::Key_Return || - keyEvent->key() == Qt::Key_Enter) { - e->accept(); - qobject_cast(o)->clearFocus(); - return true; - } - } - } - return QObject::eventFilter(o, e); - } - - void resetLut() - { - auto dsLut = - vtkScalarsToColors::SafeDownCast(colorMap->GetClientSideObject()); - if (!dsLut) { - return; - } - - // Make a deep copy to modify - lut = dsLut->NewInstance(); - lut->DeepCopy(dsLut); - slice->GetProperty()->SetLookupTable(lut); - } - - void setColorMapToGrayscale() - { - ColorMap::instance().applyPreset("Grayscale", colorMap); - } - - void onColorPresetClicked() - { - if (!colorMap) { - qCritical() << "No color map found!"; - return; - } - - PresetDialog dialog(tomviz::mainWidget()); - connect(&dialog, &PresetDialog::applyPreset, this, [this, &dialog]() { - ColorMap::instance().applyPreset(dialog.presetName(), colorMap); - // Keep the range the same - double range[2]; - range[0] = ui.previewMin->value(); - range[1] = ui.previewMax->value(); - rescaleColors(range); - render(); - }); - 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(); } - - void setSliceStop(int i) { ui.sliceStop->setValue(i); } - int sliceStop() const { return ui.sliceStop->value(); } -}; - -#include "FxiWorkflowWidget.moc" - -FxiWorkflowWidget::FxiWorkflowWidget(Operator* op, - vtkSmartPointer image, - QWidget* p) - : CustomPythonOperatorWidget(p) -{ - m_internal.reset(new Internal(op, image, this)); -} - -FxiWorkflowWidget::~FxiWorkflowWidget() = default; - -void FxiWorkflowWidget::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) -{ - 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("slice_stop")) { - m_internal->setSliceStop(map["slice_stop"].toInt()); - } - - m_internal->setReconExtraParamValues(map); -} - -void FxiWorkflowWidget::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() -{ - Superclass::writeSettings(); - m_internal->writeSettings(); -} - -} // namespace tomviz 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/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 diff --git a/tomviz/MainWindow.cxx b/tomviz/MainWindow.cxx index 4f6da8816..a30ae651e 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(); @@ -332,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:"); @@ -351,8 +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"); - QAction* fxiWorkflowAction = m_ui->menuTomography->addAction("FXI Workflow"); + m_ui->menuTomography->addAction("TomoPy Reconstruction"); m_ui->menuTomography->addSeparator(); QAction* simulationLabel = @@ -411,10 +414,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, @@ -432,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, @@ -455,13 +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")); - new AddPythonTransformReaction( - fxiWorkflowAction, "Reconstruct (FXI Workflow)", - readInPythonScript("Recon_tomopy_fxi"), true, false, false, - readInJSONDescription("Recon_tomopy_fxi")); + reconTomoPyGridRecAction, "Reconstruct (TomoPy)", + readInPythonScript("Recon_tomopy"), true, false, false, + readInJSONDescription("Recon_tomopy")); new ReconstructionReaction(reconWBP_CAction); diff --git a/tomviz/Pipeline.cxx b/tomviz/Pipeline.cxx index 141aa3e05..e285c658f 100644 --- a/tomviz/Pipeline.cxx +++ b/tomviz/Pipeline.cxx @@ -188,11 +188,65 @@ 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) { + // 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]() { + 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) { + // 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]() { + emit breakpointReached(breakpointOp); + }); + return future; + } + return execute(ds, start, nullptr); } @@ -220,7 +274,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 +296,23 @@ 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. + // 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) { + auto prevOp = operators[startIndex - 1]; + if (prevOp->isCompleted() && start->isQueued()) { + ds = transformedDataSource(ds); + } else { + startIndex = 0; + } + } + } // If we have been asked to run until the new operator we can just return // the transformed data. @@ -362,8 +432,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()) { @@ -488,9 +563,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/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/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() diff --git a/tomviz/PipelineModel.cxx b/tomviz/PipelineModel.cxx index 9ad99f51d..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,16 +872,35 @@ 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); 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..44d5c2265 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); @@ -230,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; @@ -237,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; } @@ -413,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(); } @@ -424,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()); @@ -493,15 +569,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(); } } } @@ -548,9 +661,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)) { @@ -669,6 +782,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..47de946e4 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); @@ -48,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/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/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/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 a02cc8c34..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 @@ -849,4 +908,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/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/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/ShiftRotationCenterWidget.cxx b/tomviz/ShiftRotationCenterWidget.cxx new file mode 100644 index 000000000..05a3c2560 --- /dev/null +++ b/tomviz/ShiftRotationCenterWidget.cxx @@ -0,0 +1,982 @@ +/* This source file is part of the Tomviz project, https://tomviz.org/. + It is released under the 3-Clause BSD License, see "LICENSE". */ + +#include "ShiftRotationCenterWidget.h" +#include "ui_ShiftRotationCenterWidget.h" + +#include "ActiveObjects.h" +#include "ColorMap.h" +#include "DataSource.h" +#include "InternalPythonHelper.h" +#include "PresetDialog.h" +#include "Utilities.h" + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +namespace tomviz { + +class InternalProgressDialog : public QProgressDialog +{ +public: + InternalProgressDialog(QWidget* parent = nullptr) : QProgressDialog(parent) + { + setWindowTitle("Tomviz"); + setLabelText("Generating test images..."); + setMinimum(0); + setMaximum(0); + setWindowModality(Qt::WindowModal); + + // No cancel button + setCancelButton(nullptr); + + // No close button in the corner + setWindowFlags((windowFlags() | Qt::CustomizeWindowHint) & + ~Qt::WindowCloseButtonHint); + + reset(); + } + + void keyPressEvent(QKeyEvent* e) override + { + // Do not let the user close the dialog by pressing escape + if (e->key() == Qt::Key_Escape) { + return; + } + + QProgressDialog::keyPressEvent(e); + } +}; + +class InteractorStyle : public vtkInteractorStyleImage +{ + // Our customized 2D interactor style class +public: + static InteractorStyle* New(); + + InteractorStyle() { this->SetInteractionModeToImage2D(); } + + void OnLeftButtonDown() override + { + // Override this to not do window level events, and instead do panning. + int x = this->Interactor->GetEventPosition()[0]; + int y = this->Interactor->GetEventPosition()[1]; + + this->FindPokedRenderer(x, y); + if (this->CurrentRenderer == nullptr) { + return; + } + + this->GrabFocus(this->EventCallbackCommand); + if (!this->Interactor->GetShiftKey() && + !this->Interactor->GetControlKey()) { + this->StartPan(); + } else { + this->Superclass::OnLeftButtonDown(); + } + } +}; + +vtkStandardNewMacro(InteractorStyle) + +class ShiftRotationCenterWidget::Internal : public QObject +{ + Q_OBJECT + +public: + Ui::ShiftRotationCenterWidget ui; + QPointer op; + vtkSmartPointer image; + vtkSmartPointer rotationImages; + vtkSmartPointer colorMap; + vtkSmartPointer lut; + QList rotations; + vtkNew slice; + vtkNew mapper; + vtkNew renderer; + vtkNew axesActor; + + // 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; + + // 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; + QPointer dataSource; + int sliceNumber = 0; + QScopedPointer progressDialog; + QFutureWatcher futureWatcher; + bool testRotationsSuccess = false; + QString testRotationsErrorMessage; + + Internal(Operator* o, vtkSmartPointer img, + ShiftRotationCenterWidget* p) + : op(o), image(img) + { + // Must call setupUi() before using p in any way + ui.setupUi(p); + setParent(p); + parent = p; + + renderer->SetBackground(1, 1, 1); + mapper->SetOrientation(0); + slice->SetMapper(mapper); + renderer->AddViewProp(slice); + ui.sliceView->renderWindow()->AddRenderer(renderer); + + vtkNew interactorStyle; + ui.sliceView->interactor()->SetInteractorStyle(interactorStyle); + setRotationData(vtkImageData::New()); + + // Use a child data source if one is available so the color map will match + if (op->childDataSource()) { + dataSource = op->childDataSource(); + } else if (op->dataSource()) { + dataSource = op->dataSource(); + } else { + 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 yellow center line overlay (vertical) + vtkNew lineMapper; + lineMapper->SetInputConnection(centerLine->GetOutputPort()); + centerLineActor->SetMapper(lineMapper); + centerLineActor->GetProperty()->SetColor(1, 1, 0); + 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); + + // 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"); + 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 + chartViewQn->SetRenderWindow(ui.plotViewQn->renderWindow()); + chartViewQn->SetInteractor(ui.plotViewQn->interactor()); + chartViewQn->GetScene()->AddItem(chartQn); + chartQn->SetTitle("Qn"); + 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); + + // 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; + + auto pxm = ActiveObjects::instance().proxyManager(); + vtkNew tfmgr; + colorMap = + 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); + } + + // This isn't always working in Qt designer, so set it here as well + 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 (in physical units) + auto delta = dims[1] * 0.1 * spacing[1]; + ui.start->setValue(-delta); + ui.stop->setValue(delta); + + // 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(); + + // Hide iterations by default (only shown for iterative algorithms) + updateAlgorithmUI(); + + progressDialog.reset(new InternalProgressDialog(parent)); + + updateControls(); + setupConnections(); + + // Update line positions now that all values are set + updateCenterLine(); + updateSliceLine(); + } + + void setupConnections() + { + connect(ui.testRotations, &QPushButton::pressed, this, + &Internal::startGeneratingTestImages); + connect(ui.imageViewSlider, &IntSliderWidget::valueEdited, this, + &Internal::sliderEdited); + connect(&futureWatcher, &QFutureWatcher::finished, this, + &Internal::testImagesGenerated); + connect(&futureWatcher, &QFutureWatcher::finished, + progressDialog.data(), &QProgressDialog::accept); + connect(ui.colorPresetButton, &QToolButton::clicked, this, + &Internal::onColorPresetClicked); + connect(ui.previewMin, &DoubleSliderWidget::valueEdited, this, + &Internal::onPreviewRangeEdited); + connect(ui.previewMax, &DoubleSliderWidget::valueEdited, this, + &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.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, + &Internal::onSliceChanged); + connect(ui.rotationCenter, + QOverload::of(&QDoubleSpinBox::valueChanged), this, + &Internal::updateCenterLine); + connect(ui.rotationCenter, + QOverload::of(&QDoubleSpinBox::valueChanged), this, + &Internal::updateChartIndicator); + } + + void onProjectionChanged(int val) + { + // Update the projection view to show the selected projection + projMapper->SetSliceNumber(val); + projMapper->Update(); + updateCenterLine(); + updateSliceLine(); + + ui.projectionView->renderWindow()->Render(); + } + + void onSliceChanged(int) + { + updateSliceLine(); + clearTestResults(); + } + + void clearTestResults() + { + setRotationData(vtkImageData::New()); + rotations.clear(); + qiaValues.clear(); + qnValues.clear(); + updateImageViewSlider(); + updateChart(); + render(); + } + + void updateCenterLine() + { + if (!image) { + return; + } + + double bounds[6]; + image->GetBounds(bounds); + double centerY = (bounds[2] + bounds[3]) / 2.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). + 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(); + centerLineActor->GetMapper()->Update(); + + projRenderer->ResetCameraClippingRange(); + 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 + // "Text is not set" errors caused by degenerate bounds in the + // slice axis dimension. + tomviz::setupRenderer(renderer, mapper, nullptr); + } + + void render() { ui.sliceView->renderWindow()->Render(); } + + void readSettings() + { + auto settings = pqApplicationCore::instance()->settings(); + settings->beginGroup("ShiftRotationCenterWidget"); + 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(); + } + + void writeSettings() + { + auto settings = pqApplicationCore::instance()->settings(); + settings->beginGroup("ShiftRotationCenterWidget"); + settings->setValue("steps", ui.steps->value()); + settings->setValue("algorithm", algorithm()); + settings->setValue("numIterations", ui.numIterations->value()); + settings->setValue("circMaskRatio", ui.circMaskRatio->value()); + settings->endGroup(); + } + + QList inputWidgets() + { + return { ui.start, ui.stop, ui.steps, ui.projectionNo, ui.slice, + ui.rotationCenter }; + } + + void startGeneratingTestImages() + { + progressDialog->show(); + auto future = + QtConcurrent::run(std::bind(&Internal::generateTestImages, this)); + futureWatcher.setFuture(future); + } + + void testImagesGenerated() + { + if (!testRotationsSuccess) { + auto msg = testRotationsErrorMessage; + qCritical() << msg; + QMessageBox::critical(parent, "Tomviz", msg); + 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(); + } + + updateChart(); + } + + void generateTestImages() + { + testRotationsSuccess = false; + rotations.clear(); + + { + Python python; + auto module = pythonHelper.loadModule(script); + if (!module.isValid()) { + testRotationsErrorMessage = "Failed to load script"; + return; + } + + auto func = module.findFunction("test_rotations"); + if (!func.isValid()) { + testRotationsErrorMessage = + "Failed to find function \"test_rotations\""; + return; + } + + Python::Object data = Python::createDataset(image, *dataSource); + + Python::Dict kwargs; + kwargs.set("dataset", data); + 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()); + kwargs.set("num_iter", ui.numIterations->value()); + kwargs.set("circ_mask_ratio", ui.circMaskRatio->value()); + + auto ret = func.call(kwargs); + auto result = ret.toDict(); + if (!result.isValid()) { + testRotationsErrorMessage = "Failed to execute test_rotations()"; + return; + } + + auto pyImages = result["images"]; + auto* object = Python::VTK::convertToDataObject(pyImages); + if (!object) { + testRotationsErrorMessage = + "No image data was returned from test_rotations()"; + return; + } + + auto* imageData = vtkImageData::SafeDownCast(object); + if (!imageData) { + testRotationsErrorMessage = + "No image data was returned from test_rotations()"; + return; + } + + auto centers = result["centers"]; + auto pyRotations = centers.toList(); + if (!pyRotations.isValid() || pyRotations.length() <= 0) { + testRotationsErrorMessage = + "No rotations returned from test_rotations()"; + return; + } + + 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); + } + + // If we made it this far, it was a success + // Save these settings in case the user wants to use them again... + writeSettings(); + testRotationsSuccess = true; + } + + void setRotationData(vtkImageData* data) + { + rotationImages = data; + mapper->SetInputData(rotationImages); + mapper->SetSliceNumber(0); + mapper->Update(); + } + + void resetColorRange() + { + if (!rotationDataValid()) { + return; + } + + auto* range = rotationImages->GetScalarRange(); + + auto blocked1 = QSignalBlocker(ui.previewMin); + auto blocked2 = QSignalBlocker(ui.previewMax); + ui.previewMin->setMinimum(range[0]); + ui.previewMin->setMaximum(range[1]); + ui.previewMin->setValue(range[0]); + ui.previewMax->setMinimum(range[0]); + ui.previewMax->setMaximum(range[1]); + ui.previewMax->setValue(range[1]); + + rescaleColors(range); + } + + void rescaleColors(double* range) + { + // Always perform a deep copy of the original color map + // If we always modify the control points of the same LUT, + // the control points will often change and we will end up + // with a very different LUT than we had originally. + resetLut(); + if (!lut) { + return; + } + + auto* tf = vtkColorTransferFunction::SafeDownCast(lut); + if (!tf) { + return; + } + + rescaleLut(tf, range[0], range[1]); + } + + void onPreviewRangeEdited() + { + if (!rotationDataValid() || !lut) { + return; + } + + auto* maxRange = rotationImages->GetScalarRange(); + + double range[2]; + range[0] = ui.previewMin->value(); + range[1] = ui.previewMax->value(); + + auto minDiff = (maxRange[1] - maxRange[0]) / 1000; + if (range[1] - range[0] < minDiff) { + if (sender() == ui.previewMin) { + // Move the max + range[1] = range[0] + minDiff; + auto blocked = QSignalBlocker(ui.previewMax); + ui.previewMax->setValue(range[1]); + } else { + // Move the min + range[0] = range[1] - minDiff; + auto blocked = QSignalBlocker(ui.previewMin); + ui.previewMin->setValue(range[0]); + } + } + + rescaleColors(range); + render(); + } + + void updateControls() + { + std::vector blockers; + for (auto w : inputWidgets()) { + blockers.emplace_back(w); + } + + updateImageViewSlider(); + } + + bool rotationDataValid() + { + if (!rotationImages.GetPointer()) { + return false; + } + + if (rotations.isEmpty()) { + return false; + } + + return true; + } + + void updateImageViewSlider() + { + auto blocked = QSignalBlocker(ui.imageViewSlider); + + bool enable = rotationDataValid(); + ui.testRotationsSettingsGroup->setVisible(enable); + if (!enable) { + return; + } + + auto* dims = rotationImages->GetDimensions(); + ui.imageViewSlider->setMaximum(dims[0] - 1); + + sliceNumber = dims[0] / 2; + ui.imageViewSlider->setValue(sliceNumber); + + sliderEdited(); + } + + void sliderEdited() + { + sliceNumber = ui.imageViewSlider->value(); + if (sliceNumber < rotations.size()) { + ui.currentRotation->setValue(rotations[sliceNumber]); + + // For convenience, also set the rotation center + ui.rotationCenter->setValue(rotations[sliceNumber]); + } else { + qCritical() << sliceNumber + << "is greater than the rotations size:" << rotations.size(); + } + + mapper->SetSliceNumber(sliceNumber); + mapper->Update(); + render(); + } + + bool eventFilter(QObject* o, QEvent* e) override + { + if (inputWidgets().contains(qobject_cast(o))) { + if (e->type() == QEvent::KeyPress) { + QKeyEvent* keyEvent = static_cast(e); + if (keyEvent->key() == Qt::Key_Return || + keyEvent->key() == Qt::Key_Enter) { + e->accept(); + qobject_cast(o)->clearFocus(); + return true; + } + } + } + return QObject::eventFilter(o, e); + } + + void resetLut() + { + auto dsLut = + vtkScalarsToColors::SafeDownCast(colorMap->GetClientSideObject()); + if (!dsLut) { + return; + } + + // Make a deep copy to modify + lut = dsLut->NewInstance(); + lut->DeepCopy(dsLut); + slice->GetProperty()->SetLookupTable(lut); + } + + void setColorMapToGrayscale() + { + ColorMap::instance().applyPreset("Grayscale", colorMap); + } + + void onColorPresetClicked() + { + if (!colorMap) { + qCritical() << "No color map found!"; + return; + } + + PresetDialog dialog(tomviz::mainWidget()); + connect(&dialog, &PresetDialog::applyPreset, this, [this, &dialog]() { + ColorMap::instance().applyPreset(dialog.presetName(), colorMap); + // Keep the range the same + double range[2]; + range[0] = ui.previewMin->value(); + range[1] = ui.previewMax->value(); + rescaleColors(range); + render(); + }); + dialog.exec(); + } + + void setRotationCenter(double center) { ui.rotationCenter->setValue(center); } + double rotationCenter() const { return ui.rotationCenter->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 updateAlgorithmUI() + { + 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); + } +}; + +#include "ShiftRotationCenterWidget.moc" + +ShiftRotationCenterWidget::ShiftRotationCenterWidget( + Operator* op, vtkSmartPointer image, QWidget* p) + : CustomPythonOperatorWidget(p) +{ + m_internal.reset(new Internal(op, image, this)); +} + +ShiftRotationCenterWidget::~ShiftRotationCenterWidget() = default; + +void ShiftRotationCenterWidget::getValues(QVariantMap& map) +{ + map.insert("rotation_center", m_internal->rotationCenter()); +} + +void ShiftRotationCenterWidget::setValues(const QVariantMap& map) +{ + if (map.contains("rotation_center")) { + m_internal->setRotationCenter(map["rotation_center"].toDouble()); + } + if (map.contains("algorithm")) { + m_internal->setAlgorithm(map["algorithm"].toString()); + } + if (map.contains("num_iter")) { + m_internal->ui.numIterations->setValue(map["num_iter"].toInt()); + } +} + +void ShiftRotationCenterWidget::setScript(const QString& script) +{ + Superclass::setScript(script); + m_internal->script = script; +} + +void ShiftRotationCenterWidget::writeSettings() +{ + Superclass::writeSettings(); + m_internal->writeSettings(); +} + +} // namespace tomviz 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 61% rename from tomviz/FxiWorkflowWidget.ui rename to tomviz/ShiftRotationCenterWidget.ui index 4e2cd3c5a..c9aeb39ea 100644 --- a/tomviz/FxiWorkflowWidget.ui +++ b/tomviz/ShiftRotationCenterWidget.ui @@ -1,7 +1,7 @@ - FxiWorkflowWidget - + ShiftRotationCenterWidget + 0 @@ -30,11 +30,28 @@ 0 - - - + + + + + + + + 1 + 2 + + + + + 300 + 300 + + + + + - + 2 @@ -51,7 +68,7 @@ - + Test Rotations @@ -94,6 +111,9 @@ 3 + + -100000.000000000000000 + 100000.000000000000000 @@ -117,6 +137,9 @@ 3 + + -100000.000000000000000 + 100000.000000000000000 @@ -142,6 +165,26 @@ + + + + Projection No.: + + + projectionNo + + + + + + + false + + + 100000 + + + @@ -164,29 +207,136 @@ - - - - - - - - 450 - 0 - + + + + + + Algorithm: + + + algorithm + + + + + + + + gridrec - - true + + + + fbp + + - <html><head/><body><p><span style=" font-size:large; font-weight:600;">Additional Parameters</span></p></body></html> + mlem - - true + + + + ospml_hybrid - - - + + + + + + + <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 + + + + + + + + + + + Circle Mask Ratio: + + + circMaskRatio + + + + + + + false + + + 2 + + + 0.000000000000000 + + + 1.000000000000000 + + + 0.050000000000000 + + + 0.800000000000000 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + 1 + 1 + + + + + 200 + 200 + + @@ -235,6 +385,9 @@ 3 + + -1000000.000000000000000 + 1000000.000000000000000 @@ -281,7 +434,7 @@ - Rotation: + Offset: currentRotation @@ -308,23 +461,7 @@ - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 40 - - - - - - + QGroupBox{padding-top:15px; margin-top:-15px} @@ -332,256 +469,91 @@ - - - - - - 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 - - - denoiseLevel - - - - - - - <html><head/><body><p>The level to apply to tomopy.prep.stripe.remove_stripe_fw</p></body></html> - - - 0 + <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> - - 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 - - - - - + + + + + + + + + 1 + 1 + + + + + 100 + 150 + + + + + + + + + 1 + 1 + + + + + 100 + 150 + + + + + + + @@ -613,19 +585,18 @@ - denoiseFlag - denoiseLevel - darkScale start stop steps + projectionNo slice + algorithm + numIterations + circMaskRatio testRotations currentRotation - colorPresetButton rotationCenter - sliceStart - sliceStop + colorPresetButton 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]; 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/icons/breakpoint.png b/tomviz/icons/breakpoint.png new file mode 100644 index 000000000..6c7abaf5b Binary files /dev/null and b/tomviz/icons/breakpoint.png differ diff --git a/tomviz/icons/breakpoint@2x.png b/tomviz/icons/breakpoint@2x.png new file mode 100644 index 000000000..0114dfadf Binary files /dev/null and b/tomviz/icons/breakpoint@2x.png differ diff --git a/tomviz/icons/play.png b/tomviz/icons/play.png new file mode 100644 index 000000000..5c801f27d Binary files /dev/null and b/tomviz/icons/play.png differ diff --git a/tomviz/icons/play@2x.png b/tomviz/icons/play@2x.png new file mode 100644 index 000000000..b8acb444f Binary files /dev/null and b/tomviz/icons/play@2x.png differ 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/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; 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..bfeb4fa0f 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*, vtkSMViewProxy*) +{ + 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..d941b1305 --- /dev/null +++ b/tomviz/modules/ModulePlot.cxx @@ -0,0 +1,328 @@ +/* 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 +#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(); + + 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}; + 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; + + 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(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, + &ModulePlot::onYLogScaleChanged); + + 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(); + } +} + +void ModulePlot::onXLogScaleChanged(bool logScale) +{ + if (m_chart == nullptr) { + return; + } + + 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; + } + + 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 new file mode 100644 index 000000000..7dd29f212 --- /dev/null +++ b/tomviz/modules/ModulePlot.h @@ -0,0 +1,78 @@ +/* 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; + +class QCheckBox; +class QLineEdit; + + +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 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); + 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; + QPointer m_xLogCheckBox; + QPointer m_yLogCheckBox; + QPointer m_xLabelEdit; + QPointer m_yLabelEdit; + +}; +} // namespace tomviz +#endif 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() diff --git a/tomviz/operators/EditOperatorDialog.cxx b/tomviz/operators/EditOperatorDialog.cxx index 5457d8478..3e637fd0d 100644 --- a/tomviz/operators/EditOperatorDialog.cxx +++ b/tomviz/operators/EditOperatorDialog.cxx @@ -20,6 +20,8 @@ #include #include #include +#include +#include #include #include @@ -123,6 +125,40 @@ EditOperatorDialog::EditOperatorDialog(Operator* op, DataSource* dataSource, EditOperatorDialog::~EditOperatorDialog() {} +void EditOperatorDialog::showEvent(QShowEvent* event) +{ + Superclass::showEvent(event); + + // 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(); + + // 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) { 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(); diff --git a/tomviz/operators/Operator.cxx b/tomviz/operators/Operator.cxx index a1705ed9c..010ad9bba 100644 --- a/tomviz/operators/Operator.cxx +++ b/tomviz/operators/Operator.cxx @@ -162,13 +162,18 @@ QJsonObject Operator::serialize() const } json["type"] = OperatorFactory::instance().operatorType(this); json["id"] = QString::asprintf("%p", static_cast(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/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; 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() 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/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/BinTiltSeriesByTwo.py b/tomviz/python/BinTiltSeriesByTwo.py index e8139da93..66dd1d508 100644 --- a/tomviz/python/BinTiltSeriesByTwo.py +++ b/tomviz/python/BinTiltSeriesByTwo.py @@ -1,7 +1,6 @@ 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/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/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 e9babaf70..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. @@ -21,6 +17,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 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/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..5707bd87e --- /dev/null +++ b/tomviz/python/PowerSpectrumDensity.py @@ -0,0 +1,68 @@ +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!") + + scalars = pad_to_cubic(scalars) + + 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 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/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 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/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/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.json b/tomviz/python/Recon_tomopy.json new file mode 100644 index 000000000..cb5d501aa --- /dev/null +++ b/tomviz/python/Recon_tomopy.json @@ -0,0 +1,40 @@ +{ + "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"}, + {"FBP" : "fbp"}, + {"MLEM" : "mlem"}, + {"OSPML Hybrid" : "ospml_hybrid"} + ] + }, + { + "name" : "num_iter", + "label" : "Number of Iterations", + "description" : "Number of iterations (iterative methods only)", + "type" : "int", + "default" : 5, + "minimum" : 1, + "maximum" : 1000, + "visible_if" : "algorithm == 'mlem' or algorithm == 'ospml_hybrid'" + } + ], + "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..304ac050d --- /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, 0, 1)) + + # 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 in ('mlem', 'ospml_hybrid'): + 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, 1, 0)) + + child = dataset.create_child_dataset() + child.active_scalars = rec + + return {'reconstruction': child} 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/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 f09c36837..000000000 --- a/tomviz/python/Recon_tomopy_gridrec.py +++ /dev/null @@ -1,77 +0,0 @@ -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 - - 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 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/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..8878bbcda --- /dev/null +++ b/tomviz/python/ShiftRotationCenter_tomopy.py @@ -0,0 +1,158 @@ +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 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 / dataset.spacing[1] + + # Shift the entire volume along the detector horizontal axis + shift_vec = [0.0, 0.0, 0.0] + shift_vec[1] = 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, circ_mask_ratio=0.8): + # 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') + + # 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, + } + + kwargs = { + 'f': recon_input, + '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) + + 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 + + +def rotcen_test(f, start=None, stop=None, steps=None, sli=0, + algorithm='gridrec', num_iter=15, circ_mask_ratio=0.8): + + 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 + + # 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 = {} + if algorithm not in ('gridrec', 'fbp'): + 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=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 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/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): diff --git a/tomviz/python/tomviz/_internal.py b/tomviz/python/tomviz/_internal.py index 2de288d9a..cf0120b8e 100644 --- a/tomviz/python/tomviz/_internal.py +++ b/tomviz/python/tomviz/_internal.py @@ -4,6 +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 pathlib import Path +from types import MethodType +from typing import Any, Callable import fnmatch import importlib.machinery import importlib.util @@ -15,9 +18,6 @@ import tempfile import traceback -from pathlib import Path -from typing import Callable - import tomviz import tomviz.operators @@ -130,6 +130,65 @@ 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 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 + + 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_decorator(transform_method, apply_to_each_array) + + 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 +198,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/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 6ed22dbb4..90816d7cc 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 @@ -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: @@ -699,6 +709,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: @@ -764,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 diff --git a/tomviz/python/tomviz/ptycho/ptycho.py b/tomviz/python/tomviz/ptycho/ptycho.py index 9bf5a4fb9..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')]) @@ -444,10 +452,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 +469,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 +498,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 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 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