diff --git a/viewer/widgets/JFJochSimpleImageViewer.cpp b/viewer/widgets/JFJochSimpleImageViewer.cpp index d9b0adc7..ce49c7aa 100644 --- a/viewer/widgets/JFJochSimpleImageViewer.cpp +++ b/viewer/widgets/JFJochSimpleImageViewer.cpp @@ -116,49 +116,127 @@ void JFJochSimpleImageViewer::wheelEvent(QWheelEvent *event) { } } -void JFJochSimpleImageViewer::mousePressEvent(QMouseEvent *event) { +void JFJochSimpleImageViewer::mousePressEvent(QMouseEvent* event) { if (!scene() || !has_image_) return; + if (event->button() == Qt::LeftButton) { - panning_ = true; - setCursor(Qt::ClosedHandCursor); - last_mouse_pos_ = event->pos(); + const QPointF scenePos = mapToScene(event->pos()); + + // With Shift -> ROI interactions (draw/resize/move); without -> pan + if (event->modifiers() & Qt::Modifier::SHIFT) { + // Determine if we are over an existing ROI handle + 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; + roi_start_pos_ = roi_box_.topLeft(); + roi_end_pos_ = roi_box_.bottomRight(); + setCursor(Qt::SizeAllCursor); + } else if (roi_box_.contains(scenePos)) { + mouse_event_type_ = MouseEventType::MovingROI; + last_mouse_pos_ = event->pos(); + setCursor(Qt::ClosedHandCursor); + } else { + mouse_event_type_ = MouseEventType::DrawingROI; + roi_start_pos_ = RoundPoint(scenePos); + roi_end_pos_ = roi_start_pos_; + setCursor(Qt::CrossCursor); + } + } else { + mouse_event_type_ = MouseEventType::Panning; + panning_ = true; + setCursor(Qt::ClosedHandCursor); + last_mouse_pos_ = event->pos(); + } } + QGraphicsView::mousePressEvent(event); } -void JFJochSimpleImageViewer::mouseMoveEvent(QMouseEvent *event) { +void JFJochSimpleImageViewer::mouseMoveEvent(QMouseEvent* event) { if (!scene() || !has_image_) return; - if (panning_) { - const QPointF delta = mapToScene(event->pos()) - mapToScene(last_mouse_pos_); - last_mouse_pos_ = event->pos(); - translate(delta.x(), delta.y()); - // labels positions are in scene coords, scene items re-used; no special handling - } else { - auto coord = mapToScene(event->pos()); - if ((coord.x() >= 0) - && (coord.x() < image_->image.GetWidth()) - && (coord.y() >= 0) - && (coord.y() < image_->image.GetHeight())) { - emit writeStatusBar(QString("x=%1 y=%2 %3 ") - .arg(coord.x(), 0, 'f', 1) - .arg(coord.y(), 0, 'f', 1) - .arg(image_values_[int(coord.x()) + int(coord.y()) * image_->image.GetWidth()] - ), 6000); - } else - emit writeStatusBar("", 6000); + + const QPointF scenePos = mapToScene(event->pos()); + + switch (mouse_event_type_) { + case MouseEventType::Panning: { + const QPointF delta = mapToScene(event->pos()) - mapToScene(last_mouse_pos_); + last_mouse_pos_ = event->pos(); + translate(delta.x(), delta.y()); + updateScene(); + break; + } + case MouseEventType::DrawingROI: { + roi_end_pos_ = RoundPoint(scenePos); + updateROI(); + break; + } + case MouseEventType::MovingROI: { + const QPointF delta = mapToScene(event->pos()) - mapToScene(last_mouse_pos_); + last_mouse_pos_ = event->pos(); + roi_box_.translate(delta); + updateROI(); + break; + } + case MouseEventType::ResizingROI: { + // Modify the corresponding edges based on active_handle_ + QRectF r = roi_box_; + 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; + } + roi_box_ = r.normalized(); + updateROI(); + break; + } + case MouseEventType::None: { + // Hover feedback / status bar display + if ((scenePos.x() >= 0) + && (scenePos.x() < image_->image.GetWidth()) + && (scenePos.y() >= 0) + && (scenePos.y() < image_->image.GetHeight())) { + const auto ix = int(scenePos.x()); + const auto iy = int(scenePos.y()); + const auto idx = iy * int(image_->image.GetWidth()) + ix; + if (idx >= 0 && idx < int(image_values_.size())) + emit writeStatusBar(QString("x=%1 y=%2 I=%3") + .arg(scenePos.x(), 0, 'f', 1) + .arg(scenePos.y(), 0, 'f', 1) + .arg(image_values_[size_t(idx)]), 3000); + } else { + emit writeStatusBar("", 1000); + } + break; + } } + QGraphicsView::mouseMoveEvent(event); } -void JFJochSimpleImageViewer::mouseReleaseEvent(QMouseEvent *event) { +void JFJochSimpleImageViewer::mouseReleaseEvent(QMouseEvent* event) { if (!scene() || !has_image_) return; - if (event->button() == Qt::LeftButton && panning_) { + + if (event->button() == Qt::LeftButton) { + if (mouse_event_type_ == MouseEventType::DrawingROI) { + roi_end_pos_ = RoundPoint(mapToScene(event->pos())); + updateROI(); + } panning_ = false; + mouse_event_type_ = MouseEventType::None; + active_handle_ = ResizeHandle::None; setCursor(Qt::ArrowCursor); } + QGraphicsView::mouseReleaseEvent(event); } + void JFJochSimpleImageViewer::resizeEvent(QResizeEvent *event) { QGraphicsView::resizeEvent(event); // Nothing special; keep current transform and scene @@ -293,6 +371,8 @@ void JFJochSimpleImageViewer::updateScene() { } } } + + drawROI(scene()); } template @@ -329,3 +409,167 @@ void JFJochSimpleImageViewer::renderImage(QImage &qimg, const uint8_t *input) { } } } + + +QPointF JFJochSimpleImageViewer::RoundPoint(const QPointF& p) { + return QPointF(qRound(p.x()), qRound(p.y())); +} + +JFJochSimpleImageViewer::ResizeHandle +JFJochSimpleImageViewer::hitTestROIHandle(const QPointF& scenePos, qreal tol) const { + if (roi_box_.isNull() || roi_box_.width() <= 0 || roi_box_.height() <= 0) + return ResizeHandle::None; + + const QRectF r = roi_box_; + 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 JFJochSimpleImageViewer::updateROI() { + // Normalize box if drawing + if (mouse_event_type_ == MouseEventType::DrawingROI) { + roi_box_ = QRectF(roi_start_pos_, roi_end_pos_).normalized(); + } + + // Clamp ROI to image bounds + if (has_image_) { + const QRectF bounds(0.0, 0.0, + image_->image.GetWidth(), + image_->image.GetHeight()); + roi_box_ = roi_box_.intersected(bounds); + } + + updateScene(); +} + +void JFJochSimpleImageViewer::drawROI(QGraphicsScene* scn) { + if (roi_box_.isNull() || roi_box_.width() <= 0 || roi_box_.height() <= 0) return; + + QPen pen(Qt::yellow, 2); + pen.setStyle(Qt::DashLine); + pen.setCosmetic(true); + scn->addRect(roi_box_, pen); + + // Small corner handles + const qreal handleSize = 3.0 / std::sqrt(std::max(1e-4, scale_factor_)); + auto addHandle = [&](const QPointF& p) { + scn->addRect(QRectF(p.x() - handleSize, p.y() - handleSize, 2 * handleSize, 2 * handleSize), + QPen(Qt::yellow, 1), QBrush(Qt::yellow)); + }; + addHandle(roi_box_.topLeft()); + addHandle(roi_box_.topRight()); + addHandle(roi_box_.bottomLeft()); + addHandle(roi_box_.bottomRight()); + + // Compute stats inside integer pixel ROI + QRect roi_px = roi_box_.toRect().normalized(); + ROIStats stats = computeROIStats(roi_px); + + if (stats.valid()) { + // Compose text + QString text = QString("min %1 max %2 avg %3 std %4 n=%5") + .arg(stats.min, 0, 'f', 2) + .arg(stats.max, 0, 'f', 2) + .arg(stats.avg, 0, 'f', 2) + .arg(stats.stddev, 0, 'f', 2) + .arg(qulonglong(stats.count)); + + // Choose background based on local luminance under top-left pixel of ROI + const int W = int(image_->image.GetWidth()); + const int H = int(image_->image.GetHeight()); + int px = std::clamp(roi_px.left(), 0, std::max(0, W - 1)); + int py = std::clamp(roi_px.top(), 0, std::max(0, H - 1)); + const rgb bgc = image_rgb_[size_t(py) * size_t(W) + size_t(px)]; + const bool darkText = luminance(bgc) > 128.0; + + QFont font("Arial"); + const qreal f = std::clamp(scale_factor_, 0.5, 50.0); + font.setPointSizeF(14.0 / std::sqrt(f)); + + auto* t = scn->addText(text, font); + t->setDefaultTextColor(darkText ? Qt::black : Qt::white); + t->setZValue(20.0); + + // Place near top-left of ROI, slightly above + QPointF pos = roi_box_.topLeft() + QPointF(2.0, -16.0 / std::sqrt(f)); + t->setPos(pos); + } +} + +JFJochSimpleImageViewer::ROIStats +JFJochSimpleImageViewer::computeROIStats(const QRect& roi_px) const { + ROIStats s; + if (!has_image_) return s; + + const int W = int(image_->image.GetWidth()); + const int H = int(image_->image.GetHeight()); + + const int x0 = std::clamp(roi_px.left(), 0, std::max(0, W - 1)); + const int x1 = std::clamp(roi_px.right(), 0, std::max(0, W - 1)); + const int y0 = std::clamp(roi_px.top(), 0, std::max(0, H - 1)); + const int y1 = std::clamp(roi_px.bottom(), 0, std::max(0, H - 1)); + if (x1 < x0 || y1 < y0) return s; + + // First pass: min, max, sum + double sum = 0.0L; + s.min = std::numeric_limits::infinity(); + s.max = -std::numeric_limits::infinity(); + s.count = 0; + + for (int y = y0; y <= y1; ++y) { + const size_t base = size_t(y) * size_t(W); + for (int x = x0; x <= x1; ++x) { + const size_t idx = base + size_t(x); + const double v = image_values_[idx]; + if (std::isnan(v)) continue; // skip invalid + if (v < s.min) s.min = v; + if (v > s.max) s.max = v; + sum += v; + ++s.count; + } + } + + if (!s.valid()) return s; + + s.avg = double(sum / s.count); + + // Second pass: variance + double var_sum = 0.0L; + for (int y = y0; y <= y1; ++y) { + const size_t base = size_t(y) * size_t(W); + for (int x = x0; x <= x1; ++x) { + const size_t idx = base + size_t(x); + const double v = image_values_[idx]; + if (std::isnan(v)) continue; + const double d = v - s.avg; + var_sum += d * d; + } + } + const double variance = (s.count > 1) ? (var_sum / s.count) : 0.0L; + s.stddev = std::sqrt(double(variance)); + return s; +} diff --git a/viewer/widgets/JFJochSimpleImageViewer.h b/viewer/widgets/JFJochSimpleImageViewer.h index 368a4302..387f3a05 100644 --- a/viewer/widgets/JFJochSimpleImageViewer.h +++ b/viewer/widgets/JFJochSimpleImageViewer.h @@ -28,7 +28,7 @@ public slots: signals: void foregroundChanged(float v); void backgroundChanged(float v); - void writeStatusBar(QString string, int timeout); + void writeStatusBar(QString string, int timeout_ms = 0); protected: void wheelEvent(QWheelEvent* event) override; @@ -44,15 +44,26 @@ private: QColor color; }; + struct ROIStats { + double min = std::numeric_limits::infinity(); + double max = -std::numeric_limits::infinity(); + double avg = std::numeric_limits::quiet_NaN(); + double stddev = std::numeric_limits::quiet_NaN(); + size_t count = 0; + bool valid() const { return std::isfinite(min) && std::isfinite(max) && count > 0; } + }; + void renderImage(); // builds pixmap_ from current image_ + settings - void updateScene(); // clears scene, adds pixmap + labels + void updateScene(); // clears scene, adds pixmap + labels + ROI (if any) + void drawROI(QGraphicsScene* scn); // draw box and stats + ROIStats computeROIStats(const QRect& roi_px) const; // Image and rendering state std::shared_ptr image_; bool has_image_ = false; QPixmap pixmap_; std::vector image_rgb_; // intermediate RGB buffer to feed QImage - std::vector image_values_; // original scalar per pixel (for label text) + std::vector image_values_; // original scalar values per pixel (for labels and min/max) // View/interaction double scale_factor_ = 1.0; @@ -63,9 +74,28 @@ private: float background_ = 0.0f; float foreground_ = 10.0f; ColorScale color_scale_; - - bool auto_fgbg = true; + bool show_labels_ = false; + std::vector