Files
Jungfraujoch/viewer/image_viewer/JFJochImage.cpp
T
leonarski_fandClaude Opus 5 16bf3408f0 Address code-review findings; make detection limits detector-driven
One changeset, developed together in response to a review of this branch, so the
files carry several of the changes at once. Full test suite passes (733 cases).

Spot finding
- Split ImageSpotFinder into Detect() (flag strong pixels - the expensive
  per-pixel pass) and ExtractSpots() (CCL + min/max-pix + resolution mask), with
  Run() = both. The per-image min-pix escalation now detects ONCE and repeats
  only the cheap extraction, instead of re-running the whole finder four times
  per frame as it did on the default path. It also keeps the winning attempt's
  spot list rather than re-extracting it, so the frame that is integrated is
  exactly the frame that was scored - which a GPU re-extract could not guarantee
  (float atomic ordering).
- spot_finding_time_s no longer swallows indexing time, and indexing_time_s now
  sums every escalation call instead of reporting only the last.

Detection limits follow the detector
- The azimuthal-integration upper q and the spot-finding high-resolution limit
  are now std::optional, in the C++ structs AND in the OpenAPI schema, and
  resolve to the detector's own maximum (DiffractionExperiment::GetDetectorMaxQ_
  recipA). Adaptive detection reads a pixel's ring from the azimuthal bins, so a
  pixel outside that q range could never be strong - the integration range
  silently bounded what detection could see, regardless of the requested
  resolution limit. Regenerated the C++ and TypeScript clients; the viewer and
  the web frontend each gained a "to detector edge" switch.

Detection defaults are now per workflow (measured, not assumed)
- Stills: adaptive detection, min-pix chosen per image, no resolution clipping.
- Rotation: fixed-threshold finder, min-pix 2, 1.5 A limit.
  On a 33-crystal rotation battery, adaptive detection helped four hard crystals
  but deterministically broke three (a lost space group, a halved indexing rate,
  a collapsed merge), and the detector-edge limit cost indexing on a strong
  rotation set (100.0 -> 96.8%). Each is still overridable by its flag, and
  --no-adaptive-spots is new.

Indexer seed escalation
- Stop escalating once a seed's lattice explains >= 90% of the seed spots.
  Previously any frame with >= 80 spots always paid three indexer calls, online
  broker included.

Merge-consistency filter
- --min-image-cc gated on a per-image CC computed BEFORE the stills partiality
  post-refinement and never refreshed; the refiner now recomputes it, so the
  reported CC describes the data that are actually merged.
- Replaced the per-call cc_mask argument with one MergeOnTheFly flag, so the
  merge, the error model and MergeStats can no longer disagree about which
  images are in (the --scale path merged unfiltered while its statistics were
  filtered).

Per-image B-factor refinement (-B) removed
- Measured on four serial-stills datasets: it is a no-op where the per-image fit
  is well conditioned and actively harmful where it is not (CC1/2 -8.1, R_meas
  +23.2 on the weakest large-cell set, whose fits hit their [-50, 200] bounds on
  14-25% of images). It had also been silently DISCARDED since the partiality
  post-refinement landed - reported but not applied. Rather than fix and keep a
  knob with no demonstrated benefit, the flag and the whole image_scale_b_factor
  chain are gone: setting, scaling fit, message field, CBOR, HDF5 write and
  read-back, per-image plot, OpenAPI enum, viewer column and checkbox, docs.
  ScaleOnTheFly no longer needs Ceres at all - the fit is a linear IRLS.
  (The Wilson per-image b_factor is a different quantity and stays.)

Stills partiality width now fits both of its components
- sigma^2 = gamma0^2 + (gamma_e*d*)^2 instead of a purely angular gamma_e*d*
  with gamma0 pinned to 0. Fitted per crystal by least squares of dist_ewald^2
  on d*^2. The angular-only width is fitted over a d*^2-dense population, so it
  was pinned by the high-resolution edge and collapsed at low d*: median
  partiality 0.008 beyond 13 A for reflections that were plainly recorded, 55%
  of them under the merge's partiality floor, and the survivors divided by those
  values - which inflated the merged low-resolution intensity scale 3.6x
  (~ +9 A^2 of apparent B). Measured on 5000 stills: the ramp flattens to 0.89x,
  no observation is dropped any more (701750 -> 716811), shell-mean CC1/2 and
  R-free improve slightly. Note CC1/2, R_meas, completeness and a B-refining
  R-free are all blind to that ramp, which is why it survived earlier validation;
  the cost is high-resolution R_meas (98.5 -> 101.9 shell-averaged).

Removed dead code from add-then-remove churn
- Prediction-time "still partiality" (unreachable: no setter), the phantom
  IndexingSettings::min_indexed_spot_fraction knob (getter, no setter - now the
  constant it always was), StillsPartialityRefine's caller-less Settings
  constructor and its reference to a long-gone env var, ProcessImage's unread
  bool return, an unused include, and a dead viewer overlay hook.

Also
- Viewer: the magnifier compared a QImage with itself, so its scene rect was set
  once ever and it could not pan into a larger dataset; the hover tail timer
  could fire after leaveEvent and resurrect the resolution readout outside the
  image.
- update_version.sh regenerated the frontend lock file BEFORE bumping the
  version (every release shipped an off-by-one lock), and did git rm/git add on
  a path that has not existed since the client moved to src/client - with no
  set -e, both failed silently.
- fpga/pcie_driver/postinstall.sh tested "[ ! occurrences > 0 ]", which is a
  redirect, not a test, so dkms add never ran.
- Unit tests for the adaptive-threshold host functions, which had none.

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

996 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) {
constexpr int kMaxLabels = 5000;
const QRectF visibleRect = mapToScene(viewport()->rect()).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())));
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);
}
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();
}
}