Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CamerasTable to SessionsDock #1671

Merged
merged 27 commits into from
Mar 18, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d681075
Added CamerasTable, SessionDocks and EditCommand
AdvaithRavishankar Jan 22, 2024
33c4bcb
added updates to app, commands and docks
AdvaithRavishankar Jan 23, 2024
d482bb4
Fixed bugs, drafted tests
AdvaithRavishankar Jan 23, 2024
7f74a8b
Added minor debug fixes to CamerasTableModel
AdvaithRavishankar Jan 23, 2024
3bb071f
Added Initial Tests
AdvaithRavishankar Jan 29, 2024
e24edc9
changed button to be disabled and made table in sessions docks keys
AdvaithRavishankar Jan 29, 2024
88cd019
fixed bugs with table display
AdvaithRavishankar Jan 29, 2024
a728dd4
fixed button functionality
AdvaithRavishankar Feb 6, 2024
f1b6b1b
fixed table updating
AdvaithRavishankar Feb 8, 2024
548caf5
pushed tests draft
AdvaithRavishankar Feb 8, 2024
33d2754
Use GuiState to determine current session
roomrys Mar 16, 2024
f414abb
Nit: Use camera_table instead of table["camera_table"]
roomrys Mar 16, 2024
dd3daae
Use GuiState instead of camcorder sessions list
roomrys Mar 16, 2024
45974e3
Nit: docstring format and lint
roomrys Mar 16, 2024
4f40b6e
Change row name to "camera"
roomrys Mar 16, 2024
47dfe95
Display all cameras (not just unlinked)
roomrys Mar 16, 2024
20d3e60
Reset selected camera to None after unlink video
roomrys Mar 16, 2024
ba0f556
Fix-up cameras table test
roomrys Mar 16, 2024
c493fd6
Fix-up docks test
Mar 18, 2024
907f873
Remove unused imports
Mar 18, 2024
c287df2
Merge branch 'liezl/add-gui-elements-for-sessions' of https://github.…
Mar 18, 2024
45efdab
Fix segmentation fault
Mar 18, 2024
cd8662e
Reorder buttons and tab sessions dock with others
Mar 18, 2024
25e848c
Lint
Mar 18, 2024
236d044
Update cameras model when change in selected session
Mar 18, 2024
4ecbb95
Add missing import
Mar 18, 2024
77283d0
Use "selected_session" in UnlinkVideo command
Mar 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions sleap/gui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ def __init__(
self.state["labeled_frame"] = None
self.state["last_interacted_frame"] = None
self.state["filename"] = None
self.state["session"] = None
self.state["show non-visible nodes"] = prefs["show non-visible nodes"]
self.state["show instances"] = True
self.state["show labels"] = True
Expand Down Expand Up @@ -1094,6 +1095,7 @@ def _update_gui_state(self):
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_selected_camcorder = self.state["selected_camera"] is not None

has_frame_range = bool(self.state["has_frame_range"])
has_unsaved_changes = bool(self.state["has_changes"])
Expand Down Expand Up @@ -1148,6 +1150,7 @@ def _update_gui_state(self):
self._buttons["show video"].setEnabled(has_selected_video)
self._buttons["remove video"].setEnabled(has_video)
self._buttons["delete instance"].setEnabled(has_selected_instance)
self._buttons["unlink video"].setEnabled(has_selected_camcorder)
self.suggestions_dock.suggestions_form_widget.buttons[
"generate_button"
].setEnabled(has_videos)
Expand Down Expand Up @@ -1236,6 +1239,11 @@ def _has_topic(topic_list):
if _has_topic([UpdateTopic.frame, UpdateTopic.project_instances]):
self.state["last_interacted_frame"] = self.state["labeled_frame"]

if _has_topic([UpdateTopic.sessions]):
self.sessions_dock.camera_table.model().items = self.state[
"session" # TODO(LM): Use "selected_session" here
]

def plotFrame(self, *args, **kwargs):
"""Plots (or replots) current frame."""
if self.state["video"] is None:
Expand Down
25 changes: 25 additions & 0 deletions sleap/gui/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ class UpdateTopic(Enum):
frame = 8
project = 9
project_instances = 10
sessions = 11


class AppCommand:
Expand Down Expand Up @@ -655,6 +656,10 @@ def openPrereleaseVersion(self):
"""Open the current prerelease version."""
self.execute(OpenPrereleaseVersion)

def unlink_video_from_camera(self):
"""Unlinks video from a camera"""
self.execute(UnlinkVideo)


# File Commands

Expand Down Expand Up @@ -697,6 +702,8 @@ def do_action(context: "CommandContext", params: dict):
if len(labels.videos):
context.state["video"] = labels.videos[0]

context.state["session"] = labels.sessions[0] if len(labels.sessions) else None

context.state["project_loaded"] = True
context.state["has_changes"] = params.get("changed_on_load", False) or (
filename is None
Expand Down Expand Up @@ -3901,3 +3908,21 @@ def copy_to_clipboard(text: str):
clipboard = QtWidgets.QApplication.clipboard()
clipboard.clear(mode=clipboard.Clipboard)
clipboard.setText(text, mode=clipboard.Clipboard)


class UnlinkVideo(EditCommand):
topics = [UpdateTopic.sessions]

@staticmethod
def do_action(context: CommandContext, params: dict):
camcorder = context.state["selected_camera"]
# TODO(LM): replace with state["selected_session"]
recording_session = context.state["session"]

video = camcorder.get_video(recording_session)

if video is not None and recording_session is not None:
recording_session.remove_video(video)

# Reset the selected camera
context.state["selected_camera"] = None
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UnlinkVideo class correctly implements the do_action method to unlink a video from a camera within a recording session. However, there are a few areas for improvement:

  1. The method assumes that context.state["selected_camera"] and context.state["session"] are always set and valid. It would be safer to add checks to ensure these values are not None before proceeding with the unlink operation.
  2. After unlinking the video, it resets context.state["selected_camera"] to None. This action might not be intuitive for the user, especially if they expect the selected camera to remain selected after the operation. Consider whether this behavior aligns with the user interface expectations.
  3. There's a TODO comment regarding replacing context.state["session"] with state["selected_session"]. It's important to address this TODO to ensure consistency in how sessions are referenced throughout the application.
    @staticmethod
    def do_action(context: CommandContext, params: dict):
+       if "selected_camera" not in context.state or "session" not in context.state:
+           logger.error("Selected camera or session not set.")
+           return
        camcorder = context.state["selected_camera"]
        recording_session = context.state["session"]
+       if camcorder is None or recording_session is None:
+           logger.error("Selected camera or session is None.")
+           return

        video = camcorder.get_video(recording_session)

        if video is not None:
            recording_session.remove_video(video)

-       # Reset the selected camera
-       context.state["selected_camera"] = None

Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
class UnlinkVideo(EditCommand):
topics = [UpdateTopic.sessions]
@staticmethod
def do_action(context: CommandContext, params: dict):
camcorder = context.state["selected_camera"]
# TODO(LM): replace with state["selected_session"]
recording_session = context.state["session"]
video = camcorder.get_video(recording_session)
if video is not None and recording_session is not None:
recording_session.remove_video(video)
# Reset the selected camera
context.state["selected_camera"] = None
class UnlinkVideo(EditCommand):
topics = [UpdateTopic.sessions]
@staticmethod
def do_action(context: CommandContext, params: dict):
if "selected_camera" not in context.state or "session" not in context.state:
logger.error("Selected camera or session not set.")
return
camcorder = context.state["selected_camera"]
recording_session = context.state["session"]
if camcorder is None or recording_session is None:
logger.error("Selected camera or session is None.")
return
video = camcorder.get_video(recording_session)
if video is not None:
recording_session.remove_video(video)

20 changes: 20 additions & 0 deletions sleap/gui/dataviews.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from sleap.io.dataset import Labels
from sleap.instance import LabeledFrame, Instance
from sleap.skeleton import Skeleton
from sleap.io.cameras import Camcorder, RecordingSession


class GenericTableModel(QtCore.QAbstractTableModel):
Expand Down Expand Up @@ -652,3 +653,22 @@ def columnCount(self, parent):
def flags(self, index: QtCore.QModelIndex):
"""Overrides Qt method, returns flags (editable etc)."""
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable


class CamerasTableModel(GenericTableModel):
"""Table model for unlinking `Camcorder`s and `Video`s within a `RecordingSession`.

Args:
obj: 'RecordingSession' which has information of cameras
and paired video
"""

properties = ("camera", "video")

def object_to_items(self, obj: RecordingSession):
return obj.camera_cluster.cameras

def item_to_data(self, obj: RecordingSession, item: Camcorder):

video = obj.get_video(item)
return {"camera": item.name, "video": video.filename if video else ""}
45 changes: 44 additions & 1 deletion sleap/gui/widgets/docks.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
SkeletonNodesTableModel,
SuggestionsTableModel,
VideosTableModel,
CamerasTableModel,
)
from sleap.gui.dialogs.formbuilder import YamlFormWidget
from sleap.gui.widgets.views import CollapsibleWidget
Expand Down Expand Up @@ -574,9 +575,20 @@ def create_table_edit_buttons(self) -> QWidget:

class SessionsDock(DockWidget):
def __init__(self, main_window: Optional[QMainWindow]):
super().__init__(name="Sessions", main_window=main_window, model_type=None)
self.camera_model_type = CamerasTableModel
super().__init__(name="Sessions", main_window=main_window, model_type=[self.camera_model_type])


def lay_everything_out(self) -> None:
if self.table is None:
self.create_tables()


self.wgt_layout.addWidget(self.camera_table)

video_unlink_button = self.create_video_unlink_button()
self.wgt_layout.addWidget(video_unlink_button)

triangulation_options = self.create_triangulation_options()
self.wgt_layout.addWidget(triangulation_options)

Expand All @@ -601,3 +613,34 @@ def create_triangulation_options(self) -> QWidget:
hbw = QWidget()
hbw.setLayout(hb)
return hbw

def create_models(self) -> GenericTableModel:
main_window = self.main_window
self.camera_model = self.camera_model_type(
items=main_window.state["selected_session"], context=main_window.commands
)

return {"camera_model": self.camera_model}
Copy link

@coderabbitai coderabbitai bot Feb 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method create_models returns a dictionary instead of a GenericTableModel instance. This does not align with the expected return type for model creation methods in dock widgets. Ensure consistency in return types across the application.

- return {"camera_model": self.camera_model}
+ return self.camera_model

Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
def create_models(self) -> GenericTableModel:
main_window = self.main_window
self.camera_model = self.camera_model_type(
items=main_window.state["selected_session"], context=main_window.commands
)
return {"camera_model": self.camera_model}
def create_models(self) -> GenericTableModel:
main_window = self.main_window
self.camera_model = self.camera_model_type(
items=main_window.state["selected_session"], context=main_window.commands
)
return self.camera_model

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like your idea of using a dictionary to describe the models that are returned. Let's just update the typehinting:

    def create_models(self) -> Union[GenericTableModel, Dict[str, GenericTableModel]]:

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@roomrys: Acknowledged. Updating the type hinting as suggested introduces flexibility in handling multiple models within dock widgets, which can be particularly useful for complex interfaces. It's important to ensure that other parts of the application that interact with these models are compatible with this approach. This includes reviewing methods like create_tables and any other functionality that relies on the output of create_models to handle both a single model and a dictionary of models seamlessly. Additionally, documentation and possibly some internal logic may need updates to align with this change. If you've considered these aspects and are comfortable with the implications, this update can enhance the application's architecture by accommodating more complex data management scenarios.


def create_tables(self) -> GenericTableView:
if self.model is None:
self.create_models()

main_window = self.main_window
self.camera_table = GenericTableView(
state=main_window.state,
row_name="camera",
model=self.camera_model,
)

return {"camera_table": self.camera_table}

def create_video_unlink_button(self) -> QWidget:
main_window = self.main_window

hb = QHBoxLayout()
self.add_button(hb, "Unlink Video", main_window.commands.unlink_video_from_camera)

hbw = QWidget()
hbw.setLayout(hb)
return hbw
4 changes: 2 additions & 2 deletions sleap/io/cameras.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def sessions(self) -> List["RecordingSession"]:

def get_video(self, session: "RecordingSession") -> Optional[Video]:
if session not in self._video_by_session:
logger.warning(f"{session} not found in {self}.")
logger.debug(f"{session} not found in {self}.")
return None
return self._video_by_session[session]

Expand Down Expand Up @@ -462,7 +462,7 @@ def get_video(self, camcorder: Camcorder) -> Optional[Video]:
)

