viewer: resize box/circle/azimuthal ROIs and delete with the Delete key

Extend canvas ROI editing beyond move:
- Box: drag corners/edges to resize (handles shown on the selected ROI).
- Circle: drag the perimeter to resize, interior to move.
- Azimuthal: drag the inner/outer arc to change the Q/d range, or a radial
  edge to rotate a phi bound; sampled through the geometry so it tracks
  the conic.
- Delete removes the selected ROI (the view now takes keyboard focus).

All edits go through the same live, throttled SetROIDefinition path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 14:07:16 +02:00
co-authored by Claude Opus 4.8
parent d9dfd36413
commit 00b58cd426
2 changed files with 254 additions and 55 deletions
+247 -54
View File
@@ -8,6 +8,7 @@
#include <QPainterPath>
#include <QBrush>
#include <QKeyEvent>
#include <QGraphicsPixmapItem>
#include <QGraphicsSimpleTextItem>
#include <QGraphicsScene>
@@ -20,7 +21,38 @@
#include "JFJochSimpleImage.h"
// Constructor
JFJochDiffractionImage::JFJochDiffractionImage(QWidget *parent) : JFJochImage(parent) {}
static float AngularDistance_deg(float a, float b) {
float d = std::fabs(a - b);
return (d > 180.0f) ? 360.0f - d : d;
}
static bool InPhiSector(float phi, float phi_min, float phi_max) {
if (phi_min <= phi_max)
return phi >= phi_min && phi <= phi_max;
return phi >= phi_min || phi <= phi_max;
}
JFJochDiffractionImage::JFJochDiffractionImage(QWidget *parent) : JFJochImage(parent) {
setFocusPolicy(Qt::StrongFocus); // so the Delete key reaches the view
}
JFJochImage::ResizeHandle JFJochDiffractionImage::hitTestBoxHandle(const QRectF &r, const QPointF &p, qreal tol) const {
auto on = [&](qreal a, qreal b) { return std::abs(a - b) <= tol; };
const bool L = on(p.x(), r.left()), R = on(p.x(), r.right());
const bool T = on(p.y(), r.top()), B = on(p.y(), r.bottom());
const bool inX = p.x() >= r.left() - tol && p.x() <= r.right() + tol;
const bool inY = p.y() >= r.top() - tol && p.y() <= r.bottom() + tol;
if (L && T) return ResizeHandle::TopLeft;
if (R && T) return ResizeHandle::TopRight;
if (L && B) return ResizeHandle::BottomLeft;
if (R && B) return ResizeHandle::BottomRight;
if (L && inY) return ResizeHandle::Left;
if (R && inY) return ResizeHandle::Right;
if (T && inX) return ResizeHandle::Top;
if (B && inX) return ResizeHandle::Bottom;
if (r.contains(p)) return ResizeHandle::Inside;
return ResizeHandle::None;
}
void JFJochDiffractionImage::mouseHover(QMouseEvent *event) {
auto coord = mapToScene(event->pos());
@@ -348,38 +380,67 @@ void JFJochDiffractionImage::DrawROIs() {
auto fill_brush = [&](const QColor &c) {
return show_roi_fill ? QBrush(QColor(c.red(), c.green(), c.blue(), 60)) : QBrush(Qt::NoBrush);
};
auto draw_handle = [&](const QPointF &p, const QColor &c) {
const qreal s = 4.0 / std::sqrt(std::max(1e-4, scale_factor));
addOverlayItem(scene()->addRect(QRectF(p.x() - s, p.y() - s, 2 * s, 2 * s), QPen(c, 1), QBrush(c)));
};
for (const auto &b : rois.boxes) {
QColor c = palette[color_index++ % palette_size];
const bool selected = (QString::fromStdString(b.GetName()) == selected_roi_);
const bool editing = b.GetName() == edit_name_.toStdString()
&& (roi_edit_ == RoiEdit::MoveBox || roi_edit_ == RoiEdit::ResizeBox);
QPen pen(c, selected ? 3 : 2);
pen.setCosmetic(true);
if (selected) pen.setStyle(Qt::DashLine); // highlight the editable ROI
const QRectF rect = (roi_edit_ == RoiEdit::MoveBox && b.GetName() == edit_name_.toStdString())
? edit_box_
: QRectF(b.GetXMin(), b.GetYMin(), b.GetWidth(), b.GetHeight());
const QRectF rect = editing ? edit_box_
: QRectF(b.GetXMin(), b.GetYMin(), b.GetWidth(), b.GetHeight());
addOverlayItem(scene()->addRect(rect, pen, fill_brush(c)));
AddROILabel(b.GetName(), c, rect.left(), rect.top());
if (selected) {
draw_handle(rect.topLeft(), c); draw_handle(rect.topRight(), c);
draw_handle(rect.bottomLeft(), c); draw_handle(rect.bottomRight(), c);
draw_handle({rect.center().x(), rect.top()}, c);
draw_handle({rect.center().x(), rect.bottom()}, c);
draw_handle({rect.left(), rect.center().y()}, c);
draw_handle({rect.right(), rect.center().y()}, c);
}
}
for (const auto &c_roi : rois.circles) {
QColor c = palette[color_index++ % palette_size];
const bool selected = (QString::fromStdString(c_roi.GetName()) == selected_roi_);
const bool editing = c_roi.GetName() == edit_name_.toStdString()
&& (roi_edit_ == RoiEdit::MoveCircle || roi_edit_ == RoiEdit::ResizeCircle);
QPen pen(c, selected ? 3 : 2);
pen.setCosmetic(true);
if (selected) pen.setStyle(Qt::DashLine);
QPointF center(c_roi.GetX(), c_roi.GetY());
double r = c_roi.GetRadius_pxl();
if (roi_edit_ == RoiEdit::MoveCircle && c_roi.GetName() == edit_name_.toStdString()) {
center = edit_center_;
r = edit_radius_;
}
const QPointF center = editing ? edit_center_ : QPointF(c_roi.GetX(), c_roi.GetY());
const double r = editing ? edit_radius_ : c_roi.GetRadius_pxl();
addOverlayItem(scene()->addEllipse(center.x() - r, center.y() - r, 2 * r, 2 * r, pen, fill_brush(c)));
AddROILabel(c_roi.GetName(), c, center.x(), center.y());
if (selected) {
draw_handle({center.x() + r, center.y()}, c);
draw_handle({center.x() - r, center.y()}, c);
draw_handle({center.x(), center.y() + r}, c);
draw_handle({center.x(), center.y() - r}, c);
}
}
for (const auto &az : rois.azimuthal)
DrawAzimuthalROI(az, palette[color_index++ % palette_size], geom);
for (const auto &az : rois.azimuthal) {
QColor c = palette[color_index++ % palette_size];
const bool editing = az.GetName() == edit_name_.toStdString()
&& (roi_edit_ == RoiEdit::AzimInner || roi_edit_ == RoiEdit::AzimOuter
|| roi_edit_ == RoiEdit::RotatePhiMin || roi_edit_ == RoiEdit::RotatePhiMax);
if (editing) {
const ROIAzimuthal edited = edit_has_phi_
? ROIAzimuthal(az.GetName(), edit_d_min_, edit_d_max_, edit_phi_min_, edit_phi_max_)
: ROIAzimuthal(az.GetName(), edit_d_min_, edit_d_max_);
DrawAzimuthalROI(edited, c, geom);
} else {
DrawAzimuthalROI(az, c, geom);
}
}
}
void JFJochDiffractionImage::AddROILabel(const std::string &name, const QColor &color, float px, float py) {
@@ -396,7 +457,9 @@ void JFJochDiffractionImage::AddROILabel(const std::string &name, const QColor &
void JFJochDiffractionImage::DrawAzimuthalROI(const ROIAzimuthal &az, const QColor &color,
const DiffractionGeometry &geom) {
QPen pen(color, 2); pen.setCosmetic(true);
const bool selected = (QString::fromStdString(az.GetName()) == selected_roi_);
QPen pen(color, selected ? 3 : 2); pen.setCosmetic(true);
if (selected) pen.setStyle(Qt::DashLine);
QBrush brush = show_roi_fill ? QBrush(QColor(color.red(), color.green(), color.blue(), 60))
: QBrush(Qt::NoBrush);
@@ -470,45 +533,137 @@ bool JFJochDiffractionImage::roiEditPress(const QPointF &scenePos) {
return false;
const auto &rois = image->Dataset().experiment.ROI().GetROIDefinition();
auto geom = image->Dataset().experiment.GetDiffractionGeometry();
const qreal tol = 6.0 / std::sqrt(std::max(1e-4, scale_factor));
// Pick the first box/circle under the cursor, so an ROI can be grabbed by clicking it.
auto start = [&](const std::string &name) {
edit_name_ = QString::fromStdString(name);
selected_roi_ = edit_name_;
move_last_ = scenePos;
emit roiSelected(edit_name_);
setCursor(Qt::ClosedHandCursor);
};
// Box: corners/edges resize, interior moves.
for (const auto &b : rois.boxes) {
const QRectF r(QPointF(b.GetXMin(), b.GetYMin()), QPointF(b.GetXMax(), b.GetYMax()));
if (r.contains(scenePos)) {
const ResizeHandle h = hitTestBoxHandle(r, scenePos, tol);
if (h == ResizeHandle::None)
continue;
start(b.GetName());
edit_box_ = r;
if (h == ResizeHandle::Inside) {
roi_edit_ = RoiEdit::MoveBox;
edit_name_ = QString::fromStdString(b.GetName());
edit_box_ = r;
move_last_ = scenePos;
selected_roi_ = edit_name_;
emit roiSelected(edit_name_);
setCursor(Qt::ClosedHandCursor);
return true;
} else {
roi_edit_ = RoiEdit::ResizeBox;
box_handle_ = h;
}
return true;
}
// Circle: perimeter resizes, interior moves.
for (const auto &c : rois.circles) {
const QPointF center(c.GetX(), c.GetY());
if (QLineF(center, scenePos).length() <= c.GetRadius_pxl()) {
roi_edit_ = RoiEdit::MoveCircle;
edit_name_ = QString::fromStdString(c.GetName());
edit_center_ = center;
edit_radius_ = c.GetRadius_pxl();
move_last_ = scenePos;
selected_roi_ = edit_name_;
emit roiSelected(edit_name_);
setCursor(Qt::ClosedHandCursor);
return true;
}
const double dist = QLineF(center, scenePos).length();
if (dist > c.GetRadius_pxl() + tol)
continue;
start(c.GetName());
edit_center_ = center;
edit_radius_ = c.GetRadius_pxl();
roi_edit_ = (std::abs(dist - c.GetRadius_pxl()) <= tol) ? RoiEdit::ResizeCircle : RoiEdit::MoveCircle;
return true;
}
return false; // azimuthal editing is not yet supported
// Azimuthal: inner/outer arc resizes Q/d, radial edge rotates phi.
for (const auto &az : rois.azimuthal) {
const auto [bx, by] = geom.GetDirectBeam_pxl();
const double cursor_r = QLineF(QPointF(bx, by), scenePos).length();
const double r_inner = geom.ResToPxl(az.GetDMax_A()); // larger d -> smaller radius
const double r_outer = geom.ResToPxl(az.GetDMin_A());
const float phi = geom.Phi_rad(scenePos.x(), scenePos.y()) * 180.0f / static_cast<float>(PI);
if (az.HasPhi() && !InPhiSector(phi, az.GetPhiMin_deg(), az.GetPhiMax_deg()))
continue;
if (cursor_r < r_inner - tol || cursor_r > r_outer + tol)
continue;
start(az.GetName());
edit_d_min_ = az.GetDMin_A();
edit_d_max_ = az.GetDMax_A();
edit_has_phi_ = az.HasPhi();
edit_phi_min_ = az.GetPhiMin_deg();
edit_phi_max_ = az.GetPhiMax_deg();
if (std::abs(cursor_r - r_inner) <= tol) {
roi_edit_ = RoiEdit::AzimInner;
} else if (std::abs(cursor_r - r_outer) <= tol) {
roi_edit_ = RoiEdit::AzimOuter;
} else if (az.HasPhi()) {
const float tol_deg = (cursor_r > 1.0) ? tol / cursor_r * 180.0f / static_cast<float>(PI) : 180.0f;
if (AngularDistance_deg(phi, az.GetPhiMin_deg()) <= tol_deg)
roi_edit_ = RoiEdit::RotatePhiMin;
else if (AngularDistance_deg(phi, az.GetPhiMax_deg()) <= tol_deg)
roi_edit_ = RoiEdit::RotatePhiMax;
else
roi_edit_ = RoiEdit::None; // interior: select only
} else {
roi_edit_ = RoiEdit::None;
}
return true;
}
return false;
}
void JFJochDiffractionImage::roiEditMove(const QPointF &scenePos) {
const QPointF delta = scenePos - move_last_;
move_last_ = scenePos;
if (roi_edit_ == RoiEdit::MoveBox)
edit_box_.translate(delta);
else if (roi_edit_ == RoiEdit::MoveCircle)
edit_center_ += delta;
if (!image)
return;
auto geom = image->Dataset().experiment.GetDiffractionGeometry();
const auto [bx, by] = geom.GetDirectBeam_pxl();
const float cursor_r = QLineF(QPointF(bx, by), scenePos).length();
switch (roi_edit_) {
case RoiEdit::MoveBox:
edit_box_.translate(scenePos - move_last_);
move_last_ = scenePos;
break;
case RoiEdit::ResizeBox: {
QRectF r = edit_box_;
switch (box_handle_) {
case ResizeHandle::Left: r.setLeft(scenePos.x()); break;
case ResizeHandle::Right: r.setRight(scenePos.x()); break;
case ResizeHandle::Top: r.setTop(scenePos.y()); break;
case ResizeHandle::Bottom: r.setBottom(scenePos.y()); break;
case ResizeHandle::TopLeft: r.setTopLeft(scenePos); break;
case ResizeHandle::TopRight: r.setTopRight(scenePos); break;
case ResizeHandle::BottomLeft: r.setBottomLeft(scenePos); break;
case ResizeHandle::BottomRight: r.setBottomRight(scenePos); break;
default: break;
}
edit_box_ = r.normalized();
break;
}
case RoiEdit::MoveCircle:
edit_center_ += (scenePos - move_last_);
move_last_ = scenePos;
break;
case RoiEdit::ResizeCircle:
edit_radius_ = std::max(1.0, QLineF(edit_center_, scenePos).length());
break;
case RoiEdit::AzimInner:
edit_d_max_ = geom.PxlToRes(cursor_r);
break;
case RoiEdit::AzimOuter:
edit_d_min_ = geom.PxlToRes(cursor_r);
break;
case RoiEdit::RotatePhiMin:
edit_phi_min_ = geom.Phi_rad(scenePos.x(), scenePos.y()) * 180.0f / static_cast<float>(PI);
break;
case RoiEdit::RotatePhiMax:
edit_phi_max_ = geom.Phi_rad(scenePos.x(), scenePos.y()) * 180.0f / static_cast<float>(PI);
break;
default:
return; // None: select only, nothing to drag
}
updateOverlay();
// Live recompute, but keep at most one in flight (cleared in loadImage) so the
@@ -525,7 +680,7 @@ void JFJochDiffractionImage::roiEditRelease() {
const ROIDefinition rois = BuildEditedROIDefinition();
roi_edit_ = RoiEdit::None;
setCursor(Qt::ArrowCursor);
emit roiGeometryEdited(rois); // final, exact position
emit roiGeometryEdited(rois); // final, exact geometry
}
ROIDefinition JFJochDiffractionImage::BuildEditedROIDefinition() const {
@@ -533,23 +688,61 @@ ROIDefinition JFJochDiffractionImage::BuildEditedROIDefinition() const {
if (image)
rois = image->Dataset().experiment.ROI().GetROIDefinition();
const std::string sel = edit_name_.toStdString();
if (roi_edit_ == RoiEdit::MoveBox) {
for (auto &b : rois.boxes)
if (b.GetName() == sel) {
b = ROIBox(sel, std::lround(edit_box_.left()), std::lround(edit_box_.right()),
std::lround(edit_box_.top()), std::lround(edit_box_.bottom()));
break;
}
} else if (roi_edit_ == RoiEdit::MoveCircle) {
for (auto &c : rois.circles)
if (c.GetName() == sel) {
c = ROICircle(sel, edit_center_.x(), edit_center_.y(), edit_radius_);
break;
}
switch (roi_edit_) {
case RoiEdit::MoveBox:
case RoiEdit::ResizeBox:
for (auto &b : rois.boxes)
if (b.GetName() == sel) {
b = ROIBox(sel, std::lround(edit_box_.left()), std::lround(edit_box_.right()),
std::lround(edit_box_.top()), std::lround(edit_box_.bottom()));
break;
}
break;
case RoiEdit::MoveCircle:
case RoiEdit::ResizeCircle:
for (auto &c : rois.circles)
if (c.GetName() == sel) {
c = ROICircle(sel, edit_center_.x(), edit_center_.y(), edit_radius_);
break;
}
break;
case RoiEdit::AzimInner:
case RoiEdit::AzimOuter:
case RoiEdit::RotatePhiMin:
case RoiEdit::RotatePhiMax:
for (auto &a : rois.azimuthal)
if (a.GetName() == sel) {
a = edit_has_phi_
? ROIAzimuthal(sel, edit_d_min_, edit_d_max_, edit_phi_min_, edit_phi_max_)
: ROIAzimuthal(sel, edit_d_min_, edit_d_max_);
break;
}
break;
default:
break;
}
return rois;
}
void JFJochDiffractionImage::keyPressEvent(QKeyEvent *event) {
if (event->key() == Qt::Key_Delete && image && !selected_roi_.isEmpty()) {
ROIDefinition rois = image->Dataset().experiment.ROI().GetROIDefinition();
const std::string sel = selected_roi_.toStdString();
auto erase = [&sel](auto &vec) {
for (auto it = vec.begin(); it != vec.end(); ++it)
if (it->GetName() == sel) { vec.erase(it); return true; }
return false;
};
if (erase(rois.boxes) || erase(rois.circles) || erase(rois.azimuthal)) {
selected_roi_.clear();
emit roiGeometryEdited(rois);
}
event->accept();
return;
}
QGraphicsView::keyPressEvent(event);
}
void JFJochDiffractionImage::UpdateForeground() {
if (!image || !auto_fg)
+7 -1
View File
@@ -37,15 +37,21 @@ private:
bool roiEditPress(const QPointF &scenePos) override;
void roiEditMove(const QPointF &scenePos) override;
void roiEditRelease() override;
void keyPressEvent(QKeyEvent *event) override; // Delete removes the selected ROI
[[nodiscard]] ROIDefinition BuildEditedROIDefinition() const; // current ROIs with the edit applied
[[nodiscard]] ResizeHandle hitTestBoxHandle(const QRectF &r, const QPointF &p, qreal tol) const;
QString selected_roi_;
enum class RoiEdit { None, MoveBox, MoveCircle };
enum class RoiEdit { None, MoveBox, ResizeBox, MoveCircle, ResizeCircle,
AzimInner, AzimOuter, RotatePhiMin, RotatePhiMax };
RoiEdit roi_edit_ = RoiEdit::None;
ResizeHandle box_handle_ = ResizeHandle::None;
QString edit_name_;
QRectF edit_box_;
QPointF edit_center_;
double edit_radius_ = 0;
float edit_d_min_ = 0, edit_d_max_ = 0, edit_phi_min_ = 0, edit_phi_max_ = 0; // azimuthal edit state
bool edit_has_phi_ = false;
QPointF move_last_;
bool live_pending_ = false; // a live edit is being recomputed; throttles emissions