viewer: ROI list panel — select, add, rename, delete, per-ROI results

Add JFJochViewerROIList to the side panel: a list of the dataset's ROIs
with selection, add (box/circle/azimuthal), rename and delete, plus a
statistics readout (sum/mean/std/max/valid/masked/centre-of-mass) for the
selected ROI, taken from the analysis output for the current image. Edits
emit a full ROIDefinition, routed to the worker's SetROIDefinition.

Per-ROI statistics now live in this panel rather than the canvas labels;
the diffraction image's labels show only the ROI name, and the ad-hoc
ROIIntegrationCPU computation there is removed in favour of the analysis
pipeline. The result widget now reports std dev instead of variance.

The single-ROI scratch panel remains for now and will be retired once the
interactive canvas editing replaces it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 13:56:09 +02:00
co-authored by Claude Opus 4.8
parent 4e45680e96
commit 2f8d486b51
9 changed files with 240 additions and 61 deletions
+2
View File
@@ -87,6 +87,8 @@ ADD_EXECUTABLE(jfjoch_viewer jfjoch_viewer.cpp JFJochViewerWindow.cpp JFJochView
windows/JFJoch2DAzintImageWindow.h
widgets/JFJochViewerROIResult.cpp
widgets/JFJochViewerROIResult.h
widgets/JFJochViewerROIList.cpp
widgets/JFJochViewerROIList.h
widgets/ResolutionRingWidget.cpp
widgets/ResolutionRingWidget.h
image_viewer/JFJochGridScanImage.cpp
+6
View File
@@ -123,6 +123,12 @@ JFJochViewerSidePanel::JFJochViewerSidePanel(QWidget *parent) : QWidget(parent)
connect(roiFillCheckBox, &QCheckBox::toggled, this, &JFJochViewerSidePanel::showROIFill);
layout->addWidget(roiFillCheckBox);
roi_list = new JFJochViewerROIList(this);
layout->addWidget(roi_list);
connect(this, &JFJochViewerSidePanel::imageLoaded, roi_list, &JFJochViewerROIList::loadImage);
connect(roi_list, &JFJochViewerROIList::roisChanged, this, &JFJochViewerSidePanel::roisChanged);
connect(roi_list, &JFJochViewerROIList::selectedROIChanged, this, &JFJochViewerSidePanel::selectedROIChanged);
roi = new JFJochViewerImageROIStatistics(this);
layout->addWidget(roi);
+4
View File
@@ -9,6 +9,7 @@
#include <QCheckBox>
#include "widgets/JFJochViewerImageROIStatistics.h"
#include "widgets/JFJochViewerROIList.h"
#include "../reader/JFJochReader.h"
#include "charts/JFJochSimpleChartView.h"
#include "widgets/JFJochViewerSidePanelChart.h"
@@ -19,6 +20,7 @@ class JFJochViewerSidePanel : public QWidget {
JFJochViewerSidePanelChart *chart = nullptr;
JFJochViewerImageROIStatistics *roi = nullptr;
JFJochViewerROIList *roi_list = nullptr;
ResolutionRingWidget *res_rings = nullptr;
signals:
@@ -31,6 +33,8 @@ signals:
void highlightIceRings(bool input);
void showROILabels(bool input);
void showROIFill(bool input);
void roisChanged(ROIDefinition rois);
void selectedROIChanged(QString name);
void findBeamCenter(const UnitCell &input, bool guess);
void analyze();
+3
View File
@@ -204,6 +204,9 @@ JFJochViewerWindow::JFJochViewerWindow(QWidget *parent, bool dbus, const QString
connect(viewer, &JFJochDiffractionImage::roiCalculated,
side_panel, &JFJochViewerSidePanel::SetROIResult);
connect(side_panel, &JFJochViewerSidePanel::roisChanged,
reading_worker, &JFJochImageReadingWorker::SetROIDefinition);
connect(side_panel, &JFJochViewerSidePanel::ROIBoxConfigured,
reading_worker, &JFJochImageReadingWorker::SetROIBox);
+2 -47
View File
@@ -5,7 +5,6 @@
#include "../../common/DiffractionGeometry.h"
#include "../../common/JFJochMath.h"
#include "../../common/ROIAzimuthal.h"
#include "../../image_analysis/roi/ROIIntegrationCPU.h"
#include <QPainterPath>
#include <QBrush>
@@ -23,8 +22,6 @@
// Constructor
JFJochDiffractionImage::JFJochDiffractionImage(QWidget *parent) : JFJochImage(parent) {}
JFJochDiffractionImage::~JFJochDiffractionImage() = default;
void JFJochDiffractionImage::mouseHover(QMouseEvent *event) {
auto coord = mapToScene(event->pos());
@@ -371,53 +368,12 @@ void JFJochDiffractionImage::DrawROIs() {
DrawAzimuthalROI(az, palette[color_index++ % palette_size], geom);
}
namespace {
// Adapts the viewer image for ROIIntegrationCPU, whose masking convention is
// "min() == masked, max() == saturated". The reader uses two masked sentinels
// (ERROR_PXL_VALUE == INT32_MIN and GAP_PXL_VALUE == INT32_MIN+1), so fold the
// gap value onto the error value; saturated already maps to INT32_MAX.
struct MaskedImageView {
const std::vector<int32_t> &img;
[[nodiscard]] size_t size() const { return img.size(); }
int32_t operator[](size_t i) const {
const int32_t v = img[i];
return (v == GAP_PXL_VALUE) ? ERROR_PXL_VALUE : v;
}
};
}
void JFJochDiffractionImage::ComputeROIStats() {
roi_stats_.clear();
if (!image || image->Dataset().experiment.ROI().empty())
return;
// Rebuild the engine only when the dataset changes (it rasterizes the bitmap).
if (roi_integration_dataset_ != &image->Dataset()) {
roi_integration_ = std::make_unique<ROIIntegrationCPU>(image->Dataset().experiment);
roi_integration_dataset_ = &image->Dataset();
}
if (roi_integration_->empty())
return;
try {
roi_integration_->RunROI(MaskedImageView{image->Image()}, roi_stats_);
} catch (const std::exception &) {
roi_stats_.clear(); // e.g. image/bitmap size mismatch
}
}
void JFJochDiffractionImage::AddROILabel(const std::string &name, const QColor &color, float px, float py) {
if (!show_roi_labels)
return;
QString text_str = QString::fromStdString(name);
auto it = roi_stats_.find(name);
if (it != roi_stats_.end() && it->second.pixels > 0) {
const auto &m = it->second;
text_str += QString(" Σ=%1 max=%2 n=%3").arg(m.sum).arg(m.max_count).arg(m.pixels);
}
auto *text = scene()->addText(text_str);
// Just the name; per-ROI statistics are shown in the side-panel ROI list.
auto *text = scene()->addText(QString::fromStdString(name));
text->setDefaultTextColor(color);
text->setFlag(QGraphicsItem::ItemIgnoresTransformations); // constant on-screen size
text->setPos(px, py);
@@ -516,7 +472,6 @@ void JFJochDiffractionImage::loadImage(std::shared_ptr<const JFJochReaderImage>
image = in_image;
UpdateForeground();
LoadImageInternal();
ComputeROIStats();
GeneratePixmap();
Redraw();
CalcROI();
@@ -3,15 +3,11 @@
#pragma once
#include <map>
#include <memory>
#include "JFJochImage.h"
#include "../../reader/JFJochReaderImage.h"
class ROIAzimuthal;
class DiffractionGeometry;
class ROIIntegrationCPU;
class JFJochDiffractionImage : public JFJochImage {
Q_OBJECT
@@ -27,7 +23,6 @@ public:
Q_ENUM(RingMode)
JFJochDiffractionImage(QWidget *parent = nullptr);
~JFJochDiffractionImage() override; // out-of-line for the unique_ptr to an incomplete type
private:
void addCustomOverlay() override;
@@ -35,7 +30,6 @@ private:
void DrawResolutionRings();
void DrawROIs();
void DrawAzimuthalROI(const ROIAzimuthal &az, const QColor &color, const DiffractionGeometry &geom);
void ComputeROIStats();
void AddROILabel(const std::string &name, const QColor &color, float px, float py);
void DrawSpots();
void DrawPredictions();
@@ -63,13 +57,6 @@ private:
bool show_roi_labels = false;
bool show_roi_fill = false;
// Per-ROI statistics for the current image, computed with the shared CPU ROI
// engine (the software counterpart of the FPGA roi_calc). The engine is rebuilt
// only when the dataset changes, as it rasterizes the ROI bitmap on construction.
std::map<std::string, ROIMessage> roi_stats_;
std::unique_ptr<ROIIntegrationCPU> roi_integration_;
const JFJochReaderDataset *roi_integration_dataset_ = nullptr;
bool highlight_ice_rings = true;
float ice_ring_width_Q_recipA = 0.01;
+173
View File
@@ -0,0 +1,173 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "JFJochViewerROIList.h"
#include <set>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QPushButton>
#include <QInputDialog>
#include <QListWidgetItem>
JFJochViewerROIList::JFJochViewerROIList(QWidget *parent) : QWidget(parent) {
auto *layout = new QVBoxLayout(this);
list_ = new QListWidget(this);
list_->setMaximumHeight(120);
layout->addWidget(list_);
connect(list_, &QListWidget::itemSelectionChanged, this, &JFJochViewerROIList::OnSelectionChanged);
auto *buttons = new QHBoxLayout();
type_combo_ = new QComboBox(this);
type_combo_->addItems({"Box", "Circle", "Azimuthal"});
buttons->addWidget(type_combo_);
auto *add = new QPushButton("+", this);
auto *ren = new QPushButton("Rename", this);
auto *del = new QPushButton("Delete", this);
buttons->addWidget(add);
buttons->addWidget(ren);
buttons->addWidget(del);
layout->addLayout(buttons);
connect(add, &QPushButton::clicked, this, &JFJochViewerROIList::OnAdd);
connect(ren, &QPushButton::clicked, this, &JFJochViewerROIList::OnRename);
connect(del, &QPushButton::clicked, this, &JFJochViewerROIList::OnDelete);
result_ = new JFJochViewerROIResult(this);
layout->addWidget(result_);
}
void JFJochViewerROIList::loadImage(std::shared_ptr<const JFJochReaderImage> image) {
image_ = image;
const QString keep = SelectedName();
rois_ = image_ ? image_->Dataset().experiment.ROI().GetROIDefinition() : ROIDefinition{};
RebuildList(keep);
ShowSelectedResult();
}
void JFJochViewerROIList::RebuildList(const QString &keep_selected) {
rebuilding_ = true;
list_->clear();
auto add_item = [&](const std::string &name, const char *type) {
auto *item = new QListWidgetItem(QString("%1 · %2").arg(QString::fromStdString(name), type));
item->setData(Qt::UserRole, QString::fromStdString(name)); // name independent of display text
list_->addItem(item);
};
for (const auto &b : rois_.boxes) add_item(b.GetName(), "box");
for (const auto &c : rois_.circles) add_item(c.GetName(), "circle");
for (const auto &a : rois_.azimuthal) add_item(a.GetName(), "azim");
if (!keep_selected.isEmpty()) {
for (int i = 0; i < list_->count(); i++)
if (list_->item(i)->data(Qt::UserRole).toString() == keep_selected) {
list_->setCurrentRow(i);
break;
}
}
rebuilding_ = false;
}
QString JFJochViewerROIList::SelectedName() const {
auto *item = list_->currentItem();
return item ? item->data(Qt::UserRole).toString() : QString();
}
void JFJochViewerROIList::ShowSelectedResult() {
const QString name = SelectedName();
if (image_ && !name.isEmpty()) {
const auto &roi = image_->ImageData().roi;
auto it = roi.find(name.toStdString());
if (it != roi.end()) {
result_->SetROIResult(it->second);
return;
}
}
result_->SetROIResult(ROIMessage{}); // clears the display
}
std::string JFJochViewerROIList::UniqueName() const {
std::set<std::string> used;
for (const auto &b : rois_.boxes) used.insert(b.GetName());
for (const auto &c : rois_.circles) used.insert(c.GetName());
for (const auto &a : rois_.azimuthal) used.insert(a.GetName());
for (int i = 1; ; i++) {
std::string n = "roi" + std::to_string(i);
if (!used.count(n)) return n;
}
}
void JFJochViewerROIList::OnSelectionChanged() {
if (rebuilding_)
return;
ShowSelectedResult();
emit selectedROIChanged(SelectedName());
}
void JFJochViewerROIList::OnAdd() {
if (rois_.boxes.size() + rois_.circles.size() + rois_.azimuthal.size() >= 16)
return; // bitmap allows at most 16 ROIs
const std::string name = UniqueName();
int64_t cx = 100, cy = 100;
if (image_) {
cx = image_->Dataset().experiment.GetXPixelsNum() / 2;
cy = image_->Dataset().experiment.GetYPixelsNum() / 2;
}
switch (type_combo_->currentIndex()) {
case 0: rois_.boxes.emplace_back(name, cx - 100, cx + 100, cy - 100, cy + 100); break;
case 1: rois_.circles.emplace_back(name, cx, cy, 100); break;
default: rois_.azimuthal.emplace_back(name, 2.0f, 4.0f); break;
}
emit roisChanged(rois_);
RebuildList(QString::fromStdString(name));
ShowSelectedResult();
}
void JFJochViewerROIList::OnDelete() {
const std::string name = SelectedName().toStdString();
if (name.empty())
return;
auto erase_by_name = [&name](auto &vec) {
for (auto it = vec.begin(); it != vec.end(); ++it)
if (it->GetName() == name) { vec.erase(it); return true; }
return false;
};
if (erase_by_name(rois_.boxes) || erase_by_name(rois_.circles) || erase_by_name(rois_.azimuthal)) {
emit roisChanged(rois_);
RebuildList(QString());
ShowSelectedResult();
}
}
void JFJochViewerROIList::OnRename() {
const QString old_name = SelectedName();
if (old_name.isEmpty())
return;
bool ok = false;
const QString new_name = QInputDialog::getText(this, "Rename ROI", "New name:",
QLineEdit::Normal, old_name, &ok);
if (!ok || new_name.isEmpty() || new_name == old_name)
return;
const std::string oldn = old_name.toStdString();
const std::string newn = new_name.toStdString();
for (auto &b : rois_.boxes)
if (b.GetName() == oldn) { b = ROIBox(newn, b.GetXMin(), b.GetXMax(), b.GetYMin(), b.GetYMax()); break; }
for (auto &c : rois_.circles)
if (c.GetName() == oldn) { c = ROICircle(newn, c.GetX(), c.GetY(), c.GetRadius_pxl()); break; }
for (auto &a : rois_.azimuthal)
if (a.GetName() == oldn) {
a = a.HasPhi() ? ROIAzimuthal(newn, a.GetDMin_A(), a.GetDMax_A(), a.GetPhiMin_deg(), a.GetPhiMax_deg())
: ROIAzimuthal(newn, a.GetDMin_A(), a.GetDMax_A());
break;
}
emit roisChanged(rois_);
RebuildList(new_name);
ShowSelectedResult();
}
+47
View File
@@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <memory>
#include <QWidget>
#include <QListWidget>
#include <QComboBox>
#include "JFJochViewerROIResult.h"
#include "../../common/ROIDefinition.h"
#include "../../reader/JFJochReaderImage.h"
// Manages the dataset's ROI list: select one, add (box/circle/azimuthal), rename or
// delete, and show the selected ROI's statistics for the current image. Edits are
// emitted as a complete ROIDefinition; the reading worker is the source of truth and
// recomputes the per-image ROI results, which arrive back via loadImage().
class JFJochViewerROIList : public QWidget {
Q_OBJECT
QListWidget *list_;
QComboBox *type_combo_;
JFJochViewerROIResult *result_;
ROIDefinition rois_;
std::shared_ptr<const JFJochReaderImage> image_;
bool rebuilding_ = false;
void RebuildList(const QString &keep_selected);
void ShowSelectedResult();
[[nodiscard]] QString SelectedName() const;
[[nodiscard]] std::string UniqueName() const;
public:
explicit JFJochViewerROIList(QWidget *parent = nullptr);
public slots:
void loadImage(std::shared_ptr<const JFJochReaderImage> image);
private slots:
void OnSelectionChanged();
void OnAdd();
void OnDelete();
void OnRename();
signals:
void roisChanged(ROIDefinition rois);
void selectedROIChanged(QString name);
};
+3 -1
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-only
#include "JFJochViewerROIResult.h"
#include <cmath>
#include <QGridLayout>
JFJochViewerROIResult::JFJochViewerROIResult(QWidget *parent) : QWidget(parent) {
@@ -40,10 +41,11 @@ void JFJochViewerROIResult::SetROIResult(ROIMessage roi) {
auto roi_npixel_val = static_cast<double>(roi.pixels);
double roi_mean_val = static_cast<double>(roi.sum) / roi_npixel_val;
double variance = static_cast<double>(roi.sum_square) / roi_npixel_val - roi_mean_val * roi_mean_val;
double std_dev = std::sqrt(std::max(0.0, variance));
roi_sum->setText(QString("Sum <b>%1</b>").arg(roi.sum));
roi_mean->setText(QString("Mean <b>%1</b>").arg(QString::number(roi_mean_val, 'f', 3)));
roi_var->setText(QString("Var <b>%1</b>").arg(QString::number(variance, 'f', 3)));
roi_var->setText(QString("Std <b>%1</b>").arg(QString::number(std_dev, 'f', 3)));
roi_max->setText(QString("Max <b>%1</b>").arg(roi.max_count));
roi_npixel->setText(QString("Valid <b>%1</b>").arg(roi.pixels));
roi_masked->setText(QString("Masked <b>%1</b>").arg(roi.pixels_masked));