Files
Jungfraujoch/viewer/image_viewer/JFJochImage.cpp
T
leonarski_fandClaude Opus 5 379feac2e9 Viewer: lay out pixel labels only for the area being repainted
drawPixelLabels took its range from the whole viewport rather than from the
exposed rect it was given, so a 200x40 px hover repaint still walked up to 5000
cells doing mapFromScene + QImage::pixel + drawText for each, only to have the
result clipped away. With hover feedback now rate-limited to 15 Hz that ran ~75
times a second at high zoom, against once per overlay rebuild before the
rendering rework. Intersect with the exposed rect; same in the magnifier.

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

999 lines
36 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),
};
}
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)
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;
}
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();
}
}