-
Notifications
You must be signed in to change notification settings - Fork 73
fix: Enable loading netCDF files in the napari widget #689
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
base: main
Are you sure you want to change the base?
fix: Enable loading netCDF files in the napari widget #689
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #689 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 34 34
Lines 2022 2056 +34
=========================================
+ Hits 2022 2056 +34 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
Thanks for opening the draft PR @Sparshr04. I will get back to you with feedback in a few days. |
niksirbi
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for getting this rolling, @Sparshr04!
I’ve had a closer look at the PR and also discussed it briefly with other team members during the community call. Here are some high-level comments and suggestions to guide the next steps.
Disable FPS input for netCDF files
When a user selects movement (netCDF) as the source, we should probably disable the fps input field entirely. Unlike other file types, movement netCDF files already contain time coordinates—and often an fps value in their metadata.
Allowing manual fps input could create conflicts if the user’s value doesn’t match the metadata, or if the timestamps in the file are irregular. The simplest and safest solution is to make the fps field inactive whenever movement (netCDF) is selected, and re-enable it when the user switches to another file type.
Validate datasets loaded from netCDF files
We’ll need more robust validation than simply checking for the existence of the position variable (despite what I had said in the issue).
For pose datasets, both position and confidence are required; for bounding box datasets, we also need shape. These are all used downstream by the ds_to_napari_layers() function.
You can determine the dataset type via its .ds_type attribute (see dataset attributes docs).
To avoid duplicating validation logic, it’s best to reuse existing classes and functions from movement.validators.datasets.
Here’s a sketch of what that might look like:
from napari.utils.notifications import show_error
from movement.validators.datasets import (
ValidBboxesDataset,
ValidPosesDataset,
_validate_dataset,
)
class DataLoader(QWidget):
"""Widget for loading movement datasets from file."""
def _format_data_for_layers(self):
"""Extract data required for creating napari layers."""
if self.source_software in SUPPORTED_NETCDF_FILES:
try:
ds = xr.open_dataset(self.file_path)
except Exception as e:
show_error(f"Error opening netCDF file: {e}")
return
ds_type = ds.attrs.get("ds_type", None)
validator = {
"poses": ValidPosesDataset,
"bboxes": ValidBboxesDataset,
}.get(ds_type)
if validator:
try:
_validate_dataset(ds, validator)
except (ValueError, TypeError) as e:
show_error(
f"The netCDF file does not appear to be a valid "
f"movement {ds_type} dataset: {e}"
)
returnA couple of notes:
- I’d use
show_errorrather thanshow_warning, since invalid datasets should block further processing. - Even though
_validate_datasetis a “private” function, it’s fine to use it here temporarily; if it proves generally useful, we can make it public in a future PR.
Testing
This will likely be the trickiest part of the PR, but it's important.
Relevant tests should go in tests/test_unit/test_napari_plugin/test_data_loader_widget.py.
Here are the main cases to cover:
-
Successful loading: Ensure valid
movementnetCDF files load correctly. I recently added two new sample datasets for this purpose:MOVE_two-mice_octagon.analysis.nc(poses)MOVE_single-crab_MOCA-crab-1_linear-interp.nc(bboxes)
You can access them is tests as:
file_path = pytest.DATA_PATHS.get(filename)
I would probably start by modifying existing tests , especially
test_on_load_clicked_with_valid_file_pathandtest_on_browse_clicked_file_filters. -
Invalid datasets: Check that appropriate errors are raised (and displayed via
show_error) for invalid files. A good approach is to create a fixture first, starting from a valid netCDF file, removing a required variable, and saving it to a temporary path. You’ll find similar patterns intests/fixtures/files.py. Once you have your fixtures defined, you can attempt loading them in tests. -
UI behaviour: Verify that the
fpsfield is disabled only whenmovement (netCDF)is selected. This will probably require mocking GUI interactions — see examples usingmockerintest_data_loader_widget.py.
I hope this advice helped. There is still a long way to go, but feel free to make incremental improvements to this PR and ask for help when needed.
|
Hey @niksirbi, thanks a lot for a detailed stepwise roadmap ! Here are the steps that I took to solve issues: Disabling FPS input for netCDF filesAs we discussed earlier, currently this is the approach that I have thought of (and tested the working too) — disabling the FPS for netCDF files. I created a simple function def _on_source_software_changed(self, current_text: str):
"""Enable/disable the fps spinbox based on source software."""
is_netcdf = (current_text in SUPPORTED_NETCDF_FILES)
# Disable fps box if netCDF
self.fps_spinbox.setEnabled(not is_netcdf)
if is_netcdf:
self.fps_spinbox.setToolTip(
"FPS is read directly from the netCDF file."
)
else:
self.fps_spinbox.setToolTip(
"Set the frames per second of the tracking data.\n"
"This just affects the displayed time when hovering over a point\n"
)Validate datasets loaded from netCDF filesAs you mentioned, we can determine dataset type via class DataLoader(QWidget):
"""Widget for loading movement datasets from file."""
...
def _format_data_for_layers(self):
"""Extract data required for the creation of the napari layers."""
if self.source_software in SUPPORTED_NETCDF_FILES:
# Add logic for netCDF files
try:
ds = xr.open_dataset(self.file_path)
except Exception as e:
show_error(f"Error opening netCDF file: {e}")
return
# Get the dataset type from its attributes
ds_type = ds.attrs.get("ds_type", None)
validator = {
"poses": ValidPosesDataset,
"bboxes": ValidBboxesDataset,
}.get(ds_type)
if validator:
try:
# Using validate_dataset function (currently Private)
_validate_dataset(ds, validator)
except (ValueError, TypeError) as e:
show_error(
f"The netCDF file does not appear to be a valid "
f"movement {ds_type} dataset: {e}"
)
return
else:
show_error(
f"The netCDF file has an unknown 'ds_type' attribute:"
f"{ds_type}."
)
returnStarted implementation on testing phase :) |
Testingediting in the
def test_fps_spinbox_disabled_for_netcdf(make_napari_viewer_proxy):
"""Test that the FPS spinbox is disabled for netCDF files and enabled for others."""
data_loader_widget = DataLoader(make_napari_viewer_proxy())
data_loader_widget.source_software_combo.setCurrentText("DeepLabCut")
assert data_loader_widget.fps_spinbox.isEnabled() is True
data_loader_widget.source_software_combo.setCurrentText("movement (netCDF)")
assert data_loader_widget.fps_spinbox.isEnabled() is False
data_loader_widget.source_software_combo.setCurrentText("SLEAP")
assert data_loader_widget.fps_spinbox.isEnabled() is TrueAnd yes as u mentioned about @pytest.mark.parametrize(
"source_software, expected_file_filter",
[
("DeepLabCut", "*.h5 *.csv"),
("SLEAP", "*.h5 *.slp"),
("LightningPose", "*.csv"),
("VIA-tracks", "*.csv"),
("movement (netCDF)", "*.nc"),
],
)
@pytest.mark.parametrize(
"filename, source_software, tracks_array_shape",
[
(
"VIA_single-crab_MOCA-crab-1.csv",
"VIA-tracks",
(35, 4),
), # single individual, no keypoints (bboxes)
(
"VIA_multiple-crabs_5-frames_labels.csv",
"VIA-tracks",
(430, 4),
), # multiple individuals, no keypoints (bboxes)
(
"SLEAP_single-mouse_EPM.predictions.slp",
"SLEAP",
(110910, 4),
), # single individual, multiple keypoints
(
"DLC_single-wasp.predictions.h5",
"DeepLabCut",
(2170, 4),
), # single individual, multiple keypoints
(
"DLC_two-mice.predictions.csv",
"DeepLabCut",
(1439976, 4),
), # two individuals, multiple keypoints
(
"SLEAP_three-mice_Aeon_mixed-labels.analysis.h5",
"SLEAP",
(1803, 4),
), # three individuals, one keypoint
(
"MOVE_two-mice_octagon.analysis.nc",
"movement (netCDF)",
(126000, 4),
),
(
"MOVE_single-crab_MOCA-crab-1_linear-interp.nc",
"movement (netCDF)",
(0, 0),
),
],
)This was the trickiest part yet. First I'll tell you what happened and then the solution that I think might work. I think this is happening because in our if source_software in SUPPORTED_BBOXES_FILES:
assert data_loader_widget.data_bboxes is not None
else:
# Only bounding boxes datasets should add bboxes data
assert data_loader_widget.data_bboxes is NoneSolutionI think we should add a boolean argument to all the test cases in |
|
Thanks for the update @Sparshr04. I don't see the additions you mention in the "Files changed" tab of this PR. Have you perhaps forgotten to push the commits? I can anyway comment on some of the questions you raised. I need some more time to think about the tests for succesfully loading valid sample netCDF files. I'll get back to you on that topic later. Disabling FPS input for netCDF filesYour approach with Firstly I would modify def _create_fps_widget(self):
#### EXISTING CODE ####
# Add a tooltip
self.fps_default_tooltip = (
"Set the frames per second of the tracking data.\n"
"This just affects the displayed time when hovering over a point\n"
"(it doesn't set the playback speed)."
)
self.fps_spinbox.setToolTip(self.fps_default_tooltip)
# Connect fps spinbox with _on_source_software_changed
self.source_software_combo.currentTextChanged.connect(
self._on_source_software_changed,
)
self.layout().addRow("fps:", self.fps_spinbox)Then, the new def _on_source_software_changed(self, current_text: str):
"""Enable / Disable the fps spinbox based on source software."""
is_netcdf = (current_text in SUPPORTED_NETCDF_FILES)
# Disable fps box if netCDF
self.fps_spinbox.setEnabled(not is_netcdf)
if is_netcdf:
self.fps_spinbox.setToolTip(
"Frames per second (fps) will be directly read from "
"the netCDF file attributes."
)
else:
# Restore default tooltip
self.fps_spinbox.setToolTip(self.fps_default_tooltip)Validate datasets loaded from netCDF filesAll sound good, you can just push the changes you mentioned to update this PR. UI behaviour tests
Nice! Good to know. Your The first test usses mocking to verify the source software combo box is wired to the "handler" (the def test_source_software_combo_connected_to_handler(
make_napari_viewer_proxy, mocker
):
"""Test that changing the source software combo calls the right handler."""
mock_method = mocker.patch(
"movement.napari.loader_widgets.DataLoader._on_source_software_changed"
)
data_loader_widget = DataLoader(make_napari_viewer_proxy())
netcdf_text = "movement (netCDF)"
data_loader_widget.source_software_combo.setCurrentText(netcdf_text)
mock_method.assert_called_once_with(netcdf_text)The second test calls the handler directly and tests: "Does the handler correctly update the fps spinbox state and tooltip?". It also uses parametrisation to test multiple software choices. @pytest.mark.parametrize(
"choice, fps_enabled, tooltip_contains",
[
("movement (netCDF)", False, "directly read from the netCDF file"),
("SLEAP", True, "Set the frames per second of the tracking data"),
("DeepLabCut", True, "Set the frames per second of the tracking data"),
],
)
def test_on_source_software_changed_sets_fps_state(
make_napari_viewer_proxy, choice, fps_enabled, tooltip_contains
):
"""Test that changing the source software updates the fps spinbox.
Both the enabled/disabled state and the tooltip should be updated.
"""
data_loader_widget = DataLoader(make_napari_viewer_proxy())
# initial state: fps spinbox enabled with the default tooltip
assert data_loader_widget.fps_spinbox.isEnabled()
assert (
data_loader_widget.fps_spinbox.toolTip()
== data_loader_widget.fps_default_tooltip
)
# call the handler directly
data_loader_widget._on_source_software_changed(choice)
# Assert enabled state
assert data_loader_widget.fps_spinbox.isEnabled() is fps_enabled
# Assert tooltip content
assert tooltip_contains in data_loader_widget.fps_spinbox.toolTip()This pattern is more maintainable long-term, as each test has a single clear responsibility. You will notice that we already have different sub-sections in the |
0a1e334 to
28f5be2
Compare
|
@niksirbi Thanks for a detailed review again :)
By bad, I should've mentioned that I just wanted the confirmation before I push the code. Just wanted to confirm the logic and steps before I commit the changes. UI behaviour tests
I agree, this is more robust and structured and aligns with the pattern in our code. I made the changes as u mentioned in the commit. Complete Test SuitThanks again for all the guidance. I've now implemented the full test suite based on your feedback:
Please let me know what you think! |
|



Description
What is this PR
Why is this PR needed?
The
napariloader widget could load data from third-party formats (like DeepLabCut), but it had no option to re-load a dataset that had been processed and saved as the project's recommendednetCDFfile. This created a gap in the user workflow, as discussed in issue #666What does this PR do?
This PR adds "movement (netCDF)" as a new option to the "source software" dropdown in the
loader_widgets.pyfile. It usesxarrayto open the selected.ncfile, checks that the requiredpositionvariable exists (showing a warning if not), and then passes the valid dataset to the existingds_to_napari_layersfunction for display.References
Related to #666
How has this PR been tested?
I have tested the code locally by running
napariwith these changes.napari.movementloader widget..ncfile that was previously saved bymovement.pointsandtrackslayers.pytest) also passes, showing no existing functionality was broken.Is this a breaking change?
No, this is not a breaking change. It only adds a new loader option and does not affect any of the existing loaders.
Does this PR require an update to the documentation?
Yes, the user-facing documentation for the napari widget will likely need to be updated to list "movement (netCDF)" as a supported format.
Checklist: