diff --git a/viewer/CMakeLists.txt b/viewer/CMakeLists.txt index b8a54837..2a7d4c49 100644 --- a/viewer/CMakeLists.txt +++ b/viewer/CMakeLists.txt @@ -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 diff --git a/viewer/JFJochViewerSidePanel.cpp b/viewer/JFJochViewerSidePanel.cpp index 70c94acf..bb260d64 100644 --- a/viewer/JFJochViewerSidePanel.cpp +++ b/viewer/JFJochViewerSidePanel.cpp @@ -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); diff --git a/viewer/JFJochViewerSidePanel.h b/viewer/JFJochViewerSidePanel.h index f4652fa0..0af1f790 100644 --- a/viewer/JFJochViewerSidePanel.h +++ b/viewer/JFJochViewerSidePanel.h @@ -9,6 +9,7 @@ #include #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(); diff --git a/viewer/JFJochViewerWindow.cpp b/viewer/JFJochViewerWindow.cpp index c49599be..42b905cc 100644 --- a/viewer/JFJochViewerWindow.cpp +++ b/viewer/JFJochViewerWindow.cpp @@ -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); diff --git a/viewer/image_viewer/JFJochDiffractionImage.cpp b/viewer/image_viewer/JFJochDiffractionImage.cpp index 861647a6..93889186 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.cpp +++ b/viewer/image_viewer/JFJochDiffractionImage.cpp @@ -5,7 +5,6 @@ #include "../../common/DiffractionGeometry.h" #include "../../common/JFJochMath.h" #include "../../common/ROIAzimuthal.h" -#include "../../image_analysis/roi/ROIIntegrationCPU.h" #include #include @@ -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 &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(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 image = in_image; UpdateForeground(); LoadImageInternal(); - ComputeROIStats(); GeneratePixmap(); Redraw(); CalcROI(); diff --git a/viewer/image_viewer/JFJochDiffractionImage.h b/viewer/image_viewer/JFJochDiffractionImage.h index 98943738..14917fdc 100644 --- a/viewer/image_viewer/JFJochDiffractionImage.h +++ b/viewer/image_viewer/JFJochDiffractionImage.h @@ -3,15 +3,11 @@ #pragma once -#include -#include - #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 roi_stats_; - std::unique_ptr roi_integration_; - const JFJochReaderDataset *roi_integration_dataset_ = nullptr; - bool highlight_ice_rings = true; float ice_ring_width_Q_recipA = 0.01; diff --git a/viewer/widgets/JFJochViewerROIList.cpp b/viewer/widgets/JFJochViewerROIList.cpp new file mode 100644 index 00000000..24c5bbde --- /dev/null +++ b/viewer/widgets/JFJochViewerROIList.cpp @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include "JFJochViewerROIList.h" + +#include + +#include +#include +#include +#include +#include + +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 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 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(); +} diff --git a/viewer/widgets/JFJochViewerROIList.h b/viewer/widgets/JFJochViewerROIList.h new file mode 100644 index 00000000..675c5721 --- /dev/null +++ b/viewer/widgets/JFJochViewerROIList.h @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include + +#include +#include +#include + +#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 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 image); +private slots: + void OnSelectionChanged(); + void OnAdd(); + void OnDelete(); + void OnRename(); +signals: + void roisChanged(ROIDefinition rois); + void selectedROIChanged(QString name); +}; diff --git a/viewer/widgets/JFJochViewerROIResult.cpp b/viewer/widgets/JFJochViewerROIResult.cpp index d5f766e4..c6915fc0 100644 --- a/viewer/widgets/JFJochViewerROIResult.cpp +++ b/viewer/widgets/JFJochViewerROIResult.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-only #include "JFJochViewerROIResult.h" +#include #include JFJochViewerROIResult::JFJochViewerROIResult(QWidget *parent) : QWidget(parent) { @@ -40,10 +41,11 @@ void JFJochViewerROIResult::SetROIResult(ROIMessage roi) { auto roi_npixel_val = static_cast(roi.pixels); double roi_mean_val = static_cast(roi.sum) / roi_npixel_val; double variance = static_cast(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 %1").arg(roi.sum)); roi_mean->setText(QString("Mean %1").arg(QString::number(roi_mean_val, 'f', 3))); - roi_var->setText(QString("Var %1").arg(QString::number(variance, 'f', 3))); + roi_var->setText(QString("Std %1").arg(QString::number(std_dev, 'f', 3))); roi_max->setText(QString("Max %1").arg(roi.max_count)); roi_npixel->setText(QString("Valid %1").arg(roi.pixels)); roi_masked->setText(QString("Masked %1").arg(roi.pixels_masked));