* Dataset-info plot hover shows a horizontal crosshair alongside the existing vertical one, so a run of hovers reads as a level or a drift at a glance. * `A` applies auto-contrast once instead of switching on persistent Auto mode (a new `oneShotAutoForeground()`, distinct from the toolbar's Auto toggle); pressing it a second time on the same image switches Auto on for good, and pressing it while Auto is already on leaves it on. The value itself comes from one `AutoForegroundValue()` shared with the continuous Auto path. * `Home`/`End`/`Page Up`/`Page Down` navigate the dataset (first/last image, one image forward/back); claimed in the diffraction view's keyPressEvent before QGraphicsView's default handling, which otherwise eats them to scroll the viewport. * Alt+wheel dataset navigation is removed - some window managers already took it before the app ever saw it, per the caveat that used to sit next to its entry in the shortcuts list. * The image strip dock and its dedicated thumbnail-rendering machinery in the reading worker (SetThumbnail*, RenderThumbnail_i) are removed; it cost a lot and nothing else used any of it. * Toolbar text: "Colour" -> "Color". * Color map, Auto and HDR mode now persist across sessions, the same way the window layout already does. * The Inspector's "Dataset:" path wraps at '/' (a zero-width space after each one) instead of being cut off when it doesn't fit one line. * LoadFile no longer reopens an already-open file - it forwards straight to LoadImage instead - which was the likely cause of a D-Bus client's per-image navigation lagging behind the same navigation done via the grid-scan hover. * New file-manager dock (left, tabbed with Settings via tabifyDockWidget): a directory tree filtered to *_master.h5/*_process.h5, rooted at JUNGFRAUJOCH_DATA_ROOT if set, else the last-browsed root, else the home directory. Most beamline users care about opening images, not reprocessing settings, so it's the tab raised by default. Its filter box narrows files only - a directory always passes - because filtering the directories too hid the matching files under any directory whose own name did not match, and hid the root's ancestors, which left the tree with no valid root index until the root was set again by hand. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PogHY7bWXV4bpctDPdyuDN Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1001 lines
37 KiB
C++
1001 lines
37 KiB
C++
// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "JFJochImage.h"
|
|
|
|
#include <QGraphicsSimpleTextItem>
|
|
#include <QScrollBar>
|
|
#include <QWheelEvent>
|
|
#include <QMouseEvent>
|
|
#include <QTimer>
|
|
#include <QMenu>
|
|
#include <QContextMenuEvent>
|
|
#include <QClipboard>
|
|
#include <QGuiApplication>
|
|
#include <QMimeData>
|
|
#include <QBuffer>
|
|
#include <QFileDialog>
|
|
#include <QElapsedTimer>
|
|
#include <QPainter>
|
|
#include <QtConcurrent/QtConcurrent>
|
|
|
|
QRectF JFJochImageItem::boundingRect() const {
|
|
return img_ ? QRectF(0, 0, img_->width(), img_->height()) : QRectF();
|
|
}
|
|
|
|
QPainterPath JFJochImageItem::opaqueArea() const {
|
|
// The buffer is RGB32, so the item fully covers its bounding rect
|
|
QPainterPath path;
|
|
path.addRect(boundingRect());
|
|
return path;
|
|
}
|
|
|
|
void JFJochImageItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) {
|
|
if (!img_ || img_->isNull())
|
|
return;
|
|
// QGraphicsPixmapItem defaults to Qt::FastTransformation and turned this hint off before
|
|
// drawing; keep doing that, so zoomed-in detector pixels stay sharp squares.
|
|
painter->setRenderHint(QPainter::SmoothPixmapTransform, false);
|
|
painter->drawImage(0, 0, *img_);
|
|
}
|
|
|
|
void JFJochImageItem::refresh() {
|
|
prepareGeometryChange();
|
|
update();
|
|
}
|
|
|
|
JFJochImage::JFJochImage(QWidget *parent) : QGraphicsView(parent) {
|
|
setDragMode(QGraphicsView::NoDrag); // Disable default drag mode
|
|
setTransformationAnchor(QGraphicsView::AnchorUnderMouse); // Zoom anchors
|
|
setRenderHint(QPainter::Antialiasing); // Enable smooth rendering
|
|
setRenderHint(QPainter::SmoothPixmapTransform);
|
|
|
|
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
|
|
|
setFocusPolicy(Qt::ClickFocus);
|
|
// Connect the horizontal scrollbar's valueChanged signal
|
|
connect(horizontalScrollBar(), &QScrollBar::valueChanged, this, &JFJochImage::onScroll);
|
|
|
|
// Connect the vertical scrollbar's valueChanged signal
|
|
connect(verticalScrollBar(), &QScrollBar::valueChanged, this, &JFJochImage::onScroll);
|
|
|
|
// Optional: a sensible default colormap
|
|
color_scale.Select(ColorScaleEnum::Indigo);
|
|
|
|
hover_tail_timer_ = new QTimer(this);
|
|
hover_tail_timer_->setSingleShot(true);
|
|
connect(hover_tail_timer_, &QTimer::timeout, this, &JFJochImage::UpdateHover);
|
|
}
|
|
|
|
void JFJochImage::onScroll(int value) {
|
|
if (suppress_overlay_update_)
|
|
return;
|
|
updateOverlay();
|
|
}
|
|
|
|
void JFJochImage::UpdateHover() {
|
|
hover_rate_.restart();
|
|
mouseHover(hover_scene_pos_, hover_modifiers_);
|
|
emit hoverScenePos(hover_scene_pos_);
|
|
}
|
|
|
|
void JFJochImage::ScheduleHoverUpdate(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) {
|
|
hover_scene_pos_ = scenePos;
|
|
hover_modifiers_ = modifiers;
|
|
|
|
if (!hover_rate_.isValid() || hover_rate_.elapsed() >= kHoverIntervalMs) {
|
|
hover_tail_timer_->stop();
|
|
UpdateHover();
|
|
return;
|
|
}
|
|
|
|
// Too soon. Push the catch-up back instead of queueing one per skipped motion, so it fires
|
|
// once, after the pointer stops -- a catch-up that fires mid-gesture is an extra repaint.
|
|
hover_tail_timer_->start(kHoverIntervalMs);
|
|
}
|
|
|
|
void JFJochImage::leaveEvent(QEvent *event) {
|
|
// A tail update that fires now would report a position the pointer has already left
|
|
hover_tail_timer_->stop();
|
|
QGraphicsView::leaveEvent(event);
|
|
}
|
|
|
|
void JFJochImage::ScheduleRenderImage() {
|
|
if (render_pending_)
|
|
return;
|
|
render_pending_ = true;
|
|
QTimer::singleShot(0, this, [this] {
|
|
render_pending_ = false;
|
|
RenderImage();
|
|
Redraw();
|
|
});
|
|
}
|
|
|
|
void JFJochImage::changeBackground(float val) {
|
|
background = val;
|
|
ScheduleRenderImage();
|
|
}
|
|
|
|
void JFJochImage::changeForeground(float val) {
|
|
auto_fg = false;
|
|
emit autoForegroundChanged(false);
|
|
foreground = val;
|
|
// Regenerate the image
|
|
ScheduleRenderImage();
|
|
}
|
|
|
|
void JFJochImage::setColorMap(int color_map) {
|
|
try {
|
|
color_scale.Select(static_cast<ColorScaleEnum>(color_map));
|
|
// Regenerate the image
|
|
RenderImage();
|
|
Redraw();
|
|
} catch (...) {
|
|
}
|
|
}
|
|
|
|
void JFJochImage::setFeatureColor(QColor input) {
|
|
feature_color = input;
|
|
RenderImage();
|
|
Redraw();
|
|
}
|
|
|
|
void JFJochImage::wheelEvent(QWheelEvent *event) {
|
|
if (!scene()) return;
|
|
|
|
const double zoomFactor = 1.15; // Zoom factor
|
|
|
|
// Get the position of the mouse in scene coordinates
|
|
QPointF targetScenePos = mapToScene(event->position().toPoint());
|
|
|
|
const bool exp_fg_adjust = (event->modifiers() & Qt::ControlModifier);
|
|
const bool lin_fg_adjust = (event->modifiers() & Qt::ShiftModifier) || m_adjustForegroundWithWheel;
|
|
|
|
if (exp_fg_adjust || lin_fg_adjust) {
|
|
float new_foreground = foreground;
|
|
|
|
if (exp_fg_adjust) {
|
|
const float step = (event->angleDelta().y() > 0) ? zoomFactor : (1.0 / zoomFactor);
|
|
new_foreground = foreground * step;
|
|
} else {
|
|
new_foreground = foreground + event->angleDelta().y() / 120.0f;
|
|
}
|
|
|
|
if (new_foreground < 1.0f)
|
|
new_foreground = 1.0f;
|
|
|
|
changeForeground(new_foreground);
|
|
emit foregroundChanged(foreground);
|
|
} else {
|
|
// Zooming and re-centering both move the scrollbars, and every move reaches
|
|
// onScroll(); suppress those and rebuild the overlay once, below.
|
|
suppress_overlay_update_ = true;
|
|
|
|
// Perform zooming
|
|
if (event->angleDelta().y() > 0) {
|
|
if (scale_factor * zoomFactor < 500.0) {
|
|
scale_factor *= zoomFactor;
|
|
scale(zoomFactor, zoomFactor);
|
|
}
|
|
} else {
|
|
if (scale_factor > 0.2) {
|
|
scale_factor *= 1.0 / zoomFactor;
|
|
scale(1.0 / zoomFactor, 1.0 / zoomFactor);
|
|
}
|
|
}
|
|
|
|
// Adjust the view's center to keep the zoom focused on the mouse position
|
|
QPointF updatedViewportCenter = mapToScene(viewport()->rect().center());
|
|
QPointF delta = targetScenePos - updatedViewportCenter;
|
|
translate(delta.x(), delta.y()); // Shift the view
|
|
|
|
suppress_overlay_update_ = false;
|
|
|
|
updateOverlay();
|
|
emitViewportChanged();
|
|
}
|
|
}
|
|
|
|
void JFJochImage::resizeEvent(QResizeEvent *event) {
|
|
QGraphicsView::resizeEvent(event);
|
|
|
|
if (scene())
|
|
scene()->setSceneRect(QRectF(0, 0, static_cast<qreal>(W), static_cast<qreal>(H)));
|
|
|
|
// Retry a deferred initial fit: fitToViewShorterSideOnce() skips fitting while the viewport has no
|
|
// real size yet (before the widget is laid out/shown), leaving the view at 1:1 - a small grid-scan
|
|
// plot then renders tiny, "zoomed out". This is the retry its comment promises. Only while the
|
|
// initial fit is still pending, so a later user resize never overrides a manual zoom.
|
|
if (!initial_fit_done_)
|
|
fitToViewShorterSideOnce();
|
|
|
|
updateOverlay();
|
|
}
|
|
|
|
QPointF JFJochImage::RoundPoint(const QPointF &input) {
|
|
return QPointF(qRound(input.x()), qRound(input.y()));
|
|
}
|
|
|
|
void JFJochImage::SetROIBox(QRect box) {
|
|
roi_type = RoiType::RoiBox;
|
|
roiBox= box;
|
|
roiStartPos = roiBox.topLeft();
|
|
roiEndPos = roiBox.bottomRight();
|
|
Redraw();
|
|
}
|
|
|
|
void JFJochImage::SetROICircle(double x, double y, double radius) {
|
|
roi_type = RoiType::RoiCircle;
|
|
roiBox= QRectF(x - radius, y - radius, 2 * radius, 2 * radius).normalized();
|
|
roiStartPos = roiBox.topLeft();
|
|
roiEndPos = roiBox.bottomRight();
|
|
Redraw();
|
|
}
|
|
|
|
void JFJochImage::mousePressEvent(QMouseEvent *event) {
|
|
if (!scene()) return;
|
|
|
|
if (event->button() == Qt::LeftButton) {
|
|
const QPointF scenePos = mapToScene(event->pos());
|
|
|
|
if (roiEditPress(scenePos)) {
|
|
mouse_event_type = MouseEventType::EditingExternalROI;
|
|
event->accept();
|
|
return;
|
|
}
|
|
|
|
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 (AllowROI() && roiBox.contains(scenePos)) {
|
|
mouse_event_type = MouseEventType::MovingROI;
|
|
lastMousePos = event->pos();
|
|
setCursor(Qt::ClosedHandCursor);
|
|
} else if (AllowROI() && (event->modifiers() & Qt::Modifier::SHIFT)) {
|
|
mouse_event_type = MouseEventType::DrawingROI;
|
|
roiStartPos = RoundPoint(scenePos);
|
|
roiEndPos = roiStartPos;
|
|
roi_type = (event->modifiers() & Qt::Modifier::CTRL) ? RoiType::RoiCircle : RoiType::RoiBox;
|
|
setCursor(Qt::CrossCursor);
|
|
} else {
|
|
mouse_event_type = MouseEventType::Panning;
|
|
setCursor(Qt::ClosedHandCursor);
|
|
lastMousePos = event->pos();
|
|
}
|
|
}
|
|
|
|
QGraphicsView::mousePressEvent(event);
|
|
}
|
|
void JFJochImage::mouseMoveEvent(QMouseEvent *event) {
|
|
if (!scene())
|
|
return;
|
|
|
|
const QPointF scenePos = mapToScene(event->pos());
|
|
ScheduleHoverUpdate(scenePos, event->modifiers());
|
|
QPointF delta;
|
|
|
|
switch (mouse_event_type) {
|
|
case MouseEventType::EditingExternalROI:
|
|
roiEditMove(scenePos);
|
|
return;
|
|
case MouseEventType::Panning: {
|
|
const QPoint viewDelta = event->pos() - lastMousePos;
|
|
lastMousePos = event->pos();
|
|
|
|
// Each setValue() reaches onScroll(), so the overlay was rebuilt three times per
|
|
// mouse move. Suppress those and rebuild once, below.
|
|
suppress_overlay_update_ = true;
|
|
horizontalScrollBar()->setValue(horizontalScrollBar()->value() - viewDelta.x());
|
|
verticalScrollBar()->setValue(verticalScrollBar()->value() - viewDelta.y());
|
|
suppress_overlay_update_ = false;
|
|
|
|
updateOverlay();
|
|
emitViewportChanged();
|
|
break;
|
|
}
|
|
case MouseEventType::DrawingROI:
|
|
roiEndPos = RoundPoint(scenePos);
|
|
updateROI();
|
|
break;
|
|
case MouseEventType::MovingROI:
|
|
delta = mapToScene(event->pos()) - mapToScene(lastMousePos);
|
|
lastMousePos = event->pos();
|
|
roiBox.translate(delta);
|
|
updateROI();
|
|
break;
|
|
case MouseEventType::ResizingROI: {
|
|
// Modify the corresponding edges based on active_handle_
|
|
if (roi_type == RoiType::RoiCircle) {
|
|
// Resize circle by radius only, keep center fixed
|
|
const QPointF c = roiBox.center();
|
|
const qreal dx = scenePos.x() - c.x();
|
|
const qreal dy = scenePos.y() - c.y();
|
|
qreal r = std::hypot(dx, dy);
|
|
const qreal rMin = 1.0; // clamp tiny radii
|
|
if (r < rMin) r = rMin;
|
|
roiBox = QRectF(c.x() - r, c.y() - r, 2*r, 2*r);
|
|
} else {
|
|
// Box: modify edges based on active handle
|
|
QRectF r = roiBox;
|
|
switch (active_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.setTop(scenePos.y()); r.setLeft(scenePos.x()); break;
|
|
case ResizeHandle::TopRight: r.setTop(scenePos.y()); r.setRight(scenePos.x()); break;
|
|
case ResizeHandle::BottomLeft: r.setBottom(scenePos.y()); r.setLeft(scenePos.x()); break;
|
|
case ResizeHandle::BottomRight:r.setBottom(scenePos.y()); r.setRight(scenePos.x()); break;
|
|
default: break;
|
|
}
|
|
roiBox = r.normalized();
|
|
|
|
}
|
|
updateROI();
|
|
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
|
|
if (h != hover_handle_) {
|
|
hover_handle_ = h;
|
|
updateOverlay();
|
|
}
|
|
// Set an informative cursor
|
|
switch (h) {
|
|
case ResizeHandle::Left:
|
|
case ResizeHandle::Right:
|
|
setCursor(Qt::SizeHorCursor); break;
|
|
case ResizeHandle::Top:
|
|
case ResizeHandle::Bottom:
|
|
setCursor(Qt::SizeVerCursor); break;
|
|
case ResizeHandle::TopLeft:
|
|
case ResizeHandle::BottomRight:
|
|
setCursor(Qt::SizeFDiagCursor); break;
|
|
case ResizeHandle::TopRight:
|
|
case ResizeHandle::BottomLeft:
|
|
setCursor(Qt::SizeBDiagCursor); break;
|
|
case ResizeHandle::Inside:
|
|
setCursor(Qt::OpenHandCursor); break;
|
|
case ResizeHandle::None:
|
|
setCursor(Qt::ArrowCursor); break;
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
QGraphicsView::mouseMoveEvent(event);
|
|
}
|
|
|
|
void JFJochImage::mouseReleaseEvent(QMouseEvent *event) {
|
|
if (!scene()) return;
|
|
|
|
if (event->button() == Qt::LeftButton) {
|
|
if (mouse_event_type == MouseEventType::EditingExternalROI) {
|
|
roiEditRelease();
|
|
} else {
|
|
const bool drawn = (mouse_event_type == MouseEventType::DrawingROI);
|
|
if (drawn)
|
|
roiEndPos = RoundPoint(mapToScene(event->pos()));
|
|
updateROI();
|
|
if (drawn)
|
|
roiScratchDrawn(); // turn the drawn scratch box/circle into a persistent ROI
|
|
}
|
|
}
|
|
|
|
mouse_event_type = MouseEventType::None;
|
|
active_handle_ = ResizeHandle::None;
|
|
setCursor(Qt::ArrowCursor);
|
|
QGraphicsView::mouseReleaseEvent(event);
|
|
|
|
}
|
|
|
|
void JFJochImage::contextMenuEvent(QContextMenuEvent *event) {
|
|
QMenu menu(this);
|
|
|
|
QAction *copyImageAct = menu.addAction(tr("Copy image"));
|
|
QAction *copyWithOverlayAct = menu.addAction(tr("Copy image with overlay"));
|
|
menu.addSeparator();
|
|
QAction *saveImageAct = menu.addAction(tr("Save image as JPEG..."));
|
|
QAction *saveWithOverlayAct = menu.addAction(tr("Save image with overlay as JPEG..."));
|
|
menu.addSeparator();
|
|
QAction *fitAct = menu.addAction(tr("Fit image to view"));
|
|
QAction *clearRoiAct = AllowROI() ? menu.addAction(tr("Clear ROI")) : nullptr;
|
|
|
|
const bool hasImage = (W > 0 && H > 0 && !frame_->isNull());
|
|
copyImageAct->setEnabled(hasImage);
|
|
copyWithOverlayAct->setEnabled(hasImage && scene());
|
|
saveImageAct->setEnabled(hasImage);
|
|
saveWithOverlayAct->setEnabled(hasImage && scene());
|
|
|
|
QAction *chosen = menu.exec(event->globalPos());
|
|
if (!chosen) return;
|
|
|
|
if (chosen == copyImageAct) {
|
|
copyImageToClipboard();
|
|
} else if (chosen == copyWithOverlayAct) {
|
|
copyImageWithOverlayToClipboard();
|
|
} else if (chosen == saveImageAct) {
|
|
saveImageToFile(false);
|
|
} else if (chosen == saveWithOverlayAct) {
|
|
saveImageToFile(true);
|
|
} else if (chosen == fitAct) {
|
|
fitToView();
|
|
} else if (clearRoiAct && chosen == clearRoiAct) {
|
|
clearROIInternal();
|
|
}
|
|
}
|
|
|
|
static void setClipboardAsJpegAndImage(const QImage &img, int quality = 95) {
|
|
// Provide both "image/jpeg" and generic image flavors for better compatibility
|
|
QByteArray ba;
|
|
ba.reserve(img.width() * img.height() * 3 / 2);
|
|
QBuffer buf(&ba);
|
|
buf.open(QIODevice::WriteOnly);
|
|
|
|
QImage toSave = img;
|
|
// Force 1:1 pixel ratio and standard DPI (96) to avoid scaling in consumer apps
|
|
toSave.setDevicePixelRatio(1.0);
|
|
constexpr int dotsPerMeter96DPI = 3780; // 96 DPI
|
|
toSave.setDotsPerMeterX(dotsPerMeter96DPI);
|
|
toSave.setDotsPerMeterY(dotsPerMeter96DPI);
|
|
|
|
toSave = toSave.convertToFormat(QImage::Format_ARGB32); // ensure a known format for encoding
|
|
toSave.save(&buf, "JPEG", quality);
|
|
|
|
auto *mime = new QMimeData();
|
|
mime->setData("image/jpeg", ba);
|
|
mime->setImageData(toSave); // also set as generic bitmap
|
|
QGuiApplication::clipboard()->setMimeData(mime);
|
|
|
|
}
|
|
|
|
QImage JFJochImage::renderToImage(bool with_overlay) {
|
|
QImage img;
|
|
if (with_overlay && scene()) {
|
|
// Render the entire scene (image + overlay) at native image resolution
|
|
img = QImage(int(W), int(H), QImage::Format_ARGB32_Premultiplied);
|
|
img.fill(Qt::transparent);
|
|
QPainter p(&img);
|
|
const QRectF rect(0, 0, qreal(W), qreal(H));
|
|
scene()->render(&p, rect, rect);
|
|
p.end();
|
|
} else {
|
|
// The underlying rendered image (no overlay)
|
|
img = *frame_;
|
|
}
|
|
// Ensure 1:1 pixel ratio and 96 DPI metadata to avoid rescaling in consumer apps
|
|
img.setDevicePixelRatio(1.0);
|
|
constexpr int dotsPerMeter96DPI = 3780;
|
|
img.setDotsPerMeterX(dotsPerMeter96DPI);
|
|
img.setDotsPerMeterY(dotsPerMeter96DPI);
|
|
return img;
|
|
}
|
|
|
|
void JFJochImage::copyImageToClipboard() {
|
|
if (W == 0 || H == 0 || frame_->isNull()) return;
|
|
|
|
setClipboardAsJpegAndImage(renderToImage(false), 95);
|
|
emit writeStatusBar(tr("Image copied to clipboard"), 2000);
|
|
}
|
|
|
|
void JFJochImage::copyImageWithOverlayToClipboard() {
|
|
if (W == 0 || H == 0 || !scene()) return;
|
|
|
|
setClipboardAsJpegAndImage(renderToImage(true), 95);
|
|
emit writeStatusBar(tr("Image with overlay copied to clipboard"), 2000);
|
|
}
|
|
|
|
void JFJochImage::saveImageToFile(bool with_overlay) {
|
|
if (W == 0 || H == 0 || frame_->isNull()) return;
|
|
if (with_overlay && !scene()) return;
|
|
|
|
const QString caption = with_overlay ? tr("Save image with overlay as JPEG")
|
|
: tr("Save image as JPEG");
|
|
QString file_name = QFileDialog::getSaveFileName(this, caption, QString(),
|
|
tr("JPEG image (*.jpg *.jpeg)"));
|
|
if (file_name.isEmpty())
|
|
return;
|
|
if (!file_name.endsWith(".jpg", Qt::CaseInsensitive) && !file_name.endsWith(".jpeg", Qt::CaseInsensitive))
|
|
file_name += ".jpg";
|
|
|
|
// JPEG cannot store alpha; flatten to RGB before encoding.
|
|
const QImage img = renderToImage(with_overlay).convertToFormat(QImage::Format_RGB32);
|
|
if (img.save(file_name, "JPEG", 95))
|
|
emit writeStatusBar(tr("Saved %1").arg(file_name), 3000);
|
|
else
|
|
emit writeStatusBar(tr("Failed to save %1").arg(file_name), 3000);
|
|
}
|
|
|
|
void JFJochImage::clearROIInternal() {
|
|
roiBox = QRectF(); // clear any ROI
|
|
// Keep current roi_type; ROI simply becomes empty
|
|
updateOverlay();
|
|
emit writeStatusBar(tr("ROI cleared"), 1500);
|
|
}
|
|
|
|
JFJochImage::ResizeHandle
|
|
JFJochImage::hitTestROIHandle(const QPointF& scenePos, qreal tol) const {
|
|
if (roiBox.isNull() || roiBox.width() <= 0 || roiBox.height() <= 0)
|
|
return ResizeHandle::None;
|
|
|
|
const QRectF r = roiBox;
|
|
|
|
if (roi_type == RoiType::RoiCircle) {
|
|
// Circle hit test: near perimeter -> resize, inside -> move
|
|
const QPointF c = r.center();
|
|
const qreal rx = r.width() * 0.5;
|
|
const qreal ry = r.height() * 0.5;
|
|
// Enforce circular assumption: use average radius
|
|
const qreal rad = 0.5 * (rx + ry);
|
|
const qreal dx = scenePos.x() - c.x();
|
|
const qreal dy = scenePos.y() - c.y();
|
|
const qreal d = std::hypot(dx, dy);
|
|
if (std::abs(d - rad) <= tol) {
|
|
// generic "edge" resize handle for circle
|
|
return ResizeHandle::Right;
|
|
}
|
|
if (d < rad) return ResizeHandle::Inside;
|
|
return ResizeHandle::None;
|
|
}
|
|
|
|
// Box hit test (corners first)
|
|
const QPointF tl = r.topLeft();
|
|
const QPointF tr = r.topRight();
|
|
const QPointF bl = r.bottomLeft();
|
|
const QPointF br = r.bottomRight();
|
|
|
|
auto nearPt = [&](const QPointF& a, const QPointF& b, qreal t) {
|
|
return std::abs(a.x() - b.x()) <= t && std::abs(a.y() - b.y()) <= t;
|
|
};
|
|
|
|
if (nearPt(scenePos, tl, tol)) return ResizeHandle::TopLeft;
|
|
if (nearPt(scenePos, tr, tol)) return ResizeHandle::TopRight;
|
|
if (nearPt(scenePos, bl, tol)) return ResizeHandle::BottomLeft;
|
|
if (nearPt(scenePos, br, tol)) return ResizeHandle::BottomRight;
|
|
|
|
// Edges
|
|
if (std::abs(scenePos.x() - r.left()) <= tol && scenePos.y() >= r.top() - tol && scenePos.y() <= r.bottom() + tol)
|
|
return ResizeHandle::Left;
|
|
if (std::abs(scenePos.x() - r.right()) <= tol && scenePos.y() >= r.top() - tol && scenePos.y() <= r.bottom() + tol)
|
|
return ResizeHandle::Right;
|
|
if (std::abs(scenePos.y() - r.top()) <= tol && scenePos.x() >= r.left() - tol && scenePos.x() <= r.right() + tol)
|
|
return ResizeHandle::Top;
|
|
if (std::abs(scenePos.y() - r.bottom()) <= tol && scenePos.x() >= r.left() - tol && scenePos.x() <= r.right() + tol)
|
|
return ResizeHandle::Bottom;
|
|
|
|
if (r.contains(scenePos)) return ResizeHandle::Inside;
|
|
return ResizeHandle::None;
|
|
}
|
|
|
|
void JFJochImage::updateROI() {
|
|
if (roi_type == RoiType::RoiBox) {
|
|
if (mouse_event_type == MouseEventType::DrawingROI) {
|
|
// While drawing: construct box from start/end
|
|
QRectF rect = QRectF(RoundPoint(roiStartPos), RoundPoint(roiEndPos)).normalized();
|
|
roiBox = rect;
|
|
} else {
|
|
// While moving/resizing: keep roiBox as modified, just sync corners
|
|
roiStartPos = roiBox.topLeft();
|
|
roiEndPos = roiBox.bottomRight();
|
|
}
|
|
emit roiBoxUpdated(roiBox.toRect());
|
|
} else {
|
|
double radius;
|
|
if (mouse_event_type == MouseEventType::DrawingROI) {
|
|
// Center at roiStartPos, radius from start->end
|
|
QPointF delta = roiStartPos - roiEndPos;
|
|
radius = std::sqrt(delta.x() * delta.x() + delta.y() * delta.y());
|
|
roiBox = QRectF(roiStartPos.x() - radius, roiStartPos.y() - radius,
|
|
2 * radius, 2 * radius).normalized();
|
|
} else {
|
|
// Moving/resizing: infer center/radius from roiBox
|
|
const QPointF c = roiBox.center();
|
|
radius = 0.5 * std::min(roiBox.width(), roiBox.height());
|
|
roiStartPos = c; // treat start as center for consistency
|
|
roiEndPos = QPointF(c.x() + radius, c.y()); // arbitrary point on radius
|
|
}
|
|
emit roiCircleUpdated(roiStartPos.x(), roiStartPos.y(), radius);
|
|
}
|
|
updateOverlay();
|
|
}
|
|
|
|
void JFJochImage::addOverlayItem(QGraphicsItem *item) {
|
|
overlay_items_.append(item);
|
|
}
|
|
|
|
void JFJochImage::DrawROI() {
|
|
if (!AllowROI())
|
|
return;
|
|
if (roiBox.isNull() || roiBox.width() <= 0 || roiBox.height() <= 0) return;
|
|
|
|
auto scn = scene();
|
|
if (!scn)
|
|
return;
|
|
|
|
QPen pen(feature_color, 2);
|
|
pen.setStyle(Qt::DashLine);
|
|
pen.setCosmetic(true);
|
|
|
|
const qreal f = std::clamp(scale_factor, 0.5, 50.0);
|
|
const qreal handleSize = 3.0 / std::sqrt(std::max(1e-4, f));
|
|
|
|
if (roi_type == RoiType::RoiCircle) {
|
|
// Draw circle
|
|
addOverlayItem(scn->addEllipse(roiBox, pen));
|
|
|
|
// A single handle on the circle at the rightmost point
|
|
const QPointF c = roiBox.center();
|
|
const qreal rad = 0.5 * (roiBox.width() + roiBox.height()) * 0.5; // average, should be equal
|
|
QPointF hpos = QPointF(roiBox.right(), c.y());
|
|
addOverlayItem(scn->addRect(QRectF(hpos.x() - handleSize, hpos.y() - handleSize, 2 * handleSize, 2 * handleSize),
|
|
QPen(feature_color, 1), QBrush(feature_color)));
|
|
|
|
// On hover near perimeter: draw in/out arrows along radius at handle
|
|
if (hover_handle_ != ResizeHandle::None && hover_handle_ != ResizeHandle::Inside) {
|
|
QPen apen(feature_color, 1);
|
|
apen.setCosmetic(true);
|
|
const qreal arrowLen = 8.0 / std::sqrt(std::max(1e-4, f));
|
|
// Outward arrow
|
|
addOverlayItem(scn->addLine(QLineF(c, c + QPointF(rad + arrowLen, 0)), apen));
|
|
// Inward arrow
|
|
addOverlayItem(scn->addLine(QLineF(c, c + QPointF(rad - arrowLen, 0)), apen));
|
|
}
|
|
} else {
|
|
// Box
|
|
addOverlayItem(scn->addRect(roiBox, pen));
|
|
|
|
// Corner handles
|
|
auto addHandle = [&](const QPointF& p) {
|
|
addOverlayItem(scn->addRect(QRectF(p.x() - handleSize, p.y() - handleSize, 2 * handleSize, 2 * handleSize),
|
|
QPen(feature_color, 1), QBrush(feature_color)));
|
|
};
|
|
addHandle(roiBox.topLeft());
|
|
addHandle(roiBox.topRight());
|
|
addHandle(roiBox.bottomLeft());
|
|
addHandle(roiBox.bottomRight());
|
|
|
|
// On hover over a resizable edge/corner: draw small arrows indicating resize direction
|
|
if (hover_handle_ != ResizeHandle::None && hover_handle_ != ResizeHandle::Inside) {
|
|
QPen apen(feature_color, 1);
|
|
apen.setCosmetic(true);
|
|
const qreal arrowLen = 6.0 / std::sqrt(std::max(1e-4, f));
|
|
const qreal off = 10.0 / std::sqrt(std::max(1e-4, f));
|
|
auto drawArrow = [&](const QPointF& a, const QPointF& b) {
|
|
addOverlayItem(scn->addLine(QLineF(a, b), apen));
|
|
};
|
|
const QRectF r = roiBox;
|
|
switch (hover_handle_) {
|
|
case ResizeHandle::Left:
|
|
drawArrow(QPointF(r.left(), r.center().y() - off), QPointF(r.left() - arrowLen, r.center().y() - off));
|
|
drawArrow(QPointF(r.left(), r.center().y() + off), QPointF(r.left() - arrowLen, r.center().y() + off));
|
|
break;
|
|
case ResizeHandle::Right:
|
|
drawArrow(QPointF(r.right(), r.center().y() - off), QPointF(r.right() + arrowLen, r.center().y() - off));
|
|
drawArrow(QPointF(r.right(), r.center().y() + off), QPointF(r.right() + arrowLen, r.center().y() + off));
|
|
break;
|
|
case ResizeHandle::Top:
|
|
drawArrow(QPointF(r.center().x() - off, r.top()), QPointF(r.center().x() - off, r.top() - arrowLen));
|
|
drawArrow(QPointF(r.center().x() + off, r.top()), QPointF(r.center().x() + off, r.top() - arrowLen));
|
|
break;
|
|
case ResizeHandle::Bottom:
|
|
drawArrow(QPointF(r.center().x() - off, r.bottom()), QPointF(r.center().x() - off, r.bottom() + arrowLen));
|
|
drawArrow(QPointF(r.center().x() + off, r.bottom()), QPointF(r.center().x() + off, r.bottom() + arrowLen));
|
|
break;
|
|
case ResizeHandle::TopLeft:
|
|
case ResizeHandle::TopRight:
|
|
case ResizeHandle::BottomLeft:
|
|
case ResizeHandle::BottomRight:
|
|
// For corners, show arrows on both axes (simple version)
|
|
drawArrow(QPointF(r.right(), r.center().y()), QPointF(r.right() + arrowLen, r.center().y()));
|
|
drawArrow(QPointF(r.left(), r.center().y()), QPointF(r.left() - arrowLen, r.center().y()));
|
|
drawArrow(QPointF(r.center().x(), r.top()), QPointF(r.center().x(), r.top() - arrowLen));
|
|
drawArrow(QPointF(r.center().x(), r.bottom()), QPointF(r.center().x(), r.bottom() + arrowLen));
|
|
break;
|
|
default: break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
void JFJochImage::Redraw() {
|
|
if (W*H <= 0)
|
|
return;
|
|
|
|
QGraphicsScene *currentScene = scene();
|
|
if (!currentScene) {
|
|
// First time - create a new scene
|
|
currentScene = new QGraphicsScene(this);
|
|
setScene(currentScene);
|
|
// Reset initial-fit state for a new scene
|
|
initial_fit_done_ = false;
|
|
image_item_ = nullptr; // new scene, old pointer invalid
|
|
}
|
|
|
|
// Perform initial fit only once per image size
|
|
fitToViewShorterSideOnce();
|
|
|
|
updateOverlay();
|
|
}
|
|
|
|
PixelColorMap JFJochImage::MakeColorMap() const {
|
|
// Bad pixel color
|
|
int r, g, b, a;
|
|
feature_color.getRgb(&r, &g, &b, &a);
|
|
auto bad_color = rgb{.r = static_cast<uint8_t>(r), .g = static_cast<uint8_t>(g), .b = static_cast<uint8_t>(b)};
|
|
|
|
const auto &lut_data = color_scale.LUTData();
|
|
const auto lutSize = static_cast<int>(lut_data.size());
|
|
const float lutScale = static_cast<float>(lutSize - 1);
|
|
const float range = foreground - background;
|
|
|
|
return PixelColorMap{
|
|
.lut = lut_data.data(),
|
|
.lut_size = lutSize,
|
|
.minv = background,
|
|
.range = range,
|
|
.inv_range = (range > 0) ? (lutScale / range) : 0.0f,
|
|
.inv_range_log = (range > 0) ? (lutScale / std::log1p(range)) : 0.0f,
|
|
.hdr = hdr_mode,
|
|
.gap = color_scale.Apply(ColorScaleSpecial::Gap),
|
|
.bad = bad_color,
|
|
// Saturation color
|
|
.saturated = show_saturation ? bad_color : color_scale.Apply(1.0f),
|
|
.beam_stop = show_beam_stop ? color_scale.Apply(ColorScaleSpecial::BeamStop) : bad_color,
|
|
};
|
|
}
|
|
|
|
void JFJochImage::ColorRow(size_t y, const PixelColorMap &map, QRgb *out) const {
|
|
const float *row = &image_fp[y * W];
|
|
|
|
for (size_t x = 0; x < W; ++x) {
|
|
const float fp = row[x];
|
|
|
|
rgb c;
|
|
if (!std::isfinite(fp))
|
|
c = std::isnan(fp) ? map.gap : (std::signbit(fp) ? map.bad : map.saturated);
|
|
else
|
|
c = map.Apply(fp);
|
|
|
|
out[x] = qRgb(c.r, c.g, c.b);
|
|
}
|
|
}
|
|
|
|
void JFJochImage::RenderImage() {
|
|
if (frame_->width() != int(W) || frame_->height() != int(H))
|
|
*frame_ = QImage(int(W), int(H), QImage::Format_RGB32);
|
|
|
|
// Take the data pointer once, here: scanLine() is non-const, so calling it from the
|
|
// workers below would have each of them detach the (possibly shared) buffer in parallel.
|
|
uchar *const bits = frame_->bits();
|
|
const qsizetype stride = frame_->bytesPerLine();
|
|
|
|
const PixelColorMap map = MakeColorMap();
|
|
|
|
QVector<int> rows;
|
|
rows.reserve(H);
|
|
for (int y = 0; y < H; ++y) rows.push_back(y);
|
|
|
|
QtConcurrent::blockingMap(rows, [&](int y) {
|
|
ColorRow(y, map, reinterpret_cast<QRgb *>(bits + y * stride));
|
|
});
|
|
|
|
image_dirty_ = true;
|
|
emit frameRendered();
|
|
}
|
|
|
|
void JFJochImage::ClearFrame() {
|
|
*frame_ = QImage();
|
|
image_dirty_ = true;
|
|
emit frameRendered();
|
|
}
|
|
|
|
void JFJochImage::centerOnSpot(QPointF point) {
|
|
// If W or H = 0, then conditions are never satisfied
|
|
if (point.x() >= 0 && point.x() < W && point.y() >= 0 && point.y() < H)
|
|
centerOn(point);
|
|
emitViewportChanged();
|
|
}
|
|
|
|
void JFJochImage::emitViewportChanged() {
|
|
if (m_applyingViewport || !scene())
|
|
return;
|
|
emit viewportChanged(transform(), mapToScene(viewport()->rect().center()));
|
|
}
|
|
|
|
void JFJochImage::applyViewport(QTransform transform, QPointF center) {
|
|
if (m_applyingViewport || !scene())
|
|
return;
|
|
m_applyingViewport = true;
|
|
// As in wheelEvent: one rebuild, not one per scrollbar move
|
|
suppress_overlay_update_ = true;
|
|
setTransform(transform);
|
|
scale_factor = transform.m11();
|
|
centerOn(center);
|
|
suppress_overlay_update_ = false;
|
|
updateOverlay();
|
|
m_applyingViewport = false;
|
|
}
|
|
|
|
QString JFJochImage::PixelLabel(int x, int y) const {
|
|
// Choose thresholds that fit your UI width
|
|
constexpr float kMinFixed = 1e-3;
|
|
constexpr float kMaxFixed = 1e5;
|
|
|
|
const float val = image_fp[static_cast<size_t>(y) * W + x];
|
|
const auto absVal = std::abs(val);
|
|
const auto nearest = std::nearbyint(val);
|
|
|
|
if (std::isnan(val))
|
|
return QStringLiteral("Gap");
|
|
if (std::isinf(val))
|
|
return std::signbit(val) ? QStringLiteral("Err") : QStringLiteral("Sat");
|
|
if (val == 0.0f)
|
|
return QStringLiteral("0");
|
|
if (absVal >= kMinFixed && absVal < kMaxFixed) {
|
|
if (std::abs(val - nearest) < 1e-6)
|
|
return QString::number(static_cast<qint64>(val));
|
|
if (absVal < 1e4)
|
|
return QString::number(val, 'f', label_decimals_);
|
|
return QString::number(val, 'f', std::min(label_decimals_, 2));
|
|
}
|
|
return QString::number(val, 'e', 1);
|
|
}
|
|
|
|
void JFJochImage::drawPixelLabels(QPainter *painter, const QRectF &rect) {
|
|
constexpr int kMaxLabels = 5000;
|
|
|
|
// Only the exposed area, not the whole viewport: a hover repaint dirties a couple of hundred
|
|
// pixels, and laying out every visible cell for it would do thousands of mapFromScene / pixel /
|
|
// drawText calls whose output is then clipped away - at up to 15 repaints a second.
|
|
const QRectF visibleRect = mapToScene(viewport()->rect()).boundingRect() & rect;
|
|
const int startX = std::max(0, static_cast<int>(std::floor(visibleRect.left())));
|
|
const int endX = std::min(static_cast<int>(W), static_cast<int>(std::ceil(visibleRect.right())));
|
|
const int startY = std::max(0, static_cast<int>(std::floor(visibleRect.top())));
|
|
const int endY = std::min(static_cast<int>(H), static_cast<int>(std::ceil(visibleRect.bottom())));
|
|
if (endX <= startX || endY <= startY)
|
|
return;
|
|
if ((endX - startX) * (endY - startY) > kMaxLabels)
|
|
return;
|
|
|
|
// Laid out in viewport pixels: a constant, readable size independent of the zoom
|
|
painter->save();
|
|
painter->resetTransform();
|
|
|
|
QFont font("DejaVu Sans Mono");
|
|
font.setStyleHint(QFont::TypeWriter);
|
|
font.setPixelSize(std::clamp(static_cast<int>(scale_factor * 0.3), 7, 16));
|
|
painter->setFont(font);
|
|
|
|
for (int y = startY; y < endY; y++) {
|
|
for (int x = startX; x < endX; x++) {
|
|
const QRect cell = mapFromScene(QRectF(x, y, 1, 1)).boundingRect();
|
|
// Read the colour back from the rendered image rather than keeping a full-size
|
|
// mirror of it around for the few pixels that get a label.
|
|
const QRgb pxl = frame_->pixel(x, y);
|
|
painter->setPen(luminance(rgb{.r = static_cast<uint8_t>(qRed(pxl)),
|
|
.g = static_cast<uint8_t>(qGreen(pxl)),
|
|
.b = static_cast<uint8_t>(qBlue(pxl))}) > 128.0
|
|
? Qt::black : Qt::white);
|
|
painter->drawText(cell, Qt::AlignCenter, PixelLabel(x, y));
|
|
}
|
|
}
|
|
|
|
painter->restore();
|
|
}
|
|
|
|
void JFJochImage::drawForeground(QPainter *painter, const QRectF &rect) {
|
|
QGraphicsView::drawForeground(painter, rect);
|
|
|
|
if (scale_factor > 30.0 && W * H > 0 && frame_ && !frame_->isNull())
|
|
drawPixelLabels(painter, rect);
|
|
}
|
|
|
|
void JFJochImage::resetScenePointers() {
|
|
image_item_ = nullptr;
|
|
overlay_items_.clear();
|
|
}
|
|
|
|
void JFJochImage::updateOverlay() {
|
|
if (!scene() || W * H <= 0) return;
|
|
|
|
// Remove only overlay items, keep the image item persistent
|
|
for (auto *item : overlay_items_)
|
|
scene()->removeItem(item);
|
|
qDeleteAll(overlay_items_);
|
|
overlay_items_.clear();
|
|
|
|
// Ensure the image item exists and is up-to-date. Refreshing it marks the whole item
|
|
// dirty, which forces a full repaint of the viewport, so only do it when the image
|
|
// really changed - not on every pan and zoom.
|
|
if (!image_item_) {
|
|
image_item_ = new JFJochImageItem(frame_);
|
|
image_item_->setZValue(0);
|
|
scene()->addItem(image_item_);
|
|
image_dirty_ = false;
|
|
} else if (image_dirty_) {
|
|
image_item_->refresh();
|
|
image_dirty_ = false;
|
|
}
|
|
|
|
DrawROI();
|
|
|
|
addCustomOverlay();
|
|
}
|
|
|
|
void JFJochImage::addCustomOverlay() {}
|
|
|
|
|
|
|
|
void JFJochImage::fitToView() {
|
|
initial_fit_done_ = false;
|
|
Redraw();
|
|
}
|
|
|
|
void JFJochImage::fitToViewShorterSideOnce() {
|
|
if (initial_fit_done_ && prev_H == H && prev_W == W && prev_aspect_ == pixel_aspect_)
|
|
return;
|
|
|
|
|
|
if (W == 0 || H == 0 || !viewport())
|
|
return;
|
|
|
|
prev_H = H;
|
|
prev_W = W;
|
|
prev_aspect_ = pixel_aspect_;
|
|
|
|
// Guard against tiny or zero viewport (happens before layout settles)
|
|
const QSize vp = viewport()->size();
|
|
if (vp.width() < 8 || vp.height() < 8)
|
|
return; // resizeEvent / showEvent will retry once the layout has settled
|
|
|
|
if (scene())
|
|
scene()->setSceneRect(QRectF(0, 0, static_cast<qreal>(W), static_cast<qreal>(H)));
|
|
|
|
const auto oldAnchor = transformationAnchor();
|
|
setTransformationAnchor(QGraphicsView::AnchorViewCenter);
|
|
setTransform(QTransform());
|
|
|
|
// Non-square pixels are fitted as if the image were as tall as they draw it, and the extra
|
|
// height is then put back into the transform: the image still fits, and the uniform zooms
|
|
// that follow keep the proportion.
|
|
fitInView(QRectF(0, 0, static_cast<qreal>(W), static_cast<qreal>(H) * pixel_aspect_), Qt::KeepAspectRatio);
|
|
scale(1.0, pixel_aspect_);
|
|
|
|
scale_factor = transform().m11();
|
|
centerOn(QPointF(static_cast<qreal>(W) * 0.5, static_cast<qreal>(H) * 0.5));
|
|
setTransformationAnchor(oldAnchor);
|
|
|
|
initial_fit_done_ = true;
|
|
}
|
|
|
|
void JFJochImage::adjustForeground(bool input) {
|
|
m_adjustForegroundWithWheel = input;
|
|
}
|
|
|
|
double JFJochImage::GetScaleFactor() const {
|
|
return scale_factor;
|
|
}
|
|
|
|
void JFJochImage::setZoom(double input) {
|
|
if (std::isfinite(input) && input > 0) {
|
|
scale_factor = input;
|
|
if (!scene())
|
|
return;
|
|
setTransform(QTransform::fromScale(input, input * pixel_aspect_));
|
|
updateOverlay();
|
|
emitViewportChanged();
|
|
}
|
|
}
|