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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user