feat: hover HUD with pixel coords and grey scale bar on sample camera

Bottom-right corner shows the hovered pixel coordinates with a grey
1-2-5 scale bar above them, derived from geom.pixel_in_mm and the
current zoom. Works in viewing mode (before the interaction gate),
clears on leave, and skips drawing outside the image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 19:54:50 +02:00
co-authored by Claude Fable 5
parent ef4bc011ab
commit 5f068a6305
3 changed files with 99 additions and 0 deletions
+1
View File
@@ -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
+74
View File
@@ -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
+24
View File
@@ -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