import time
import numpy as np
from PyQt6.QtCore import QPoint, QPointF, QRectF, QSize, Qt, pyqtSignal
from PyQt6.QtGui import QBrush, QColor, QCursor, QFontMetricsF, QImage, QPainter, QPen, QPixmap, QStaticText
from PyQt6.QtWidgets import (QFrame, QGraphicsPixmapItem, QGraphicsScene,
QGraphicsView, QLabel, QPushButton)
from magscope.ui.widgets import BeadGraphic
[docs]
class VideoViewer(QGraphicsView):
[docs]
coordinatesChanged: 'pyqtSignal' = pyqtSignal(QPoint)
[docs]
clicked: 'pyqtSignal' = pyqtSignal(QPoint)
[docs]
sceneClicked: 'pyqtSignal' = pyqtSignal(QPoint, object)
[docs]
_MINIMAP_MIN_SIZE = 120
[docs]
_MINIMAP_MAX_SIZE = 220
[docs]
_MINIMAP_LABEL_SPACING = 6
[docs]
_MINIMAP_ZOOM_HEIGHT = 26
def __init__(self, scale_factor=1.25):
super().__init__()
[docs]
self._mouse_start_pos = QPoint()
[docs]
self._mouse_start_time = 0.
[docs]
self.scale_factor = scale_factor
[docs]
self.scene = QGraphicsScene(self)
[docs]
self._image = QGraphicsPixmapItem()
self._image.setShapeMode(QGraphicsPixmapItem.ShapeMode.MaskShape)
self.scene.addItem(self._image)
self.setScene(self.scene)
self.setTransformationAnchor(
QGraphicsView.ViewportAnchor.AnchorUnderMouse)
self.setResizeAnchor(QGraphicsView.ViewportAnchor.AnchorUnderMouse)
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setBackgroundBrush(QBrush(QColor(30, 30, 30)))
self.setFrameShape(QFrame.Shape.NoFrame)
self.setViewportUpdateMode(QGraphicsView.ViewportUpdateMode.FullViewportUpdate)
[docs]
self._overlay_entries: list[tuple[QRectF, QPointF, str, bool, str]] = []
[docs]
self._visible_overlay_entries: list[tuple[QRectF, str, bool]] | None = None
[docs]
self._visible_label_entries: list[tuple[QPointF, QStaticText, bool]] | None = None
[docs]
self._overlay_cache_pixmap = QPixmap()
[docs]
self._overlay_cache_dirty = True
[docs]
self._overlay_cache_size = QSize()
[docs]
self._overlay_cache_device_pixel_ratio = 0.0
[docs]
self._static_label_cache: dict[str, QStaticText] = {}
[docs]
self._label_metrics = QFontMetricsF(BeadGraphic.LABEL_FONT)
[docs]
self._label_ascent = self._label_metrics.ascent()
[docs]
self._marker_x = np.empty((0,), dtype=float)
[docs]
self._marker_y = np.empty((0,), dtype=float)
[docs]
self._minimap_label = QLabel(self.viewport())
self._minimap_label.setFrameShape(QFrame.Shape.Panel)
self._minimap_label.setFrameShadow(QFrame.Shadow.Sunken)
self._minimap_label.setStyleSheet(
"background-color: rgba(20, 20, 20, 190);"
"border: 1px solid rgba(255, 255, 255, 120);"
)
self._minimap_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._minimap_label.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents, True
)
self._minimap_label.hide()
[docs]
self._minimap_zoom_label = QLabel(self.viewport())
self._minimap_zoom_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._minimap_zoom_label.setStyleSheet(
"color: white;"
"background-color: rgba(20, 20, 20, 190);"
"border: 1px solid rgba(255, 255, 255, 120);"
)
self._minimap_zoom_label.setAttribute(
Qt.WidgetAttribute.WA_TransparentForMouseEvents, True
)
self._minimap_zoom_label.hide()
self._minimap_reset_button.setCursor(Qt.CursorShape.PointingHandCursor)
self._minimap_reset_button.clicked.connect(lambda: self.reset_view())
self._minimap_reset_button.hide()
[docs]
self._minimap_base = QPixmap()
self.set_image_to_default()
[docs]
def set_bead_overlay(
self,
bead_rois: dict[int, tuple[int, int, int, int]],
active_bead_id: int | None,
selected_bead_id: int | None,
reference_bead_id: int | None,
label_overrides: dict[int, str] | None = None,
state_overrides: dict[int, str] | None = None,
) -> None:
overlay_entries: list[tuple[QRectF, QPointF, str, bool, str]] = []
label_overrides = {} if label_overrides is None else label_overrides
state_overrides = {} if state_overrides is None else state_overrides
for bead_id, roi in bead_rois.items():
if bead_id in state_overrides:
state = state_overrides[bead_id]
elif bead_id == selected_bead_id:
state = 'selected'
elif bead_id == reference_bead_id:
state = 'reference'
else:
state = 'default'
x0, x1, y0, y1 = roi
overlay_entries.append((
QRectF(x0, y0, x1 - x0, y1 - y0),
BeadGraphic.label_scene_position_for_roi(roi),
state,
bead_id == active_bead_id,
label_overrides.get(bead_id, str(bead_id)),
))
self._overlay_entries = overlay_entries
self._invalidate_overlay_view_cache()
[docs]
def _invalidate_overlay_view_cache(self) -> None:
self._visible_overlay_entries = None
self._visible_label_entries = None
self._overlay_cache_dirty = True
self._overlay_cache_pixmap = QPixmap()
self._overlay_cache_size = QSize()
self._overlay_cache_device_pixel_ratio = 0.0
[docs]
def _get_static_label(self, label_text: str) -> QStaticText:
static_label = self._static_label_cache.get(label_text)
if static_label is None:
static_label = QStaticText(label_text)
static_label.prepare(font=BeadGraphic.LABEL_FONT)
self._static_label_cache[label_text] = static_label
return static_label
[docs]
def _rebuild_overlay_view_cache(self) -> None:
if not self._overlay_entries:
self._visible_overlay_entries = []
self._visible_label_entries = []
return
visible_scene_rect = self.mapToScene(self.viewport().rect()).boundingRect()
visible_overlay_entries: list[tuple[QRectF, str, bool]] = []
visible_label_entries: list[tuple[QPointF, QStaticText, bool]] = []
for roi_rect, label_point, state, is_active, label_text in self._overlay_entries:
if not is_active and not roi_rect.intersects(visible_scene_rect):
continue
view_rect = QRectF(self.mapFromScene(roi_rect).boundingRect())
visible_overlay_entries.append((view_rect, state, is_active))
view_point = self.mapFromScene(label_point)
visible_label_entries.append((
QPointF(view_point.x(), view_point.y() + self._label_ascent),
self._get_static_label(label_text),
is_active,
))
self._visible_overlay_entries = visible_overlay_entries
self._visible_label_entries = visible_label_entries
[docs]
def _rebuild_overlay_cache_pixmap(self) -> None:
viewport_size = self.viewport().size()
if viewport_size.isEmpty() or not self._overlay_entries:
self._overlay_cache_pixmap = QPixmap()
self._overlay_cache_dirty = False
self._overlay_cache_size = QSize()
self._overlay_cache_device_pixel_ratio = 0.0
return
if self._visible_overlay_entries is None or self._visible_label_entries is None:
self._rebuild_overlay_view_cache()
visible_overlay_entries = self._visible_overlay_entries
visible_label_entries = self._visible_label_entries
assert visible_overlay_entries is not None
assert visible_label_entries is not None
device_pixel_ratio = self.devicePixelRatioF()
overlay_pixmap = QPixmap(
max(1, int(round(viewport_size.width() * device_pixel_ratio))),
max(1, int(round(viewport_size.height() * device_pixel_ratio))),
)
overlay_pixmap.setDevicePixelRatio(device_pixel_ratio)
overlay_pixmap.fill(Qt.GlobalColor.transparent)
BeadGraphic._ensure_shared_pens_and_brushes()
assert BeadGraphic._shared_pens is not None
assert BeadGraphic._shared_brushes is not None
painter = QPainter(overlay_pixmap)
try:
state_rects: dict[str, list[QRectF]] = {
'default': [],
'selected': [],
'reference': [],
}
for roi_rect, state, is_active in visible_overlay_entries:
if is_active:
continue
state_rects[state].append(roi_rect)
for state in ('default', 'selected', 'reference'):
if not state_rects[state]:
continue
painter.setPen(BeadGraphic._shared_pens[state])
painter.setBrush(BeadGraphic._shared_brushes[state])
for roi_rect in state_rects[state]:
painter.drawRect(roi_rect)
painter.setFont(BeadGraphic.LABEL_FONT)
painter.setPen(BeadGraphic.LABEL_COLOR)
for label_point, label_text, is_active in visible_label_entries:
if is_active:
continue
painter.drawStaticText(label_point, label_text)
finally:
painter.end()
self._overlay_cache_pixmap = overlay_pixmap
self._overlay_cache_dirty = False
self._overlay_cache_size = viewport_size
self._overlay_cache_device_pixel_ratio = device_pixel_ratio
[docs]
def _ensure_overlay_cache_pixmap(self) -> None:
viewport_size = self.viewport().size()
if viewport_size.isEmpty() or not self._overlay_entries:
self._overlay_cache_pixmap = QPixmap()
self._overlay_cache_dirty = False
self._overlay_cache_size = QSize()
self._overlay_cache_device_pixel_ratio = 0.0
return
device_pixel_ratio = self.devicePixelRatioF()
if (
self._overlay_cache_dirty
or self._overlay_cache_pixmap.isNull()
or self._overlay_cache_size != viewport_size
or self._overlay_cache_device_pixel_ratio != device_pixel_ratio
):
self._rebuild_overlay_cache_pixmap()
[docs]
def plot(self, x, y, size):
self._marker_x = np.asarray(x, dtype=float)
self._marker_y = np.asarray(y, dtype=float)
self._marker_size = max(1, int(round(size)))
self.viewport().update()
[docs]
def clear_crosshairs(self):
self._marker_x = np.empty((0,), dtype=float)
self._marker_y = np.empty((0,), dtype=float)
self._marker_size = 0
self.viewport().update()
[docs]
def set_image_to_default(self):
width = 128
default_image = np.zeros((width, width), dtype=np.uint8)
default_image[1::2, 1::2] = 255
default_pixmap = QPixmap.fromImage(
QImage(default_image, width, width,
QImage.Format.Format_Grayscale8))
self._empty = False
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
self._image.setPixmap(default_pixmap)
self._minimap_base = default_pixmap
self.reset_view(round(self.scale_factor**self._zoom))
self._refresh_minimap()
[docs]
def has_image(self):
return not self._empty
[docs]
def image_scene_rect(self) -> QRectF:
return QRectF(self._image.pixmap().rect())
[docs]
def reset_view(self, scale=1):
rect = self.image_scene_rect()
if not rect.isNull():
self.scene.setSceneRect(rect)
self.setSceneRect(rect)
if (scale := max(1, scale)) == 1:
self._zoom = 0
if self.has_image():
unity = self.transform().mapRect(QRectF(0, 0, 1, 1))
self.scale(1 / unity.width(), 1 / unity.height())
viewrect = self.viewport().rect()
scenerect = self.transform().mapRect(rect)
factor = min(viewrect.width() / scenerect.width(),
viewrect.height() / scenerect.height()) * scale
self._fit_scale = factor if factor > 0 else 1.0
self.scale(factor, factor)
self.centerOn(self._image)
self.update_coordinates()
self._invalidate_overlay_view_cache()
self._refresh_minimap()
[docs]
def clear_image(self):
self._empty = True
self.setDragMode(QGraphicsView.DragMode.NoDrag)
self._image.setPixmap(QPixmap())
self.scene.setSceneRect(QRectF())
self.reset_view(round(self.scale_factor**self._zoom))
self._minimap_base = QPixmap()
self._minimap_label.hide()
self._minimap_zoom_label.hide()
self._minimap_reset_button.hide()
[docs]
def set_pixmap(self, pixmap):
self._image.setPixmap(pixmap)
if not pixmap.isNull():
self._empty = False
self._minimap_base = pixmap
rect = self.image_scene_rect()
self.scene.setSceneRect(rect)
self.setSceneRect(rect)
self._refresh_minimap()
[docs]
def zoom_level(self):
return self._zoom
[docs]
def zoom(self, step):
zoom = max(0, self._zoom + (step := int(step)))
if zoom != self._zoom:
self._zoom = zoom
if self._zoom > 0:
if step > 0:
factor = self.scale_factor**step
else:
factor = 1 / self.scale_factor**abs(step)
self.scale(factor, factor)
self._invalidate_overlay_view_cache()
else:
self.reset_view()
self._refresh_minimap()
[docs]
def wheelEvent(self, event):
delta = event.angleDelta().y()
self.zoom(delta and delta // abs(delta))
[docs]
def resizeEvent(self, event):
super().resizeEvent(event)
self.reset_view()
self._refresh_minimap()
[docs]
def toggle_drag_mode(self):
if self.dragMode() == QGraphicsView.DragMode.ScrollHandDrag:
self.setDragMode(QGraphicsView.DragMode.NoDrag)
elif not self._image.pixmap().isNull():
self.setDragMode(QGraphicsView.DragMode.ScrollHandDrag)
[docs]
def update_coordinates(self, pos=None):
if self._image.isUnderMouse():
if pos is None:
pos = self.mapFromGlobal(QCursor.pos())
point = self.mapToScene(pos).toPoint()
else:
point = QPoint()
self.coordinatesChanged.emit(point)
[docs]
def mouseMoveEvent(self, event):
self.update_coordinates(event.position().toPoint())
super().mouseMoveEvent(event)
[docs]
def leaveEvent(self, event):
self.coordinatesChanged.emit(QPoint())
super().leaveEvent(event)
[docs]
def mousePressEvent(self, event):
self._mouse_start_pos = event.position().toPoint()
self._mouse_start_time = time.time()
super().mousePressEvent(event)
[docs]
def mouseReleaseEvent(self, event):
duration = time.time() - self._mouse_start_time
if duration < 0.5:
if self._image.isUnderMouse() and event.button() in (
Qt.MouseButton.LeftButton,
Qt.MouseButton.RightButton,
):
mouse_move_dist = event.position().toPoint(
) - self._mouse_start_pos
mouse_move_dist = mouse_move_dist.x() * mouse_move_dist.x(
) + mouse_move_dist.y() * mouse_move_dist.y()
if mouse_move_dist < 32:
point = self.mapToScene(
event.position().toPoint()).toPoint()
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit(point)
self.sceneClicked.emit(point, event.button())
super().mouseReleaseEvent(event)
[docs]
def scrollContentsBy(self, dx, dy):
super().scrollContentsBy(dx, dy)
self._invalidate_overlay_view_cache()
self._refresh_minimap()
[docs]
def _refresh_minimap(self):
if self._minimap_base.isNull() or self._zoom <= 0:
self._minimap_label.hide()
self._minimap_zoom_label.hide()
self._minimap_reset_button.hide()
return
if not self._layout_minimap():
self._minimap_label.hide()
self._minimap_zoom_label.hide()
self._minimap_reset_button.hide()
return
label_size = self._minimap_label.size()
if label_size.width() <= 0 or label_size.height() <= 0:
self._minimap_label.hide()
self._minimap_zoom_label.hide()
self._minimap_reset_button.hide()
return
scaled_size = self._minimap_base.size().scaled(
label_size, Qt.AspectRatioMode.KeepAspectRatio)
if scaled_size.isEmpty():
self._minimap_label.hide()
self._minimap_zoom_label.hide()
self._minimap_reset_button.hide()
return
minimap_pixmap = QPixmap(label_size)
minimap_pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(minimap_pixmap)
offset_x = (label_size.width() - scaled_size.width()) // 2
offset_y = (label_size.height() - scaled_size.height()) // 2
scaled_pixmap = self._minimap_base.scaled(
scaled_size,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
painter.drawPixmap(offset_x, offset_y, scaled_pixmap)
highlight_rect = self._compute_highlight_rect(
scaled_size, offset_x, offset_y)
if highlight_rect is not None and not highlight_rect.isEmpty():
pen = QPen(QColor(255, 0, 0, 200))
pen.setWidth(2)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(highlight_rect)
painter.end()
self._minimap_label.setPixmap(minimap_pixmap)
self._minimap_label.show()
zoom_percent = self._current_zoom_percent()
if zoom_percent is not None:
self._minimap_zoom_label.setText(f"{zoom_percent:.0f}%")
self._minimap_zoom_label.show()
self._minimap_reset_button.show()
else:
self._minimap_zoom_label.hide()
self._minimap_reset_button.hide()
[docs]
def _layout_minimap(self):
viewport_size = self.viewport().size()
if viewport_size.isEmpty():
return False
size = min(
max(
min(viewport_size.width(), viewport_size.height()) // 4,
self._MINIMAP_MIN_SIZE,
),
self._MINIMAP_MAX_SIZE,
)
zoom_height = max(
self._minimap_zoom_label.sizeHint().height(),
self._MINIMAP_ZOOM_HEIGHT,
self._minimap_reset_button.sizeHint().height(),
)
required_height = (
size
+ self._MINIMAP_LABEL_SPACING
+ zoom_height
+ 2 * self._MINIMAP_MARGIN
)
if (
viewport_size.width() <= 2 * self._MINIMAP_MARGIN
or viewport_size.height() <= required_height
):
return False
top = self._MINIMAP_MARGIN
left = viewport_size.width() - size - self._MINIMAP_MARGIN
self._minimap_label.setGeometry(left, top, size, size)
available_width = size
button_hint_width = self._minimap_reset_button.sizeHint().width()
button_width = min(button_hint_width, available_width)
spacing = (
self._MINIMAP_BUTTON_SPACING if available_width > button_width else 0
)
label_width = available_width - button_width - spacing
if label_width <= 0:
label_width = max(available_width // 2, 1)
button_width = available_width - label_width - spacing
if label_width <= 0 or button_width <= 0:
return False
row_top = top + size + self._MINIMAP_LABEL_SPACING
self._minimap_zoom_label.setGeometry(
left,
row_top,
label_width,
zoom_height,
)
self._minimap_reset_button.setGeometry(
left + label_width + spacing,
row_top,
button_width,
zoom_height,
)
self._minimap_label.raise_()
self._minimap_zoom_label.raise_()
self._minimap_reset_button.raise_()
return True
[docs]
def _compute_highlight_rect(self, scaled_size, offset_x, offset_y):
if self._image.pixmap().isNull():
return None
viewport_rect = self.viewport().rect()
if viewport_rect.isNull():
return None
scene_polygon = self.mapToScene(viewport_rect)
scene_rect = scene_polygon.boundingRect()
image_rect = QRectF(self._image.pixmap().rect())
scene_rect = scene_rect.intersected(image_rect)
if scene_rect.isEmpty():
return QRectF()
scale_x = scaled_size.width() / image_rect.width()
scale_y = scaled_size.height() / image_rect.height()
x = (scene_rect.left() - image_rect.left()) * scale_x + offset_x
y = (scene_rect.top() - image_rect.top()) * scale_y + offset_y
width = scene_rect.width() * scale_x
height = scene_rect.height() * scale_y
highlight = QRectF(x, y, width, height)
label_rect = QRectF(0, 0, self._minimap_label.width(), self._minimap_label.height())
return highlight.intersected(label_rect)
[docs]
def _current_zoom_percent(self):
if self._fit_scale <= 0:
return None
current_scale = self.transform().m11()
if current_scale <= 0:
return None
return (current_scale / self._fit_scale) * 100
[docs]
def drawForeground(self, painter, rect):
super().drawForeground(painter, rect)
if self._overlay_entries:
self._ensure_overlay_cache_pixmap()
if not self._overlay_cache_pixmap.isNull():
painter.save()
painter.resetTransform()
painter.drawPixmap(0, 0, self._overlay_cache_pixmap)
painter.restore()
if self._marker_size <= 0 or self._marker_x.size == 0 or self._marker_y.size == 0:
return
scene_transform = painter.worldTransform()
painter.save()
painter.resetTransform()
marker_pen = QPen(QColor('red'))
marker_pen.setWidth(1 if self._marker_size <= 3 else 2)
painter.setPen(marker_pen)
half_size = max(1, self._marker_size // 2)
for x, y in zip(self._marker_x, self._marker_y):
if not rect.contains(x, y):
continue
view_point = scene_transform.map(QPointF(float(x), float(y)))
px = view_point.x()
py = view_point.y()
if half_size <= 1:
painter.drawPoint(QPointF(px, py))
continue
painter.drawLine(QPointF(px - half_size, py), QPointF(px + half_size, py))
painter.drawLine(QPointF(px, py - half_size), QPointF(px, py + half_size))
painter.restore()