fixtracks/fixtracks/widgets/detectiontimeline.py

329 lines
12 KiB
Python

import logging
import numpy as np
import pandas as pd
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QWidget, QVBoxLayout, QSizePolicy, QLabel
from PySide6.QtWidgets import QGraphicsView, QGraphicsScene, QGraphicsItem, QGraphicsRectItem, QGraphicsLineItem
from PySide6.QtCore import Qt, QRectF, QRectF
from PySide6.QtGui import QBrush, QColor, QPen, QFont
from fixtracks.utils.signals import DetectionTimelineSignals
class Window(QGraphicsRectItem):
signals = DetectionTimelineSignals()
def __init__(self, x, y, width, height, pen, brush):
super().__init__(x, y, width, height)
self._width = width
self.setPen(pen)
self.setBrush(brush)
self.setZValue(1.0)
self.setAcceptHoverEvents(True) # Enable hover events if needed
self.setFlags(
QGraphicsItem.ItemIsMovable | # Enables item dragging
QGraphicsItem.ItemIsSelectable # Enables item selection
)
self._y = y
def setWindowX(self, newx):
logging.debug("timeline.window: set position to %.3f", newx)
self.setX(newx)
self.signals.windowMoved.emit()
def setWindowWidth(self, newwidth):
logging.debug("timeline.window: update window width to %f", newwidth)
self._width = newwidth
r = self.rect()
r.setWidth(newwidth)
self.setRect(r)
self.signals.windowMoved.emit()
def setWindow(self, newx:float, newwidth:float):
def setWindow(self, newx: float, newwidth: float):
"""
Update the window to the specified range.
Parameters
----------
newx : float
The new x-coordinate of the window.
newwidth : float
The new width of the window.
Returns
-------
None
"""
logging.debug("timeline.window: update window to range %.5f to %.5f", newx, newwidth)
self._width = newwidth
r = self.rect()
self.setRect(newx, r.y(), self._width, r.height())
self.signals.windowMoved.emit()
def mouseMoveEvent(self, event):
super().mouseMoveEvent(event)
def mousePressEvent(self, event):
self.setCursor(Qt.ClosedHandCursor)
super().mousePressEvent(event)
def mouseReleaseEvent(self, event):
logging.debug("Timeline.Window:MouseRelease event!")
r = self.sceneBoundingRect()
if r.left() < 0:
self.setX(0.)
if r.right() > self.scene().width():
self.setX(self.scene().width() - self._width)
if r.y() != self._y:
self.setY(self._y)
super().mouseReleaseEvent(event)
self.signals.windowMoved.emit()
def hoverEnterEvent(self, event):
super().hoverEnterEvent(event)
class DetectionTimeline(QWidget):
signals = DetectionTimelineSignals()
def __init__(self, detectiondata=None, trackone_id=1, tracktwo_id=2, parent=None):
super().__init__(parent)
self._trackone = trackone_id
self._tracktwo = tracktwo_id
self._data = detectiondata
self._rangeStart = 0.0
self._rangeStop = 0.005
self._total_width = 2000
self._stepCount = 300
self._bg_brush = QBrush(QColor(20, 20, 20, 255))
transparent_brush = QBrush(QColor(200, 200, 200, 64))
self._white_pen = QPen(QColor.fromString("white"))
self._white_pen.setWidth(0.1)
self._t1_pen = QPen(QColor.fromString("orange"))
self._t1_pen.setWidth(2)
self._t2_pen = QPen(QColor(0, 255, 0, 255))
self._t2_pen.setWidth(2)
self._other_pen = QPen(QColor.fromString("red"))
self._other_pen.setWidth(2)
axis_pen = QPen(QColor.fromString("white"))
axis_pen.setWidth(2)
font = QFont()
font.setPointSize(15)
font.setBold(False)
self._window = Window(0, 0, 100, 60, axis_pen, transparent_brush)
self._window.signals.windowMoved.connect(self.on_windowMoved)
self._scene = QGraphicsScene(QRectF(0, 0, self._total_width, 65.))
self._scene.setBackgroundBrush(self._bg_brush)
self._scene.addItem(self._window)
self._view = QGraphicsView()
# self._view.setRenderHints(QPainter.RenderHint.Antialiasing | QPainter.RenderHint.SmoothPixmapTransform);
self._view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._view.setScene(self._scene)
self._view.fitInView(self._scene.sceneRect(), aspectRadioMode=Qt.AspectRatioMode.KeepAspectRatio)
self.fit_scene_to_view()
t1_label = self._scene.addText("track 1", font)
t1_label.setDefaultTextColor(self._t1_pen.color())
t1_label.setPos(0, 50)
t2_label = self._scene.addText("track 2", font)
t2_label.setFont(font)
t2_label.setDefaultTextColor(self._t2_pen.color())
t2_label.setPos(100, 50)
other_label = self._scene.addText("unassigned", font)
other_label.setFont(font)
other_label.setDefaultTextColor(self._other_pen.color())
other_label.setPos(200, 50)
self._position_label = QLabel("")
f = self._position_label.font()
f.setPointSize(9)
self._position_label.setFont(f)
layout = QVBoxLayout()
layout.addWidget(self._view)
layout.addWidget(self._position_label, Qt.AlignmentFlag.AlignRight)
self.setLayout(layout)
if self._data is not None:
self.draw_coverage()
# self.setMaximumHeight(100)
# self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
def setDetectionData(self, data):
self._data = data
for i in self._scene.items():
if isinstance(i, QGraphicsLineItem):
self._scene.removeItem(i)
self.draw_coverage()
def draw_coverage(self):
# FIXME this must be disentangled. timeline should not have to deal with two different ways of data storage
if isinstance(self._data, pd.DataFrame):
maxframe = np.max(self._data.frame.values)
bins = np.linspace(0, maxframe, self._stepCount)
pos = np.linspace(0, self._scene.width(), self._stepCount)
track1_frames = self._data.frame.values[self._data.track == self._trackone]
track2_frames = self._data.frame.values[self._data.track == self._tracktwo]
other_frames = self._data.frame.values[(self._data.track != self._trackone) &
(self._data.track != self._tracktwo)]
elif isinstance(self._data, dict):
maxframe = np.max(self._data["frame"])
bins = np.linspace(0, maxframe, self._stepCount)
pos = np.linspace(0, self._scene.width(), self._stepCount)
track1_frames = self._data["frame"][self._data["track"] == self._trackone]
track2_frames = self._data["frame"][self._data["track"] == self._tracktwo]
other_frames = self._data["frame"][(self._data["track"] != self._trackone) &
(self._data["track"] != self._tracktwo)]
else:
return
t1_coverage, _ = np.histogram(track1_frames, bins=bins)
t1_coverage = t1_coverage > 0
t2_coverage, _ = np.histogram(track2_frames, bins=bins)
t2_coverage = t2_coverage > 0
other_coverage, _ = np.histogram(other_frames, bins=bins)
other_coverage = other_coverage > 0
for i in range(len(t1_coverage)-1):
if t1_coverage[i]: self._scene.addLine(pos[i], 0, pos[i], 15., pen=self._t1_pen)
if t2_coverage[i]: self._scene.addLine(pos[i], 17, pos[i], 32., pen=self._t2_pen)
if other_coverage[i]: self._scene.addLine(pos[i], 34, pos[i], 49., pen=self._other_pen)
def updatePositionLabel(self):
start = np.round(self._rangeStart * 100, 4)
stop = np.round(self._rangeStop * 100, 4)
self._position_label.setText(f"Current position: {start}% to {stop}% of data.")
@property
def rangeStart(self):
return self._rangeStart
@property
def rangeStop(self):
return self._rangeStop
def fit_scene_to_view(self):
"""Scale the image to fit the QGraphicsView."""
self._view.fitInView(self._scene.sceneRect(), Qt.KeepAspectRatio)
def resizeEvent(self, event):
"""Handle window resizing to fit the image."""
super().resizeEvent(event)
self.fit_scene_to_view()
def on_windowMoved(self):
scene_width = self._scene.width()
self._rangeStart = self._window.sceneBoundingRect().left() / scene_width
self._rangeStop = self._window.sceneBoundingRect().right() / scene_width
logging.debug("Timeline: WindowUpdated positions start: %.3f end: %.3f", self.rangeStart, self.rangeStop)
self.updatePositionLabel()
self.signals.windowMoved.emit()
def setWindowPos(self, newx: float):
"""Set the x-position of the selection window.
Parameters
----------
newx : float
The new x position of the selection window given in percent of the data.
"""
if newx < 0.0:
newx = 0.0
elif newx > 1.0:
newx = 1.0
logging.debug("Timeline:setWindow to new position %.4f", newx)
x_rel = np.round(newx * self._total_width)
self._window.setWindowX(x_rel)
def setWindowWidth(self, width: float):
"""Set the width of the selection window.
Parameters
----------
width : float
The width in a range 0.0 to 1.0 (aka 0% to 100% of the span.)
"""
logging.debug("Set window width to new value %.5f of %i total width", width, self._total_width)
span = np.round(width * self._total_width)
self._window.setWindowWidth(np.round(span))
def setWindow(self, xpos:float, width:float):
"""
Set the window position and width.
Parameters
----------
xpos : float
The x position of the window as a fraction of the total data.
Must be between 0.0 and 1.0. Values outside this range will be clamped.
width : float
The width of the window as a fraction of the total data.
Returns
-------
None
"""
if xpos < 0.0:
xpos = 0.0
elif xpos > 1.0:
xpos = 1.0
xstart = xpos * self._total_width
span = width * self._total_width
self._window.setWindow(xstart, span)
def windowBounds(self):
return self._rangeStart, self._rangeStop
def main():
def back(start_x):
view.setWindowPos(start_x)
print(view.windowBounds())
def fwd(start_x):
view.setWindowPos(start_x)
print(view.windowBounds())
def zero():
view.setWindowPos(0.0)
print(view.windowBounds())
import pickle
import numpy as np
from PySide6.QtWidgets import QApplication, QPushButton, QHBoxLayout
from fixtracks.info import PACKAGE_ROOT
logging.basicConfig(level=logging.DEBUG, force=True)
datafile = PACKAGE_ROOT / "data/merged_small.pkl"
with open(datafile, "rb") as f:
df = pickle.load(f)
start_x = 0.1
app = QApplication([])
window = QWidget()
window.setMinimumSize(200, 75)
view = DetectionTimeline(df)
fwdBtn = QPushButton(">>")
fwdBtn.clicked.connect(lambda: fwd(0.5))
zeroBtn = QPushButton("0->|")
zeroBtn.clicked.connect(zero)
backBtn = QPushButton("<<")
backBtn.clicked.connect(lambda: back(0.2))
btnLyt = QHBoxLayout()
btnLyt.addWidget(backBtn)
btnLyt.addWidget(zeroBtn)
btnLyt.addWidget(fwdBtn)
view.setWindowPos(start_x)
layout = QVBoxLayout()
layout.addWidget(view)
layout.addLayout(btnLyt)
window.setLayout(layout)
window.show()
app.exec()
if __name__ == "__main__":
main()