-
Notifications
You must be signed in to change notification settings - Fork 75
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 2070 2111 +41
=========================================
+ Hits 2070 2111 +41 ☔ 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! |
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 implementing the changes, @Sparshr04!
Since we now have full test coverage and all CI checks are passing, I’ve marked this PR as “ready for review” to signal that it’s getting close to completion.
I’ve left a few inline comments on the current implementation; most are minor suggestions.
As for what’s still missing:
- As discussed in the community call last Friday, it would be good to update the current value of the (disabled) FPS widget to match
ds.attrs["fps"]after loading the dataset from a netCDF file. This will visually reassure users that the correctfpsvalue has been read. It should just take a few extra lines of code, plus a corresponding test. - The GUI user guide (
docs/source/user_guide/gui.md), particularly the “Load the tracked dataset” section, needs to be updated to reflect the added support for netCDF files.
I suggest you focus first on addressing the inline comments and implementing point 1 above. Once that’s done, I’ll handle the documentation updates (point 2) myself, as I still want to think about how to do it exactly.
Thanks again for the great progress on this — it’s shaping up really well! 👏
…ed test function.
…corresponding tests
a1d6a93 to
dee0601
Compare
|
Hey @niksirbi Thanks for a detailed review once again!
Here is how I have approached the logic and testing : Well the main code change was simple if "fps" in ds.attrs:
self.fps_spinbox.setValue(ds.attrs["fps"])Testing I am thinking of adding extra parameter to self.fps = self.fps_spinbox.value()Let me know what you think :) |
|
Thanks for taking care of all the comments @Sparshr04 ! I agree with your implementation of the fps updating. Regarding the test, adding an extra parameter to @pytest.mark.parametrize(
"filename, source_software, set_fps, expected_fps",
[
# For netCDF files, fps should be read from file metadata
(
"MOVE_two-mice_octagon.analysis.nc",
"movement (netCDF)",
1.0,
50.0, # fps from file overwrites set_fps
),
# For non-netCDF files, fps should be the value set by the user
(
"DLC_single-wasp.predictions.h5",
"DeepLabCut",
30.0,
30.0, # set_fps persists
),
],
ids=["netcdf_file", "dlc_file"],
)
def test_fps_handling_on_load(
filename,
source_software,
set_fps,
expected_fps,
make_napari_viewer_proxy,
):
"""Test that FPS is correctly handled when loading files.
For netCDF files (.nc), the FPS should be read from file metadata.
For all other file types, the FPS should be the value set in the spinbox.
"""
# Instantiate the napari viewer and the data loader widget
viewer = make_napari_viewer_proxy()
data_loader_widget = DataLoader(viewer)
# Set the file path
file_path = pytest.DATA_PATHS.get(filename)
data_loader_widget.file_path_edit.setText(file_path.as_posix())
# Set the source software
data_loader_widget.source_software_combo.setCurrentText(source_software)
# Set the fps spinbox to the desired value
data_loader_widget.fps_spinbox.setValue(set_fps)
# Load the file
data_loader_widget._on_load_clicked()
# Check that the fps attribute matches the expected value
assert data_loader_widget.fps == expected_fps
# Check that the value of the fps spinbox has also been updated
assert data_loader_widget.fps_spinbox.value() == expected_fpsI think that the above 2 test cases are sufficient for now, no need to go overboard. Let me know what you think. I do realise that some of my testing preferences are a matter of taste. Apart from that, I'm approving this PR as it's definitely good enough. I will try to find a bit of time in the next days to document this feature, and after that I'll merge it. |
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.
I'm pre-approving this PR. Docs are missing, but I will take care of that myself before merging.
|
Thanks a lot for the review and feedback @niksirbi !
I completely agree, makes things cleaner and easier to maintain. To be honest I was gonna suggest for the same approach. Over the time we have made the code more readable and structured in the tests. I mean why leave a stone unturned :) I’ll proceed with this direction and keep the structure simple and readable as we discussed. |
|



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: