#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Line measurement tool.
Interaction:
- Toggle ON → click once to place the start point.
- Move mouse → live preview of the line (start → cursor).
- Click again → finalize the end point; line is drawn.
- Drag center → move the whole line.
- Drag endpoint → reposition that endpoint (live update).
- Toggle OFF → line is erased.
Physical length is computed using the pixel sizes stored in the two
scale bars (scale_bar_1 for X, scale_bar_2 for Y).
"""
import logging
from typing import Optional
import numpy as np
import pyqtgraph as pg
from PyQt5 import QtWidgets, QtCore, QtGui
_HANDLE_HIT_PX = 12
_HANDLE_SIZE_PX = 10
def _line_pen():
p = pg.mkPen('y', width=2)
p.setCosmetic(True)
return p
def _handle_pen():
p = pg.mkPen('c', width=1)
p.setCosmetic(True)
return p
def _handle_brush():
return QtGui.QBrush(QtGui.QColor(0, 200, 255, 200))
[docs]
class LineProfileManager:
_IDLE = 'idle' # waiting for first click
_PLACING = 'placing' # start set, tracking mouse for end
_PLACED = 'placed' # line finalised
_MOVING = 'moving' # dragging center handle
_DRAG_ENDPOINT = 'drag_endpoint' # dragging one of the two endpoint handles
def __init__(self, image_view: pg.ImageView,
stats_label: QtWidgets.QLabel,
logger: Optional[logging.Logger] = None):
self.image_view = image_view
self.stats_label = stats_label
self.logger = logger
self.scalebar_manager = None
self._gv = image_view.ui.graphicsView
self._item: Optional[QtWidgets.QGraphicsLineItem] = None
self._handle_c: Optional[QtWidgets.QGraphicsRectItem] = None # center
self._handle_p1: Optional[QtWidgets.QGraphicsRectItem] = None # start
self._handle_p2: Optional[QtWidgets.QGraphicsRectItem] = None # end
self._x1 = self._y1 = self._x2 = self._y2 = 0.0
self._state = self._IDLE
self._enabled = False
self._last_image: Optional[np.ndarray] = None
self._drag_start_sp = None
self._drag_start_geom = None # (x1,y1,x2,y2) snapshot for move
self._drag_endpoint_idx = None # 1 or 2
self._shift = False
self._vp_filter = _LineVpFilter(self)
self._key_filter = _LineKeyFilter(self)
self._gv.viewport().installEventFilter(self._vp_filter)
app = QtWidgets.QApplication.instance()
if app:
app.installEventFilter(self._key_filter)
# ── public API ────────────────────────────────────────────────────────
[docs]
def set_scalebar_manager(self, scalebar_manager):
self.scalebar_manager = scalebar_manager
[docs]
def toggle(self, state):
from PyQt5.QtCore import Qt
if state == Qt.Checked:
self._enabled = True
self._state = self._IDLE
self._gv.viewport().setCursor(QtCore.Qt.CrossCursor)
self.stats_label.setText("Line: click to set start point")
else:
self._enabled = False
self._remove_graphics()
self._state = self._IDLE
self._gv.viewport().setCursor(QtCore.Qt.ArrowCursor)
self.stats_label.setText("No line")
[docs]
def update_stats(self, image: np.ndarray):
self._last_image = image
if self._state == self._PLACED:
self._refresh_stats()
[docs]
def reset(self):
self._remove_graphics()
if self._enabled:
self._state = self._IDLE
self._gv.viewport().setCursor(QtCore.Qt.CrossCursor)
self.stats_label.setText("Line: click to set start point")
[docs]
def cleanup(self):
self._remove_graphics()
try:
self._gv.viewport().removeEventFilter(self._vp_filter)
except Exception:
pass
try:
app = QtWidgets.QApplication.instance()
if app:
app.removeEventFilter(self._key_filter)
except Exception:
pass
# ── helpers ───────────────────────────────────────────────────────────
def _sc(self):
return self._gv.scene()
def _to_scene(self, vp_pos) -> QtCore.QPointF:
return self._gv.mapToScene(vp_pos)
def _pixel_sizes(self):
if self.scalebar_manager is None:
return 1.0, 1.0
return (self.scalebar_manager.scale_bar_1.pixel_size,
self.scalebar_manager.scale_bar_2.pixel_size)
# ── graphics ──────────────────────────────────────────────────────────
def _remove_graphics(self):
sc = self._sc()
for attr in ('_item', '_handle_c', '_handle_p1', '_handle_p2'):
item = getattr(self, attr)
if item is not None:
if sc:
sc.removeItem(item)
setattr(self, attr, None)
def _make_handle(self) -> QtWidgets.QGraphicsRectItem:
s = _HANDLE_SIZE_PX / 2.0
h = QtWidgets.QGraphicsRectItem(-s, -s, 2*s, 2*s)
h.setPen(_handle_pen())
h.setBrush(_handle_brush())
h.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresTransformations)
h.setZValue(1001)
return h
def _create_graphics(self):
self._remove_graphics()
sc = self._sc()
if sc is None:
return
self._item = QtWidgets.QGraphicsLineItem(
self._x1, self._y1, self._x2, self._y2)
self._item.setPen(_line_pen())
self._item.setZValue(1000)
sc.addItem(self._item)
self._handle_c = self._make_handle()
self._handle_p1 = self._make_handle()
self._handle_p2 = self._make_handle()
sc.addItem(self._handle_c)
sc.addItem(self._handle_p1)
sc.addItem(self._handle_p2)
self._place_handles()
def _place_handles(self):
if self._handle_c is not None:
self._handle_c.setPos((self._x1 + self._x2) / 2.0,
(self._y1 + self._y2) / 2.0)
if self._handle_p1 is not None:
self._handle_p1.setPos(self._x1, self._y1)
if self._handle_p2 is not None:
self._handle_p2.setPos(self._x2, self._y2)
def _apply_geom(self):
if self._item is not None:
self._item.setLine(self._x1, self._y1, self._x2, self._y2)
self._place_handles()
def _hit(self, handle, vp_pos) -> bool:
if handle is None:
return False
h_vp = self._gv.mapFromScene(handle.pos())
return (np.hypot(vp_pos.x() - h_vp.x(),
vp_pos.y() - h_vp.y()) <= _HANDLE_HIT_PX)
# ── constraint ────────────────────────────────────────────────────────
def _constrain(self, ax, ay, fx, fy):
if not self._shift:
return fx, fy
if abs(fx - ax) >= abs(fy - ay):
return fx, ay # horizontal
return ax, fy # vertical
# ── mouse events ──────────────────────────────────────────────────────
def _on_press(self, vp_pos) -> bool:
if not self._enabled:
return False
sp = self._to_scene(vp_pos)
if self._state == self._IDLE:
self._x1 = self._x2 = sp.x()
self._y1 = self._y2 = sp.y()
self._create_graphics()
self._state = self._PLACING
self.stats_label.setText("Line: drag to end, release to place")
return True
if self._state == self._PLACED:
if self._hit(self._handle_p1, vp_pos):
self._state = self._DRAG_ENDPOINT
self._drag_endpoint_idx = 1
self._gv.viewport().setCursor(QtCore.Qt.CrossCursor)
return True
if self._hit(self._handle_p2, vp_pos):
self._state = self._DRAG_ENDPOINT
self._drag_endpoint_idx = 2
self._gv.viewport().setCursor(QtCore.Qt.CrossCursor)
return True
if self._hit(self._handle_c, vp_pos):
self._state = self._MOVING
self._drag_start_sp = sp
self._drag_start_geom = (self._x1, self._y1,
self._x2, self._y2)
self._gv.viewport().setCursor(QtCore.Qt.SizeAllCursor)
return True
return False
def _on_move(self, vp_pos) -> bool:
sp = self._to_scene(vp_pos)
if self._state == self._PLACING:
x, y = self._constrain(self._x1, self._y1, sp.x(), sp.y())
self._x2, self._y2 = x, y
self._apply_geom()
self._refresh_stats()
return True
if self._state == self._DRAG_ENDPOINT:
if self._drag_endpoint_idx == 1:
x, y = self._constrain(self._x2, self._y2, sp.x(), sp.y())
self._x1, self._y1 = x, y
else:
x, y = self._constrain(self._x1, self._y1, sp.x(), sp.y())
self._x2, self._y2 = x, y
self._apply_geom()
self._refresh_stats()
return True
if self._state == self._MOVING:
dx = sp.x() - self._drag_start_sp.x()
dy = sp.y() - self._drag_start_sp.y()
x1, y1, x2, y2 = self._drag_start_geom
self._x1, self._y1 = x1 + dx, y1 + dy
self._x2, self._y2 = x2 + dx, y2 + dy
self._apply_geom()
return True
return False
def _on_release(self, vp_pos) -> bool:
if self._state == self._PLACING:
sp = self._to_scene(vp_pos)
x, y = self._constrain(self._x1, self._y1, sp.x(), sp.y())
self._x2, self._y2 = x, y
self._apply_geom()
self._state = self._PLACED
self._gv.viewport().setCursor(QtCore.Qt.ArrowCursor)
self._refresh_stats()
return True
if self._state in (self._MOVING, self._DRAG_ENDPOINT):
self._state = self._PLACED
self._drag_endpoint_idx = None
self._refresh_stats()
self._gv.viewport().setCursor(QtCore.Qt.ArrowCursor)
return True
return False
# ── stats ─────────────────────────────────────────────────────────────
def _refresh_stats(self):
img_item = self.image_view.getImageItem()
if img_item is None:
return
p1 = img_item.mapFromScene(QtCore.QPointF(self._x1, self._y1))
p2 = img_item.mapFromScene(QtCore.QPointF(self._x2, self._y2))
ix1, iy1 = p1.x(), p1.y()
ix2, iy2 = p2.x(), p2.y()
dx_px = ix2 - ix1
dy_px = iy2 - iy1
length_px = float(np.hypot(dx_px, dy_px))
px_x, px_y = self._pixel_sizes()
length_um = float(np.hypot(dx_px * px_x, dy_px * px_y))
length_mm = length_um / 1000.0
angle = float(np.degrees(np.arctan2(dy_px, dx_px)))
if self._state in (self._PLACING, self._DRAG_ENDPOINT):
self.stats_label.setText(
f"Line: {length_px:.0f} px | {length_um:.2f} µm")
return
self.stats_label.setText(
f"Line\n"
f"Length: {length_px:.1f} px\n"
f" {length_um:.2f} µm ({length_mm:.4f} mm)\n"
f"ΔX: {abs(dx_px):.1f} px = {abs(dx_px)*px_x:.2f} µm\n"
f"ΔY: {abs(dy_px):.1f} px = {abs(dy_px)*px_y:.2f} µm\n"
f"Angle: {angle:.1f}°\n"
f"Start: ({ix1:.1f}, {iy1:.1f})\n"
f"End: ({ix2:.1f}, {iy2:.1f})")
class _LineKeyFilter(QtCore.QObject):
def __init__(self, mgr):
super().__init__()
self.mgr = mgr
def eventFilter(self, _obj, event):
t = event.type()
if t == QtCore.QEvent.KeyPress and event.key() == QtCore.Qt.Key_Shift:
self.mgr._shift = True
elif t == QtCore.QEvent.KeyRelease and event.key() == QtCore.Qt.Key_Shift:
self.mgr._shift = False
return False
class _LineVpFilter(QtCore.QObject):
def __init__(self, mgr: LineProfileManager):
super().__init__()
self.mgr = mgr
def eventFilter(self, _obj, event):
t = event.type()
if t == QtCore.QEvent.MouseButtonPress:
if event.button() == QtCore.Qt.LeftButton:
return self.mgr._on_press(event.pos())
elif t == QtCore.QEvent.MouseMove:
return self.mgr._on_move(event.pos())
elif t == QtCore.QEvent.MouseButtonRelease:
if event.button() == QtCore.Qt.LeftButton:
return self.mgr._on_release(event.pos())
return False