Files
Jungfraujoch/viewer/image_viewer/JFJochImage.cpp
T
leonarski_fandClaude Opus 5 d2ce65f857 Viewer: magnifier displays the frame the main view already rendered
The magnifier and the main view are two views of the same image at different
position and zoom, but the magnifier ran the whole pipeline again on its own
copy: it wrapped the same int32 buffer in a SimpleImage, converted it to float,
coloured every pixel and kept its own full-size QImage. That is a second
conversion and two extra full-detector buffers (20 MB at 2.8 Mpx, 138 MB at
18 Mpx) to feed a 320x320 window.

Separate producing a frame from displaying one:

- JFJochImage keeps the rendered frame in a shared_ptr<QImage> (the pointer is
  stable for the widget's lifetime; only the contents change, so the existing
  buffer reuse is unaffected), publishes it via Frame() and announces new
  pixels with frameRendered().
- JFJochImageItem holds that shared_ptr instead of a reference to a member of
  its owner, which also removes a lifetime coupling.
- JFJochFollowerImage is a small read-only view of such a frame with its own
  zoom and centre. It shows only the image: overlays, ROI tools and per-pixel
  labels belong to the view that owns the data.
- The magnifier becomes one of those, fed from frameRendered().

Consequences beyond the saving: the magnifier now agrees with the main view on
colour map, contrast and HDR mode, which it never did -- it was wired to
neither, so it always drew with its own defaults. And the visibility guard
added in 6d1af4921 is gone: there is no longer any per-frame work to skip, so
nothing needs guarding. That guard was a workaround for this design.

Stepping 30 frames with the magnifier open: 5550 -> 4810 ms CPU, which is what
it costs with the magnifier closed (4770 ms) -- it is now free either way.

Verified: main image panel and a drag-pan stay pixel-identical to the
pre-refactor binary (AE=0); the magnifier follows the cursor, updates on a new
frame, and now tracks a colour-map change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:11:42 +02:00

1096 lines
39 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::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_ = hitTestROIHandle(scenePos, 4.0 / std::sqrt(std::max(1e-4, scale_factor)));
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)) {
mouse_event_type = MouseEventType::MovingROI;
lastMousePos = event->pos();
setCursor(Qt::ClosedHandCursor);
} else if (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: {
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 = menu.addAction(tr("Clear ROI"));
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 (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
CalcROI(); // will emit a zeroed ROI message
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);
}
CalcROI();
updateOverlay();
}
void JFJochImage::addOverlayItem(QGraphicsItem *item) {
overlay_items_.append(item);
}
void JFJochImage::DrawROI() {
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),
};
}
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;
}
void JFJochImage::writePixelLabels() {
static QFont font([] {
QFont f("DejaVu Sans Mono");
f.setStyleHint(QFont::TypeWriter);
f.setPixelSize(1);
return f;
}());
static const QString kGap = QStringLiteral("Gap");
static const QString kErr = QStringLiteral("Err");
static const QString kSat = QStringLiteral("Sat");
QRectF visibleRect = mapToScene(viewport()->geometry()).boundingRect();
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())));
const int visW = std::max(0, endX - startX);
const int visH = std::max(0, endY - startY);
int maxLabels = 5000;
// Choose thresholds that fit your UI width
constexpr float kMinFixed = 1e-3;
constexpr float kMaxFixed = 1e5;
if (visW * visH <= maxLabels) {
EnsurePixelValues();
QString numBuf; // reused buffer
for (int y = startY; y < endY; y ++) {
for (int x = startX; x < endX; x++) {
const int idx = y * W + x;
const float val = image_fp[idx];
const auto absVal = std::abs(val);
const auto nearest = std::nearbyint(val);
const QString* pText = nullptr;
if (std::isnan(val)) {
pText = &kGap;
} else if (std::isinf(val)) {
pText = std::signbit(val) ? &kErr : &kSat;
} else if (val == 0.0f) {
numBuf = QStringLiteral("0");
pText = &numBuf;
} else if (absVal >= kMinFixed && absVal < kMaxFixed) {
if (std::abs(val - nearest) < 1e-6)
numBuf = QString::number(static_cast<qint64>(val));
else if (absVal < 1e4)
numBuf = QString::number(val, 'f', label_decimals_);
else
numBuf = QString::number(val, 'f', std::min(label_decimals_, 2));
pText = &numBuf;
} else {
numBuf = QString::number(val, 'e', 1);
pText = &numBuf;
}
auto *textItem = new QGraphicsSimpleTextItem(*pText);
textItem->setFont(font);
// 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);
if (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)
textItem->setBrush(Qt::black);
else
textItem->setBrush(Qt::white);
textItem->setPos(x + 0.3, y + 0.2);
textItem->setTransform(QTransform::fromScale(0.2, 0.2));
scene()->addItem(textItem);
addOverlayItem(textItem);
}
}
}
}
void JFJochImage::resetScenePointers() {
image_item_ = nullptr;
overlay_items_.clear();
}
void JFJochImage::updateOverlay() {
if (!scene() || W * H <= 0) return;
beforeOverlayCleared();
// 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;
}
if (scale_factor > 30.0)
writePixelLabels();
DrawROI();
addCustomOverlay();
}
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;
Redraw();
}
void JFJochImage::fitToViewShorterSideOnce() {
if (initial_fit_done_ && prev_H == H && prev_W == W)
return;
if (W == 0 || H == 0 || !viewport())
return;
prev_H = H;
prev_W = W;
// Guard against tiny or zero viewport (happens before layout settles)
const QSize vp = viewport()->size();
if (vp.width() < 8 || vp.height() < 8) {
// remember last tried size; resizeEvent/showEvent will retry
last_fit_viewport_ = vp;
return;
}
if (scene())
scene()->setSceneRect(QRectF(0, 0, static_cast<qreal>(W), static_cast<qreal>(H)));
const auto oldAnchor = transformationAnchor();
setTransformationAnchor(QGraphicsView::AnchorViewCenter);
setTransform(QTransform());
fitInView(QRectF(0, 0, static_cast<qreal>(W), static_cast<qreal>(H)), Qt::KeepAspectRatio);
scale_factor = transform().m11();
centerOn(QPointF(static_cast<qreal>(W) * 0.5, static_cast<qreal>(H) * 0.5));
setTransformationAnchor(oldAnchor);
initial_fit_done_ = true;
last_fit_viewport_ = vp;
}
void JFJochImage::adjustForeground(bool input) {
m_adjustForegroundWithWheel = input;
}
void JFJochImage::beforeOverlayCleared() {}
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));
updateOverlay();
emitViewportChanged();
}
}