if camcorder not in self._video_by_camcorder:
logger.warning(
logger.debug(
f"Camcorder {camcorder.name} is not linked to a video in this "
f"RecordingSession."
)
Expand Down
55 changes: 55 additions & 0 deletions tests/gui/test_dataviews.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import pytestqt

from sleap.gui.dataviews import *
from sleap.io.cameras import RecordingSession


def test_skeleton_nodes(qtbot, centered_pair_predictions):
Expand Down Expand Up @@ -84,3 +85,57 @@ def test_table_sort_string(qtbot):
# Make sure we can sort with both numbers and strings (i.e., "")
table.model().sort(0)
table.model().sort(1)


def test_camera_table(qtbot, multiview_min_session_labels):

session = multiview_min_session_labels.sessions[0]
camcorders = session.camera_cluster.cameras

table_model = CamerasTableModel(items=session)
num_rows = table_model.rowCount()

assert table_model.columnCount() == 2
assert num_rows == len(camcorders)

table = GenericTableView(
row_name="camera",
model=table_model,
)

# Test if all comcorders are presented in the correct row
for i in range(num_rows):
table.selectRow(i)

# Check first column
assert table.getSelectedRowItem() == camcorders[i]
assert table.model().data(table.currentIndex()) == camcorders[i].name

# Check second column
index = table.model().index(i, 1)
linked_video_filename = camcorders[i].get_video(session).filename
assert table.model().data(index) == linked_video_filename

# Test if a comcorder change is reflected
idxs_to_remove = [1, 2, 7]
for idx in idxs_to_remove:
multiview_min_session_labels.sessions[0].remove_video(
camcorders[idx].get_video(multiview_min_session_labels.sessions[0])
)
table.model().items = session

for i in range(num_rows):
table.selectRow(i)

# Check first column
assert table.getSelectedRowItem() == camcorders[i]
assert table.model().data(table.currentIndex()) == camcorders[i].name

# Check second column
index = table.model().index(i, 1)
linked_video = camcorders[i].get_video(session)
if i in idxs_to_remove:
assert table.model().data(index) == ""
else:
linked_video_filename = linked_video.filename
assert table.model().data(index) == linked_video_filename
50 changes: 49 additions & 1 deletion tests/gui/widgets/test_docks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import pytest
from sleap import Labels, Video
from sleap.gui.app import MainWindow
from sleap.gui.commands import OpenSkeleton
from sleap.gui.commands import OpenSkeleton, UnlinkVideo
from sleap.gui.widgets.docks import (
InstancesDock,
SuggestionsDock,
Expand Down Expand Up @@ -107,3 +107,51 @@ def test_instances_dock(qtbot):
assert dock.name == "Instances"
assert dock.main_window is main_window
assert dock.wgt_layout is dock.widget().layout()


def test_sessions_dock_cameras_table(qtbot, multiview_min_session_labels):
labels = multiview_min_session_labels
session = labels.sessions[0]
camcorders = session.camera_cluster.cameras
main_window = MainWindow(labels=labels)
assert main_window.state["session"] == session

dock = main_window.sessions_dock
table = dock.camera_table

# Testing if cameras_table is loaded correctly

# Test if all comcorders are presented in the correct row
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a typographical error in the comment: "comcorders" should be corrected to "camcorders" for clarity and correctness.

-    # Test if all comcorders are presented in the correct row
+    # Test if all camcorders are presented in the correct row

Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
# Test if all comcorders are presented in the correct row
# Test if all camcorders are presented in the correct row

for i, cam in enumerate(camcorders):
table.selectRow(i)

# Check first column
assert table.getSelectedRowItem() == cam
assert table.model().data(table.currentIndex()) == cam.name

# Check second column
index = table.model().index(i, 1)
linked_video_filename = cam.get_video(session).filename
assert table.model().data(index) == linked_video_filename

# Test if a comcorder change is reflected
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another typographical error: "comcorder" should be corrected to "camcorder".

-    # Test if a comcorder change is reflected
+    # Test if a camcorder change is reflected

Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
# Test if a comcorder change is reflected
# Test if a camcorder change is reflected

idxs_to_remove = [1, 2, 7]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of magic numbers in idxs_to_remove = [1, 2, 7] should be documented or derived programmatically to improve readability and maintainability.

Consider documenting the rationale behind the choice of indices in idxs_to_remove or deriving them programmatically to avoid magic numbers.

for idx in idxs_to_remove:
main_window.state["selected_camera"] = camcorders[idx]
main_window._buttons["unlink video"].click()

for i, cam in enumerate(camcorders):
table.selectRow(i)

# Check first column
assert table.getSelectedRowItem() == camcorders[i]
assert table.model().data(table.currentIndex()) == camcorders[i].name

# Check second column
index = table.model().index(i, 1)
linked_video = camcorders[i].get_video(session)
if i in idxs_to_remove:
assert table.model().data(index) == ""
else:
linked_video_filename = linked_video.filename
assert table.model().data(index) == linked_video_filename
Comment on lines +125 to +170
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test function test_sessions_dock_cameras_table is well-structured and covers important aspects of the SessionsDock cameras table functionality. However, there are a few areas where improvements can be made:

  1. Typographical Error: There's a minor typographical error in the comments. The word "comcorders" should be corrected to "camcorders" for clarity and correctness. This occurs in lines 134 and 147.

  2. Test Coverage for Unlink Video: The test covers the scenario where videos are unlinked from camcorders, but it might be beneficial to also verify that the UnlinkVideo command is correctly updating the underlying data model. This could involve checking that the session object reflects the changes made by the UnlinkVideo command.

  3. Magic Numbers: The test uses magic numbers (e.g., idxs_to_remove = [1, 2, 7]) without explanation. It would improve readability and maintainability to either document why these specific indices are chosen or to derive these indices programmatically based on the test setup.

  4. Error Handling: Consider adding tests for error scenarios, such as attempting to unlink a video from a camcorder that doesn't have a linked video. This would ensure robustness in handling edge cases.

-    # Test if all comcorders are presented in the correct row
+    # Test if all camcorders are presented in the correct row

Consider enhancing the test to verify that the UnlinkVideo command updates the underlying data model as expected.

Document the rationale behind the choice of indices in idxs_to_remove or derive them programmatically to avoid magic numbers.

Would you like assistance in adding tests for error scenarios, such as handling unlink operations for camcorders without linked videos?


Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
def test_sessions_dock_cameras_table(qtbot, multiview_min_session_labels):
labels = multiview_min_session_labels
session = labels.sessions[0]
camcorders = session.camera_cluster.cameras
main_window = MainWindow(labels=labels)
assert main_window.state["session"] == session
dock = main_window.sessions_dock
table = dock.camera_table
# Testing if cameras_table is loaded correctly
# Test if all comcorders are presented in the correct row
for i, cam in enumerate(camcorders):
table.selectRow(i)
# Check first column
assert table.getSelectedRowItem() == cam
assert table.model().data(table.currentIndex()) == cam.name
# Check second column
index = table.model().index(i, 1)
linked_video_filename = cam.get_video(session).filename
assert table.model().data(index) == linked_video_filename
# Test if a comcorder change is reflected
idxs_to_remove = [1, 2, 7]
for idx in idxs_to_remove:
main_window.state["selected_camera"] = camcorders[idx]
main_window._buttons["unlink video"].click()
for i, cam in enumerate(camcorders):
table.selectRow(i)
# Check first column
assert table.getSelectedRowItem() == camcorders[i]
assert table.model().data(table.currentIndex()) == camcorders[i].name
# Check second column
index = table.model().index(i, 1)
linked_video = camcorders[i].get_video(session)
if i in idxs_to_remove:
assert table.model().data(index) == ""
else:
linked_video_filename = linked_video.filename
assert table.model().data(index) == linked_video_filename
def test_sessions_dock_cameras_table(qtbot, multiview_min_session_labels):
labels = multiview_min_session_labels
session = labels.sessions[0]
camcorders = session.camera_cluster.cameras
main_window = MainWindow(labels=labels)
assert main_window.state["session"] == session
dock = main_window.sessions_dock
table = dock.camera_table
# Testing if cameras_table is loaded correctly
# Test if all camcorders are presented in the correct row
for i, cam in enumerate(camcorders):
table.selectRow(i)
# Check first column
assert table.getSelectedRowItem() == cam
assert table.model().data(table.currentIndex()) == cam.name
# Check second column
index = table.model().index(i, 1)
linked_video_filename = cam.get_video(session).filename
assert table.model().data(index) == linked_video_filename
# Test if a comcorder change is reflected
idxs_to_remove = [1, 2, 7]
for idx in idxs_to_remove:
main_window.state["selected_camera"] = camcorders[idx]
main_window._buttons["unlink video"].click()
for i, cam in enumerate(camcorders):
table.selectRow(i)
# Check first column
assert table.getSelectedRowItem() == camcorders[i]
assert table.model().data(table.currentIndex()) == camcorders[i].name
# Check second column
index = table.model().index(i, 1)
linked_video = camcorders[i].get_video(session)
if i in idxs_to_remove:
assert table.model().data(index) == ""
else:
linked_video_filename = linked_video.filename
assert table.model().data(index) == linked_video_filename