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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user