"""A shared line ruler that measures image coordinates without changing pixels."""
from math import hypot, isfinite
from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, Qt, Signal
from PySide6.QtGui import QColor, QPen
from ..i18n import tr
from ..theme import active_palette
[docs]
class ImageRuler(QObject):
"""Keep a line in image pixels and paint it through the host's transform.
:param parent: owning image canvas or view; defaults to None.
Hosts pass their widget-to-image mapping to :meth:`handle` and inverse
mapping to :meth:`paint`. Zoom and pan never change the measured length.
Pixel units are always shown. Physical units require validated
calibration, either explicit through :meth:`set_spacing` or stated by the
image file's own header through :meth:`calibrate_from_file`; camera
magnification is never guessed. Right-click clears while the tool is
active.
"""
changed = Signal()
def __init__(self, parent=None):
"""Start with no line, no calibration and drawing disabled."""
super().__init__(parent)
self.active = False
self.start = None
self.end = None
self._drawing = False
self.spacing = None
self.unit = 'µm'
[docs]
def clear(self):
"""Remove the line without changing tool activation or calibration.
:returns: None; emits ``changed`` even when no line was present.
"""
self.start = self.end = None
self._drawing = False
self.changed.emit()
[docs]
def set_active(self, active):
"""Enable drawing; disabling preserves the finished line.
:param active: whether unmodified left drags measure instead of edit.
:returns: None; emits ``changed`` after updating activation.
"""
self.active = bool(active)
self._drawing = False
self.changed.emit()
[docs]
def set_spacing(self, x=None, y=None, unit='µm'):
"""Set physical distance per image pixel, or clear calibration.
:param x: positive finite distance per horizontal image pixel in ``unit``;
defaults to None, which clears calibration and ignores ``y``.
:param y: positive finite distance per vertical image pixel in ``unit``;
defaults to None, which uses ``x`` for both axes.
:param unit: physical unit label; defaults to ``'µm'``. The caller supplies
spacing in this unit; this method does not convert units.
:raises ValueError: when spacing is nonpositive or nonfinite.
:returns: None; emits ``changed`` after updating calibration.
"""
if x is None:
self.spacing = None
else:
spacing = (float(x), float(x if y is None else y))
if any(not isfinite(v) or v <= 0 for v in spacing):
raise ValueError(tr('Pixel spacing must be finite and positive.'))
self.spacing = spacing
self.unit = str(unit)
self.changed.emit()
[docs]
def calibrate_from_file(self, path, shape=None):
"""Take the pixel spacing the shown image's own file header states.
Only a size the file states counts (OME ``PhysicalSizeX/Y``, an
ImageJ micron calibration or centimetre resolution tags, read by
:func:`spacr.point_spread.image_optics_metadata`); a magnification
in the file name, an objective table or a default never calibrates
the ruler. Calibration is cleared first, so a file that states
nothing leaves the ruler in pixels.
:param path: the image file being shown; None or '' only clears.
:param shape: the displayed array's shape; defaults to None, which
skips the check. When given, the header's (Y, X) must equal its
first two or last two dimensions, so a resampled or cropped
display is never measured with the file's spacing.
:returns: the (x, y) spacing in µm now set, or None when the ruler
stays uncalibrated.
"""
self.set_spacing()
if not path:
return None
from ...point_spread import image_optics_metadata
try:
stated = image_optics_metadata(path)
except Exception:
return None
size = stated.get('pixel_size_um')
if size is None:
return None
if shape is not None:
header = stated.get('image_shape')
dims = tuple(int(n) for n in shape)
if header is None or tuple(int(n) for n in header.value) not in (dims[:2], dims[-2:]):
return None
try:
y, x = size.value
self.set_spacing(x, y, unit='µm')
except (TypeError, ValueError):
self.set_spacing()
return None
return self.spacing
[docs]
def length(self, physical=False):
"""Return the line length, or None before a line exists.
:param physical: False (default) uses image pixels; True uses calibrated
per-axis spacing, including different horizontal and vertical values.
:returns: Euclidean length as a float in pixels or ``unit``; None when
no line exists or physical length is requested without calibration.
"""
if self.start is None or self.end is None or (physical and self.spacing is None):
return None
dx, dy = (self.end[i] - self.start[i] for i in range(2))
sx, sy = self.spacing if physical else (1.0, 1.0)
return hypot(dx * sx, dy * sy)
[docs]
def label(self):
"""Return pixel length and, only when calibrated, physical length.
:returns: text with lengths to two decimal places; empty before a line exists.
"""
length = self.length()
if length is None:
return ''
text = tr('{length:.2f} px', length=length)
physical = self.length(physical=True)
if physical is not None:
text += tr(' · {length:.2f} {unit}', length=physical, unit=self.unit)
return text
[docs]
def handle(self, event, to_image):
"""Consume ruler mouse gestures; return False for navigation gestures.
:param event: a mouse press, move or release from the host canvas.
:param to_image: widget QPointF -> image (x, y), or None off-image.
:returns: True for consumed ruler gestures; False when the host should
handle the event. Coordinates outside the image do not move endpoints.
"""
if not self.active:
return False
kind = event.type()
if kind in (QEvent.MouseButtonPress, QEvent.MouseButtonRelease):
if event.button() == Qt.RightButton and not event.modifiers():
if kind == QEvent.MouseButtonPress:
self.clear()
event.accept()
return True
if kind == QEvent.MouseButtonPress:
if event.button() != Qt.LeftButton or event.modifiers():
return False
point = to_image(event.position())
if point is not None:
self.start = self.end = tuple(map(float, point))
self._drawing = True
self.changed.emit()
event.accept()
return True
if self._drawing and kind in (QEvent.MouseMove, QEvent.MouseButtonRelease):
if kind == QEvent.MouseButtonRelease and event.button() != Qt.LeftButton:
return False
point = to_image(event.position())
if point is not None:
self.end = tuple(map(float, point))
if kind == QEvent.MouseButtonRelease:
self._drawing = False
self.changed.emit()
event.accept()
return True
return False
[docs]
def paint(self, painter, to_widget):
"""Draw endpoints, line and readout in widget coordinates.
:param painter: an active painter for the host canvas/viewport.
:param to_widget: image (x, y) -> widget QPointF or QPoint, or None.
:returns: None; draws nothing when a line or either mapped endpoint is missing.
"""
if self.start is None or self.end is None:
return
a, b = to_widget(*self.start), to_widget(*self.end)
if a is None or b is None:
return
a, b = QPointF(a), QPointF(b)
palette = active_palette()
painter.save()
pen = QPen(QColor(palette['accent']), 2)
pen.setCosmetic(True)
painter.setPen(pen)
painter.drawLine(a, b)
painter.setBrush(QColor(palette['fg']))
painter.drawEllipse(a, 3, 3)
painter.drawEllipse(b, 3, 3)
text = self.label()
metrics = painter.fontMetrics()
width, height = metrics.horizontalAdvance(text) + 14, metrics.height() + 8
device = painter.device()
x = max(0, min(device.width() - width, (a.x() + b.x()) / 2 + 8))
y = max(0, min(device.height() - height, (a.y() + b.y()) / 2 + 8))
badge = QRectF(x, y, width, height)
background = QColor(palette['bg'])
background.setAlpha(210)
painter.setPen(Qt.NoPen)
painter.setBrush(background)
painter.drawRoundedRect(badge, 4, 4)
painter.setPen(QColor(palette['fg']))
painter.drawText(badge, Qt.AlignCenter, text)
painter.restore()