"""
Drop-in replacement for QSlider with additional features.
"""
from PySide2 import QtCore, QtWidgets, QtGui
from PySide2.QtGui import QPen, QBrush, QColor, QKeyEvent, QPolygonF, QPainterPath
from sleap.gui.color import ColorManager
import attr
import itertools
import numpy as np
from enum import Enum
from typing import Callable, Dict, Iterable, List, Optional, Tuple, Union
# for debug, we can filter out short tracks from slider
SEEKBAR_MIN_TRACK_LEN_TO_SHOW = 0
[docs]@attr.s(auto_attribs=True, eq=False)
class SliderMark:
"""
Class to hold data for an individual mark on the slider.
Attributes:
type: Type of the mark, options are:
* "simple" (single value)
* "simple_thin" ( ditto )
* "filled"
* "open"
* "predicted"
* "tick"
* "tick_column"
* "track" (range of values)
val: Beginning of mark range
end_val: End of mark range (for "track" marks)
row: The row that the mark goes in; used for tracks.
color: Color of mark, can be string or (r, g, b) tuple.
filled: Whether the mark is shown filled (solid color).
"""
type: str
val: float
end_val: float = None
row: int = None
track: "Track" = None
_color: Union[tuple, str] = "black"
@property
def color(self):
"""Returns color of mark."""
colors = dict(
simple="black",
simple_thin="black",
filled="blue",
open="blue",
predicted=(1, 170, 247), # light blue
tick="lightGray",
tick_column="gray",
)
if self.type in colors:
return colors[self.type]
else:
return self._color
@color.setter
def color(self, val):
"""Sets color of mark."""
self._color = val
@property
def QColor(self):
"""Returns color of mark as `QColor`."""
c = self.color
if type(c) == str:
return QColor(c)
else:
return QColor(*c)
@property
def filled(self):
"""Returns whether mark is filled or open."""
if self.type == "open":
return False
else:
return True
@property
def top_pad(self):
if self.type == "tick_column":
return 40
if self.type == "tick":
return 0
return 2
@property
def bottom_pad(self):
if self.type == "tick_column":
return 200
if self.type == "tick":
return 0
return 2
@property
def visual_width(self):
if self.type in ("open", "filled", "tick"):
return 2
if self.type in ("tick_column", "simple", "predicted"):
return 1
return 0
def get_height(self, container_height):
if self.type == "track":
return 2
height = container_height
# if self.padded:
height -= self.top_pad + self.bottom_pad
return height
[docs]class VideoSlider(QtWidgets.QGraphicsView):
"""Drop-in replacement for QSlider with additional features.
Args:
orientation: ignored (here for compatibility with QSlider)
min: initial minimum value
max: initial maximum value
val: initial value
marks: initial set of values to mark on slider
this can be either
* list of values to mark
* list of (track, value)-tuples to mark
Signals:
mousePressed: triggered on Qt event
mouseMoved: triggered on Qt event
mouseReleased: triggered on Qt event
keyPress: triggered on Qt event
keyReleased: triggered on Qt event
valueChanged: triggered when value of slider changes
selectionChanged: triggered when slider range selection changes
heightUpdated: triggered when the height of slider changes
"""
mousePressed = QtCore.Signal(float, float)
mouseMoved = QtCore.Signal(float, float)
mouseReleased = QtCore.Signal(float, float)
keyPress = QtCore.Signal(QKeyEvent)
keyRelease = QtCore.Signal(QKeyEvent)
valueChanged = QtCore.Signal(int)
selectionChanged = QtCore.Signal(int, int)
heightUpdated = QtCore.Signal()
def __init__(
self,
orientation=-1, # for compatibility with QSlider
min=0,
max=1,
val=0,
marks=None,
*args,
**kwargs,
):
super(VideoSlider, self).__init__(*args, **kwargs)
self.scene = QtWidgets.QGraphicsScene()
self.setScene(self.scene)
self.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setMouseTracking(True)
self._get_val_tooltip = None
self.tick_index_offset = 1
self.zoom_factor = 1
self._track_rows = 0
self._track_height = 5
self._max_tracks_stacked = 120
self._track_stack_skip_count = 10
self._header_label_height = 20
self._header_graph_height = 40
self._header_height = self._header_label_height # room for frame labels
self._min_height = 19 + self._header_height
self._base_font = QtGui.QFont()
self._base_font.setPixelSize(10)
self._tick_marks = []
# Add border rect
outline_rect = QtCore.QRectF(0, 0, 200, self._min_height - 3)
self.box_rect = outline_rect
# self.outlineBox = self.scene.addRect(outline_rect)
# self.outlineBox.setPen(QPen(QColor("black", alpha=0)))
# Add drag handle rect
self._handle_width = 6
handle_rect = QtCore.QRect(
0, self._handle_top, self._handle_width, self._handle_height
)
self.setMinimumHeight(self._min_height)
self.setMaximumHeight(self._min_height)
self.handle = self.scene.addRect(handle_rect)
self.handle.setPen(QPen(QColor(80, 80, 80)))
self.handle.setBrush(QColor(128, 128, 128, 128))
# Add (hidden) rect to highlight selection
self.select_box = self.scene.addRect(
QtCore.QRect(0, 1, 0, outline_rect.height() - 2)
)
self.select_box.setPen(QPen(QColor(80, 80, 255)))
self.select_box.setBrush(QColor(80, 80, 255, 128))
self.select_box.hide()
self.zoom_box = self.scene.addRect(
QtCore.QRect(0, 1, 0, outline_rect.height() - 2)
)
self.zoom_box.setPen(QPen(QColor(80, 80, 80, 64)))
self.zoom_box.setBrush(QColor(80, 80, 80, 64))
self.zoom_box.hide()
self.scene.setBackgroundBrush(QBrush(QColor(200, 200, 200)))
self.clearSelection()
self.setEnabled(True)
self.setMinimum(min)
self.setMaximum(max)
self.setValue(val)
self.setMarks(marks)
pen = QPen(QColor(80, 80, 255), 0.5)
pen.setCosmetic(True)
self.poly = self.scene.addPath(QPainterPath(), pen, self.select_box.brush())
self.headerSeries = dict()
self._draw_header()
# Methods to match API for QSlider
[docs] def value(self) -> float:
"""Returns value of slider."""
return self._val_main
[docs] def setValue(self, val: float) -> float:
"""Sets value of slider."""
self._val_main = val
x = self._toPos(val)
self.handle.setPos(x, 0)
self.ensureVisible(x, 0, self._handle_width, 0, 3, 0)
[docs] def setMinimum(self, min: float) -> float:
"""Sets minimum value for slider."""
self._val_min = min
[docs] def setMaximum(self, max: float) -> float:
"""Sets maximum value for slider."""
self._val_max = max
[docs] def setEnabled(self, val: float) -> float:
"""Set whether the slider is enabled."""
self._enabled = val
[docs] def enabled(self):
"""Returns whether slider is enabled."""
return self._enabled
# Methods for working with visual positions (mapping to and from, redrawing)
def _update_visual_positions(self):
"""Updates the visual x position of handle and slider annotations."""
x = self._toPos(self.value())
self.handle.setPos(x, 0)
for mark in self._mark_items.keys():
if mark.type == "track":
width_in_frames = mark.end_val - mark.val
width = max(2, self._toPos(width_in_frames))
else:
width = mark.visual_width
x = self._toPos(mark.val, center=True)
self._mark_items[mark].setPos(x, 0)
if mark in self._mark_labels:
label_x = max(
0, x - self._mark_labels[mark].boundingRect().width() // 2
)
self._mark_labels[mark].setPos(label_x, 4)
rect = self._mark_items[mark].rect()
rect.setWidth(width)
rect.setHeight(
mark.get_height(
container_height=self.box_rect.height() - self._header_height
)
)
self._mark_items[mark].setRect(rect)
def _get_min_max_slider_heights(self):
tracks = self._track_rows
if tracks == 0:
min_height = self._min_height
max_height = self._min_height
else:
# Start with padding height
extra_height = 8 + self._header_height
min_height = extra_height
max_height = extra_height
# Add height for tracks
min_height += self._track_height * min(tracks, 20)
max_height += self._track_height * min(tracks, self._max_tracks_stacked)
# Make sure min/max height is at least 19, even if few tracks
min_height = max(self._min_height, min_height)
max_height = max(self._min_height, max_height)
return min_height, max_height
def _update_slider_height(self):
"""Update the height of the slider."""
min_height, max_height = self._get_min_max_slider_heights()
# TODO: find the current height of the scrollbar
# self.horizontalScrollBar().height() gives the wrong value
scrollbar_height = 18
self.setMaximumHeight(max_height + scrollbar_height)
self.setMinimumHeight(min_height + scrollbar_height)
# Redraw all marks with new height and y position
marks = self.getMarks()
self.setMarks(marks)
self.resizeEvent()
self.heightUpdated.emit()
def _toPos(self, val: float, center=False) -> float:
"""
Converts slider value to x position on slider.
Args:
val: The slider value.
center: Whether to offset by half the width of drag handle,
so that plotted location will light up with center of handle.
Returns:
x position.
"""
x = val
x -= self._val_min
x /= max(1, self._val_max - self._val_min)
x *= self._slider_width
if center:
x += self.handle.rect().width() / 2.0
return x
def _toVal(self, x: float, center=False) -> float:
"""Converts x position to slider value."""
val = x
val /= self._slider_width
val *= max(1, self._val_max - self._val_min)
val += self._val_min
val = round(val)
return val
@property
def _slider_width(self) -> float:
"""Returns visual width of slider."""
return self.box_rect.width() - self.handle.rect().width()
@property
def slider_visible_value_range(self) -> float:
"""Value range that's visible given current size and zoom."""
return self._toVal(self.width() - 1)
@property
def _mark_area_height(self) -> float:
_, max_height = self._get_min_max_slider_heights()
return max_height - 3 - self._header_height
@property
def value_range(self) -> float:
return self._val_max - self._val_min
@property
def box_rect(self) -> QtCore.QRectF:
return self._box_rect
@box_rect.setter
def box_rect(self, rect: QtCore.QRectF):
self._box_rect = rect
# Update the scene rect so that it matches how much space we
# currently want for drawing everything.
rect.setWidth(rect.width() - 1)
self.setSceneRect(rect)
# Methods for range selection and zoom
[docs] def clearSelection(self):
"""Clears selection endpoints."""
self._selection = []
self.select_box.hide()
[docs] def startSelection(self, val):
"""Adds initial selection endpoint.
Called when user starts dragging to select range in slider.
Args:
val: value of endpoint
"""
self._selection.append(val)
[docs] def endSelection(self, val, update: bool = False):
"""Add final selection endpoint.
Called during or after the user is dragging to select range.
Args:
val: value of endpoint
update:
"""
# If we want to update endpoint and there's already one, remove it
if update and len(self._selection) % 2 == 0:
self._selection.pop()
# Add the selection endpoint
self._selection.append(val)
a, b = self._selection[-2:]
if a == b:
self.clearSelection()
else:
self._draw_selection(a, b)
# Emit signal (even if user selected same region as before)
self.selectionChanged.emit(*self.getSelection())
[docs] def setSelection(self, start_val, end_val):
"""Selects clip from start_val to end_val."""
self.startSelection(start_val)
self.endSelection(end_val, update=True)
[docs] def hasSelection(self) -> bool:
"""Returns True if a clip is selected, False otherwise."""
a, b = self.getSelection()
return a < b
[docs] def getSelection(self):
"""Returns start and end value of current selection endpoints."""
a, b = 0, 0
if len(self._selection) % 2 == 0 and len(self._selection) > 0:
a, b = self._selection[-2:]
start = min(a, b)
end = max(a, b)
return start, end
def _draw_selection(self, a: float, b: float):
self._update_selection_box_positions(self.select_box, a, b)
def _draw_zoom_box(self, a: float, b: float):
self._update_selection_box_positions(self.zoom_box, a, b)
def _update_selection_box_positions(self, box_object, a: float, b: float):
"""Update box item on slider.
Args:
box_object: The box to update
a: one endpoint value
b: other endpoint value
Returns:
None.
"""
start = min(a, b)
end = max(a, b)
start_pos = self._toPos(start, center=True)
end_pos = self._toPos(end, center=True)
box_rect = QtCore.QRect(
start_pos, self._header_height, end_pos - start_pos, self.box_rect.height(),
)
box_object.setRect(box_rect)
box_object.show()
def _update_selection_boxes_on_resize(self):
for box_object in (self.select_box, self.zoom_box):
rect = box_object.rect()
rect.setHeight(self._handle_height)
box_object.setRect(rect)
if self.select_box.isVisible():
self._draw_selection(*self.getSelection())
[docs] def moveSelectionAnchor(self, x: float, y: float):
"""
Moves selection anchor in response to mouse position.
Args:
x: x position of mouse
y: y position of mouse
Returns:
None.
"""
x = max(x, 0)
x = min(x, self.box_rect.width())
anchor_val = self._toVal(x, center=True)
if len(self._selection) % 2 == 0:
self.startSelection(anchor_val)
self._draw_selection(anchor_val, self._selection[-1])
[docs] def releaseSelectionAnchor(self, x, y):
"""
Finishes selection in response to mouse release.
Args:
x: x position of mouse
y: y position of mouse
Returns:
None.
"""
x = max(x, 0)
x = min(x, self.box_rect.width())
anchor_val = self._toVal(x)
self.endSelection(anchor_val)
def moveZoomDrag(self, x: float, y: float):
if getattr(self, "_zoom_start_val", None) is None:
self._zoom_start_val = self._toVal(x, center=True)
current_val = self._toVal(x, center=True)
self._draw_zoom_box(current_val, self._zoom_start_val)
def releaseZoomDrag(self, x, y):
self.zoom_box.hide()
val_a = self._zoom_start_val
val_b = self._toVal(x, center=True)
val_start = min(val_a, val_b)
val_end = max(val_a, val_b)
# pad the zoom
val_range = val_end - val_start
val_start -= val_range * 0.05
val_end += val_range * 0.05
self.setZoomRange(val_start, val_end)
self._zoom_start_val = None
def setZoomRange(self, start_val: float, end_val: float):
zoom_val_range = end_val - start_val
if zoom_val_range > 0:
self.zoom_factor = self.value_range / zoom_val_range
else:
self.zoom_factor = 1
self.resizeEvent()
center_val = start_val + zoom_val_range / 2
center_pos = self._toPos(center_val)
self.centerOn(center_pos, 0)
# Methods for modifying marks on slider
[docs] def setNumberOfTracks(self, track_rows):
"""Set the number of tracks to show in slider.
Args:
track_rows: the number of tracks to show
"""
self._track_rows = track_rows
self._update_slider_height()
[docs] def clearMarks(self):
"""Clears all marked values for slider."""
if hasattr(self, "_mark_items"):
for item in self._mark_items.values():
self.scene.removeItem(item)
if hasattr(self, "_mark_labels"):
for item in self._mark_labels.values():
self.scene.removeItem(item)
self._marks = set() # holds mark position
self._mark_items = dict() # holds visual Qt object for plotting mark
self._mark_labels = dict()
[docs] def setMarks(self, marks: Iterable[Union[SliderMark, int]]):
"""Sets all marked values for the slider.
Args:
marks: iterable with all values to mark
Returns:
None.
"""
self.clearMarks()
# Add tick marks first so they're behind other marks
self._add_tick_marks()
if marks is not None:
for mark in marks:
if not isinstance(mark, SliderMark):
mark = SliderMark("simple", mark)
self.addMark(mark, update=False)
self._update_visual_positions()
[docs] def setTickMarks(self):
"""Resets which tick marks to show."""
self._clear_tick_marks()
self._add_tick_marks()
def _clear_tick_marks(self):
if not hasattr(self, "_tick_marks"):
return
for mark in self._tick_marks:
self.removeMark(mark)
def _add_tick_marks(self):
val_range = self.slider_visible_value_range
if val_range < 20:
val_order = 1
else:
val_order = 10
while val_range // val_order > 24:
val_order *= 10
self._tick_marks = []
for tick_pos in range(
self._val_min + val_order - 1, self._val_max + 1, val_order
):
self._tick_marks.append(SliderMark("tick", tick_pos))
for tick_mark in self._tick_marks:
self.addMark(tick_mark, update=False)
[docs] def removeMark(self, mark: SliderMark):
"""Removes an individual mark."""
if mark in self._mark_labels:
self.scene.removeItem(self._mark_labels[mark])
del self._mark_labels[mark]
if mark in self._mark_items:
self.scene.removeItem(self._mark_items[mark])
del self._mark_items[mark]
if mark in self._marks:
self._marks.remove(mark)
[docs] def getMarks(self, type: str = ""):
"""Returns list of marks."""
if type:
return [mark for mark in self._marks if mark.type == type]
return self._marks
[docs] def addMark(self, new_mark: SliderMark, update: bool = True):
"""Adds a marked value to the slider.
Args:
new_mark: value to mark
update: Whether to redraw slider with new mark.
Returns:
None.
"""
# check if mark is within slider range
if new_mark.val > self._val_max:
return
if new_mark.val < self._val_min:
return
self._marks.add(new_mark)
v_top_pad = self._header_height + 1
v_bottom_pad = 1
v_top_pad += new_mark.top_pad
v_bottom_pad += new_mark.bottom_pad
width = new_mark.visual_width
v_offset = v_top_pad
if new_mark.type == "track":
v_offset += self._get_track_vertical_pos(
*self._get_track_column_row(new_mark.row)
)
height = new_mark.get_height(
container_height=self.box_rect.height() - self._header_height
)
color = new_mark.QColor
pen = QPen(color, 0.5)
pen.setCosmetic(True)
brush = QBrush(color) if new_mark.filled else QBrush()
line = self.scene.addRect(-width // 2, v_offset, width, height, pen, brush)
self._mark_items[new_mark] = line
if new_mark.type == "tick":
# Show tick mark behind other slider marks
self._mark_items[new_mark].setZValue(0)
# Add a text label to show in header area
mark_label_text = (
f"{new_mark.val + self.tick_index_offset:g}" # sci notation if large
)
self._mark_labels[new_mark] = self.scene.addSimpleText(
mark_label_text, self._base_font
)
elif new_mark.type == "track":
# Show tracks over tick marks
self._mark_items[new_mark].setZValue(2)
else:
# Show in front of tick marks and behind track lines
self._mark_items[new_mark].setZValue(1)
if update:
self._update_visual_positions()
def _get_track_column_row(self, raw_row: int) -> Tuple[int, int]:
"""
Returns the column and row for a given track index.
If there are many tracks we "wrap" around to showing tracks at the top
of the slider (so that it's not too tall). Each time we "wrap" back to
the top is a new "column" which starts at "row" 0.
"""
if raw_row < self._max_tracks_stacked:
return 0, raw_row
else:
rows_after_first_col = raw_row - self._max_tracks_stacked
rows_per_later_cols = (
self._max_tracks_stacked - self._track_stack_skip_count
)
rows_down = rows_after_first_col % rows_per_later_cols
col = (rows_after_first_col // rows_per_later_cols) + 1
return col, rows_down
def _get_track_vertical_pos(self, col: int, row: int) -> int:
"""
Returns visible vertical position of track in given column and row.
The "column" and "row" are given by _get_track_column_row.
"""
if col == 0:
return row * self._track_height
else:
return (self._track_height * self._track_stack_skip_count) + (
self._track_height * row
)
def _is_track_in_new_column(self, row: int) -> bool:
"""Returns whether this track is at the top of a new column."""
_, row_down = self._get_track_column_row(row)
return row_down == 0
# Methods for header graph
def _get_header_series_len(self):
if hasattr(self.headerSeries, "keys"):
series_frame_max = max(self.headerSeries.keys())
else:
series_frame_max = len(self.headerSeries)
return series_frame_max
@property
def _header_series_items(self):
"""Yields (frame idx, val) for header series items."""
if hasattr(self.headerSeries, "items"):
for key, val in self.headerSeries.items():
yield key, val
else:
for key in range(len(self.headerSeries)):
val = self.headerSeries[key]
yield key, val
def _draw_header(self):
"""Draws the header graph."""
if len(self.headerSeries) == 0 or self._header_height == 0:
self.poly.setPath(QPainterPath())
return
series_frame_max = self._get_header_series_len()
step = series_frame_max // int(self._slider_width)
step = max(step, 1)
count = series_frame_max // step * step
sampled = np.full((count), 0.0, dtype=np.float)
for key, val in self._header_series_items:
if key < count:
sampled[key] = val
sampled = np.max(sampled.reshape(count // step, step), axis=1)
series = {i * step: sampled[i] for i in range(count // step)}
series_min = np.min(sampled) - 1
series_max = np.max(sampled)
series_scale = (self._header_graph_height) / (series_max - series_min)
def toYPos(val):
return self._header_height - ((val - series_min) * series_scale)
step_chart = False # use steps rather than smooth line
points = []
points.append((self._toPos(0, center=True), toYPos(series_min)))
for idx, val in series.items():
points.append((self._toPos(idx, center=True), toYPos(val)))
if step_chart:
points.append((self._toPos(idx + step, center=True), toYPos(val)))
points.append(
(self._toPos(max(series.keys()) + 1, center=True), toYPos(series_min))
)
# Convert to list of QtCore.QPointF objects
points = list(itertools.starmap(QtCore.QPointF, points))
self.poly.setPath(self._pointsToPath(points))
def _pointsToPath(self, points: List[QtCore.QPointF]) -> QPainterPath:
"""Converts list of `QtCore.QPointF` objects to a `QPainterPath`."""
path = QPainterPath()
path.addPolygon(QPolygonF(points))
return path
# Methods for working with slider handle
def mapMouseXToHandleX(self, x) -> float:
x -= self.handle.rect().width() / 2.0
x = max(x, 0)
x = min(x, self.box_rect.width() - self.handle.rect().width())
return x
[docs] def moveHandle(self, x, y):
"""Move handle in response to mouse position.
Emits valueChanged signal if value of slider changed.
Args:
x: x position of mouse
y: y position of mouse
"""
x = self.mapMouseXToHandleX(x)
val = self._toVal(x)
# snap to nearby mark within handle
mark_vals = [mark.val for mark in self._marks]
handle_left = self._toVal(x - self.handle.rect().width() / 2)
handle_right = self._toVal(x + self.handle.rect().width() / 2)
marks_in_handle = [
mark for mark in mark_vals if handle_left < mark < handle_right
]
if marks_in_handle:
marks_in_handle.sort(key=lambda m: (abs(m - val), m > val))
val = marks_in_handle[0]
old = self.value()
self.setValue(val)
if old != val:
self.valueChanged.emit(self._val_main)
@property
def _handle_top(self) -> float:
"""Returns y position of top of handle (i.e., header height)."""
return 1 + self._header_height
@property
def _handle_height(self, outline_rect=None) -> float:
"""
Returns visual height of handle.
Args:
outline_rect: The rect of the outline box for the slider. This
is only required when calling during initialization (when the
outline box doesn't yet exist).
Returns:
Height of handle in pixels.
"""
return self._mark_area_height
# Methods for selection of contiguously marked ranges of frames
[docs] def contiguousSelectionMarksAroundVal(self, val):
"""Selects contiguously marked frames around value."""
if not self.isMarkedVal(val):
return
dec_val = self.getStartContiguousMark(val)
inc_val = self.getEndContiguousMark(val)
self.setSelection(dec_val, inc_val)
[docs] def getStartContiguousMark(self, val: int) -> int:
"""
Returns first marked value in contiguously marked region around val.
"""
last_val = val
dec_val = self._dec_contiguous_marked_val(last_val)
while last_val > dec_val > self._val_min:
last_val = dec_val
dec_val = self._dec_contiguous_marked_val(last_val)
return dec_val
[docs] def getEndContiguousMark(self, val: int) -> int:
"""
Returns last marked value in contiguously marked region around val.
"""
last_val = val
inc_val = self._inc_contiguous_marked_val(last_val)
while last_val < inc_val < self._val_max:
last_val = inc_val
inc_val = self._inc_contiguous_marked_val(last_val)
return inc_val
def getMarksAtVal(self, val: int) -> List[SliderMark]:
if val is None:
return []
return [
mark
for mark in self._marks
if (mark.val == val and mark.type not in ("tick", "tick_column"))
or (mark.type == "track" and mark.val <= val < mark.end_val)
]
[docs] def isMarkedVal(self, val: int) -> bool:
"""Returns whether value has mark."""
if self.getMarksAtVal(val):
return True
return False
def _dec_contiguous_marked_val(self, val):
"""Decrements value within contiguously marked range if possible."""
dec_val = min(
(
mark.val
for mark in self._marks
if mark.type == "track" and mark.val < val <= mark.end_val
),
default=val,
)
if dec_val < val:
return dec_val
if val - 1 in [mark.val for mark in self._marks]:
return val - 1
# Return original value if we can't decrement it w/in contiguous range
return val
def _inc_contiguous_marked_val(self, val):
"""Increments value within contiguously marked range if possible."""
inc_val = max(
(
mark.end_val - 1
for mark in self._marks
if mark.type == "track" and mark.val <= val < mark.end_val
),
default=val,
)
if inc_val > val:
return inc_val
if val + 1 in [mark.val for mark in self._marks]:
return val + 1
# Return original value if we can't decrement it w/in contiguous range
return val
# Method for cursor
def _update_cursor_for_event(self, event):
if event.modifiers() == QtCore.Qt.ShiftModifier:
self.setCursor(QtCore.Qt.CrossCursor)
elif event.modifiers() == QtCore.Qt.AltModifier:
self.setCursor(QtCore.Qt.SizeHorCursor)
else:
self.unsetCursor()
# Methods which override QGraphicsView
[docs] def resizeEvent(self, event=None):
"""Override method to update visual size when necessary.
Args:
event
"""
outline_rect = self.box_rect
handle_rect = self.handle.rect()
outline_rect.setHeight(self._mark_area_height + self._header_height)
if event is not None:
visual_width = event.size().width() - 1
else:
visual_width = self.width() - 1
drawn_width = visual_width * self.zoom_factor
outline_rect.setWidth(drawn_width)
self.box_rect = outline_rect
handle_rect.setTop(self._handle_top)
handle_rect.setHeight(self._handle_height)
self.handle.setRect(handle_rect)
self._update_selection_boxes_on_resize()
self.setTickMarks()
self._update_visual_positions()
self._draw_header()
super(VideoSlider, self).resizeEvent(event)
[docs] def mousePressEvent(self, event):
"""Override method to move handle for mouse press/drag.
Args:
event
"""
scenePos = self.mapToScene(event.pos())
# Do nothing if not enabled
if not self.enabled():
return
# Do nothing if click outside slider area
if not self.box_rect.contains(scenePos):
return
move_function = None
release_function = None
self._update_cursor_for_event(event)
# Shift : selection
if event.modifiers() == QtCore.Qt.ShiftModifier:
move_function = self.moveSelectionAnchor
release_function = self.releaseSelectionAnchor
self.clearSelection()
# No modifier : go to frame
elif event.modifiers() == QtCore.Qt.NoModifier:
move_function = self.moveHandle
release_function = None
# Alt (option) : zoom
elif event.modifiers() == QtCore.Qt.AltModifier:
move_function = self.moveZoomDrag
release_function = self.releaseZoomDrag
else:
event.accept() # mouse events shouldn't be passed to video widgets
# Connect to signals
if move_function is not None:
self.mouseMoved.connect(move_function)
def done(x, y):
self.unsetCursor()
if release_function is not None:
release_function(x, y)
if move_function is not None:
self.mouseMoved.disconnect(move_function)
self.mouseReleased.disconnect(done)
self.mouseReleased.connect(done)
# Emit signal
self.mouseMoved.emit(scenePos.x(), scenePos.y())
self.mousePressed.emit(scenePos.x(), scenePos.y())
[docs] def mouseMoveEvent(self, event):
"""Override method to emit mouseMoved signal on drag."""
scenePos = self.mapToScene(event.pos())
# Update cursor type based on current modifier key
self._update_cursor_for_event(event)
# Show tooltip with information about frame under mouse
if self._get_val_tooltip:
hover_frame_idx = self._toVal(self.mapMouseXToHandleX(scenePos.x()))
tooltip = self._get_val_tooltip(hover_frame_idx)
QtWidgets.QToolTip.showText(event.globalPos(), tooltip)
self.mouseMoved.emit(scenePos.x(), scenePos.y())
[docs] def mouseReleaseEvent(self, event):
"""Override method to emit mouseReleased signal on release."""
scenePos = self.mapToScene(event.pos())
self.mouseReleased.emit(scenePos.x(), scenePos.y())
[docs] def mouseDoubleClickEvent(self, event):
"""Override method to move handle for mouse double-click.
Args:
event
"""
scenePos = self.mapToScene(event.pos())
# Do nothing if not enabled
if not self.enabled():
return
# Do nothing if click outside slider area
if not self.box_rect.contains(scenePos):
return
if event.modifiers() == QtCore.Qt.ShiftModifier:
self.contiguousSelectionMarksAroundVal(self._toVal(scenePos.x()))
[docs] def leaveEvent(self, event):
self.unsetCursor()
[docs] def keyPressEvent(self, event):
"""Catch event and emit signal so something else can handle event."""
self._update_cursor_for_event(event)
self.keyPress.emit(event)
event.accept()
[docs] def keyReleaseEvent(self, event):
"""Catch event and emit signal so something else can handle event."""
self.unsetCursor()
self.keyRelease.emit(event)
event.accept()
[docs] def boundingRect(self) -> QtCore.QRectF:
"""Method required by Qt."""
return self.box_rect
[docs] def paint(self, *args, **kwargs):
"""Method required by Qt."""
super(VideoSlider, self).paint(*args, **kwargs)
# Map meaning of mark to the type of mark
[docs]class SemanticMarkType(Enum):
user = "simple"
predicted_no_track = "simple_thin"
suggested_with_user = "filled"
suggested_with_nothing = "open"
suggested_with_predicted = "predicted"
[docs]def set_slider_marks_from_labels(
slider: VideoSlider,
labels: "Labels",
video: "Video",
color_manager: Optional[ColorManager] = None,
):
"""
Sets slider marks using track information from `Labels` object.
Args:
slider: the slider we're updating
labels: the dataset with tracks and labeled frames
video: the video for which to show marks
Returns:
None
"""
if color_manager is None:
color_manager = ColorManager(labels=labels)
# Make function which can be used to get tooltip text when hovering
# over a given value (i.e., frame index) in the slider.
def get_val_tooltip(idx: int) -> str:
tooltip = f"Frame {idx+1}"
frame_mark_types = {mark.type for mark in slider.getMarksAtVal(idx)}
if SemanticMarkType.user.value in frame_mark_types:
tooltip += "\nuser labeled"
elif SemanticMarkType.predicted_no_track.value in frame_mark_types:
tooltip += "\nprediction without track identity"
elif SemanticMarkType.suggested_with_user.value in frame_mark_types:
tooltip += "\nsuggested frame with user labels"
elif SemanticMarkType.suggested_with_nothing.value in frame_mark_types:
tooltip += "\nsuggested frame (no labels)"
elif SemanticMarkType.suggested_with_predicted.value in frame_mark_types:
tooltip += "\nsuggested frame with prediction"
elif "track" in frame_mark_types:
tooltip += "\nprediction with track identity"
lf = labels.find(video, idx)
if lf:
lf = lf[0]
user_instance_count = len(lf.user_instances)
pred_instance_count = len(lf.predicted_instances)
if pred_instance_count:
tooltip += f"\n{pred_instance_count} predicted instance"
if pred_instance_count > 1:
tooltip += "s"
if user_instance_count:
tooltip += f"\n{user_instance_count} user instance"
if user_instance_count > 1:
tooltip += "s"
return tooltip
# Set slider to use this function for getting tooltip text
slider.setTooltipCallable(get_val_tooltip)
##########################################
# Make the slider marks for this dataset #
##########################################
lfs = labels.find(video)
slider_marks = []
track_row = 0
# Add marks with track
track_occupancy = labels.get_track_occupancy(video)
for track in labels.tracks:
if track in track_occupancy and not track_occupancy[track].is_empty:
if track_row > 0 and slider._is_track_in_new_column(track_row):
slider_marks.append(
SliderMark("tick_column", val=track_occupancy[track].start)
)
track_len = track_occupancy[track].end - track_occupancy[track].start
# for debugging we can only show tracks above certain length
if track_len > SEEKBAR_MIN_TRACK_LEN_TO_SHOW:
for occupancy_range in track_occupancy[track].list:
slider_marks.append(
SliderMark(
"track",
val=occupancy_range[0],
end_val=occupancy_range[1],
row=track_row,
color=color_manager.get_track_color(track),
)
)
track_row += 1
# Frames with instance without track
untracked_frames = set()
if None in track_occupancy:
for occupancy_range in track_occupancy[None].list:
untracked_frames.update({val for val in range(*occupancy_range)})
labeled_marks = {lf.frame_idx for lf in lfs}
user_labeled = {lf.frame_idx for lf in lfs if len(lf.user_instances)}
suggested_frames = set(labels.get_video_suggestions(video))
all_simple_frames = set()
all_simple_frames.update(untracked_frames)
all_simple_frames.update(suggested_frames)
all_simple_frames.update(user_labeled)
for frame_idx in all_simple_frames:
if frame_idx in suggested_frames:
if frame_idx in user_labeled:
# suggested frame with user labeled instances
mark_type = SemanticMarkType.suggested_with_user
elif frame_idx in labeled_marks:
# suggested frame with only predicted instances
mark_type = SemanticMarkType.suggested_with_predicted
else:
# suggested frame without any instances
mark_type = SemanticMarkType.suggested_with_nothing
elif frame_idx in user_labeled:
# frame with user labeled instances
mark_type = SemanticMarkType.user
else:
# no user instances, predicted instance without track identity
mark_type = SemanticMarkType.predicted_no_track
mark_type = mark_type.value
slider_marks.append(SliderMark(mark_type, val=frame_idx))
slider.setNumberOfTracks(track_row) # total number of tracks to show
slider.setMarks(slider_marks)
if __name__ == "__main__":
app = QtWidgets.QApplication([])
window = VideoSlider(
min=0,
max=20,
val=15,
marks=(10, 15), # ((0,10),(0,15),(1,10),(1,11),(2,12)), tracks=3
)
window.valueChanged.connect(lambda x: print(x))
window.show()
app.exec_()