diff --git a/src/aare/gui/styles.py b/src/aare/gui/styles.py index ec5ec8cb..cbf05261 100644 --- a/src/aare/gui/styles.py +++ b/src/aare/gui/styles.py @@ -460,6 +460,7 @@ PATH_END = "#d20f39" # raster path gradient end + end circle (red) LEGEND_BG = "#eff1f5" # base LEGEND_TEXT = "#4c4f69" # text TOOLTIP_TEXT = "#4c4f69" # camera coords tooltip pen — NOT the QToolTip popup +SCALE_BAR_GREY = "#8c8fa1" # hover HUD scale bar (Latte overlay1 grey) MARK_TOOLTIP_GOLD = "#df8e1d" # yellow MARK_TOOLTIP_ORANGE = "#fe640b" # peach MARK_TOOLTIP_RED = "#d20f39" # red diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index b8e4171d..d02a0285 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -56,6 +56,7 @@ from aare.gui.styles import ( MARKER_GREEN, PATH_END, PATH_START, + SCALE_BAR_GREY, SHADOW, TARGET_COLORS, THEME_SUNSET, @@ -207,6 +208,9 @@ class SampleCameraImageLabel(QGraphicsView): self._detections = [] # list of dicts from publisher self._det_shape = None # shape from payload [h,w] so we can scale + # Scene position under the cursor; drives the corner coords/scale HUD. + self._hover_pos: QPointF | None = None + def _update_camera_interaction_feedback(self) -> None: if self._camera_available: self.viewport().setCursor(Qt.CursorShape.ArrowCursor) @@ -482,6 +486,7 @@ class SampleCameraImageLabel(QGraphicsView): self._draw_detections(painter, rect) self._draw_target_point(painter) self._draw_overlay_legend(painter) + self._draw_hover_hud(painter) self._draw_help_overlay(painter) def resizeEvent(self, event): @@ -577,9 +582,17 @@ class SampleCameraImageLabel(QGraphicsView): if self._session_badge_hovered: self._session_badge_hovered = False self.update() + if self._hover_pos is not None: + self._hover_pos = None + self.update() super().leaveEvent(event) def mouseMoveEvent(self, event): + # Hover HUD before the interaction gate: watching is free, so the + # coords/scale readout must work even without the session baton. + self._hover_pos = self.mapToScene(event.pos()) + self.update() + # Badge hover feedback must run BEFORE the interaction gate: the # badge is visible precisely when interaction is disabled. hovered = self._session_badge_rect is not None and self._session_badge_rect.contains( @@ -1254,6 +1267,67 @@ class SampleCameraImageLabel(QGraphicsView): painter.restore() + @staticmethod + def _scale_bar(um_per_view_px: float) -> tuple[float, str]: + """Nice 1-2-5 scale-bar length (µm) for the current zoom, with label.""" + target_um = um_per_view_px * 120.0 # aim for a ~120 px wide bar + exponent = math.floor(math.log10(target_um)) + base = target_um / 10.0**exponent + nice = 5.0 if base >= 5.0 else 2.0 if base >= 2.0 else 1.0 + bar_um = nice * 10.0**exponent + label = f"{bar_um / 1000.0:g} mm" if bar_um >= 1000.0 else f"{bar_um:g} µm" + return bar_um, label + + def _draw_hover_hud(self, painter: QPainter): + # Bottom-right HUD: grey scale bar over the hovered pixel coordinates. + if self._hover_pos is None or self.pixmap_item is None: + return + if not self.pixmap_item.sceneBoundingRect().contains(self._hover_pos): + return + + painter.save() + painter.resetTransform() + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + + font = QFont() + font.setPointSize(10) + font.setBold(True) + painter.setFont(font) + fm = QFontMetrics(font) + + margin = 18 + right = self.viewport().width() - margin + coords_text = f"{self._hover_pos.x():.0f}, {self._hover_pos.y():.0f} pxl" + coords_baseline = self.viewport().height() - margin - fm.descent() + grey = qcolor(SCALE_BAR_GREY) + + def shadowed_text(x: float, baseline: float, text: str, color: QColor): + painter.setPen(QPen(qcolor(SHADOW, 200))) + painter.drawText(QPointF(x + 1, baseline + 1), text) + painter.setPen(QPen(color)) + painter.drawText(QPointF(x, baseline), text) + + shadowed_text( + right - fm.horizontalAdvance(coords_text), coords_baseline, coords_text, qcolor(WHITE) + ) + + # view-pixel scale: scene px -> viewport px via the current zoom. + zoom = self.transform().m11() + if zoom > 0: + um_per_view_px = self._geom.pixel_in_mm * 1000.0 / zoom + bar_um, label = self._scale_bar(um_per_view_px) + bar_px = bar_um / um_per_view_px + bar_y = coords_baseline - fm.ascent() - 12 + painter.setPen(QPen(grey, 3)) + painter.drawLine(QPointF(right - bar_px, bar_y), QPointF(right, bar_y)) + painter.drawLine(QPointF(right - bar_px, bar_y - 4), QPointF(right - bar_px, bar_y + 4)) + painter.drawLine(QPointF(right, bar_y - 4), QPointF(right, bar_y + 4)) + shadowed_text( + right - (bar_px + fm.horizontalAdvance(label)) / 2, bar_y - 8, label, grey + ) + + painter.restore() + @Slot(bool) def set_show_detections(self, show: bool): self._show_detections = show diff --git a/tests/unit/gui/test_camera_image.py b/tests/unit/gui/test_camera_image.py index 2451310b..17b6fd23 100644 --- a/tests/unit/gui/test_camera_image.py +++ b/tests/unit/gui/test_camera_image.py @@ -229,6 +229,30 @@ def test_alt_wheel_axis_swap_still_changes_exposure(camera): assert len(sent) == n +def test_hover_hud_coords_and_scale_bar(camera): + # Hover inside the image: bottom-right HUD paints coords + grey scale bar. + _mouse_move(camera, QPoint(400, 300)) + assert camera._hover_pos is not None + camera.grab() + + # Hover outside the image (negative scene coords): HUD skips drawing. + _mouse_move(camera, QPoint(-10, -10)) + camera.grab() + + # Leaving the view clears the readout entirely. + camera.leaveEvent(QEvent(QEvent.Type.Leave)) + assert camera._hover_pos is None + camera.grab() + + +def test_scale_bar_nice_numbers(): + # 1-2-5 progression against the ~120 px target, and the mm label swap. + assert SampleCameraImageLabel._scale_bar(1.0) == (100.0, "100 µm") + assert SampleCameraImageLabel._scale_bar(2.0) == (200.0, "200 µm") + assert SampleCameraImageLabel._scale_bar(5.0) == (500.0, "500 µm") + assert SampleCameraImageLabel._scale_bar(10.0) == (1000.0, "1 mm") + + def test_autoscale_fits_from_the_first_frame(camera): from PySide6.QtGui import QPixmap