Viewer: the region of interest belongs to the diffraction view alone

Drawing an ROI only means something where there are detector counts to
accumulate. Gate the gesture on a virtual AllowROI(), true only for
JFJochDiffractionImage: shift-drag, the resize handles, the hover cursor and the
"Clear ROI" context entry now do nothing in the azimuthal, grid-scan and
calibration views, which cannot report anything about a box anyway.

The statistics move out of the base class into the diffraction view and read the
int32 image directly, so no float copy of the detector image is built for them
either. With the labels already converted, image_fp is now untouched by the
diffraction view, and the lazy EnsurePixelValues machinery it needed is gone.
image_fp stays as the base's representation for the views whose data really is
float: the azimuthal profile, the grid-scan 1/sigma^2 map, and the calibration
viewer's eight source types.

Removed with it: the ROI readouts in the calibration and 2D azimuthal windows,
which were the only two consumers of roiCalculated -- the diffraction view
emitted it and nothing listened. Nothing surfaces ROI statistics now; the
pixel-mask case wants rectangles counting excluded pixels and deserves its own
design. JFJochViewerROIResult is still used by the side-panel ROI list, so the
widget stays.

Verified in the GUI: shift-drag in the diffraction view still draws the box,
turns it into a named ROI and runs the statistics; fit-view panel remains
pixel-identical to the pre-series baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 21:32:47 +02:00
co-authored by Claude Opus 5
parent 327e2645cf
commit 85c4908afc
9 changed files with 111 additions and 155 deletions
-2
View File
@@ -23,7 +23,6 @@ void JFJochAzIntImage::Clear() {
void JFJochAzIntImage::imageLoaded(std::shared_ptr<const JFJochReaderImage> in_image) {
if (!in_image) {
Clear();
CalcROI();
return;
}
@@ -71,7 +70,6 @@ void JFJochAzIntImage::imageLoaded(std::shared_ptr<const JFJochReaderImage> in_i
// Render the image and redraw using base class functionality
RenderImage();
Redraw();
CalcROI();
} else {
Clear();
}
+83 -29
View File
@@ -127,7 +127,6 @@ void JFJochDiffractionImage::LoadImageInternal() {
W = image->Dataset().experiment.GetXPixelsNum();
H = image->Dataset().experiment.GetYPixelsNum();
pixel_values_valid_ = false; // image_fp is filled on demand, see EnsurePixelValues
}
void JFJochDiffractionImage::ColorRow(size_t y, const PixelColorMap &map, QRgb *out) const {
@@ -150,34 +149,6 @@ void JFJochDiffractionImage::ColorRow(size_t y, const PixelColorMap &map, QRgb *
}
}
void JFJochDiffractionImage::EnsurePixelValues() {
if (pixel_values_valid_ || !image)
return;
const auto &img = image->Image();
image_fp.resize(W*H);
QVector<int> rows;
rows.reserve(H);
for (int y = 0; y < H; ++y) rows.push_back(y);
// Fill the float image with pixel data from the array
QtConcurrent::blockingMap(rows, [&](int y) {
for (size_t pxl = y * W; pxl < (y + 1) * W; pxl++) {
auto val = img[pxl];
if (val == GAP_PXL_VALUE)
image_fp[pxl] = NAN;
else if (val == ERROR_PXL_VALUE)
image_fp[pxl] = -INFINITY;
else if (val == SATURATED_PXL_VALUE)
image_fp[pxl] = INFINITY;
else
image_fp[pxl] = static_cast<float>(val);
}
});
pixel_values_valid_ = true;
}
void JFJochDiffractionImage::DrawSpots() {
// Compute current visible area in scene coordinates
@@ -1011,6 +982,89 @@ QString JFJochDiffractionImage::HoverResolutionLabel() const {
return QString("d = %1 \u00C5").arg(QString::number(hover_resolution, 'f', 2));
}
ROIMessage JFJochDiffractionImage::AccumulateROI(
int64_t xmin, int64_t xmax, int64_t ymin, int64_t ymax,
const std::function<bool(int64_t, int64_t)> &inside) const {
int64_t roi_val = 0;
uint64_t roi_val_2 = 0;
int64_t roi_max = INT64_MIN;
uint64_t roi_npixel = 0;
uint64_t roi_npixel_masked = 0;
float x_weighted = 0.0f;
float y_weighted = 0.0f;
// Clamp bounds defensively to the image
xmin = std::max<int64_t>(0, xmin);
ymin = std::max<int64_t>(0, ymin);
xmax = std::min<int64_t>(W, xmax);
ymax = std::min<int64_t>(H, ymax);
const auto &pixels = image->Image();
for (int64_t y = ymin; y < ymax; ++y) {
for (int64_t x = xmin; x < xmax; ++x) {
if (!inside(x, y)) continue;
const int32_t val = pixels[x + W * y];
if (val == SATURATED_PXL_VALUE || val == ERROR_PXL_VALUE) {
roi_npixel_masked++;
} else if (val != GAP_PXL_VALUE) {
x_weighted += static_cast<float>(val) * x;
y_weighted += static_cast<float>(val) * y;
roi_val += val;
roi_val_2 += static_cast<uint64_t>(val) * val;
if (val > roi_max) roi_max = val;
roi_npixel++;
}
}
}
return ROIMessage{
.sum = roi_val,
.sum_square = roi_val_2,
.max_count = roi_max,
.pixels = roi_npixel,
.pixels_masked = roi_npixel_masked,
.x_weighted = std::lroundf(x_weighted),
.y_weighted = std::lroundf(y_weighted),
};
}
void JFJochDiffractionImage::CalcROI() {
if (!image || W * H == 0) {
auto msg = ROIMessage{.pixels = 0, .pixels_masked = 0};
emit roiCalculated(msg);
return;
}
auto box_norm = roiBox.normalized();
// Using the rectangle as-is; you can adjust inclusivity if needed
const int64_t xmin = box_norm.left();
const int64_t xmax = box_norm.right();
const int64_t ymin = box_norm.top();
const int64_t ymax = box_norm.bottom();
ROIMessage msg{};
if (roi_type == RoiType::RoiBox)
msg = AccumulateROI(xmin, xmax, ymin, ymax,
[](int64_t, int64_t) { return true; }); // everything in the rectangle
else {
const QPointF delta = roiStartPos - roiEndPos;
const float cx = static_cast<float>(roiStartPos.x());
const float cy = static_cast<float>(roiStartPos.y());
const float r2 = static_cast<float>(delta.x() * delta.x() + delta.y() * delta.y());
msg = AccumulateROI(xmin, xmax, ymin, ymax,
[cx, cy, r2](int64_t x, int64_t y) {
const float dx = static_cast<float>(x) - cx;
const float dy = static_cast<float>(y) - cy;
return dx * dx + dy * dy <= r2;
});
}
emit roiCalculated(msg);
}
QString JFJochDiffractionImage::PixelLabel(int x, int y) const {
if (!image)
return {};
+11 -4
View File
@@ -3,6 +3,8 @@
#pragma once
#include <functional>
#include <QPainterPath>
#include "JFJochImage.h"
@@ -29,6 +31,12 @@ Q_OBJECT
// Counts are exact integers: label them from the int32 image rather than from a float copy,
// which both avoids materialising that copy and cannot round large summed values.
[[nodiscard]] QString PixelLabel(int x, int y) const override;
// This is the view that has detector counts, so it is the one that offers an ROI
[[nodiscard]] bool AllowROI() const override { return true; }
void CalcROI() override;
[[nodiscard]] ROIMessage AccumulateROI(int64_t xmin, int64_t xmax, int64_t ymin, int64_t ymax,
const std::function<bool(int64_t, int64_t)> &inside) const;
void drawForeground(QPainter *painter, const QRectF &rect) override;
public:
enum class RingMode {Auto, Estimation, Manual, None, IceRings};
@@ -39,11 +47,9 @@ private:
void addCustomOverlay() override;
void LoadImageInternal();
// Colour straight from the int32 detector image; image_fp is only materialised when the
// base class actually needs pixel values (ROI statistics, per-pixel labels).
// Colour straight from the int32 detector image; no float copy of it is ever built
void ColorRow(size_t y, const PixelColorMap &map, QRgb *out) const override;
void EnsurePixelValues() override;
bool pixel_values_valid_ = false;
void DrawResolutionRings();
void DrawROIs();
void DrawAzimuthalROI(const ROIAzimuthal &az, const QColor &color, const DiffractionGeometry &geom);
@@ -113,6 +119,7 @@ private:
void mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) override;
signals:
void roiCalculated(ROIMessage &output);
void roiGeometryEdited(ROIDefinition rois);
void roiSelected(QString name); // user picked an ROI by clicking it on the image
public slots:
@@ -14,7 +14,6 @@ void JFJochGridScanImage::clear() {
if (scene())
scene()->clear();
resetScenePointers();
CalcROI();
}
void JFJochGridScanImage::loadData(const std::vector<float> &data, const GridScanSettings &settings, bool in_one_over_d2) {
@@ -60,7 +59,6 @@ void JFJochGridScanImage::loadData(const std::vector<float> &data, const GridSca
RenderImage();
Redraw();
CalcROI();
}
void JFJochGridScanImage::mouseHover(const QPointF &pt, Qt::KeyboardModifiers modifiers) {
+11 -97
View File
@@ -238,18 +238,20 @@ void JFJochImage::mousePressEvent(QMouseEvent *event) {
return;
}
active_handle_ = hitTestROIHandle(scenePos, 4.0 / std::sqrt(std::max(1e-4, scale_factor)));
active_handle_ = AllowROI()
? hitTestROIHandle(scenePos, 4.0 / std::sqrt(std::max(1e-4, scale_factor)))
: ResizeHandle::None;
if (active_handle_ != ResizeHandle::None && active_handle_ != ResizeHandle::Inside) {
mouse_event_type = MouseEventType::ResizingROI;
roiStartPos = roiBox.topLeft();
roiEndPos = roiBox.bottomRight();
setCursor(Qt::SizeAllCursor);
} else if (roiBox.contains(scenePos)) {
} else if (AllowROI() && roiBox.contains(scenePos)) {
mouse_event_type = MouseEventType::MovingROI;
lastMousePos = event->pos();
setCursor(Qt::ClosedHandCursor);
} else if (event->modifiers() & Qt::Modifier::SHIFT) {
} else if (AllowROI() && (event->modifiers() & Qt::Modifier::SHIFT)) {
mouse_event_type = MouseEventType::DrawingROI;
roiStartPos = RoundPoint(scenePos);
roiEndPos = roiStartPos;
@@ -333,6 +335,8 @@ void JFJochImage::mouseMoveEvent(QMouseEvent *event) {
break;
}
case MouseEventType::None: {
if (!AllowROI())
break;
const qreal tol = 4.0 / std::sqrt(std::max(1e-4, scale_factor));
ResizeHandle h = hitTestROIHandle(scenePos, tol);
// Update hover state so overlay can draw arrows/handles accordingly
@@ -400,7 +404,7 @@ void JFJochImage::contextMenuEvent(QContextMenuEvent *event) {
QAction *saveWithOverlayAct = menu.addAction(tr("Save image with overlay as JPEG..."));
menu.addSeparator();
QAction *fitAct = menu.addAction(tr("Fit image to view"));
QAction *clearRoiAct = menu.addAction(tr("Clear ROI"));
QAction *clearRoiAct = AllowROI() ? menu.addAction(tr("Clear ROI")) : nullptr;
const bool hasImage = (W > 0 && H > 0 && !frame_->isNull());
copyImageAct->setEnabled(hasImage);
@@ -421,7 +425,7 @@ void JFJochImage::contextMenuEvent(QContextMenuEvent *event) {
saveImageToFile(true);
} else if (chosen == fitAct) {
fitToView();
} else if (chosen == clearRoiAct) {
} else if (clearRoiAct && chosen == clearRoiAct) {
clearROIInternal();
}
}
@@ -607,6 +611,8 @@ void JFJochImage::addOverlayItem(QGraphicsItem *item) {
}
void JFJochImage::DrawROI() {
if (!AllowROI())
return;
if (roiBox.isNull() || roiBox.width() <= 0 || roiBox.height() <= 0) return;
auto scn = scene();
@@ -924,99 +930,7 @@ void JFJochImage::updateOverlay() {
void JFJochImage::addCustomOverlay() {}
ROIMessage JFJochImage::accumulateROI(
int64_t xmin, int64_t xmax,
int64_t ymin, int64_t ymax,
const std::function<bool(int64_t, int64_t)> &inside) {
int64_t roi_val = 0;
uint64_t roi_val_2 = 0;
int64_t roi_max = INT64_MIN;
uint64_t roi_npixel = 0;
uint64_t roi_npixel_masked = 0;
float x_weighted = 0.0f;
float y_weighted = 0.0f;
// Clamp bounds defensively to the image
xmin = std::max<int64_t>(0, xmin);
ymin = std::max<int64_t>(0, ymin);
xmax = std::min<int64_t>(W, xmax);
ymax = std::min<int64_t>(H, ymax);
for (int64_t y = ymin; y < ymax; ++y) {
for (int64_t x = xmin; x < xmax; ++x) {
if (!inside(x, y)) continue;
float val = image_fp[x + W * y];
if (std::isinf(val)) {
roi_npixel_masked++;
} else if (std::isfinite(val)) {
x_weighted += val * x;
y_weighted += val * y;
roi_val += val;
roi_val_2 += val * val;
if (val > roi_max) roi_max = val;
roi_npixel++;
}
}
}
return ROIMessage{
.sum = roi_val,
.sum_square = roi_val_2,
.max_count = roi_max,
.pixels = roi_npixel,
.pixels_masked = roi_npixel_masked,
.x_weighted = std::lroundf(x_weighted),
.y_weighted = std::lroundf(y_weighted),
};
}
void JFJochImage::CalcROI() {
if (W*H == 0) {
auto msg = ROIMessage{
.pixels = 0,
.pixels_masked = 0};
emit roiCalculated(msg);
return;
}
auto box_norm = roiBox.normalized();
// accumulateROI only reads pixel values inside the box, so an empty ROI needs none
if (box_norm.width() > 0 && box_norm.height() > 0)
EnsurePixelValues();
// Using the rectangle as-is; you can adjust inclusivity if needed
int64_t xmin = box_norm.left();
int64_t xmax = box_norm.right();
int64_t ymin = box_norm.top();
int64_t ymax = box_norm.bottom();
ROIMessage msg{};
if (roi_type == RoiType::RoiBox)
msg = accumulateROI(
xmin, xmax, ymin, ymax,
[](int64_t, int64_t) { return true; } // everything in the rectangle
);
else {
QPointF delta = roiStartPos - roiEndPos;
double radius2 = delta.x() * delta.x() + delta.y() * delta.y();
const float cx = static_cast<float>(roiStartPos.x());
const float cy = static_cast<float>(roiStartPos.y());
const float r2 = static_cast<float>(radius2);
msg = accumulateROI(
xmin, xmax, ymin, ymax,
[cx, cy, r2](int64_t x, int64_t y) {
const float dx = static_cast<float>(x) - cx;
const float dy = static_cast<float>(y) - cy;
const float dist2 = dx * dx + dy * dy;
return dist2 <= r2;
}
);
}
emit roiCalculated(msg);
}
void JFJochImage::fitToView() {
initial_fit_done_ = false;
+6 -7
View File
@@ -91,9 +91,10 @@ class JFJochImage : public QGraphicsView {
void mouseMoveEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
ROIMessage accumulateROI(int64_t xmin, int64_t xmax, int64_t ymin, int64_t ymax,
const std::function<bool(int64_t,int64_t)>& inside);
protected:
// Only the view that owns detector counts offers a region of interest; for the others a
// shift-drag would draw a box that means nothing.
[[nodiscard]] virtual bool AllowROI() const { return false; }
virtual void beforeOverlayCleared();
bool show_saturation = false;
@@ -184,6 +185,9 @@ protected:
ResizeHandle hitTestROIHandle(const QPointF& scenePos, qreal tol = 3.0) const;
// Statistics over roiBox, for the view that has pixel values to accumulate
virtual void CalcROI() {}
void updateOverlay();
void RenderImage();
PixelColorMap MakeColorMap() const;
@@ -191,9 +195,6 @@ protected:
// maps image_fp; a subclass whose source is already a compact buffer can map that directly
// and skip materialising the float image.
virtual void ColorRow(size_t y, const PixelColorMap &map, QRgb *out) const;
// Fill image_fp, which the base class reads for ROI statistics and per-pixel value labels.
// Subclasses that colour without it fill it on demand here rather than on every frame.
virtual void EnsurePixelValues() {}
// Re-render once the event queue drains. The foreground slider and the wheel emit far
// faster than a large image can be recoloured, so intermediate values are dropped
// instead of queueing a full recolour per event.
@@ -205,7 +206,6 @@ protected:
// once per scrollbar; the gesture rebuilds it once itself.
bool suppress_overlay_update_ = false;
void Redraw();
void CalcROI();
// Invalidate image_item_ and overlay tracking after scene()->clear()
void resetScenePointers();
@@ -228,7 +228,6 @@ signals:
void writeStatusBar(QString string, int timeout_ms = 0);
void roiBoxUpdated(QRect box);
void roiCircleUpdated(double x, double y, double radius);
void roiCalculated(ROIMessage &output);
void viewportChanged(QTransform transform, QPointF center);
void hoverScenePos(QPointF scenePos);
// A new frame has been rendered into Frame(). Follower views repaint on this.
@@ -30,14 +30,12 @@ void JFJochSimpleImage::setImage(std::shared_ptr<const SimpleImage> img) {
loadImageInternal();
RenderImage();
Redraw();
CalcROI();
} else {
image_.reset();
W = 0; H = 0;
if (scene())
scene()->clear();
resetScenePointers();
CalcROI();
}
}
@@ -3,7 +3,6 @@
#include <QStatusBar>
#include "JFJoch2DAzintImageWindow.h"
#include "../widgets/JFJochViewerROIResult.h"
JFJoch2DAzintImageWindow::JFJoch2DAzintImageWindow(QWidget *parent) : JFJochHelperWindow(parent) {
QWidget *centralWidget = new QWidget(this);
@@ -25,12 +24,9 @@ JFJoch2DAzintImageWindow::JFJoch2DAzintImageWindow(QWidget *parent) : JFJochHelp
foreground_row->addWidget(new QLabel("Foreground:"));
foreground_row->addWidget(foreground_slider);
auto roi_result = new JFJochViewerROIResult(this);
grid_layout->addLayout(background_row, 0, 0, 1, 2);
grid_layout->addLayout(foreground_row, 1, 0, 1, 2);
grid_layout->addWidget(viewer, 2, 0, 1, 2);
grid_layout->addWidget(roi_result, 3, 0, 1, 2);
centralWidget->setLayout(grid_layout);
connect(viewer, &JFJochAzIntImage::backgroundChanged,
@@ -45,8 +41,6 @@ JFJoch2DAzintImageWindow::JFJoch2DAzintImageWindow(QWidget *parent) : JFJochHelp
foreground_slider->setValue(val);
});
connect(viewer, &JFJochAzIntImage::roiCalculated, roi_result, &JFJochViewerROIResult::SetROIResult);
connect(background_slider, &SliderPlusBox::valueChanged, viewer, &JFJochAzIntImage::changeBackground);
connect(foreground_slider, &SliderPlusBox::valueChanged, viewer, &JFJochAzIntImage::changeForeground);
@@ -7,7 +7,6 @@
#include <QHBoxLayout>
#include <QLabel>
#include "../widgets/JFJochViewerROIResult.h"
JFJochCalibrationWindow::JFJochCalibrationWindow(QWidget *parent) : JFJochHelperWindow(parent) {
QWidget *centralWidget = new QWidget(this);
@@ -37,14 +36,11 @@ JFJochCalibrationWindow::JFJochCalibrationWindow(QWidget *parent) : JFJochHelper
foreground_row->addWidget(new QLabel("Foreground:"));
foreground_row->addWidget(foreground_slider);
auto roi_result = new JFJochViewerROIResult(this);
grid_layout->addWidget(calibration_option, 0, 0);
grid_layout->addWidget(color_map_select, 0, 1);
grid_layout->addLayout(background_row, 1, 0, 1, 2);
grid_layout->addLayout(foreground_row, 2, 0, 1, 2);
grid_layout->addWidget(viewer, 3, 0, 1, 2);
grid_layout->addWidget(roi_result, 4, 0, 1, 2);
connect(viewer, &JFJochSimpleImage::backgroundChanged,
[this] (float val) {
@@ -57,8 +53,6 @@ JFJochCalibrationWindow::JFJochCalibrationWindow(QWidget *parent) : JFJochHelper
QSignalBlocker blocker(foreground_slider);
foreground_slider->setValue(val);
});
connect(viewer, &JFJochSimpleImage::roiCalculated, roi_result, &JFJochViewerROIResult::SetROIResult);
connect(background_slider, &SliderPlusBox::valueChanged, viewer, &JFJochSimpleImage::changeBackground);
connect(foreground_slider, &SliderPlusBox::valueChanged, viewer, &JFJochSimpleImage::changeForeground);