class MainWindow(QMainWindow):
"""The SLEAP GUI application.
Each project (`Labels` dataset) that you have loaded in the GUI will
have its own `MainWindow` object.
Attributes:
labels: The :class:`Labels` dataset. If None, a new, empty project
(i.e., :class:`Labels` object) will be created.
state: Object that holds GUI state, e.g., current video, frame,
whether to show node labels, etc.
"""
def __init__(
self,
labels_path: Optional[str] = None,
labels: Optional[Labels] = None,
reset: bool = False,
no_usage_data: bool = False,
*args,
**kwargs,
):
"""Initialize the app.
Args:
labels_path: Path to saved :class:`Labels` dataset.
reset: If `True`, reset preferences to default (including window state).
no_usage_data: If `True`, launch GUI without sharing usage data regardless
of stored preferences.
"""
super(MainWindow, self).__init__(*args, **kwargs)
self.setAcceptDrops(True)
self.state = GuiState()
self.labels = labels or Labels()
self.commands = CommandContext(
state=self.state, app=self, update_callback=self.on_data_update
)
self.shortcuts = Shortcuts()
self._menu_actions = dict()
self._buttons = dict()
self._child_windows = dict()
self.overlays = dict()
self.state.connect("filename", self.setWindowTitle)
self.state["skeleton"] = Skeleton()
self.state["labeled_frame"] = None
self.state["last_interacted_frame"] = None
self.state["filename"] = None
self.state["show non-visible nodes"] = prefs["show non-visible nodes"]
self.state["show mean node score"] = prefs["show mean node score"]
self.state["show instances"] = True
self.state["show labels"] = True
self.state["show edges"] = True
# Transient per-instance canvas visibility (Instances dock checkboxes:
# hidden set, view-only instance, and per-instance "show non-visible
# nodes" override). Reset on each real frame change in
# `_after_plot_change`; never persisted.
self.state[INSTANCE_HIDDEN_KEY] = set()
self.state[VIEW_ONLY_INSTANCE_KEY] = None
self.state[SHOW_NONVISIBLE_OVERRIDE_KEY] = {}
# Label QC "display mode" (#2783): a transient, session-only review aid.
# It always starts in "manual" (normal view) and is intentionally NOT
# persisted across launches -- a selection-relative mode that hides
# instances would look like a bug on the next startup. "manual" keeps the
# Instances-dock columns in control; other modes drive the transient keys
# above. Not reset on frame change (the mode persists within a session).
self.state[QC_DISPLAY_MODE_KEY] = QC_MODE_MANUAL
# (video, frame_idx) of the last plotted frame, so `_after_plot_change`
# clears the transient visibility above only when the frame truly changes
# (not on same-frame replots like marker-size or add-instance).
self._vis_last_frame_key = None
self.state["edge style"] = prefs["edge style"]
self.state["fit"] = False
self.state["fit_selection"] = False
self.state["actual_size"] = False
self.state["color predicted"] = prefs["color predicted"]
self.state["trail_length"] = prefs["trail length"]
self.state["trail_node"] = prefs["trail node"]
self.state["trail_alpha"] = prefs["trail alpha"]
self.state["trail_alpha_fade"] = prefs["trail alpha fade"]
self.state["marker size"] = prefs["marker size"]
self.state["propagate track labels"] = prefs["propagate track labels"]
self.state["node label size"] = prefs["node label size"]
self.state["share usage data"] = prefs["share usage data"]
self.state["experimental features"] = False
self.state["skeleton_preview_image"] = None
self.state["skeleton_description"] = "No skeleton loaded yet"
if no_usage_data:
self.state["share usage data"] = False
self.state["clipboard_track"] = None
self.state["clipboard_instance"] = None
self.state.connect("marker size", self.plotFrame)
self.state.connect("node label size", self.plotFrame)
self.state.connect("show non-visible nodes", self._on_show_non_visible_toggled)
# Label QC display mode (#2783): a non-manual mode derives the
# per-instance visibility from the mode + selection and replots; switching
# back to "manual" clears the mode-driven state. Selection changes are
# followed only in a non-manual mode -- in the default "manual" mode
# selecting an instance stays lightweight (no replot), as before.
self.state.connect(QC_DISPLAY_MODE_KEY, self._on_qc_display_mode_changed)
self.state.connect("instance", self._on_qc_selection_changed)
if self.state["share usage data"]:
ping_analytics()
self._initialize_gui()
if reset:
print("Reseting GUI state and preferences...")
prefs.reset_to_default()
elif len(prefs["window state"]) > 0:
print("Restoring GUI state...")
self.restoreState(prefs["window state"])
if labels_path is not None:
self.commands.loadProjectFile(filename=labels_path)
elif labels is not None:
self.commands.loadLabelsObject(labels=labels)
else:
self.state["project_loaded"] = False
def setWindowTitle(self, value):
"""Sets window title (if value is not None)."""
if value is not None:
super(MainWindow, self).setWindowTitle(
f"{value} - SLEAP v{sleap.version.__version__}"
)
def event(self, e: QEvent) -> bool:
"""Custom event handler.
We use this to ignore events that would clear status bar.
Args:
e: The event.
Returns:
True if we ignore event, otherwise returns whatever the usual
event handler would return.
"""
if e.type() == QEvent.StatusTip:
if e.tip() == "":
return True
return super().event(e)
def closeEvent(self, event):
"""Close application window, prompting for saving as needed."""
# Clean up video player resources BEFORE saving preferences.
# This prevents a semaphore leak that occurs when restoreState() is used.
# The leak happens because restoreState() interferes with proper cleanup
# of the multiprocessing.RLock in MediaVideo.
if hasattr(self, "player"):
# Explicitly close the video to release its resources
if hasattr(self.player, "video") and self.player.video is not None:
self.player.video.close()
self.player.video = None
# Stop the worker thread
if hasattr(self.player, "cleanup"):
self.player.cleanup()
# Save window state.
prefs["window state"] = self.saveState()
prefs["marker size"] = self.state["marker size"]
prefs["show non-visible nodes"] = self.state["show non-visible nodes"]
prefs["show mean node score"] = self.state["show mean node score"]
prefs["node label size"] = self.state["node label size"]
prefs["edge style"] = self.state["edge style"]
prefs["propagate track labels"] = self.state["propagate track labels"]
prefs["color predicted"] = self.state["color predicted"]
prefs["trail length"] = self.state["trail_length"]
prefs["trail node"] = self.state["trail_node"]
prefs["trail alpha"] = self.state["trail_alpha"]
prefs["trail alpha fade"] = self.state["trail_alpha_fade"]
prefs["share usage data"] = self.state["share usage data"]
# Save preferences.
prefs.save()
if not self.state["has_changes"]:
# No unsaved changes, so accept event (close)
event.accept()
else:
msgBox = QMessageBox()
msgBox.setText("Do you want to save the changes to this project?")
msgBox.setInformativeText("If you don't save, your changes will be lost.")
msgBox.setStandardButtons(
QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel
)
msgBox.setDefaultButton(QMessageBox.Save)
ret_val = msgBox.exec_()
if ret_val == QMessageBox.Cancel:
# cancel close by ignoring event
event.ignore()
elif ret_val == QMessageBox.Discard:
# don't save, just close
event.accept()
elif ret_val == QMessageBox.Save:
# save
self.commands.saveProject()
# accept event (closes window)
event.accept()
def dragEnterEvent(self, event):
# Accept the drag if it carries file URLs. Files dropped from a file
# manager are exposed as a "text/uri-list" payload on all platforms
# (Linux/macOS/Windows), which is what dropEvent() parses below.
# (Previously this only accepted a Windows-specific MIME type, so
# drag-and-drop silently did nothing on Linux and macOS.)
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dragMoveEvent(self, event):
# Keep showing the "accept" cursor while a valid drag hovers the window.
if event.mimeData().hasUrls():
event.acceptProposedAction()
def dropEvent(self, event):
if not event.mimeData().hasUrls():
return
# Parse filenames
filenames = event.mimeData().data("text/uri-list").data().decode()
filenames = [parse_uri_path(f.strip()) for f in filenames.strip().split("\n")]
filenames = [f for f in filenames if f]
exts = [Path(f).suffix for f in filenames]
if len(exts) == 1 and exts[0].lower() == ".slp":
if self.state["project_loaded"]:
# Merge
self.commands.mergeProject(filenames=filenames)
else:
# Load
self.commands.openProject(filename=filenames[0], first_open=True)
elif exts and all(ext.lower()[1:] in available_video_exts() for ext in exts):
# Import videos
self.commands.showImportVideos(filenames=filenames)
else:
dropped = ", ".join(exts) or "(unknown)"
QMessageBox.warning(
self,
"Unsupported file type",
f"Couldn't open the dropped file(s): {dropped}\n\n"
f"Supported formats: .slp, .{', .'.join(available_video_exts())}",
)
return
event.acceptProposedAction()
@property
def labels(self) -> Labels:
return self.state["labels"]
@labels.setter
def labels(self, value):
self.state["labels"] = value
def _initialize_gui(self):
"""Creates menus, dock windows, starts timers to update gui state."""
self._create_color_manager()
self._create_video_player()
self.statusBar()
self._create_menus()
self._create_dock_windows()
self._load_overlays()
# Create timer to update state of gui at 20 millisec. intervals
self.update_gui_timer = QtCore.QTimer()
self.update_gui_timer.timeout.connect(self._update_gui_state)
self.update_gui_timer.start(20)
def _create_video_player(self):
"""Creates and connects :class:`QtVideoPlayer` for gui."""
self.player = QtVideoPlayer(
color_manager=self.color_manager, state=self.state, context=self.commands
)
self.player.changedPlot.connect(self._after_plot_change)
self.player.updatedPlot.connect(self._after_plot_update)
self.player.view.instanceDoubleClicked.connect(
self._handle_instance_double_click
)
self.player.seekbar.selectionChanged.connect(lambda: self.updateStatusMessage())
self.setCentralWidget(self.player)
def switch_frame(video):
"""Jump to last labeled frame"""
last_label = find_last(self.labels, video)
if last_label is not None:
self.state["frame_idx"] = last_label.frame_idx
else:
self.state["frame_idx"] = 0
def update_frame_chunk_suggestions(video):
"""Set upper limit of frame_chunk spinbox to number frames in video."""
method_layout = (
self.suggestions_dock.suggestions_form_widget.form_layout.fields[
"method"
]
)
frame_chunk_layout = method_layout.page_layouts["frame chunk"]
frame_to_spinbox = frame_chunk_layout.fields["frame_to"]
frame_from_spinbox = frame_chunk_layout.fields["frame_from"]
if video is not None:
frame_to_spinbox.setMaximum(len(video))
frame_from_spinbox.setMaximum(len(video))
self.state.connect(
"video",
callbacks=[
switch_frame,
lambda x: self._update_seekbar_marks(),
update_frame_chunk_suggestions,
],
)
def _create_color_manager(self):
self.color_manager = ColorManager(self.labels)
self.color_manager.palette = self.state.get("palette", default="standard")
def _create_menus(self):
"""Creates main application menus."""
# shortcuts = Shortcuts()
# add basic menu item
def add_menu_item(menu, key: str, name: str, action: Callable):
menu_item = menu.addAction(name, action, self.shortcuts[key])
self._menu_actions[key] = menu_item
return menu_item
# set menu checkmarks
def connect_check(key):
self._menu_actions[key].setCheckable(True)
self._menu_actions[key].setChecked(self.state[key])
self.state.connect(
key, lambda checked: self._menu_actions[key].setChecked(checked)
)
# add checkable menu item connected to state variable
def add_menu_check_item(menu, key: str, name: str):
menu_item = add_menu_item(menu, key, name, lambda: self.state.toggle(key))
connect_check(key)
return menu_item
# check and uncheck submenu items
def _menu_check_single(menu, item_text):
"""Helper method to select exactly one submenu item."""
for menu_item in menu.actions():
if menu_item.text() == str(item_text):
menu_item.setChecked(True)
else:
menu_item.setChecked(False)
# add submenu with checkable items
def add_submenu_choices(menu, title, options, key):
submenu = menu.addMenu(title)
self.state.connect(key, lambda x: _menu_check_single(submenu, x))
for option in options:
submenu_item = submenu.addAction(
f"{option}", lambda x=option: self.state.set(key, x)
)
submenu_item.setCheckable(True)
self.state.emit(key)
### File Menu ###
fileMenu = self.menuBar().addMenu("File")
add_menu_item(fileMenu, "new", "New Project", self.commands.newProject)
add_menu_item(fileMenu, "open", "Open Project...", self.commands.openProject)
import_types_menu = fileMenu.addMenu("Import...")
add_menu_item(
import_types_menu,
"import_coco",
"COCO dataset...",
self.commands.importCoco,
)
add_menu_item(
import_types_menu,
"import_dlc",
"DeepLabCut dataset...",
self.commands.importDLC,
)
add_menu_item(
import_types_menu,
"import_dlc_folder",
"Multiple DeepLabCut datasets from folder...",
self.commands.importDLCFolder,
)
add_menu_item(
import_types_menu,
"import_nwb",
"NWB dataset...",
self.commands.importNWB,
)
add_menu_item(
import_types_menu,
"import_analysis",
"SLEAP Analysis HDF5...",
self.commands.importAnalysisFile,
)
add_menu_item(
fileMenu,
"import predictions",
"Merge into Project...",
self.commands.mergeProject,
)
fileMenu.addSeparator()
add_menu_item(fileMenu, "add videos", "Add Videos...", self.commands.addVideo)
add_menu_item(
fileMenu, "replace videos", "Replace Videos...", self.commands.replaceVideo
)
fileMenu.addSeparator()
add_menu_item(fileMenu, "save", "Save", self.commands.saveProject)
add_menu_item(fileMenu, "save as", "Save As...", self.commands.saveProjectAs)
export_analysis_menu = fileMenu.addMenu("Export Analysis HDF5...")
add_menu_item(
export_analysis_menu,
"export_analysis_current",
"Current Video...",
self.commands.exportAnalysisFile,
)
add_menu_item(
export_analysis_menu,
"export_analysis_video",
"All Videos...",
lambda: self.commands.exportAnalysisFile(all_videos=True),
)
export_csv_menu = fileMenu.addMenu("Export Analysis CSV...")
add_menu_item(
export_csv_menu,
"export_csv_current",
"Current Video...",
self.commands.exportCSVFile,
)
add_menu_item(
export_csv_menu,
"export_csv_all",
"All Videos...",
lambda: self.commands.exportCSVFile(all_videos=True),
)
add_menu_item(fileMenu, "export_nwb", "Export NWB...", self.commands.exportNWB)
fileMenu.addSeparator()
add_menu_item(
fileMenu, "reset prefs", "Reset preferences to defaults...", self.resetPrefs
)
add_menu_item(
fileMenu,
"open preference directory",
"Open Preferences Directory...",
self.openPrefs,
)
fileMenu.addSeparator()
add_menu_item(fileMenu, "close", "Quit", self.close)
### Go Menu ###
goMenu = self.menuBar().addMenu("Go")
add_menu_item(
goMenu,
"goto next labeled",
"Next Labeled Frame",
self.commands.nextLabeledFrame,
)
add_menu_item(
goMenu,
"goto prev labeled",
"Previous Labeled Frame",
self.commands.previousLabeledFrame,
)
add_menu_item(
goMenu,
"goto last interacted",
"Last Interacted Frame",
self.commands.lastInteractedFrame,
)
add_menu_item(
goMenu,
"goto next user",
"Next User Labeled Frame",
self.commands.nextUserLabeledFrame,
)
add_menu_item(
goMenu,
"goto prev user",
"Previous User Labeled Frame",
self.commands.prevUserLabeledFrame,
)
add_menu_item(
goMenu,
"goto next suggestion",
"Next Suggestion",
self._goto_next_suggestion_or_flag,
)
add_menu_item(
goMenu,
"goto prev suggestion",
"Previous Suggestion",
self._goto_prev_suggestion_or_flag,
)
add_menu_item(
goMenu,
"goto next track spawn",
"Next Track Spawn Frame",
self.commands.nextTrackFrame,
)
goMenu.addSeparator()
def next_vid():
self.state.increment_in_list("video", self.labels.videos)
def prev_vid():
self.state.increment_in_list("video", self.labels.videos, reverse=True)
add_menu_item(goMenu, "next video", "Next Video", next_vid)
add_menu_item(goMenu, "prev video", "Previous Video", prev_vid)
goMenu.addSeparator()
add_menu_item(goMenu, "goto frame", "Go to Frame...", self.commands.gotoFrame)
add_menu_item(
goMenu, "select to frame", "Select to Frame...", self.commands.selectToFrame
)
goMenu.addSeparator()
add_menu_item(
goMenu,
"select next",
"Select Next Instance",
lambda: self.state.increment_in_list(
"instance", get_instances_to_show(self.state["labeled_frame"])
),
)
add_menu_item(
goMenu,
"clear selection",
"Clear Selection",
lambda: self.state.set("instance", None),
)
### View Menu ###
viewMenu = self.menuBar().addMenu("View")
self.viewMenu = viewMenu # store as attribute so docks can add items
viewMenu.setToolTipsVisible(True)
viewMenu.addSeparator()
add_menu_check_item(viewMenu, "fit", "Fit View to Instances")
add_menu_check_item(viewMenu, "fit_selection", "Fit View to Selection")
add_menu_check_item(viewMenu, "actual_size", "Actual Size (1:1)")
# Make fit, fit_selection, and actual_size mutually exclusive
def _on_fit_changed(value):
if value:
self.state["fit_selection"] = False
self.state["actual_size"] = False
def _on_fit_selection_changed(value):
if value:
self.state["fit"] = False
self.state["actual_size"] = False
def _on_actual_size_changed(value):
if value:
self.state["fit"] = False
self.state["fit_selection"] = False
self.player.zoomToActualSize()
else:
self.player.view.clearZoom()
self.player.view.updateViewer()
self.state.connect("fit", _on_fit_changed)
self.state.connect("fit_selection", _on_fit_selection_changed)
self.state.connect("actual_size", _on_actual_size_changed)
viewMenu.addSeparator()
add_menu_check_item(viewMenu, "color predicted", "Color Predicted Instances")
add_submenu_choices(
menu=viewMenu,
title="Color Palette",
options=self.color_manager.palette_names,
key="palette",
)
distinctly_color_options = ("instances", "nodes", "edges")
add_submenu_choices(
menu=viewMenu,
title="Apply Distinct Colors To",
options=distinctly_color_options,
key="distinctly_color",
)
self.state["palette"] = prefs["palette"]
self.state["distinctly_color"] = "instances"
viewMenu.addSeparator()
add_menu_check_item(viewMenu, "show instances", "Show Instances")
add_menu_check_item(
viewMenu, "show non-visible nodes", "Show Non-Visible Nodes"
)
add_menu_check_item(viewMenu, "show labels", "Show Node Names")
add_menu_check_item(viewMenu, "show edges", "Show Edges")
add_menu_check_item(viewMenu, "show mean node score", "Show Mean Node Score")
# Instance-focus display-mode selector (#2783), mirrored from the QC
# dock's "Display:" combo so the modes are reachable from the menu too.
# Kept in sync with QC_DISPLAY_MODE_KEY: menu clicks set it; external
# changes (e.g. the dock combo) re-check the matching item.
qc_display_menu = viewMenu.addMenu("Instance Focus")
self._qc_display_actions = {}
for _label, _mode in QC_MODE_CHOICES:
_act = qc_display_menu.addAction(
_label, lambda m=_mode: self.state.set(QC_DISPLAY_MODE_KEY, m)
)
_act.setCheckable(True)
self._qc_display_actions[_mode] = _act
def _sync_qc_display_menu(mode):
for _m, _a in self._qc_display_actions.items():
_a.setChecked(_m == mode)
self.state.connect(QC_DISPLAY_MODE_KEY, _sync_qc_display_menu)
self.state.emit(QC_DISPLAY_MODE_KEY)
add_submenu_choices(
menu=viewMenu,
title="Edge Style",
options=("Line", "Wedge"),
key="edge style",
)
# XXX
add_submenu_choices(
menu=viewMenu,
title="Node Marker Size",
options=prefs["node marker sizes"],
key="marker size",
)
add_submenu_choices(
menu=viewMenu,
title="Node Label Size",
options=prefs["node label sizes"],
key="node label size",
)
viewMenu.addSeparator()
add_submenu_choices(
menu=viewMenu,
title="Trail Length",
options=TrackTrailOverlay.get_length_options(),
key="trail_length",
)
self.trail_node_menu = viewMenu.addMenu("Trail Node")
self.trail_node_menu.setToolTip(
"Which point the trail follows: the instance centroid, or a named "
"skeleton node.\n\n"
"Trails now render for untracked / single-instance data too. "
"Without tracks, trail color follows each frame's instance order "
"rather than a stable identity, so colors may shift between frames."
)
self._update_trail_node_menu()
self.state.connect("trail_node", self._sync_trail_node_menu)
add_menu_check_item(viewMenu, "trail_alpha_fade", "Fade Older Trail Segments")
add_submenu_choices(
menu=viewMenu,
title="Trail Opacity",
options=(0.25, 0.5, 0.75, 1.0),
key="trail_alpha",
)
viewMenu.addSeparator()
add_menu_item(
viewMenu,
"export clip",
"Render Video Clip with Instances...",
self.commands.exportLabeledClip,
)
viewMenu.addSeparator()
### Label Menu ###
instance_adding_methods = dict(
best="Best",
template="Average Instance",
force_directed="Force Directed",
random="Random",
prior_frame="Copy prior frame",
prediction="Copy predictions",
)
def new_instance_menu_action():
"""Determine which action to use when using Ctrl + I or menu Add Instance.
We always add an offset of 10.
"""
method_key = [
key
for (key, val) in instance_adding_methods.items()
if val == self.state["instance_init_method"]
]
if method_key:
self.commands.newInstance(init_method=method_key[0], offset=10)
labelMenu = self.menuBar().addMenu("Labels")
add_menu_item(
labelMenu, "add instance", "Add Instance", new_instance_menu_action
)
add_submenu_choices(
menu=labelMenu,
title="Instance Placement Method",
options=instance_adding_methods.values(),
key="instance_init_method",
)
self.state["instance_init_method"] = instance_adding_methods["best"]
add_menu_item(
labelMenu,
"delete instance",
"Delete Instance",
self.commands.deleteSelectedInstance,
)
add_menu_item(
labelMenu,
"merge instance",
"Merge Instance",
lambda: self.commands.mergeInstance(),
)
add_menu_item(
labelMenu,
"custom delete",
"Custom Instance Delete...",
self.commands.deleteDialog,
)
labelMenu.addSeparator()
self.negative_frame_action = labelMenu.addAction(
"Mark Frame as Negative",
self.commands.toggleCurrentFrameNegative,
self.shortcuts["mark negative"],
)
self.negative_frame_action.setCheckable(True)
self.negative_frame_action.setToolTip(
"Mark this frame as a negative (background) frame with no animals, "
"used as a training example to reduce false positives."
)
labelMenu.addSeparator()
add_menu_item(
labelMenu,
"extract clip and labels",
"Extract Clip and Labels...",
lambda: self.commands.exportLabelsSubset(as_package=False),
)
add_menu_item(
labelMenu,
"extract clip labels package",
"Extract Clip Labels Package...",
lambda: self.commands.exportLabelsSubset(as_package=True),
)
labelMenu.addSeparator()
add_menu_item(
labelMenu,
"add instances from all frame predictions",
"Add Instances from All Predictions on Current Frame",
self.commands.addUserInstancesFromPredictions,
)
add_menu_item(
labelMenu,
"accept all predictions",
"Accept All Predictions...",
self.commands.addUserInstancesFromAllPredictions,
)
labelMenu.addSeparator()
labelMenu.addAction(
"Copy Instance",
self.commands.copyInstance,
Qt.CTRL | Qt.Key_C,
)
labelMenu.addAction(
"Paste Instance",
self.commands.pasteInstance,
Qt.CTRL | Qt.Key_V,
)
labelMenu.addSeparator()
add_menu_item(
labelMenu,
"delete frame predictions",
"Delete Predictions on Current Frame",
self.commands.deleteFramePredictions,
)
add_menu_item(
labelMenu,
"delete all predictions",
"Delete All Predictions...",
self.commands.deletePredictions,
)
add_menu_item(
labelMenu,
"delete clip predictions",
"Delete Predictions from Clip...",
self.commands.deleteClipPredictions,
)
add_menu_item(
labelMenu,
"delete area predictions",
"Delete Predictions from Area...",
self.commands.deleteAreaPredictions,
)
add_menu_item(
labelMenu,
"delete score predictions",
"Delete Predictions with Low Score...",
self.commands.deleteLowScorePredictions,
)
add_menu_item(
labelMenu,
"delete max instance predictions",
"Delete Predictions beyond Max Instances...",
self.commands.deleteInstanceLimitPredictions,
)
add_menu_item(
labelMenu,
"delete frame limit predictions",
"Delete Predictions beyond Frame Limit...",
self.commands.deleteFrameLimitPredictions,
)
add_menu_item(
labelMenu,
"delete user frame predictions",
"Delete Predictions on User-Labeled Frames...",
self.commands.deleteUserFramePredictions,
)
### Analyze Menu ###
analyzeMenu = self.menuBar().addMenu("Analyze")
analyzeMenu.addAction(
"Instance Size Distribution...", self._open_size_distribution
)
analyzeMenu.addAction("Label QC...", self._open_label_qc)
### Tracks Menu ###
tracksMenu = self.menuBar().addMenu("Tracks")
self.track_menu = tracksMenu.addMenu("Set Instance Track")
add_menu_check_item(
tracksMenu, "propagate track labels", "Propagate Track Labels"
).setToolTip(
"If enabled, setting a track will also apply to subsequent "
"instances of the same track."
)
add_menu_item(
tracksMenu,
"transpose",
"Transpose Instance Tracks",
self.commands.transposeInstance,
)
tracksMenu.addSeparator()
add_menu_item(
tracksMenu,
"delete track",
"Delete Instance and Track",
self.commands.deleteSelectedInstanceTrack,
)
self.delete_tracks_menu = tracksMenu.addMenu("Delete Track")
self.delete_tracks_menu.setEnabled(False)
self.delete_multiple_tracks_menu = tracksMenu.addMenu("Delete Multiple Tracks")
self.delete_multiple_tracks_menu.setToolTip(
"Delete either only 'Unused' tracks or 'All' tracks, and update "
"instances. Instances are not removed."
)
add_menu_item(
self.delete_multiple_tracks_menu,
"delete unused tracks",
"Unused",
lambda: self.commands.deleteMultipleTracks(delete_all=False),
)
add_menu_item(
self.delete_multiple_tracks_menu,
"delete all tracks",
"All",
lambda: self.commands.deleteMultipleTracks(delete_all=True),
)
tracksMenu.addSeparator()
tracksMenu.addAction(
"Copy Instance Track",
self.commands.copyInstanceTrack,
Qt.CTRL | Qt.SHIFT | Qt.Key_C,
)
tracksMenu.addAction(
"Paste Instance Track",
self.commands.pasteInstanceTrack,
Qt.CTRL | Qt.SHIFT | Qt.Key_V,
)
tracksMenu.addSeparator()
seekbar_header_options = (
"None",
"Point Displacement (sum)",
"Point Displacement (max)",
"Primary Point Displacement (sum)",
"Primary Point Displacement (max)",
"Tracking Score (mean)",
"Tracking Score (min)",
"Instance Score (sum)",
"Instance Score (min)",
"Point Score (sum)",
"Point Score (min)",
"Number of predicted points",
"Min Centroid Proximity",
)
add_submenu_choices(
menu=tracksMenu,
title="Seekbar Header",
options=seekbar_header_options,
key="seekbar_header",
)
self.state["seekbar_header"] = "None"
self.state.connect("seekbar_header", self._set_seekbar_header)
### Predict Menu ###
predictionMenu = self.menuBar().addMenu("Predict")
predictionMenu.setToolTipsVisible(True)
add_menu_item(
predictionMenu,
"training",
"Run Training...",
lambda: self._show_learning_dialog("training"),
)
add_menu_item(
predictionMenu,
"inference",
"Run Inference...",
lambda: self._show_learning_dialog("inference"),
)
predictionMenu.addSeparator()
add_menu_item(
predictionMenu,
"show metrics",
"Evaluation Metrics for Trained Models...",
self._show_metrics_dialog,
)
predictionMenu.addSeparator()
labels_package_menu = predictionMenu.addMenu("Export Labels Package...")
add_menu_item(
labels_package_menu,
"export user labels package",
"Labeled frames",
self.commands.exportUserLabelsPackage,
).setToolTip(
"Export user-labeled frames with image data into a single SLP file.\n\n"
"Use this for archiving a dataset with labeled frames only."
)
add_menu_item(
labels_package_menu,
"export labels package",
"Labeled + suggested frames (recommended)",
self.commands.exportTrainingPackage,
).setToolTip(
"Export user-labeled frames and suggested frames with image data into a "
"single SLP file.\n\n"
"Use this for human-in-the-loop training to enable remote inference on "
"unlabeled frames."
)
add_menu_item(
labels_package_menu,
"export full package",
"Labeled + predicted + suggested frames",
self.commands.exportFullPackage,
).setToolTip(
"Export all frames (including predictions) and suggested frames with image "
"data into a single SLP file.\n\n"
"Use this when you need to store images for predicted frames, such as for "
"proofreading or reproducibility."
)
predictionMenu.addSeparator()
add_menu_item(
predictionMenu,
"training on colab",
"Train on Google Colab...",
lambda: self.commands.openWebsite(
"https://colab.research.google.com/github/talmolab/sleap/blob/develop/docs/notebooks/Training_and_inference_using_Google_Drive.ipynb"
),
)
############
helpMenu = self.menuBar().addMenu("Help")
helpMenu.addAction(
"Documentation", lambda: self.commands.openWebsite("https://sleap.ai")
)
helpMenu.addAction(
"GitHub",
lambda: self.commands.openWebsite("https://github.com/talmolab/sleap"),
)
helpMenu.addAction(
"Releases",
lambda: self.commands.openWebsite(
"https://github.com/talmolab/sleap/releases"
),
)
helpMenu.addSeparator()
helpMenu.addAction("Check for Updates...", self._show_update_checker_dialog)
helpMenu.addSeparator()
usageMenu = helpMenu.addMenu("Improve SLEAP")
add_menu_check_item(usageMenu, "share usage data", "Share usage data")
usageMenu.addAction(
"What is usage data?",
lambda: self.commands.openWebsite(
"https://docs.sleap.ai/latest/help/#usage"
),
)
helpMenu.addSeparator()
helpMenu.addAction("Keyboard Shortcuts", self._show_keyboard_shortcuts_window)
add_menu_check_item(helpMenu, "experimental features", "Experimental Features")
def process_events_then(self, action: Callable):
"""Decorates a function with a call to first process events."""
def wrapped_function(*args):
QApplication.instance().processEvents()
action(*args)
return wrapped_function
def _create_dock_windows(self):
"""Create dock windows and connect them to GUI."""
self.videos_dock = VideosDock(self)
self.skeleton_dock = SkeletonDock(self, tab_with=self.videos_dock)
self.suggestions_dock = SuggestionsDock(self, tab_with=self.videos_dock)
self.instances_dock = InstancesDock(self, tab_with=self.videos_dock)
# Create QC dock (hidden by default, shown when user clicks menu item)
self._create_qc_dock()
# Bring videos tab forward.
self.videos_dock.wgt_layout.parent().parent().raise_()
def _create_qc_dock(self):
"""Create the QC dock widget (hidden by default)."""
from sleap.gui.dialogs.qc import QCDockWidget
def navigate_callback(video_idx: int, frame_idx: int, instance_idx: int):
"""Navigate to the specified frame and highlight instance."""
if self.labels is not None and video_idx < len(self.labels.videos):
video = self.labels.videos[video_idx]
self.commands.gotoVideoAndFrameAndInstance(
video, frame_idx, instance_idx
)
# Create the dock widget (with no labels initially)
self._qc_dock = QCDockWidget(
labels=None,
navigate_callback=navigate_callback,
parent=self,
)
# Add to main window's dock area on the right side
self.addDockWidget(Qt.RightDockWidgetArea, self._qc_dock)
# Tabify with other docks on the right side (after instances_dock to be last)
self.tabifyDockWidget(self.instances_dock, self._qc_dock)
# Add toggle action to View menu
self.viewMenu.addAction(self._qc_dock.toggleViewAction())
# Start hidden (closed) - user opens via Analyze menu
self._qc_dock.hide()
def _load_overlays(self):
"""Load all standard video overlays."""
self.overlays["track_labels"] = TrackListOverlay(
labels=self.labels, player=self.player
)
self.overlays["trails"] = TrackTrailOverlay(
labels=self.labels,
player=self.player,
trail_length=self.state["trail_length"],
trail_node=self.state["trail_node"],
trail_alpha=self.state["trail_alpha"],
trail_alpha_fade=self.state["trail_alpha_fade"],
)
self.overlays["instance"] = InstanceOverlay(
labels=self.labels, player=self.player, state=self.state
)
self.overlays["negative_frame"] = NegativeFrameOverlay(
labels=self.labels, player=self.player
)
# When gui state changes, we also want to set corresponding attribute
# on overlay (or color manager shared by overlays) so that they can
# update/redraw as needed.
def overlay_state_connect(overlay, state_key, overlay_attribute=None):
overlay_attribute = overlay_attribute or state_key
self.state.connect(
state_key,
callbacks=[
lambda x: setattr(overlay, overlay_attribute, x),
self.plotFrame,
],
)
overlay_state_connect(self.overlays["trails"], "trail_length")
overlay_state_connect(self.overlays["trails"], "trail_node")
overlay_state_connect(self.overlays["trails"], "trail_alpha")
overlay_state_connect(self.overlays["trails"], "trail_alpha_fade")
overlay_state_connect(self.color_manager, "palette")
overlay_state_connect(self.color_manager, "distinctly_color")
overlay_state_connect(self.color_manager, "color predicted", "color_predicted")
self.state.connect("palette", lambda x: self._update_seekbar_marks())
# update the skeleton tables since we may want to redraw colors
for state_var in ("palette", "distinctly_color", "edge style"):
self.state.connect(
state_var, lambda x: self.on_data_update([UpdateTopic.skeleton])
)
# Set defaults
self.state["trail_length"] = prefs["trail length"]
# Emit signals for default that may have been set earlier
self.state.emit("palette")
self.state.emit("distinctly_color")
self.state.emit("color predicted")
def _update_gui_state(self):
"""Enable/disable GUI items based on the current state."""
has_selected_instance = self.state["instance"] is not None
has_selected_node = self.state["selected_node"] is not None
has_selected_edge = self.state["selected_edge"] is not None
has_selected_video = self.state["selected_video"] is not None
has_video = self.state["video"] is not None
has_frame_range = bool(self.state["has_frame_range"])
has_unsaved_changes = bool(self.state["has_changes"])
has_videos = self.labels is not None and len(self.labels.videos) > 0
has_multiple_videos = self.labels is not None and len(self.labels.videos) > 1
has_labeled_frames = self.labels is not None and any(
(lf.video == self.state["video"] for lf in self.labels)
)
has_suggestions = self.labels is not None and bool(self.labels.suggestions)
has_tracks = self.labels is not None and (len(self.labels.tracks) > 0)
has_multiple_instances = (
self.state["labeled_frame"] is not None
and len(self.state["labeled_frame"].instances) > 1
)
# Merge requires at least two *user* instances (predicted excluded).
has_multiple_user_instances = (
self.state["labeled_frame"] is not None
and len(self.state["labeled_frame"].user_instances) > 1
)
# todo: exclude predicted instances from count
has_nodes_selected = (
self.skeleton_dock.skeletonEdgesSrc.currentIndex() > -1
and self.skeleton_dock.skeletonEdgesDst.currentIndex() > -1
)
control_key_down = QApplication.queryKeyboardModifiers() == Qt.ControlModifier
# Update menus
self.track_menu.setEnabled(has_selected_instance)
self.delete_tracks_menu.setEnabled(has_tracks)
self._menu_actions["clear selection"].setEnabled(has_selected_instance)
self._menu_actions["delete instance"].setEnabled(has_selected_instance)
self._menu_actions["delete clip predictions"].setEnabled(has_frame_range)
# Enable/disable "Extract Clip and Labels" and "Extract Clip Labels Package"
self._menu_actions["extract clip and labels"].setEnabled(has_frame_range)
self._menu_actions["extract clip labels package"].setEnabled(has_frame_range)
self._menu_actions["transpose"].setEnabled(has_multiple_instances)
self._menu_actions["merge instance"].setEnabled(has_multiple_user_instances)
self._menu_actions["save"].setEnabled(has_unsaved_changes)
self._menu_actions["next video"].setEnabled(has_multiple_videos)
self._menu_actions["prev video"].setEnabled(has_multiple_videos)
self._menu_actions["goto next labeled"].setEnabled(has_labeled_frames)
self._menu_actions["goto prev labeled"].setEnabled(has_labeled_frames)
# Enable suggestion navigation if there are suggestions OR QC flags
has_qc_flags = hasattr(self, "_qc_dock") and self._qc_dock.has_flags
has_nav_targets = has_suggestions or has_qc_flags
self._menu_actions["goto next suggestion"].setEnabled(has_nav_targets)
self._menu_actions["goto prev suggestion"].setEnabled(has_nav_targets)
self._menu_actions["goto next track spawn"].setEnabled(has_tracks)
# Update buttons
self._buttons["add edge"].setEnabled(has_nodes_selected)
self._buttons["delete edge"].setEnabled(has_selected_edge)
self._buttons["delete node"].setEnabled(has_selected_node)
self._buttons["toggle grayscale"].setEnabled(has_video)
self._buttons["show video"].setEnabled(has_selected_video)
self._buttons["remove video"].setEnabled(has_video)
self._buttons["delete instance"].setEnabled(has_selected_instance)
self.suggestions_dock.suggestions_form_widget.buttons[
"generate_button"
].setEnabled(has_videos)
# Update overlays
self.overlays["track_labels"].visible = (
control_key_down and has_selected_instance
)
def on_data_update(self, what: List[UpdateTopic]):
def _has_topic(topic_list):
if UpdateTopic.all in what:
return True
for topic in topic_list:
if topic in what:
return True
return False
if _has_topic(
[
UpdateTopic.frame,
UpdateTopic.skeleton,
UpdateTopic.project_instances,
UpdateTopic.tracks,
]
):
self.plotFrame()
if _has_topic(
[
UpdateTopic.frame,
UpdateTopic.project_instances,
UpdateTopic.tracks,
UpdateTopic.suggestions,
]
):
self._update_seekbar_marks()
# Toggling the negative-frame flag does not change the plotted
# frame, so refresh the status bar (and menu check) explicitly.
self.updateStatusMessage()
if _has_topic(
[UpdateTopic.frame, UpdateTopic.project_instances, UpdateTopic.tracks]
):
self._update_track_menu()
if _has_topic([UpdateTopic.video]):
self.videos_dock.table.model().items = [x for x in self.labels.videos]
if _has_topic([UpdateTopic.skeleton]):
self.skeleton_dock.nodes_table.model().items = self.state["skeleton"]
self.skeleton_dock.edges_table.model().items = self.state["skeleton"]
self.skeleton_dock.skeletonEdgesSrc.model().skeleton = self.state[
"skeleton"
]
self.skeleton_dock.skeletonEdgesDst.model().skeleton = self.state[
"skeleton"
]
if self.labels.skeletons:
self.suggestions_dock.suggestions_form_widget.set_field_options(
"node", self.labels.skeletons[0].node_names
)
if hasattr(self, "trail_node_menu"):
self._update_trail_node_menu()
if _has_topic([UpdateTopic.project, UpdateTopic.on_frame]):
self.instances_dock.table.model().items = self.state["labeled_frame"]
if _has_topic([UpdateTopic.project]):
# Keep the QC dock pointed at the currently loaded project. The dock
# is created once and persists across project loads, so without this
# it can hold a stale (or empty) Labels object and report "Need at
# least 2 instances" until something re-triggers its visibility sync.
# update_labels is a no-op when the labels object is unchanged.
if hasattr(self, "_qc_dock"):
self._qc_dock.update_labels(self.labels)
if _has_topic([UpdateTopic.suggestions]):
self.suggestions_dock.table.model().items = self.labels.suggestions
if _has_topic([UpdateTopic.project_instances, UpdateTopic.suggestions]):
# update count of suggested frames w/ labeled instances
suggestion_status_text = ""
suggestion_list = self.labels.suggestions
if suggestion_list:
# Build set of (video, frame_idx) for frames with user instances
# O(m) where m = labeled frames, then O(n) lookups for n suggestions
# Total: O(n + m) instead of O(n * m) from calling find() per suggestion
user_labeled_frames = {
(lf.video, lf.frame_idx)
for lf in self.labels
if lf.has_user_instances
}
labeled_count = sum(
1
for suggestion in suggestion_list
if (suggestion.video, suggestion.frame_idx) in user_labeled_frames
)
prc = (labeled_count / len(suggestion_list)) * 100
suggestion_status_text = (
f"{labeled_count}/{len(suggestion_list)} labeled ({prc:.1f}%)"
)
self.suggestions_dock.suggested_count_label.setText(suggestion_status_text)
if _has_topic([UpdateTopic.frame, UpdateTopic.project_instances]):
self.state["last_interacted_frame"] = self.state["labeled_frame"]
def _recompute_qc_flags_into_state(self):
"""Recompute the transient per-instance keys from the QC display mode.
Does NOT replot -- callers either replot themselves (the QC display-mode
callbacks) or are already inside the plot path (`_after_plot_change`). In
"manual" mode this is a no-op so the Instances-dock columns (#2755/#2782)
stay in control. Otherwise the mode OWNS all three transient keys: it
overwrites the hidden set and the show-non-visible override wholesale, and
forces view-only off (the mode decides visibility, not a per-row radio).
See `sleap.gui.state.compute_qc_visibility` for the mode -> flags mapping.
"""
mode = self.state[QC_DISPLAY_MODE_KEY]
if mode == QC_MODE_MANUAL:
return
instances = get_instances_to_show(self.state["labeled_frame"])
selected = self.state["instance"]
global_snv = self.state.get("show non-visible nodes", default=True)
flags = compute_qc_visibility(mode, selected, instances, global_snv)
self.state[INSTANCE_HIDDEN_KEY] = {
iid for iid, (vis, _) in flags.items() if not vis
}
self.state[VIEW_ONLY_INSTANCE_KEY] = None
self.state[SHOW_NONVISIBLE_OVERRIDE_KEY] = {
iid: snv for iid, (_, snv) in flags.items()
}
def _on_qc_display_mode_changed(self, *args):
"""The Label QC display mode itself changed (#2783): re-derive + replot.
A non-manual mode derives the transient per-instance keys from the
current selection; switching back to "manual" clears the mode-driven
keys so the Instances-dock columns (#2755/#2782) regain control. Either
way a full `plotFrame` is REQUIRED because `show_non_visible` is baked
into each `QtInstance` at creation -- `setVisible` cannot resurrect
node/edge children that were never built.
"""
if self.state[QC_DISPLAY_MODE_KEY] == QC_MODE_MANUAL:
# Hand control back to the Instances-dock columns.
self.state[INSTANCE_HIDDEN_KEY] = set()
self.state[VIEW_ONLY_INSTANCE_KEY] = None
self.state[SHOW_NONVISIBLE_OVERRIDE_KEY] = {}
else:
self._recompute_qc_flags_into_state()
self.plotFrame()
def _on_qc_selection_changed(self, *args):
"""Selection changed: follow it only when a non-manual QC mode is active.
In the default "manual" mode selecting an instance must stay lightweight
(NO replot), matching pre-#2783 behavior -- otherwise every canvas click
would rebuild the whole frame. A non-manual mode re-derives the
per-instance flags for the new selection and replots (e.g. `selected_only`
follows the active instance).
"""
if self.state[QC_DISPLAY_MODE_KEY] == QC_MODE_MANUAL:
return
self._recompute_qc_flags_into_state()
self.plotFrame()
def _on_show_non_visible_toggled(self, *args):
"""Global "Show Non-Visible Nodes" toggled (Shift+V): re-derive + replot.
The toggle is a master gate even inside an Instance Focus mode (#2783): in
a non-manual mode, re-derive the per-instance occluded flags with the new
global value (`compute_qc_visibility` ANDs the mode's occluded display with
it), so turning it off hides occluded keypoints for every instance. The
recompute is a no-op in manual mode, where the global flag is just the
per-instance default as before -- either way we then replot.
"""
self._recompute_qc_flags_into_state()
self.plotFrame()
def plotFrame(self, *args, **kwargs):
"""Plots (or replots) current frame."""
if self.state["video"] is None:
return
self.player.plot()
def _after_plot_update(self, frame_idx):
"""Run after plot is updated, but stay on same frame."""
overlay: TrackTrailOverlay = self.overlays["trails"]
overlay.redraw(self.state["video"], frame_idx)
def _after_plot_change(self, player, frame_idx, selected_inst):
"""Called each time a new frame is drawn."""
# Store the current frame_idx and LabeledFrame (or make new, empty object)
# self.state["frame_idx"] = frame_idx
self.state["labeled_frame"] = (
self.labels.find(self.state["video"], frame_idx, return_new=True)[0]
if frame_idx is not None
else None
)
# Reset transient per-instance visibility only when the frame actually
# changes: the instances (and thus the id()-keyed visibility state)
# differ per frame. `_after_plot_change` also fires on same-frame replots
# (marker size, add instance, palette, etc.); resetting there would wipe
# the user's hide / view-only selections, so gate on the (video,
# frame_idx) key. Must run BEFORE the overlay redraw below so the
# instance overlay applies the cleared state.
frame_key = (self.state["video"], frame_idx)
if frame_key != self._vis_last_frame_key:
self._vis_last_frame_key = frame_key
self.state[INSTANCE_HIDDEN_KEY] = set()
self.state[VIEW_ONLY_INSTANCE_KEY] = None
self.state[SHOW_NONVISIBLE_OVERRIDE_KEY] = {}
# A non-manual QC display mode (#2783) owns these transient keys, so
# re-derive them for the freshly navigated frame. No `plotFrame` here:
# we are already inside the plot path and the overlay redraw below
# will apply the recomputed state (calling plotFrame would recurse).
self._recompute_qc_flags_into_state()
# Show instances, etc, for this frame
for overlay in self.overlays.values():
overlay.redraw(self.state["video"], frame_idx)
# Select instance if there was already selection
if selected_inst is not None:
player.view.selectInstance(selected_inst)
else:
self.state["instance"] = None
if self.state["fit"]:
player.zoomToFit()
elif self.state["fit_selection"]:
player.zoomToSelection()
elif self.state["actual_size"]:
player.zoomToActualSize()
# Update related displays
self.updateStatusMessage()
self.on_data_update([UpdateTopic.on_frame])
# Trigger event after the overlays have been added
player.view.updatedViewer.emit()
def updateStatusMessage(self, message: Optional[str] = None):
"""Updates status bar."""
current_video = self.state["video"]
frame_idx = self.state["frame_idx"] or 0
spacer = " "
if message is None:
message = ""
if len(self.labels.videos) > 0 and current_video is not None:
for i, video in enumerate(self.labels.videos):
if video.filename == current_video.filename:
same_dataset = (
(video.backend.dataset == current_video.backend.dataset)
if hasattr(video.backend, "dataset")
else True
) # `dataset` attr exists only for hdf5 backend
# not for mediavideo
if same_dataset:
index = i
break
message += f"Video {index + 1}/"
message += f"{len(self.labels.videos)}"
message += spacer
if current_video is not None:
message += f"Frame: {frame_idx + 1:,}/{len(current_video):,}"
if self.player.seekbar.hasSelection():
start, end = self.state["frame_range"]
message += spacer
message += f"Selection: {start + 1:,}-{end:,} ({end - start:,} frames)"
message += f"{spacer}Labeled Frames: "
if current_video is not None:
message += str(
get_labeled_frame_count(self.labels, current_video, "user")
)
if len(self.labels.videos) > 1:
message += " in video, "
if len(self.labels.videos) > 1:
project_user_frame_count = get_labeled_frame_count(
self.labels, filter="user"
)
message += f"{project_user_frame_count} in project"
if current_video is not None:
pred_frame_count = get_labeled_frame_count(
self.labels, current_video, "predicted"
)
if pred_frame_count:
message += f"{spacer}Predicted Frames: {pred_frame_count:,}"
percentage = pred_frame_count / len(current_video) * 100
message += f" ({percentage:.2f}%)"
message += " in video"
lf = self.state["labeled_frame"]
# TODO: revisit with LabeledFrame.unused_predictions() & instances_to_show()
n_instances = 0 if lf is None else len(get_instances_to_show(lf))
message += f"{spacer}Current frame: {n_instances} instances"
if (n_instances > 0) and not self.state["show instances"]:
hide_key = self.shortcuts["show instances"].toString()
message += f" [Hidden] Press '{hide_key}' to toggle."
self.statusBar().setStyleSheet("color: red")
else:
self.statusBar().setStyleSheet("")
if lf is not None and lf.is_negative:
message += f"{spacer}[NEGATIVE FRAME]"
# Keep the Labels-menu negative-frame checkmark in sync with the frame.
if hasattr(self, "negative_frame_action"):
current_lf = self.state["labeled_frame"]
self.negative_frame_action.setChecked(
bool(current_lf is not None and current_lf.is_negative)
)
self.statusBar().showMessage(message)
def resetPrefs(self):
"""Reset preferences to defaults."""
prefs.reset_to_default()
msg = QMessageBox()
msg.setText(
"Note: Some preferences may not take effect until application is restarted."
)
msg.exec_()
def openPrefs(self):
"""Open preference file directory"""
pref_path = get_config_file("preferences.yaml")
# Make sure the pref_path is a directory rather than a file
if pref_path.is_file():
pref_path = pref_path.parent
# Open the file explorer at the folder containing the preferences.yaml file
if sys.platform == "win32":
subprocess.Popen(["explorer", str(pref_path)])
elif sys.platform == "darwin":
subprocess.Popen(["open", str(pref_path)])
else:
subprocess.Popen(["xdg-open", str(pref_path)])
@staticmethod
def _trail_node_menu_label(option: str) -> str:
return "Centroid" if option == "centroid" else option
def _sync_trail_node_menu(self, value):
"""Check the Trail Node menu item matching `value`, uncheck the rest."""
for action in self.trail_node_menu.actions():
action.setChecked(action.text() == self._trail_node_menu_label(value))
def _update_trail_node_menu(self):
"""Rebuild the Trail Node menu from the current skeleton.
Options are per-project (skeleton node names), unlike the other Trail
submenus, so this rebuilds on skeleton changes rather than being built
once with a fixed option list.
"""
self.trail_node_menu.clear()
options = TrackTrailOverlay.get_node_options(self.labels)
if self.state["trail_node"] not in options:
# Stale selection from a previously loaded project with a
# different skeleton -- fall back to centroid.
self.state["trail_node"] = "centroid"
for option in options:
action = self.trail_node_menu.addAction(
self._trail_node_menu_label(option),
lambda x=option: self.state.set("trail_node", x),
)
action.setCheckable(True)
action.setChecked(self.state["trail_node"] == option)
def _update_track_menu(self):
"""Updates track menu options."""
self.track_menu.clear()
self.delete_tracks_menu.clear()
# Create a dictionary mapping track indices to Qt.Key values
key_mapping = {
0: Qt.Key_1,
1: Qt.Key_2,
2: Qt.Key_3,
3: Qt.Key_4,
4: Qt.Key_5,
5: Qt.Key_6,
6: Qt.Key_7,
7: Qt.Key_8,
8: Qt.Key_9,
9: Qt.Key_0,
}
for track_ind, track in enumerate(self.labels.tracks):
key_command = ""
if track_ind < 9:
key_command = Qt.CTRL | key_mapping[track_ind]
self.track_menu.addAction(
f"{track.name}",
lambda x=track: self.commands.setInstanceTrack(x),
key_command,
)
self.delete_tracks_menu.addAction(
f"{track.name}", lambda x=track: self.commands.deleteTrack(x)
)
self.track_menu.addAction(
"New Track", self.commands.addTrack, Qt.CTRL | Qt.Key_0
)
def _update_seekbar_marks(self):
"""Updates marks on seekbar."""
set_slider_marks_from_labels(
self.player.seekbar, self.labels, self.state["video"], self.color_manager
)
def _set_seekbar_header(self, graph_name: str):
"""Updates graph shown in seekbar header based on menu selection."""
data_obj = StatisticSeries(self.labels)
header_functions = {
"Point Displacement (sum)": data_obj.get_point_displacement_series,
"Point Displacement (max)": data_obj.get_point_displacement_series,
"Primary Point Displacement (sum)": (
data_obj.get_primary_point_displacement_series
),
"Primary Point Displacement (max)": (
data_obj.get_primary_point_displacement_series
),
"Tracking Score (mean)": data_obj.get_tracking_score_series,
"Tracking Score (min)": data_obj.get_tracking_score_series,
"Instance Score (sum)": data_obj.get_instance_score_series,
"Instance Score (min)": data_obj.get_instance_score_series,
"Point Score (sum)": data_obj.get_point_score_series,
"Point Score (min)": data_obj.get_point_score_series,
"Number of predicted points": data_obj.get_point_count_series,
"Min Centroid Proximity": data_obj.get_min_centroid_proximity_series,
}
if graph_name == "None":
self.player.seekbar.clearHeader()
else:
if graph_name in header_functions:
kwargs = dict(video=self.state["video"])
reduction_name = re.search("\\((sum|max|min|mean)\\)", graph_name)
if reduction_name is not None:
kwargs["reduction"] = reduction_name.group(1)
series = header_functions[graph_name](**kwargs)
self.player.seekbar.setHeaderSeries(series)
else:
print(f"Could not find function for {header_functions}")
def _get_frames_for_prediction(self):
"""Builds options for frames on which to run inference.
Args:
None.
Returns:
Dictionary, keys are names of options (e.g., "clip", "random"),
values are {video: list of frame indices} dictionaries.
"""
user_labeled_frames = self.labels.user_labeled_frames
def remove_user_labeled(video, frame_idxs):
if len(frame_idxs) == 0:
return frame_idxs
video_user_labeled_frame_idxs = {
lf.frame_idx for lf in user_labeled_frames if lf.video == video
}
return list(set(frame_idxs) - video_user_labeled_frame_idxs)
current_video = self.state["video"]
selection = dict()
selection["frame"] = {current_video: [self.state["frame_idx"]]}
# Use negative number in list for range (i.e., "0,-123" means "0-123")
# The ranges should be [X, Y) like standard Python ranges
def encode_range(a: int, b: int) -> Tuple[int, int]:
return a, -b
clip_range = self.state.get("frame_range", default=(0, 0))
selection["clip"] = {current_video: encode_range(*clip_range)}
selection["video"] = {current_video: encode_range(0, len(current_video))}
selection["all_videos"] = {
video: encode_range(0, len(video)) for video in self.labels.videos
}
selection["suggestions"] = {
video: remove_user_labeled(video, get_video_suggestions(self.labels, video))
for video in self.labels.videos
}
# For random sample options, store candidate pools (all frames)
# Actual sampling is done in the dialog based on sample_count and exclusions
# This allows re-sampling when "skip user labeled" checkbox changes
selection["random"] = {
video: list(range(video.shape[0])) for video in self.labels.videos
}
# Always provide random_video option (current video sampling)
selection["random_video"] = {current_video: list(range(current_video.shape[0]))}
if user_labeled_frames:
selection["user"] = {
video: [lf.frame_idx for lf in user_labeled_frames if lf.video == video]
for video in self.labels.videos
}
# Frames with predictions (for UC2: Refresh Predictions)
selection["predicted"] = {
video: [
lf.frame_idx
for lf in self.labels.find(video)
if lf.has_predicted_instances
]
for video in self.labels.videos
}
return selection
def apply_frame_exclusions(
self,
frame_selection: Dict[Video, List[int]],
exclude_user_labeled: bool = False,
exclude_predicted: bool = False,
) -> Dict[Video, List[int]]:
"""Apply exclusion filters to a frame selection.
Args:
frame_selection: Dictionary mapping videos to lists of frame indices.
exclude_user_labeled: If True, exclude frames with user-labeled instances.
exclude_predicted: If True, exclude frames with predicted instances.
Returns:
Filtered dictionary with excluded frames removed.
"""
result = {}
for video, frames in frame_selection.items():
# Handle range-encoded frames (negative second value means range)
if isinstance(frames, tuple) and len(frames) == 2:
start, end = frames
if end < 0:
# Decode range to list
frames = list(range(start, -end))
else:
frames = [start, end]
filtered = set(frames)
if exclude_user_labeled:
user_labeled = {
lf.frame_idx
for lf in self.labels.user_labeled_frames
if lf.video == video
}
filtered -= user_labeled
if exclude_predicted:
predicted = {
lf.frame_idx
for lf in self.labels.find(video)
if lf.has_predicted_instances
}
filtered -= predicted
result[video] = sorted(filtered)
return result
def _show_learning_dialog(self, mode: str):
"""Helper function to show learning dialog in given mode.
Args:
mode: A string representing mode for dialog, which could be:
* "training"
* "inference"
Returns:
None.
"""
from sleap.gui.learning.dialog import LearningDialog
if "inference" in self.overlays:
QMessageBox(
text="In order to use this function you must first quit and "
"re-open SLEAP to release resources used by visualizing "
"model outputs."
).exec_()
return
if self.labels is None or len(self.labels.videos) == 0:
QMessageBox(
text=(
"This project has no videos. Please add a video before "
"running training or inference."
)
).exec_()
return
if not self.state["filename"]:
QMessageBox(
text=("Please save your project before running training or inference.")
).exec_()
return
if self.state["has_changes"]:
QMessageBox(
text=(
"You have unsaved changes. Please save before running "
"training or inference."
)
).exec_()
return
if self._child_windows.get(mode, None) is None:
# Re-use existing dialog widget.
self._child_windows[mode] = LearningDialog(
mode,
self.state["filename"],
self.labels,
parent=self,
)
self._child_windows[mode]._handle_learning_finished.connect(
self._handle_learning_finished
)
self._child_windows[mode].navigate_to_instance.connect(
self._handle_navigate_to_instance
)
else:
# Update data in existing dialog widget.
self._child_windows[mode].labels = self.labels
self._child_windows[mode].labels_filename = self.state["filename"]
try:
self._child_windows[mode].skeleton = self.labels.skeleton
except ValueError:
self._child_windows[mode].skeleton = None
self._child_windows[mode].update_file_lists()
self._child_windows[mode].frame_selection = self._get_frames_for_prediction()
self._child_windows[mode].open()
def _handle_learning_finished(self, new_count: int):
"""Called when inference finishes."""
if (
len(self.labels.skeletons) > 0
and self.state["skeleton"] not in self.labels.skeletons
):
# Update the GUI state skeleton if the labels skeleton changed after merge.
self.state["skeleton"] = self.labels.skeletons[-1]
# we ran inference so update display/ui
self.on_data_update([UpdateTopic.all])
if new_count > 0:
self.commands.changestack_push("new predictions")
def _handle_navigate_to_instance(
self, video_idx: int, frame_idx: int, instance_idx: int
):
"""Handle navigation request from training dialog's Size Distribution widget."""
if video_idx < len(self.labels.videos):
video = self.labels.videos[video_idx]
self.commands.gotoVideoAndFrameAndInstance(video, frame_idx, instance_idx)
def _show_metrics_dialog(self):
self._child_windows["metrics"] = MetricsTableDialog(self.state["filename"])
self._child_windows["metrics"].show()
def _handle_instance_double_click(
self, instance: Instance, event: QtGui.QMouseEvent = None
):
"""
Handles when the user has double-clicked an instance.
If prediction, then copy to new user-instance.
If already user instance, then add any missing nodes (in case
skeleton has been changed after instance was created).
Args:
instance: The :class:`Instance` that was double-clicked.
"""
# When a predicted instance is double-clicked, add a new instance
if hasattr(instance, "score"):
mark_complete = False
# Mark the nodes as "complete" if shift-key is down
if event is not None and event.modifiers() & Qt.ShiftModifier:
mark_complete = True
self.commands.newInstance(
copy_instance=instance, mark_complete=mark_complete
)
# When a regular instance is double-clicked, add any missing points
else:
self.commands.completeInstanceNodes(instance)
def _show_keyboard_shortcuts_window(self):
"""Shows gui for viewing/modifying keyboard shortucts."""
ShortcutDialog().exec_()
def _show_update_checker_dialog(self):
"""Shows the update checker dialog."""
from sleap.gui.dialogs.update_checker import UpdateCheckerDialog
dialog = UpdateCheckerDialog(self)
dialog.exec_()
def _goto_next_suggestion_or_flag(self):
"""Go to next suggestion or QC flag, depending on which is active.
If QC dock is visible and is the active tab (or floating) with flags,
navigate to the next QC flag. Otherwise, navigate to next suggestion.
"""
if hasattr(self, "_qc_dock") and self._qc_dock.is_active_for_navigation:
self._qc_dock.goto_next_flag()
else:
self.commands.nextSuggestedFrame()
def _goto_prev_suggestion_or_flag(self):
"""Go to previous suggestion or QC flag, depending on which is active.
If QC dock is visible and is the active tab (or floating) with flags,
navigate to the previous QC flag. Otherwise, navigate to prev suggestion.
"""
if hasattr(self, "_qc_dock") and self._qc_dock.is_active_for_navigation:
self._qc_dock.goto_prev_flag()
else:
self.commands.prevSuggestedFrame()
def _open_size_distribution(self):
"""Opens the instance size distribution analysis dialog."""
if self.labels is None or len(self.labels) == 0:
QMessageBox.warning(
self,
"No Labels",
"Please load labels with user-labeled instances first.",
)
return
from sleap.gui.dialogs.size_distribution import SizeDistributionDialog
def navigate_callback(video_idx: int, frame_idx: int, instance_idx: int):
"""Navigate to the specified frame and highlight instance."""
if video_idx < len(self.labels.videos):
video = self.labels.videos[video_idx]
self.commands.gotoVideoAndFrameAndInstance(
video, frame_idx, instance_idx
)
dialog = SizeDistributionDialog(
labels=self.labels,
navigate_callback=navigate_callback,
parent=self,
)
dialog.show()
def _open_label_qc(self):
"""Opens the label QC analysis dock widget.
The dock widget is tabbed with other right-side docks and can be
undocked to float. Its state is saved with the window state.
"""
if self.labels is None or len(self.labels) == 0:
QMessageBox.warning(
self,
"No Labels",
"Please load labels with user-labeled instances first.",
)
return
# Update labels and show the dock (created at init time)
self._qc_dock.update_labels(self.labels)
self._qc_dock.show()
self._qc_dock.raise_()