viewer: remove/clear runs, stable per-run colours, processing rate + ETA

- "Remove result" toolbar action drops a reprocessing run: reader RemoveSnapshot
  (Original is protected; if the removed run was active the view falls back to
  Original), worker RemoveRun, and the table row is removed.
- Opening a new file clears the jobs table (worker fileOpened -> clearJobs) to
  match the reader, which already resets snapshots on ReadFile.
- Plot fixes: each run keeps a stable colour by its position (Original = blue),
  so colours no longer swap when the active run changes; the current-image marker
  is hidden from the legend (was the stray "[Image #N]" entry).
- The status bar shows processing rate (Hz) and estimated remaining time while a
  job runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 12:00:15 +02:00
co-authored by Claude Opus 4.8
parent aceff23ce2
commit 2af822436e
10 changed files with 119 additions and 8 deletions
+12
View File
@@ -152,6 +152,18 @@ void JFJochHDF5Reader::RegisterSnapshot(const std::string &name, const std::stri
snapshots_[name] = metadata;
}
void JFJochHDF5Reader::RemoveSnapshot(const std::string &name) {
std::unique_lock ul(hdf5_mutex);
if (name == "Original")
return; // the original file metadata is always kept
snapshots_.erase(name);
if (active_snapshot_ == name) {
active_metadata_ = snapshots_.at("Original");
active_snapshot_ = "Original";
SetStartMessage(active_metadata_->Dataset());
}
}
void JFJochHDF5Reader::SetActiveSnapshot(const std::string &name) {
std::unique_lock ul(hdf5_mutex);
+1
View File
@@ -60,6 +60,7 @@ public:
// Metadata snapshots over the same images. The original file is registered as "Original" on
// ReadFile and is the initial active snapshot; reprocessing results can be added later.
void RegisterSnapshot(const std::string &name, const std::string &master_path);
void RemoveSnapshot(const std::string &name); // "Original" cannot be removed
void SetActiveSnapshot(const std::string &name);
std::vector<std::string> SnapshotNames() const;
std::string ActiveSnapshot() const;
+11
View File
@@ -294,6 +294,7 @@ void JFJochImageReadingWorker::LoadFile_i(const QString &filename, qint64 image_
if (!http_mode)
run_labels_["Original"] = "Original";
EmitRuns_i();
emit fileOpened(); // listeners (e.g. the jobs table) reset their per-file state
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
@@ -987,6 +988,16 @@ void JFJochImageReadingWorker::RenameRun(QString id, QString label) {
EmitRuns_i();
}
void JFJochImageReadingWorker::RemoveRun(QString id) {
QMutexLocker ul(&m);
if (http_mode || id == "Original")
return;
file_reader.RemoveSnapshot(id.toStdString());
run_labels_.erase(id.toStdString());
// Refresh the (possibly now-Original) active view; ActivateSnapshot_i also re-emits runsChanged.
ActivateSnapshot_i(QString::fromStdString(file_reader.ActiveSnapshot()));
}
void JFJochImageReadingWorker::RegisterProcessingSnapshot(QString id, QString label, QString master_path) {
QMutexLocker ul(&m);
if (http_mode) {
+2
View File
@@ -159,6 +159,7 @@ signals:
void liveRateChanged(double hz);
void snapshotsChanged(QStringList names, QString active);
void runsChanged(QVector<RunData> runs, QString active_id);
void fileOpened(); // a new file/stream was loaded - listeners should reset per-file state
public:
JFJochImageReadingWorker(const SpotFindingSettings &settings, const DiffractionExperiment& experiment, QObject *parent = nullptr);
@@ -210,4 +211,5 @@ public slots:
void RegisterProcessingSnapshot(QString id, QString label, QString master_path);
void SetActiveSnapshot(QString name);
void RenameRun(QString id, QString label); // change a run's display label (legend)
void RemoveRun(QString id); // drop a reprocessing snapshot (not "Original")
};
+26 -5
View File
@@ -3,9 +3,24 @@
#include <QGridLayout>
#include <QPushButton>
#include <QColor>
#include "JFJochViewerDatasetInfo.h"
namespace {
// Stable colour per run by its position in the run list (Original is index 0 -> blue), so a
// run keeps its colour whether it is the active (primary) line or an overlay.
QColor RunColor(int index) {
static const QColor palette[] = {
QColor(0x1f, 0x77, 0xb4), QColor(0xff, 0x7f, 0x0e), QColor(0x2c, 0xa0, 0x2c),
QColor(0xd6, 0x27, 0x28), QColor(0x94, 0x67, 0xbd), QColor(0x8c, 0x56, 0x4b),
QColor(0xe3, 0x77, 0xc2), QColor(0x7f, 0x7f, 0x7f),
};
constexpr int n = static_cast<int>(sizeof(palette) / sizeof(palette[0]));
return palette[((index % n) + n) % n];
}
}
JFJochViewerDatasetInfo::JFJochViewerDatasetInfo(QWidget *parent) : QWidget(parent) {
auto layout = new QGridLayout(this);
combo_box = new QComboBox(this);
@@ -210,21 +225,27 @@ void JFJochViewerDatasetInfo::UpdatePlot() {
bool one_over_d2 = false;
std::vector<float> data = ExtractMetric(*dataset, val, one_over_d2);
// One overlay line per other run, plus the in-progress live run.
// One overlay line per other run, plus the in-progress live run. Each run keeps a stable colour
// tied to its position in the list, so colours don't swap when the active run changes.
std::vector<JFJochDatasetInfoChartView::NamedSeries> overlays;
QString primary_name;
QColor primary_color;
int run_index = 0;
for (const auto &run: runs_) {
if (run.id == active_id_) { primary_name = run.label; continue; }
const QColor color = RunColor(run_index++);
if (run.id == active_id_) { primary_name = run.label; primary_color = color; continue; }
if (!run.dataset || run.dataset == dataset) continue; // never draw the active run twice
bool ignore = false;
overlays.push_back({run.label, ExtractMetric(*run.dataset, val, ignore)});
overlays.push_back({run.label, ExtractMetric(*run.dataset, val, ignore), color});
}
if (live_run_ && live_run_ != dataset) {
bool ignore = false;
overlays.push_back({QStringLiteral("Live"), ExtractMetric(*live_run_, val, ignore)});
overlays.push_back({QStringLiteral("Live"), ExtractMetric(*live_run_, val, ignore),
RunColor(static_cast<int>(runs_.size()))});
}
chart_view->loadValues(data, image_number, one_over_d2, dataset.get(), primary_name, std::move(overlays));
chart_view->loadValues(data, image_number, one_over_d2, dataset.get(), primary_name,
std::move(overlays), primary_color);
if (dataset->experiment.GetGridScan()) {
stack->setCurrentWidget(grid_scan_image);
+4
View File
@@ -403,8 +403,12 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString
statusbar, &JFJochViewerStatusBar::display);
connect(processingJobsWindow, &JFJochProcessingJobsWindow::renameRun,
reading_worker, &JFJochImageReadingWorker::RenameRun);
connect(processingJobsWindow, &JFJochProcessingJobsWindow::removeRun,
reading_worker, &JFJochImageReadingWorker::RemoveRun);
connect(reading_worker, &JFJochImageReadingWorker::httpConnectionChanged,
processingJobsWindow, &JFJochProcessingJobsWindow::onHttpConnectionChanged);
connect(reading_worker, &JFJochImageReadingWorker::fileOpened,
processingJobsWindow, &JFJochProcessingJobsWindow::clearJobs);
connect(reading_worker, &JFJochImageReadingWorker::runsChanged, this,
[this](QVector<RunData> runs, QString active) {
lastRuns = std::move(runs);
+10 -1
View File
@@ -5,6 +5,7 @@
#include <QApplication>
#include <QClipboard>
#include <QCategoryAxis>
#include <QtCharts/QLegendMarker>
#include "JFJochDatasetInfoChartView.h"
@@ -130,9 +131,10 @@ void JFJochDatasetInfoChartView::resetZoom() {
void JFJochDatasetInfoChartView::loadValues(const std::vector<float> &input, int64_t image, bool one_over_d2,
const JFJochReaderDataset *dataset, const QString &primaryName,
std::vector<NamedSeries> overlays) {
std::vector<NamedSeries> overlays, const QColor &primaryColor) {
m_yOneOverD = one_over_d2;
primaryName_ = primaryName;
primary_color_ = primaryColor;
// d -> 1/d^2 for resolution plots; identity otherwise. Applied to every series alike.
auto transform = [one_over_d2](const std::vector<float> &in, std::vector<float> &out) {
@@ -223,6 +225,8 @@ void JFJochDatasetInfoChartView::buildTimeDomainChart() {
series = new QLineSeries(this);
if (!primaryName_.isEmpty())
series->setName(primaryName_);
if (primary_color_.isValid())
series->setColor(primary_color_);
currentSeries = new QScatterSeries(this);
double dispMin = std::numeric_limits<double>::infinity();
@@ -236,6 +240,8 @@ void JFJochDatasetInfoChartView::buildTimeDomainChart() {
for (const auto &ov: overlays_) {
auto *line = new QLineSeries(this);
line->setName(ov.name);
if (ov.color.isValid())
line->setColor(ov.color);
appendSeries(line, ov.values, dispMin, dispMax);
overlayLines.push_back(line);
}
@@ -418,6 +424,9 @@ void JFJochDatasetInfoChartView::buildTimeDomainChart() {
for (auto *ax: finalAxes)
line->attachAxis(ax);
}
// The current-image marker is not a run - keep it out of the legend.
for (auto *marker: chart()->legend()->markers(currentSeries))
marker->setVisible(false);
chart()->legend()->setVisible(!overlays_.empty());
chart()->legend()->setAlignment(Qt::AlignBottom);
}
+5 -2
View File
@@ -11,6 +11,7 @@
#include <QGraphicsLineItem>
#include <QTimer>
#include <QPointer>
#include <QColor>
#ifdef JFJOCH_USE_FFTW
#include <fftw3.h>
@@ -47,9 +48,10 @@ class JFJochDatasetInfoChartView : public QChartView {
public:
// One overlay line (a non-active run), drawn alongside the primary series with a legend entry.
struct NamedSeries { QString name; std::vector<float> values; };
struct NamedSeries { QString name; std::vector<float> values; QColor color; };
private:
std::vector<NamedSeries> overlays_;
QColor primary_color_; // colour of the active (primary) run, if assigned
// Append (binned) finite points of vals to s, extending the [mn, mx] data range.
void appendSeries(QLineSeries *s, const std::vector<float> &vals, double &mn, double &mx) const;
@@ -92,7 +94,8 @@ public:
bool one_over_d2,
const JFJochReaderDataset *dataset = nullptr,
const QString &primaryName = QString(),
std::vector<NamedSeries> overlays = {});
std::vector<NamedSeries> overlays = {},
const QColor &primaryColor = QColor());
};
@@ -51,6 +51,7 @@ JFJochProcessingJobsWindow::JFJochProcessingJobsWindow(JFJochImageReadingWorker
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);
@@ -260,6 +261,7 @@ void JFJochProcessingJobsWindow::newJob() {
running_row_ = row;
job_timer_.start();
controller_->start(inputs.file, experiment, inputs.pixel_mask, config);
emit writeStatusBar("Started processing job " + label);
}
@@ -271,6 +273,36 @@ void JFJochProcessingJobsWindow::cancelJob() {
}
}
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()))
@@ -307,6 +339,17 @@ void JFJochProcessingJobsWindow::onProgress(quint64 done, quint64 total) {
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) {
@@ -3,6 +3,7 @@
#pragma once
#include <QElapsedTimer>
#include <QString>
#include <QWidget>
#include <vector>
@@ -29,16 +30,19 @@ signals:
void registerSnapshot(QString id, QString label, QString master_path);
void activateSnapshot(QString id);
void renameRun(QString id, QString label);
void removeRun(QString id);
void writeStatusBar(QString message, int timeout_ms = 0);
// Live per-image results while a job runs, for the dataset-info plots (nullptr clears it).
void liveDataset(std::shared_ptr<const JFJochReaderDataset> dataset);
public slots:
void onHttpConnectionChanged(bool connected, QString addr);
void clearJobs(); // reset the table on a new file
private slots:
void newJob();
void cancelJob();
void removeResult();
void viewResults();
void showOriginal();
void onPhase(QString phase);
@@ -74,6 +78,7 @@ private:
QStackedWidget *stack_; // page 0: jobs table, page 1: HTTP-mode message
QTableWidget *table_;
QProgressBar *running_bar_ = nullptr; // lives in the running row's Status cell
QElapsedTimer job_timer_; // running job wall-clock, for rate + ETA
int running_row_ = -1;
int job_counter_ = 0;
std::vector<JobInfo> jobs_;