03a09c43bc
Styling: - Entry widgets (line edits, spin boxes, combos, item views) are salmon (the GUI default) when inactive and turn white while focused/active. - A teal accent (#2a9d8f) for selections and the QProgressBar chunk, against the salmon background. - Move the jobs-table Status column (the progress bar) to column 2 so it stays visible in the narrow dock instead of scrolling off the right edge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
408 lines
16 KiB
C++
408 lines
16 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||
// SPDX-License-Identifier: GPL-3.0-only
|
||
|
||
#include "JFJochProcessingJobsWindow.h"
|
||
#include "../../process/JFJochProcessCommandLine.h"
|
||
|
||
#include <QApplication>
|
||
#include <QCheckBox>
|
||
#include <QClipboard>
|
||
#include <QComboBox>
|
||
#include <QDateTime>
|
||
#include <QDialog>
|
||
#include <QDialogButtonBox>
|
||
#include <QFileInfo>
|
||
#include <QFormLayout>
|
||
#include <QHeaderView>
|
||
#include <QLabel>
|
||
#include <QLineEdit>
|
||
#include <QMessageBox>
|
||
#include <QProgressBar>
|
||
#include <QPushButton>
|
||
#include <QSpinBox>
|
||
#include <QStackedWidget>
|
||
#include <QTableWidget>
|
||
#include <QToolBar>
|
||
#include <QVBoxLayout>
|
||
|
||
#include <thread>
|
||
|
||
namespace {
|
||
// Status (the progress bar) is column 2 so it stays visible in the narrow dock.
|
||
enum Column { COL_NAME = 0, COL_STATUS, COL_STARTED, COL_MODE, COL_IMAGES, COL_INDEX, COL_CELL, COL_COUNT };
|
||
|
||
int default_threads() {
|
||
const unsigned hc = std::thread::hardware_concurrency();
|
||
return hc == 0 ? 4 : static_cast<int>(hc);
|
||
}
|
||
}
|
||
|
||
JFJochProcessingJobsWindow::JFJochProcessingJobsWindow(JFJochImageReadingWorker *worker, QWidget *parent)
|
||
: QWidget(parent), worker_(worker) {
|
||
setWindowTitle("Processing");
|
||
|
||
controller_ = new JFJochProcessController(this);
|
||
connect(controller_, &JFJochProcessController::phaseChanged, this, &JFJochProcessingJobsWindow::onPhase);
|
||
connect(controller_, &JFJochProcessController::progress, this, &JFJochProcessingJobsWindow::onProgress);
|
||
connect(controller_, &JFJochProcessController::finished, this, &JFJochProcessingJobsWindow::onFinished);
|
||
connect(controller_, &JFJochProcessController::failed, this, &JFJochProcessingJobsWindow::onFailed);
|
||
connect(controller_, &JFJochProcessController::liveDataset, this, &JFJochProcessingJobsWindow::liveDataset);
|
||
|
||
toolbar_ = new QToolBar("Jobs", this);
|
||
toolbar_->setMovable(false);
|
||
toolbar_->addAction("New job…", this, &JFJochProcessingJobsWindow::newJob);
|
||
toolbar_->addAction("Cancel", this, &JFJochProcessingJobsWindow::cancelJob);
|
||
toolbar_->addAction("Remove result", this, &JFJochProcessingJobsWindow::removeResult);
|
||
toolbar_->addSeparator();
|
||
toolbar_->addAction("View results", this, &JFJochProcessingJobsWindow::viewResults);
|
||
toolbar_->addAction("Show original", this, &JFJochProcessingJobsWindow::showOriginal);
|
||
|
||
table_ = new QTableWidget(0, COL_COUNT, this);
|
||
table_->setMinimumHeight(60); // let the bottom dock shrink freely
|
||
table_->setHorizontalHeaderLabels({"Name", "Status", "Started", "Mode", "Images", "Index %", "Unit cell"});
|
||
table_->horizontalHeader()->setStretchLastSection(true);
|
||
table_->setSelectionBehavior(QAbstractItemView::SelectRows);
|
||
table_->setSelectionMode(QAbstractItemView::SingleSelection);
|
||
// Only the Name column is editable (per-item flags); editing it renames the run's legend label.
|
||
table_->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed);
|
||
connect(table_, &QTableWidget::itemChanged, this, [this](QTableWidgetItem *item) {
|
||
if (item->column() != COL_NAME)
|
||
return;
|
||
const int row = item->row();
|
||
if (row < 0 || row >= static_cast<int>(jobs_.size()) || item->text() == jobs_[row].label)
|
||
return;
|
||
jobs_[row].label = item->text();
|
||
emit renameRun(jobs_[row].id, jobs_[row].label);
|
||
});
|
||
|
||
auto *message = new QLabel("Dataset re-processing is currently available only in File mode.\n\n"
|
||
"Open a stored HDF5 file to run processing jobs.", this);
|
||
message->setAlignment(Qt::AlignCenter);
|
||
message->setWordWrap(true);
|
||
|
||
stack_ = new QStackedWidget(this);
|
||
stack_->addWidget(table_); // page 0
|
||
stack_->addWidget(message); // page 1
|
||
|
||
auto *layout = new QVBoxLayout(this);
|
||
layout->setContentsMargins(0, 0, 0, 0);
|
||
layout->addWidget(toolbar_);
|
||
layout->addWidget(stack_);
|
||
}
|
||
|
||
void JFJochProcessingJobsWindow::onHttpConnectionChanged(bool connected, QString addr) {
|
||
// Re-processing reads a stored HDF5 file; it is not available for the live HTTP stream.
|
||
stack_->setCurrentIndex(connected ? 1 : 0);
|
||
toolbar_->setEnabled(!connected);
|
||
}
|
||
|
||
int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec &spec) {
|
||
QDialog dlg(this);
|
||
dlg.setWindowTitle("New processing job");
|
||
|
||
auto *mode = new QComboBox(&dlg);
|
||
mode->addItem("Full analysis", static_cast<int>(ProcessMode::FullAnalysis));
|
||
mode->addItem("Azimuthal integration", static_cast<int>(ProcessMode::AzimuthalIntegration));
|
||
|
||
auto *start_image = new QSpinBox(&dlg);
|
||
start_image->setRange(0, 1'000'000'000);
|
||
start_image->setValue(0);
|
||
|
||
auto *end_image = new QSpinBox(&dlg);
|
||
end_image->setRange(0, 1'000'000'000);
|
||
end_image->setValue(0);
|
||
end_image->setSpecialValueText("end"); // 0 => to the last image
|
||
|
||
auto *threads = new QSpinBox(&dlg);
|
||
threads->setRange(1, 256);
|
||
threads->setValue(default_threads());
|
||
|
||
auto *save = new QCheckBox("Save _process.h5", &dlg);
|
||
save->setChecked(true);
|
||
|
||
const QString stem = QFileInfo(inputs.file).completeBaseName().remove(QStringLiteral("_master"));
|
||
auto *prefix = new QLineEdit(QStringLiteral("%1_run%2").arg(stem).arg(job_counter_ + 1), &dlg);
|
||
|
||
auto *rotation = new QCheckBox("Rotation indexing (two-pass)", &dlg);
|
||
rotation->setChecked(inputs.experiment.GetGoniometer().has_value());
|
||
auto *scaling = new QCheckBox("Scale && merge", &dlg);
|
||
|
||
auto sync_full = [&] {
|
||
const bool full = mode->currentData().toInt() == static_cast<int>(ProcessMode::FullAnalysis);
|
||
rotation->setEnabled(full);
|
||
scaling->setEnabled(full);
|
||
};
|
||
connect(mode, &QComboBox::currentIndexChanged, &dlg, sync_full);
|
||
sync_full();
|
||
|
||
auto *form = new QFormLayout;
|
||
form->addRow("Mode", mode);
|
||
form->addRow("Start image", start_image);
|
||
form->addRow("End image", end_image);
|
||
form->addRow("Threads", threads);
|
||
form->addRow(save);
|
||
form->addRow("Output prefix", prefix);
|
||
form->addRow(rotation);
|
||
form->addRow(scaling);
|
||
|
||
int result = 0;
|
||
auto *run = new QPushButton("Run locally", &dlg);
|
||
auto *copy = new QPushButton("Copy command", &dlg);
|
||
auto *cancel = new QPushButton("Cancel", &dlg);
|
||
run->setDefault(true);
|
||
connect(run, &QPushButton::clicked, &dlg, [&] { result = 1; dlg.accept(); });
|
||
connect(copy, &QPushButton::clicked, &dlg, [&] { result = 2; dlg.accept(); });
|
||
connect(cancel, &QPushButton::clicked, &dlg, [&] { result = 0; dlg.reject(); });
|
||
|
||
auto *buttons = new QHBoxLayout;
|
||
buttons->addStretch();
|
||
buttons->addWidget(run);
|
||
buttons->addWidget(copy);
|
||
buttons->addWidget(cancel);
|
||
|
||
auto *layout = new QVBoxLayout(&dlg);
|
||
layout->addLayout(form);
|
||
layout->addLayout(buttons);
|
||
|
||
dlg.exec();
|
||
if (result != 0) {
|
||
spec.mode = static_cast<ProcessMode>(mode->currentData().toInt());
|
||
spec.start_image = start_image->value();
|
||
spec.end_image = end_image->value();
|
||
spec.threads = threads->value();
|
||
spec.save = save->isChecked();
|
||
spec.prefix = prefix->text();
|
||
spec.rotation = rotation->isEnabled() && rotation->isChecked();
|
||
spec.scaling = scaling->isEnabled() && scaling->isChecked();
|
||
}
|
||
return result;
|
||
}
|
||
|
||
ProcessConfig JFJochProcessingJobsWindow::buildConfig(const JobSpec &spec, const ReprocessingInputs &inputs) const {
|
||
ProcessConfig config;
|
||
config.mode = spec.mode;
|
||
config.nthreads = spec.threads;
|
||
config.start_image = spec.start_image;
|
||
config.end_image = spec.end_image > 0 ? spec.end_image : -1; // 0 => to the end
|
||
config.output_prefix = spec.save ? spec.prefix.toStdString() : std::string();
|
||
config.spot_finding = inputs.spot_finding;
|
||
if (spec.mode == ProcessMode::FullAnalysis) {
|
||
config.rotation_indexing = spec.rotation;
|
||
config.two_pass_rotation = true;
|
||
config.run_scaling = spec.scaling;
|
||
}
|
||
return config;
|
||
}
|
||
|
||
void JFJochProcessingJobsWindow::newJob() {
|
||
const ReprocessingInputs inputs = worker_->GetReprocessingInputs();
|
||
if (!inputs.valid) {
|
||
QMessageBox::information(this, "Processing", "Open a file first (processing is not available for live HTTP data).");
|
||
return;
|
||
}
|
||
|
||
JobSpec spec;
|
||
const int action = askJob(inputs, spec);
|
||
if (action == 0)
|
||
return;
|
||
|
||
const ProcessConfig config = buildConfig(spec, inputs);
|
||
|
||
// Rotation indexing must be enabled on the indexing settings too (config.rotation_indexing only
|
||
// drives the two-pass pre-pass); otherwise IndexAndRefine never builds a rotation indexer.
|
||
DiffractionExperiment experiment = inputs.experiment;
|
||
if (spec.mode == ProcessMode::FullAnalysis) {
|
||
auto idx = experiment.GetIndexingSettings();
|
||
idx.RotationIndexing(spec.rotation);
|
||
experiment.ImportIndexingSettings(idx);
|
||
}
|
||
|
||
if (action == 2) { // copy command line
|
||
const QString cmd = QString::fromStdString(
|
||
JFJochProcessCommandLine(config, experiment, inputs.file.toStdString()));
|
||
QApplication::clipboard()->setText(cmd);
|
||
QMessageBox::information(this, "Command line", cmd + "\n\n(copied to clipboard)");
|
||
return;
|
||
}
|
||
|
||
if (controller_->running()) {
|
||
QMessageBox::information(this, "Processing", "A job is already running; wait for it to finish or cancel it.");
|
||
return;
|
||
}
|
||
|
||
const bool full = spec.mode == ProcessMode::FullAnalysis;
|
||
const int run_number = ++job_counter_;
|
||
const QString label = QStringLiteral("%1 %2").arg(full ? "Full" : "AzInt").arg(run_number);
|
||
const QString id = QStringLiteral("run-%1").arg(run_number);
|
||
|
||
JobInfo info;
|
||
info.id = id;
|
||
info.label = label;
|
||
if (spec.save)
|
||
info.snapshot_path = QString::fromStdString(config.output_prefix) + "_process.h5";
|
||
|
||
const int row = table_->rowCount();
|
||
table_->insertRow(row);
|
||
jobs_.push_back(info); // jobs_[row] must exist before setItem(COL_NAME) fires itemChanged
|
||
|
||
auto fixed = [](const QString &text) { // a non-editable cell
|
||
auto *cell = new QTableWidgetItem(text);
|
||
cell->setFlags(cell->flags() & ~Qt::ItemIsEditable);
|
||
return cell;
|
||
};
|
||
|
||
const QString range_text = (spec.start_image == 0 && spec.end_image == 0)
|
||
? QStringLiteral("all")
|
||
: QStringLiteral("%1–%2").arg(spec.start_image)
|
||
.arg(spec.end_image > 0 ? QString::number(spec.end_image) : QStringLiteral("end"));
|
||
const int expected = spec.end_image > spec.start_image ? spec.end_image - spec.start_image : 0;
|
||
|
||
table_->setItem(row, COL_NAME, new QTableWidgetItem(label)); // editable (default flags)
|
||
table_->setItem(row, COL_STARTED, fixed(QDateTime::currentDateTime().toString("HH:mm:ss")));
|
||
table_->setItem(row, COL_MODE, fixed(full ? "Full" : "AzInt"));
|
||
table_->setItem(row, COL_IMAGES, fixed(range_text));
|
||
table_->setItem(row, COL_STATUS, fixed("queued"));
|
||
table_->setItem(row, COL_INDEX, fixed("-"));
|
||
table_->setItem(row, COL_CELL, fixed("-"));
|
||
|
||
// A progress bar lives in the Status cell while the job runs; it shows the phase as text until
|
||
// image processing starts, then "<done> / <expected>" with the bar filling in the background.
|
||
running_bar_ = new QProgressBar(table_);
|
||
running_bar_->setAlignment(Qt::AlignCenter);
|
||
running_bar_->setRange(0, expected > 0 ? expected : 1);
|
||
running_bar_->setValue(0);
|
||
running_bar_->setFormat("queued");
|
||
table_->setCellWidget(row, COL_STATUS, running_bar_);
|
||
|
||
running_row_ = row;
|
||
|
||
job_timer_.start();
|
||
controller_->start(inputs.file, experiment, inputs.pixel_mask, config);
|
||
emit writeStatusBar("Started processing job " + label);
|
||
}
|
||
|
||
void JFJochProcessingJobsWindow::cancelJob() {
|
||
if (controller_->running()) {
|
||
controller_->cancel();
|
||
emit writeStatusBar("Cancelling processing job…");
|
||
}
|
||
}
|
||
|
||
void JFJochProcessingJobsWindow::removeResult() {
|
||
const int row = table_->currentRow();
|
||
if (row < 0 || row >= static_cast<int>(jobs_.size()))
|
||
return;
|
||
if (row == running_row_) {
|
||
QMessageBox::information(this, "Processing", "Cancel the running job before removing it.");
|
||
return;
|
||
}
|
||
emit removeRun(jobs_[row].id); // drops the snapshot from the reader (no-op for Original)
|
||
|
||
QSignalBlocker block(table_); // row removal must not look like a rename
|
||
table_->removeRow(row);
|
||
jobs_.erase(jobs_.begin() + row);
|
||
if (running_row_ > row)
|
||
--running_row_;
|
||
}
|
||
|
||
void JFJochProcessingJobsWindow::clearJobs() {
|
||
if (controller_->running())
|
||
controller_->cancel();
|
||
emit liveDataset(nullptr);
|
||
|
||
QSignalBlocker block(table_);
|
||
table_->setRowCount(0);
|
||
jobs_.clear();
|
||
job_counter_ = 0;
|
||
running_row_ = -1;
|
||
running_bar_ = nullptr;
|
||
}
|
||
|
||
void JFJochProcessingJobsWindow::viewResults() {
|
||
const int row = table_->currentRow();
|
||
if (row < 0 || row >= static_cast<int>(jobs_.size()))
|
||
return;
|
||
if (!jobs_[row].has_result) {
|
||
QMessageBox::information(this, "Processing", "This job has no saved results to view.");
|
||
return;
|
||
}
|
||
emit activateSnapshot(jobs_[row].id);
|
||
}
|
||
|
||
void JFJochProcessingJobsWindow::showOriginal() {
|
||
emit activateSnapshot("Original");
|
||
}
|
||
|
||
void JFJochProcessingJobsWindow::setStatus(int row, const QString &text) {
|
||
if (row >= 0 && row < table_->rowCount())
|
||
table_->item(row, COL_STATUS)->setText(text);
|
||
}
|
||
|
||
void JFJochProcessingJobsWindow::onPhase(QString phase) {
|
||
// The "Processing images" phase is shown by onProgress; other phases (first pass, scaling, ...)
|
||
// show their name as text over an empty bar.
|
||
if (!running_bar_ || phase == "Processing images")
|
||
return;
|
||
running_bar_->setRange(0, 1);
|
||
running_bar_->setValue(0);
|
||
running_bar_->setFormat(phase);
|
||
}
|
||
|
||
void JFJochProcessingJobsWindow::onProgress(quint64 done, quint64 total) {
|
||
if (!running_bar_)
|
||
return;
|
||
running_bar_->setRange(0, static_cast<int>(total));
|
||
running_bar_->setValue(static_cast<int>(done));
|
||
running_bar_->setFormat("%v / %m");
|
||
|
||
// Processing rate and ETA in the status bar.
|
||
if (job_timer_.isValid() && done > 0) {
|
||
const double secs = job_timer_.elapsed() / 1000.0;
|
||
if (secs > 0.0) {
|
||
const double hz = done / secs;
|
||
const double remaining = hz > 0.0 ? (total - done) / hz : 0.0;
|
||
emit writeStatusBar(QStringLiteral("Processing %1 Hz — ~%2 s remaining")
|
||
.arg(hz, 0, 'f', 1).arg(qRound(remaining)));
|
||
}
|
||
}
|
||
}
|
||
|
||
void JFJochProcessingJobsWindow::onFinished(ProcessResult result) {
|
||
emit liveDataset(nullptr); // the finished run takes over from the live overlay
|
||
const int row = running_row_;
|
||
running_row_ = -1;
|
||
if (row >= 0)
|
||
table_->removeCellWidget(row, COL_STATUS); // deletes running_bar_
|
||
running_bar_ = nullptr;
|
||
if (row < 0 || row >= static_cast<int>(jobs_.size()))
|
||
return;
|
||
|
||
setStatus(row, result.cancelled ? "cancelled" : "done");
|
||
if (result.indexing_rate.has_value())
|
||
table_->item(row, COL_INDEX)->setText(QStringLiteral("%1%").arg(result.indexing_rate.value() * 100.0, 0, 'f', 0));
|
||
if (result.consensus_cell.has_value()) {
|
||
const auto &c = result.consensus_cell.value();
|
||
table_->item(row, COL_CELL)->setText(
|
||
QStringLiteral("%1 %2 %3 %4 %5 %6")
|
||
.arg(c.a, 0, 'f', 1).arg(c.b, 0, 'f', 1).arg(c.c, 0, 'f', 1)
|
||
.arg(c.alpha, 0, 'f', 1).arg(c.beta, 0, 'f', 1).arg(c.gamma, 0, 'f', 1));
|
||
}
|
||
|
||
if (!result.cancelled && result.written_master_path.has_value() && !jobs_[row].snapshot_path.isEmpty()) {
|
||
jobs_[row].has_result = true;
|
||
emit registerSnapshot(jobs_[row].id, jobs_[row].label, jobs_[row].snapshot_path); // also activates it
|
||
}
|
||
emit writeStatusBar(result.cancelled ? "Processing cancelled" : "Processing finished");
|
||
}
|
||
|
||
void JFJochProcessingJobsWindow::onFailed(QString error) {
|
||
emit liveDataset(nullptr); // clear the live overlay
|
||
const int row = running_row_;
|
||
running_row_ = -1;
|
||
if (row >= 0)
|
||
table_->removeCellWidget(row, COL_STATUS); // deletes running_bar_
|
||
running_bar_ = nullptr;
|
||
setStatus(row, "failed");
|
||
QMessageBox::warning(this, "Processing failed", error);
|
||
}
|