From 7991f14dcf31877f0363f1f2a4392586a04d6eee Mon Sep 17 00:00:00 2001 From: Liezl Maree <38435167+roomrys@users.noreply.github.com> Date: Thu, 19 Dec 2024 09:47:25 -0800 Subject: [PATCH] Release 1.4.1 (#2068) * Remove no-op code from #1498 * Add options to set background color when exporting video (#1328) * implement #921 * simplified form / refractor * Add test function and update cli docs * Improve test function to check background color * Improve comments * Change background options to lowercase * Use coderabbitai suggested `fill` --------- Co-authored-by: Shrivaths Shyam <52810689+shrivaths16@users.noreply.github.com> Co-authored-by: Liezl Maree <38435167+roomrys@users.noreply.github.com> * Increase range on batch size (#1513) * Increase range on batch size * Set maximum to a factor of 2 * Set default callable for `match_lists_function` (#1520) * Set default for `match_lists_function` * Move test code to official tests * Check using expected values * Allow passing in `Labels` to `app.main` (#1524) * Allow passing in `Labels` to `app.main` * Load the labels object through command * Add warning when unable to switch back to CPU mode * Replace (broken) `--unrag` with `--ragged` (#1539) * Fix unrag always set to true in sleap-export * Replace unrag with ragged * Fix typos * Add function to create app (#1546) * Refactor `AddInstance` command (#1561) * Refactor AddInstance command * Add staticmethod wrappers * Return early from set_visible_nodes * Import DLC with uniquebodyparts, add Tracks (#1562) * Import DLC with uniquebodyparts, add Tracks * add tests * correct tests * Make the hdf5 videos store as int8 format (#1559) * make the hdf5 video dataset type as proper int8 by padding with zeros * add gzip compression * Scale new instances to new frame size (#1568) * Fix typehinting in `AddInstance` * brought over changes from my own branch * added suggestions * Ensured google style comments --------- Co-authored-by: roomrys <38435167+roomrys@users.noreply.github.com> Co-authored-by: sidharth srinath * Fix package export (#1619) Add check for empty videos * Add resize/scroll to training GUI (#1565) * Make resizable training GUI and add adaptive scroll bar * Set a maximum window size --------- Co-authored-by: Liezl Maree <38435167+roomrys@users.noreply.github.com> * support loading slp files with non-compound types and str in metadata (#1566) Co-authored-by: Liezl Maree <38435167+roomrys@users.noreply.github.com> * change inference pipeline option to tracking-only (#1666) change inference pipeline none option to tracking-only * Add ABL:AOC 2023 Workshop link (#1673) * Add ABL:AOC 2023 Workshop link * Trigger website build * Graceful failing with seeking errors (#1712) * Don't try to seek to faulty last frame on provider initialization * Catch seeking errors and pass * Lint * Fix IndexError for hdf5 file import for single instance analysis files (#1695) * Fix hdf5 read for single instance analysis files * Add test * Small test files * removing unneccessary fixtures * Replace imgaug with albumentations (#1623) What's the worst that could happen? * Initial commit * Fix augmentation * Update more deps requirements * Use pip for installing albumentations and avoid reinstalling OpenCV * Update other conda envs * Fix out of bounds albumentations issues and update dependencies (#1724) * Install albumentations using conda-forge in environment file * Conda install albumentations * Add ndx-pose to pypi requirements * Keep out of bounds points * Black * Add ndx-pose to conda install in environment file * Match environment file without cuda * Ordered dependencies * Add test * Delete comments * Add conda packages to mac environment file * Order dependencies in pypi requirements * Add tests with zeroes and NaNs for augmentation * Back * Black * Make comment one line * Add todo for later * Black * Update to new TensorFlow conda package (#1726) * Build conda package locally * Try 2.8.4 * Merge develop into branch to fix dependencies * Change tensorflow version to 2.7.4 in where conda packages are used * Make tensorflow requirements in pypi looser * Conda package has TensorFlow 2.7.0 and h5py and numpy installed via conda * Change tensorflow version in `environment_no_cuda.yml` to test using CI * Test new sleap/tensorflow package * Reset build number * Bump version * Update mac deps * Update to Arm64 Mac runners * pin `importlib-metadata` * Pin more stuff on mac * constrain `opencv` version due to new qt dependencies * Update more mac stuff * Patches to get to green * More mac skipping --------- Co-authored-by: Talmo Pereira Co-authored-by: Talmo Pereira * Fix CI on macosx-arm64 (#1734) * Build conda package locally * Try 2.8.4 * Merge develop into branch to fix dependencies * Change tensorflow version to 2.7.4 in where conda packages are used * Make tensorflow requirements in pypi looser * Conda package has TensorFlow 2.7.0 and h5py and numpy installed via conda * Change tensorflow version in `environment_no_cuda.yml` to test using CI * Test new sleap/tensorflow package * Reset build number * Bump version * Update mac deps * Update to Arm64 Mac runners * pin `importlib-metadata` * Pin more stuff on mac * constrain `opencv` version due to new qt dependencies * Update more mac stuff * Patches to get to green * More mac skipping * Re-enable mac tests * Handle GPU re-init * Fix mac build CI * Widen tolerance for movenet correctness test * Fix build ci * Try for manual build without upload * Try to reduce training CI time * Rework actions * Fix miniforge usage * Tweaks * Fix build ci * Disable manual build * Try merging CI coverage * GPU/CPU usage in tests * Lint * Clean up * Fix test skip condition * Remove scratch test --------- Co-authored-by: eberrigan * Add option to export to CSV via sleap-convert and API (#1730) * Add csv as a format option * Add analysis to format * Add csv suffix to output path * Add condition for csv analysis file * Add export function to Labels class * delete print statement * lint * Add `analysis.csv` as parametrize input for `sleap-convert` tests * test `export_csv` method added to `Labels` class * black formatting * use `Path` to construct filename * add `analysis.csv` to cli guide for `sleap-convert` --------- Co-authored-by: Talmo Pereira * Only propagate Transpose Tracks when propagate is checked (#1748) Fix always-propagate transpose tracks issue * View Hyperparameter nonetype fix (#1766) Pass config getter argument to fetch hyperparameters * Adding ragged metadata to `info.json` (#1765) Add ragged metadata to info.json file * Add batch size to GUI for inference (#1771) * Fix conda builds (#1776) * test conda packages in a test environment as part of CI * do not test sleap import using conda build * use github environment variables to define build path for each OS in the matrix and add print statements for testing * figure out paths one OS at a time * github environment variables work in subsequent steps not current step * use local builds first * print env info * try simple environment creation * try conda instead of mamba * fix windows build path * fix windows build path * add comment to reference pull request * remove test stage from conda build for macs and test instead by creating the environment in a workflow * test workflow by pushing to current branch * test conda package on macos runner * Mac build does not need nvidia channel * qudida and albumentations are conda installed now * add comment with original issue * use python 3.9 * use conda match specifications syntax * make print statements more readable for troubleshooting python versioning * clean up build file * update version for pre-release * add TODO * add tests for conda packages before uploading * update ci comments and branches * remove macos test of pip wheel since python 3.9 is not supported by setup-python action * Upgrade build actions for release (#1779) * update `build.yml` so it matches updates from `build_manual.yml` * test `build.yml` without uploading * test again using build_manual.yml * build pip wheel with Ubuntu and turn off caching so build.yml exactly matches build_manual.yml * `build.yml` on release only and upload * testing caching * `use-only-tar-bz2: true` makes environment unsolvable, change it back * Update .github/workflows/build_manual.yml Co-authored-by: Liezl Maree <38435167+roomrys@users.noreply.github.com> * Update .github/workflows/build.yml Co-authored-by: Liezl Maree <38435167+roomrys@users.noreply.github.com> * bump pre-release version * fix version for pre-release * run build and upload on release! * try setting `CACHE_NUMBER` to 1 with `use-only-tar-bz2` set to true * increasing the cache number to reset the cache does work when `use-only-tar-bz2` is set to true * publish and upload on release only --------- Co-authored-by: Liezl Maree <38435167+roomrys@users.noreply.github.com> * Add ZMQ support via GUI and CLI (#1780) * Add ZMQ support via GUI and CLI, automatic port handler, separate utils module for the functions * Change menu name to match deleting predictions beyond max instance (#1790) Change menu and function names * Fix website build and remove build cache across workflows (#1786) * test with build_manual on push * comment out caching in build manual * remove cache step from builad manual since environment resolves when this is commented out * comment out cache in build ci * remove cache from build on release * remove cache from website build * test website build on push * add name to checkout step * update checkout to v4 * update checkout to v4 in build ci * remove cache since build ci works without it * update upload-artifact to v4 in build ci * update second chechout to v4 in build ci * update setup-python to v5 in build ci * update download-artifact to v4 in build ci * update checkout to v4 in build ci * update checkout to v4 in website build * update setup-miniconda to v3.0.3 in website build * update actions-gh-pages to v4 in website build * update actions checkout and setup-python in ci * update checkout action in ci to v4 * pip install lxml[html_clean] because of error message during action * add error message to website to explain why pip install lxml[html_clean] * remove my branch for pull request * Bump to 1.4.1a1 (#1791) * bump versions to 1.4.1a1 * we can change the version on the installation page since this will be merged into the develop branch and not main * Fix windows conda package upload and build ci (#1792) * windows OS is 2022 not 2019 on runner * upload windows conda build manually but not pypi build * remove comment and run build ci * change build manual back so that it doesn't upload * remove branch from build manual * update installation docs for 1.4.1a1 * Fix zmq inference (#1800) * Ensure that we always pass in the zmq_port dict to LossViewer * Ensure zmq_ports has correct keys inside LossViewer * Use specified controller and publish ports for first attempted addresses * Add test for ports being set in LossViewer * Add max attempts to find unused port * Fix find free port loop and add for controller port also * Improve code readablility and reuse * Improve error message when unable to find free port * Set selected instance to None after removal (#1808) * Add test that selected instance set to None after removal * Set selected instance to None after removal * Add `InstancesList` class to handle backref to `LabeledFrame` (#1807) * Add InstancesList class to handle backref to LabeledFrame * Register structure/unstructure hooks for InstancesList * Add tests for the InstanceList class * Handle case where instance are passed in but labeled_frame is None * Add tests relevant methods in LabeledFrame * Delegate setting frame to InstancesList * Add test for PredictedInstance.frame after complex merge * Add todo comment to not use Instance.frame * Add rtest for InstasnceList.remove * Use normal list for informative `merged_instances` * Add test for copy and clear * Add copy and clear methods, use normal lists in merge method * Bump to v1.4.1a2 (#1835) bump to 1.4.1a2 * Updated trail length viewing options (#1822) * updated trail length optptions * Updated trail length options in the view menu * Updated `prefs` to include length info from `preferences.yaml` * Added trail length as method of `MainWindow` * Updated trail length documentation * black formatting --------- Co-authored-by: Keya Loding * Handle case when no frame selection for trail overlay (#1832) * Menu option to open preferences directory and update to util functions to pathlib (#1843) * Add menu to view preferences directory and update to pathlib * text formatting * Add `Keep visualizations` checkbox to training GUI (#1824) * Renamed save_visualizations to view_visualizations for clarity * Added Delete Visualizations button to the training pipeline gui, exposed del_viz_predictions config option to the user * Reverted view_ back to save_ and changed new training checkbox to Keep visualization images after training. * Fixed keep_viz config option state override bug and updated keep_viz doc description * Added test case for reading training CLI argument correctly * Removed unnecessary testing code * Creating test case to check for viz folder * Finished tests to check CLI argument reading and viz directory existence * Use empty string instead of None in cli args test * Use keep_viz_images false in most all test configs (except test to override config) --------- Co-authored-by: roomrys <38435167+roomrys@users.noreply.github.com> * Allowing inference on multiple videos via `sleap-track` (#1784) * implementing proposed code changes from issue #1777 * comments * configuring output_path to support multiple video inputs * fixing errors from preexisting test cases * Test case / code fixes * extending test cases for mp4 folders * test case for output directory * black and code rabbit fixes * code rabbit fixes * as_posix errors resolved * syntax error * adding test data * black * output error resolved * edited for push to dev branch * black * errors fixed, test cases implemented * invalid output test and invalid input test * deleting debugging statements * deleting print statements * black * deleting unnecessary test case * implemented tmpdir * deleting extraneous file * fixing broken test case * fixing test_sleap_track_invalid_output * removing support for multiple slp files * implementing talmo's comments * adding comments * Add object keypoint similarity method (#1003) * Add object keypoint similarity method * fix max_tracking * correct off-by-one error * correct off-by-one error * Generate suggestions using max point displacement threshold (#1862) * create function max_point_displacement, _max_point_displacement_video. Add to yaml file. Create test for new function . . . will need to edit * remove unnecessary for loop, calculate proper displacement, adjusted tests accordingly * Increase range for displacement threshold * Fix frames not found bug * Return the latter frame index * Lint --------- Co-authored-by: roomrys <38435167+roomrys@users.noreply.github.com> * Added Three Different Cases for Adding a New Instance (#1859) * implemented paste with offset * right click and then default will paste the new instance at the location of the cursor * modified the logics for creating new instance * refined the logic * fixed the logic for right click * refined logics for adding new instance at a specific location * Remove print statements * Comment code * Ensure that we choose a non nan reference node * Move OOB nodes to closest in-bounds position --------- Co-authored-by: roomrys <38435167+roomrys@users.noreply.github.com> * Allow csv and text file support on sleap track (#1875) * initial changes * csv support and test case * increased code coverage * Error fixing, black, deletion of (self-written) unused code * final edits * black * documentation changes * documentation changes * Fix GUI crash on scroll (#1883) * Only pass wheelEvent to children that can handle it * Add test for wheelEvent * Fix typo to allow rendering videos with mp4 (Mac) (#1892) Fix typo to allow rendering videos with mp4 * Do not apply offset when double clicking a `PredictedInstance` (#1888) * Add offset argument to newInstance and AddInstance * Apply offset of 10 for Add Instance menu button (Ctrl + I) * Add offset for docks Add Instance button * Make the QtVideoPlayer context menu unit-testable * Add test for creating a new instance * Add test for "New Instance" button in `InstancesDock` * Fix typo in docstring * Add docstrings and typehinting * Remove unused imports and sort imports * Refactor video writer to use imageio instead of skvideo (#1900) * modify `VideoWriter` to use imageio with ffmpeg backend * check to see if ffmpeg is present * use the new check for ffmpeg * import imageio.v2 * add imageio-ffmpeg to environments to test * using avi format for now * remove SKvideo videowriter * test `VideoWriterImageio` minimally * add more documentation for ffmpeg * default mp4 for ffmpeg should be mp4 * print using `IMAGEIO` when using ffmpeg * mp4 for ffmpeg * use mp4 ending in test * test `VideoWriterImageio` with avi file extension * test video with odd size * remove redundant filter since imageio-ffmpeg resizes automatically * black * remove unused import * use logging instead of print statement * import cv2 is needed for resize * remove logging * Use `Video.from_filename` when structuring videos (#1905) * Use Video.from_filename when structuring videos * Modify removal_test_labels to have extension in filename * Use | instead of + in key commands (#1907) * Use | instead of + in key commands * Lint * Replace QtDesktop widget in preparation for PySide6 (#1908) * Replace to-be-depreciated QDesktopWidget * Remove unused imports and sort remaining imports * Remove unsupported |= operand to prepare for PySide6 (#1910) Fixes TypeError: unsupported operand type(s) for |=: 'int' and 'Option' * Use positional argument for exception type (#1912) traceback.format_exception has changed it's first positional argument's name from etype to exc in python 3.7 to 3.10 * Replace all Video structuring with Video.cattr() (#1911) * Remove unused AsyncVideo class (#1917) Remove unused AsyncVideo * Refactor `LossViewer` to use matplotlib (#1899) * use updated syntax for QtAgg backend of matplotlib * start add features to `MplCanvas` to replace QtCharts features in `LossViewer` (untested) * remove QtCharts imports and replace with MplCanvas * remove QtCharts imports and replace with MplCanvas * start using MplCanvas in LossViwer instead of QtCharts (untested) * use updated syntax * Uncomment all commented out QtChart * Add debug code * Refactor monitor to use LossViewer._init_series method * Add monitor only debug code * Add methods for setting up axes and legend * Add the matplotlib canvas to the widget * Resize axis with data (no log support yet) * Try using PathCollection for "batch" * Get "batch" plotting with ax.scatter (no log support yet) * Add log support * Add a _resize_axis method * Modify init_series to work for ax.plot as well * Use matplotlib to plot epoch_loss line * Add method _add_data_to_scatter * Add _add_data_to_plot method * Add docstring to _resize_axes * Add matplotlib plot for val_loss * Add matplotlib scatter for val_loss_best * Avoid errors with setting log scale before any positive values * Add x and y axes labels * Set title (removing html tags) * Add legend * Adjust positioning of plot * Lint * Leave MplCanvas unchanged * Removed unused training_monitor.LossViewer * Resize fonts * Move legend outside of plot * Add debug code for montitor aesthetics * Use latex formatting to bold parts of title * Make axes aesthetic * Add midpoint grid lines * Set initial limits on x and y axes to be 0+ * Ensure x axis minimum is always resized to 0+ * Adjust plot to account for plateau patience title * Add debug code for plateau patience title line * Lint * Set thicker line width * Remove unused import * Set log axis on initialization * Make tick labels smaller * Move plot down a smidge * Move ylabel left a bit * Lint * Add class LossPlot * Refactor LossViewer to use LossPlot * Remove QtCharts code * Remove debug codes * Allocate space for figure items based on item's size * Refactor LossPlot to use underscores for internal methods * Ensure y_min, y_max not equal Otherwise we get an unnecessary teminal message: UserWarning: Attempting to set identical bottom == top == 3.0 results in singular transformations; automatically expanding. self.axes.set_ylim(y_min, y_max) --------- Co-authored-by: roomrys Co-authored-by: roomrys <38435167+roomrys@users.noreply.github.com> * Refactor `LossViewer` to use underscores for internal method names (#1919) Refactor LossViewer to use underscores for internal method names * Manually handle `Instance.from_predicted` structuring when not `None` (#1930) * Use `tf.math.mod` instead of `%` (#1931) * Option for Max Stride to be 128 (#1941) Co-authored-by: Max Weinberg * Add discussion comment workflow (#1945) * Add a bot to autocomment on workflow * Use github markdown warning syntax * Add a multiline warning * Change happy coding to happy SLEAPing Co-authored-by: Talmo Pereira --------- Co-authored-by: roomrys Co-authored-by: Talmo Pereira * Add comment on issue workflow (#1946) * Add workflow to test conda packages (#1935) * Add missing imageio-ffmpeg to meta.ymls (#1943) * Update installation docs 1.4.1 (#1810) * [wip] Updated installation docs * Add tabs for different OS installations * Move installation methods to tabs * Use tabs.css * FIx styling error (line under last tab in terminal hint) * Add installation instructions before TOC * Replace mamba with conda * Lint * Find good light colors not switching when change dark/light themes * Get color scheme switching with dark/light toggle button * Upgrade website build dependencies * Remove seemingly unneeded dependencies from workflow * Add myst-nb>=0.16.0 lower bound * Trigger dev website build * Fix minor typo in css * Add miniforge and one-liner installs for package managers --------- Co-authored-by: roomrys Co-authored-by: Talmo Pereira * Add imageio dependencies for pypi wheel (#1950) Add imagio dependencies for pypi wheel Co-authored-by: roomrys * Do not always color skeletons table black (#1952) Co-authored-by: roomrys * Remove no module named work error (#1956) * Do not always color skeletons table black * Remove offending (possibly unneeded) line that causes the no module named work error to print in terminal * Remove offending (possibly unneeded) line that causes the no module named work error to print in terminal * Remove accidentally added changes * Add (failing) test to ensure menu-item updates with state change * Reconnect callback for menu-item (using lambda) * Add (failing) test to ensure menu-item updates with state change Do not assume inital state * Reconnect callback for menu-item (using lambda) --------- Co-authored-by: roomrys * Add `normalized_instance_similarity` method (#1939) * Add normalize function * Expose normalization function * Fix tests * Expose object keypoint sim function * Fix tests * Handle skeleton decoding internally (#1961) * Reorganize (and add) imports * Add (and reorganize) imports * Modify decode_preview_image to return bytes if specified * Implement (minimally tested) replace_jsonpickle_decode * Add support for using idx_to_node map i.e. loading from Labels (slp file) * Ignore None items in reduce_list * Convert large function to SkeletonDecoder class * Update SkeletonDecoder.decode docstring * Move decode_preview_image to SkeletonDecoder * Use SkeletonDecoder instead of jsonpickle in tests * Remove unused imports * Add test for decoding dict vs tuple pystates * Handle skeleton encoding internally (#1970) * start class `SkeletonEncoder` * _encoded_objects need to be a dict to add to * add notebook for testing * format * fix type in docstring * finish classmethod for encoding Skeleton as a json string * test encoded Skeleton as json string by decoding it * add test for decoded encoded skeleton * update jupyter notebook for easy testing * constraining attrs in dev environment to make sure decode format is always the same locally * encode links first then encode source then target then type * save first enconding statically as an input to _get_or_assign_id so that we do not always get py/id * save first encoding statically * first encoding is passed to _get_or_assign_id * use first_encoding variable to determine if we should assign a py/id * add print statements for debugging * update notebook for easy testing * black * remove comment * adding attrs constraint to show this passes for certain attrs version only * add import * switch out jsonpickle.encode * oops remove import * can attrs be unconstrained? * forgot comma * pin attrs for testing * test Skeleton from json, template, with symmetries, and template * use SkeletonEncoder.encode * black * try removing None values in EdgeType reduced * Handle case when nodes are replaced by integer indices from caller * Remove prototyping notebook * Remove attrs pins * Remove sort keys (which flips the neccessary ordering of our py/ids) * Do not add extra indents to encoded file * Only append links after fully encoded (fat-finger) * Remove outdated comment * Lint --------- Co-authored-by: Talmo Pereira Co-authored-by: roomrys <38435167+roomrys@users.noreply.github.com> * Pin ndx-pose<0.2.0 (#1978) * Pin ndx-pose<0.2.0 * Typo * Sort encoded `Skeleton` dictionary for backwards compatibility (#1975) * Add failing test to check that encoded Skeleton is sorted * Sort Skeleton dictionary before encoding * Remove unused import * Disable comment bot for now * Fix COCO Dataset Loading for Invisible Keypoints (#2035) Update coco.py # Fix COCO Dataset Loading for Invisible Keypoints ## Issue When loading COCO datasets, keypoints marked as invisible (flag=0) are currently skipped and later placed randomly within the instance's bounding box. However, in COCO format, these keypoints may still have valid coordinate information that should be preserved (see toy_dataset for expected vs. current behavior). ## Changes Modified the COCO dataset loading logic to: - Check if invisible keypoints (flag=0) have non-zero coordinates - If coordinates are (0,0), skip the point (existing behavior) - If coordinates are not (0,0), create the point at those coordinates but mark it as not visible - Maintain existing behavior for visible (flag=2) and labeled * Lint * Add tracking score as seekbar header options (#2047) * Add `tracking_score` as a constructor arg for `PredictedInstance` * Add `tracking_score` to ID models * Add fixture with tracking scores * Add tracking score to seekbar header * Add bonsai guide for sleap docs (#2050) * [WIP] Add bonsai guide page * Add more information to the guide with images * add branch for website build * Typos * fix links * Include suggestions * Add more screenshots and refine the doc * Remove branch from website workflow * Completed documentation edits from PR made by reviewer + review bot. --------- Co-authored-by: Shrivaths Shyam <52810689+shrivaths16@users.noreply.github.com> Co-authored-by: Liezl Maree <38435167+roomrys@users.noreply.github.com> * Don't mark complete on instance scaling (#2049) * Add check for instances with track assigned before training ID models (#2053) * Add menu item for deleting instances beyond frame limit (#1797) * Add menu item for deleting instances beyond frame limit * Add test function to test the instances returned * typos * Update docstring * Add frame range form * Extend command to use frame range --------- Co-authored-by: Talmo Pereira * Highlight instance box on hover (#2055) * Make node marker and label sizes configurable via preferences (#2057) * Make node marker and label sizes configurable via preferences * Fix test * Enable touchpad pinch to zoom (#2058) * Fix import PySide2 -> qtpy (#2065) * Fix import PySide2 -> qtpy * Remove unnecessary print statements. * Add channels for pip conda env (#2067) * Add channels for pypi conda env * Trigger dev website build * Separate the video name and its filepath columns in `VideoTablesModel` (#2052) * add option to show video names with filepath * add doc * new feature added successfully * delete unnecessary code * remove attributes from video object * Update dataviews.py * remove all properties * delete toggle option * remove video show * fix the order of the columns * remove options * Update sleap/gui/dataviews.py Co-authored-by: Liezl Maree <38435167+roomrys@users.noreply.github.com> * Update sleap/gui/dataviews.py Co-authored-by: Liezl Maree <38435167+roomrys@users.noreply.github.com> * use pathlib instead of substrings * Update dataviews.py Co-authored-by: Liezl Maree <38435167+roomrys@users.noreply.github.com> * Use Path instead of pathlib.Path and sort imports and remove unused imports * Use item.filename instead of getattr --------- Co-authored-by: Liezl Maree <38435167+roomrys@users.noreply.github.com> * Make status bar dependent on UI mode (#2063) * remove bug for dark mode * fix toggle case --------- Co-authored-by: Liezl Maree <38435167+roomrys@users.noreply.github.com> * Bump version to 1.4.1 (#2062) * Bump version to 1.4.1 * Trigger conda/pypi builds (no upload) * Trigger website build * Add dev channel to installation instructions --------- Co-authored-by: Talmo Pereira * Add -c sleap/label/dev channel for win/linux - also trigger website build --------- Co-authored-by: Scott Yang <67733409+scott-yj-yang@users.noreply.github.com> Co-authored-by: Shrivaths Shyam <52810689+shrivaths16@users.noreply.github.com> Co-authored-by: getzze Co-authored-by: Lili Karashchuk Co-authored-by: Sidharth Srinath <69165889+ssrinath22@users.noreply.github.com> Co-authored-by: sidharth srinath Co-authored-by: Talmo Pereira Co-authored-by: KevinZ0217 <96039456+KevinZ0217@users.noreply.github.com> Co-authored-by: Elizabeth <106755962+eberrigan@users.noreply.github.com> Co-authored-by: Talmo Pereira Co-authored-by: eberrigan Co-authored-by: vaibhavtrip29 <82742234+vaibhavtrip29@users.noreply.github.com> Co-authored-by: Keya Loding <117320751+keyaloding@users.noreply.github.com> Co-authored-by: Keya Loding Co-authored-by: Hajin Park Co-authored-by: Elise Davis Co-authored-by: gqcpm <63070177+gqcpm@users.noreply.github.com> Co-authored-by: Andrew Park Co-authored-by: roomrys Co-authored-by: MweinbergUmass <143860933+MweinbergUmass@users.noreply.github.com> Co-authored-by: Max Weinberg Co-authored-by: DivyaSesh <64513125+gitttt-1234@users.noreply.github.com> Co-authored-by: Felipe Parodi <53239931+felipe-parodi@users.noreply.github.com> Co-authored-by: croblesMed --- .conda/README.md | 4 +- .conda/condarc.yaml | 1 + .conda/meta.yaml | 32 +- .conda_mac/build.sh | 1 - .conda_mac/condarc.yaml | 1 - .conda_mac/meta.yaml | 28 +- .github/ISSUE_TEMPLATE/bug_report.md | 16 +- .../workflows/archive/comment-template.yml | 71 + .github/workflows/archive/comment.yml | 24 + .github/workflows/build.yml | 121 +- .github/workflows/build_conda_ci.yml | 179 +++ .github/workflows/build_manual.yml | 213 ++- .../{build_ci.yml => build_pypi_ci.yml} | 74 +- .github/workflows/ci.yml | 81 +- .github/workflows/website.yml | 24 +- AUTHORS | 2 + README.rst | 4 +- dev_requirements.txt | 7 +- docs/_static/bonsai-connection.jpg | Bin 0 -> 113805 bytes docs/_static/bonsai-filecapture.jpg | Bin 0 -> 68990 bytes docs/_static/bonsai-predictcentroids.jpg | Bin 0 -> 76547 bytes docs/_static/bonsai-predictposeidentities.jpg | Bin 0 -> 85845 bytes docs/_static/bonsai-predictposes.jpg | Bin 0 -> 79334 bytes docs/_static/bonsai-workflow.jpg | Bin 0 -> 33481 bytes docs/_static/css/tabs.css | 91 ++ docs/conf.py | 16 +- docs/guides/bonsai.md | 75 ++ docs/guides/cli.md | 27 +- docs/guides/gui.md | 2 +- docs/guides/index.md | 5 + docs/guides/proofreading.md | 2 + docs/installation.md | 424 +++--- ..._and_inference_on_an_example_dataset.ipynb | 4 +- environment.yml | 13 +- environment_mac.yml | 12 +- environment_no_cuda.yml | 14 +- pypi_requirements.txt | 9 +- requirements.txt | 6 +- sleap/config/frame_range_form.yaml | 13 + sleap/config/labeled_clip_form.yaml | 4 + sleap/config/pipeline_form.yaml | 70 +- sleap/config/suggestions.yaml | 9 +- sleap/config/training_editor_form.yaml | 9 +- sleap/gui/app.py | 141 +- sleap/gui/commands.py | 445 +++++-- sleap/gui/dataviews.py | 53 +- sleap/gui/dialogs/export_clip.py | 8 +- sleap/gui/dialogs/filedialog.py | 3 +- sleap/gui/dialogs/frame_range.py | 42 + sleap/gui/dialogs/metrics.py | 3 +- sleap/gui/learning/dialog.py | 88 +- sleap/gui/learning/runners.py | 53 +- sleap/gui/overlays/base.py | 1 - sleap/gui/overlays/tracks.py | 23 +- sleap/gui/suggestions.py | 52 + sleap/gui/utils.py | 28 + sleap/gui/widgets/docks.py | 10 +- sleap/gui/widgets/monitor.py | 1176 ++++++++++++----- sleap/gui/widgets/mpl.py | 5 +- sleap/gui/widgets/training_monitor.py | 566 -------- sleap/gui/widgets/video.py | 143 +- sleap/info/feature_suggestions.py | 2 +- sleap/info/metrics.py | 181 +-- sleap/info/summary.py | 44 +- sleap/instance.py | 239 +++- sleap/io/asyncvideo.py | 218 --- sleap/io/convert.py | 22 +- sleap/io/dataset.py | 13 + sleap/io/format/coco.py | 3 + sleap/io/format/deeplabcut.py | 52 +- sleap/io/format/hdf5.py | 51 +- sleap/io/format/sleap_analysis.py | 2 +- sleap/io/video.py | 46 +- sleap/io/videowriter.py | 71 +- sleap/io/visuals.py | 34 +- sleap/nn/config/outputs.py | 6 +- sleap/nn/data/augmentation.py | 89 +- sleap/nn/data/pipelines.py | 16 +- sleap/nn/data/providers.py | 4 +- sleap/nn/inference.py | 411 ++++-- sleap/nn/peak_finding.py | 2 +- sleap/nn/system.py | 26 +- sleap/nn/tracker/components.py | 110 +- sleap/nn/tracking.py | 114 +- sleap/nn/training.py | 31 +- sleap/prefs.py | 12 +- sleap/skeleton.py | 528 +++++++- .../training_profiles/baseline.centroid.json | 1 + .../baseline_large_rf.bottomup.json | 1 + .../baseline_large_rf.single.json | 1 + .../baseline_large_rf.topdown.json | 1 + .../baseline_medium_rf.bottomup.json | 1 + .../baseline_medium_rf.single.json | 1 + .../baseline_medium_rf.topdown.json | 1 + .../pretrained.bottomup.json | 1 + .../pretrained.centroid.json | 1 + .../training_profiles/pretrained.single.json | 1 + .../training_profiles/pretrained.topdown.json | 1 + sleap/util.py | 48 +- sleap/version.py | 3 +- .../labeled-data/video/CollectedData_LM.csv | 14 +- .../labeled-data/video/maudlc_testdata.csv | 8 + .../labeled-data/video/maudlc_testdata_v2.csv | 8 + tests/data/dlc/madlc_230_config.yaml | 8 +- ..._robot.000_small_robot_3_frame.analysis.h5 | Bin 0 -> 26408 bytes .../initial_config.json | 1 + .../training_config.json | 1 + .../initial_config.json | 2 +- .../training_config.json | 2 +- .../initial_config.json | 1 + .../training_config.json | 1 + .../initial_config.json | 1 + .../training_config.json | 1 + .../initial_config.json | 1 + .../training_config.json | 1 + .../initial_config.json | 1 + .../training_config.json | 1 + .../fly_skeleton_legs_pystate_dict.json | 1 + tests/data/tracks/clip.predictions.slp | Bin 0 -> 396184 bytes tests/fixtures/datasets.py | 21 + tests/fixtures/skeletons.py | 15 +- tests/fixtures/videos.py | 9 + tests/gui/learning/test_dialog.py | 20 + tests/gui/test_app.py | 8 + tests/gui/test_commands.py | 126 +- tests/gui/test_dialogs.py | 1 - tests/gui/test_monitor.py | 23 +- tests/gui/test_suggestions.py | 14 + tests/gui/test_video_player.py | 44 +- tests/gui/widgets/test_docks.py | 36 +- tests/info/test_metrics.py | 55 + tests/info/test_summary.py | 15 +- tests/io/test_asyncvideo.py | 31 - tests/io/test_convert.py | 4 +- tests/io/test_dataset.py | 46 +- tests/io/test_formats.py | 86 +- tests/io/test_videowriter.py | 63 +- tests/io/test_visuals.py | 41 + tests/nn/architectures/test_common.py | 2 +- .../nn/architectures/test_encoder_decoder.py | 2 +- tests/nn/architectures/test_hourglass.py | 2 +- tests/nn/architectures/test_leap.py | 2 +- .../architectures/test_pretrained_encoders.py | 2 +- tests/nn/architectures/test_resnet.py | 2 +- tests/nn/architectures/test_unet.py | 2 +- tests/nn/config/test_config_utils.py | 2 +- tests/nn/data/test_augmentation.py | 114 +- tests/nn/data/test_data_training.py | 2 +- tests/nn/data/test_edge_maps.py | 2 +- tests/nn/data/test_identity.py | 2 +- tests/nn/data/test_instance_centroids.py | 2 +- tests/nn/data/test_instance_cropping.py | 2 +- tests/nn/data/test_normalization.py | 2 +- tests/nn/data/test_offset_regression.py | 2 +- tests/nn/data/test_pipelines.py | 2 +- tests/nn/data/test_providers.py | 2 +- tests/nn/data/test_resizing.py | 6 +- tests/nn/data/test_utils.py | 2 +- tests/nn/test_evals.py | 2 +- tests/nn/test_heads.py | 2 +- tests/nn/test_inference.py | 507 ++++++- tests/nn/test_inference_identity.py | 2 +- tests/nn/test_model.py | 2 +- tests/nn/test_nn_utils.py | 2 +- tests/nn/test_paf_grouping.py | 2 +- tests/nn/test_peak_finding.py | 2 +- tests/nn/test_system.py | 24 +- tests/nn/test_tracker_components.py | 83 +- tests/nn/test_tracking_integration.py | 4 +- tests/nn/test_training.py | 68 +- tests/test_instance.py | 231 +++- tests/test_skeleton.py | 98 +- tests/test_util.py | 8 - 173 files changed, 6329 insertions(+), 2492 deletions(-) create mode 100644 .github/workflows/archive/comment-template.yml create mode 100644 .github/workflows/archive/comment.yml create mode 100644 .github/workflows/build_conda_ci.yml rename .github/workflows/{build_ci.yml => build_pypi_ci.yml} (71%) create mode 100644 docs/_static/bonsai-connection.jpg create mode 100644 docs/_static/bonsai-filecapture.jpg create mode 100644 docs/_static/bonsai-predictcentroids.jpg create mode 100644 docs/_static/bonsai-predictposeidentities.jpg create mode 100644 docs/_static/bonsai-predictposes.jpg create mode 100644 docs/_static/bonsai-workflow.jpg create mode 100644 docs/_static/css/tabs.css create mode 100644 docs/guides/bonsai.md create mode 100644 sleap/config/frame_range_form.yaml create mode 100644 sleap/gui/dialogs/frame_range.py create mode 100644 sleap/gui/utils.py delete mode 100644 sleap/gui/widgets/training_monitor.py delete mode 100644 sleap/io/asyncvideo.py create mode 100644 tests/data/dlc/labeled-data/video/maudlc_testdata.csv create mode 100644 tests/data/dlc/labeled-data/video/maudlc_testdata_v2.csv create mode 100644 tests/data/hdf5_format_v1/small_robot.000_small_robot_3_frame.analysis.h5 create mode 100644 tests/data/skeleton/fly_skeleton_legs_pystate_dict.json create mode 100644 tests/data/tracks/clip.predictions.slp create mode 100644 tests/info/test_metrics.py delete mode 100644 tests/io/test_asyncvideo.py diff --git a/.conda/README.md b/.conda/README.md index 71a49d7f1..65fadd36e 100644 --- a/.conda/README.md +++ b/.conda/README.md @@ -3,7 +3,7 @@ This folder defines the conda package build for Linux and Windows. There are run To build, first go to the base repo directory and install the build environment: ``` -mamba env create -f environment_build.yml -n sleap_build && conda activate sleap_build +conda env create -f environment_build.yml -n sleap_build && conda activate sleap_build ``` And finally, run the build command pointing to this directory: @@ -15,7 +15,7 @@ conda build .conda --output-folder build -c conda-forge -c nvidia -c https://con To install the local package: ``` -mamba create -n sleap_0 -c conda-forge -c nvidia -c ./build -c https://conda.anaconda.org/sleap/ -c anaconda sleap=x.x.x +conda create -n sleap_0 -c conda-forge -c nvidia -c ./build -c https://conda.anaconda.org/sleap/ -c anaconda sleap=x.x.x ``` replacing x.x.x with the version of SLEAP that you just built. diff --git a/.conda/condarc.yaml b/.conda/condarc.yaml index f9ac6efbe..c5fbc2d96 100644 --- a/.conda/condarc.yaml +++ b/.conda/condarc.yaml @@ -1,5 +1,6 @@ channels: - conda-forge - nvidia + - https://conda.anaconda.org/sleap/label/dev - sleap - anaconda diff --git a/.conda/meta.yaml b/.conda/meta.yaml index caffe9fcb..c1781a3ee 100644 --- a/.conda/meta.yaml +++ b/.conda/meta.yaml @@ -16,7 +16,7 @@ source: path: ../ build: - number: 1 + number: 0 requirements: host: @@ -32,12 +32,12 @@ requirements: # unnecessary pypi packages are installed via the build script (bld.bat, build.sh) - conda-forge::attrs ==21.4.0 - conda-forge::cattrs ==1.1.1 - - conda-forge::h5py ==3.1 # [not win] - - conda-forge::imgaug ==0.4.0 + - conda-forge::h5py ==3.7.0 + - conda-forge::imageio-ffmpeg # Required for imageio to read/write videos with ffmpeg - conda-forge::jsmin - conda-forge::jsonpickle ==1.2 - conda-forge::networkx - - conda-forge::opencv + - conda-forge::opencv <4.9.0 - conda-forge::pandas - conda-forge::pillow >=8.3.2 - conda-forge::psutil @@ -53,20 +53,24 @@ requirements: - conda-forge::scikit-learn ==1.0 - conda-forge::scikit-video - conda-forge::seaborn + - conda-forge::qudida + - conda-forge::albumentations + - conda-forge::ndx-pose <0.2.0 + - conda-forge::importlib-metadata ==4.11.4 run: - conda-forge::python ==3.7.12 # Run into _MAX_WINDOWS_WORKERS not found if < - conda-forge::attrs ==21.4.0 - conda-forge::cattrs ==1.1.1 + - conda-forge::imageio-ffmpeg # Required for imageio to read/write videos with ffmpeg - conda-forge::cudatoolkit ==11.3.1 - conda-forge::cudnn=8.2.1 - nvidia::cuda-nvcc=11.3 - - conda-forge::h5py ==3.1 # [not win] - - conda-forge::imgaug ==0.4.0 + - conda-forge::h5py ==3.7.0 - conda-forge::jsmin - conda-forge::jsonpickle ==1.2 - conda-forge::networkx - numpy >=1.19.5,<1.23.0 # Linux likes anaconda, windows likes conda-forge - - conda-forge::opencv + - conda-forge::opencv <4.9.0 - conda-forge::pandas - conda-forge::pillow >=8.3.2 - conda-forge::psutil @@ -82,9 +86,15 @@ requirements: - conda-forge::scikit-learn ==1.0 - conda-forge::scikit-video - conda-forge::seaborn - - sleap::tensorflow >=2.6.3,<2.11 # No windows GPU support for >2.10, sleap channel has 2.6.3 + - sleap/label/dev::tensorflow ==2.7.0 # TODO: Switch to main label when updated - conda-forge::tensorflow-hub <0.14.0 # Causes pynwb conflicts on linux GH-1446 + - conda-forge::qudida + - conda-forge::albumentations + - conda-forge::ndx-pose <0.2.0 + - conda-forge::importlib-metadata ==4.11.4 -test: - imports: - - sleap \ No newline at end of file +# This no longer works so we have moved it to the build workflow +# https://github.com/talmolab/sleap/pull/1744 +# test: +# imports: +# - sleap \ No newline at end of file diff --git a/.conda_mac/build.sh b/.conda_mac/build.sh index 2036035f6..a68193560 100644 --- a/.conda_mac/build.sh +++ b/.conda_mac/build.sh @@ -2,7 +2,6 @@ # Install anything that didn't get conda installed via pip. # We need to turn pip index back on because Anaconda turns it off for some reason. - export PIP_NO_INDEX=False export PIP_NO_DEPENDENCIES=False export PIP_IGNORE_INSTALLED=False diff --git a/.conda_mac/condarc.yaml b/.conda_mac/condarc.yaml index df2727c74..c1be41bf1 100644 --- a/.conda_mac/condarc.yaml +++ b/.conda_mac/condarc.yaml @@ -1,4 +1,3 @@ -# This file is not used at the moment, but when github actions can be used to build the package, it needs to be listed. # https://github.com/github/roadmap/issues/528 channels: diff --git a/.conda_mac/meta.yaml b/.conda_mac/meta.yaml index 7496f2057..8f773badf 100644 --- a/.conda_mac/meta.yaml +++ b/.conda_mac/meta.yaml @@ -16,14 +16,14 @@ about: summary: {{ data.get('description') }} build: - number: 1 + number: 0 source: path: ../ requirements: host: - - conda-forge::python ~=3.9 + - conda-forge::python >=3.9.0, <3.10.0 - anaconda::numpy >=1.19.5,<1.23.0 - conda-forge::setuptools - conda-forge::packaging @@ -34,11 +34,11 @@ requirements: - conda-forge::attrs >=21.2.0 - conda-forge::cattrs ==1.1.1 - conda-forge::h5py - - conda-forge::imgaug ==0.4.0 + - conda-forge::imageio-ffmpeg # Required for imageio to read/write videos with ffmpeg - conda-forge::jsmin - conda-forge::jsonpickle ==1.2 - conda-forge::keras <2.10.0,>=2.9.0rc0 # Required by tensorflow-macos - - conda-forge::networkx + - conda-forge::networkx <3.3 - conda-forge::opencv - conda-forge::pandas - conda-forge::pillow @@ -55,17 +55,20 @@ requirements: - conda-forge::scikit-learn ==1.0 - conda-forge::scikit-video - conda-forge::seaborn + - conda-forge::qudida + - conda-forge::albumentations + - conda-forge::ndx-pose <0.2.0 run: - - conda-forge::python ~=3.9 + - conda-forge::python >=3.9.0, <3.10.0 - conda-forge::attrs >=21.2.0 - conda-forge::cattrs ==1.1.1 - conda-forge::h5py - - conda-forge::imgaug ==0.4.0 + - conda-forge::imageio-ffmpeg # Required for imageio to read/write videos with ffmpeg - conda-forge::jsmin - conda-forge::jsonpickle ==1.2 - conda-forge::keras <2.10.0,>=2.9.0rc0 # Required by tensorflow-macos - - conda-forge::networkx + - conda-forge::networkx <3.3 - anaconda::numpy >=1.19.5,<1.23.0 - conda-forge::opencv - conda-forge::pandas @@ -83,8 +86,11 @@ requirements: - conda-forge::scikit-learn ==1.0 - conda-forge::scikit-video - conda-forge::seaborn - - conda-forge::tensorflow-hub + # - conda-forge::tensorflow-hub # pulls in tensorflow cpu from conda-forge + - conda-forge::qudida + - conda-forge::albumentations + - conda-forge::ndx-pose <0.2.0 -test: - imports: - - sleap +# test: +# imports: +# - sleap diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 8c95f28dc..6a92c2e3b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -25,15 +25,15 @@ Tell us a little about the system you're using. Please include information about how you installed. --> -- OS: +- OS: -- Version(s): - -- SLEAP installation method (listed [here](https://sleap.ai/installation.html#)): - - [ ] [Conda from package](https://sleap.ai/installation.html#conda-package) - - [ ] [Conda from source](https://sleap.ai/installation.html#conda-from-source) - - [ ] [pip package](https://sleap.ai/installation.html#pip-package) - - [ ] [Apple Silicon Macs](https://sleap.ai/installation.html#apple-silicon-macs) +- Version(s): + +- SLEAP installation method (listed [here](https://sleap.ai/installation.html#)): + - [ ] [Conda from package](https://sleap.ai/installation.html#conda-package) + - [ ] [Conda from source](https://sleap.ai/installation.html#conda-from-source) + - [ ] [pip package](https://sleap.ai/installation.html#pip-package) + - [ ] [Apple Silicon Macs](https://sleap.ai/installation.html#apple-silicon-macs)
Environment packages diff --git a/.github/workflows/archive/comment-template.yml b/.github/workflows/archive/comment-template.yml new file mode 100644 index 000000000..3bef84531 --- /dev/null +++ b/.github/workflows/archive/comment-template.yml @@ -0,0 +1,71 @@ +name: Reusable Comment Workflow + +on: + workflow_call: + inputs: + subject_id: + required: true + type: string + body_prefix: + required: true + type: string + comment_type: + required: true + type: string + +jobs: + comment: + runs-on: ubuntu-latest + steps: + - name: Post a comment + uses: actions/github-script@v6 + with: + script: | + const { owner, repo } = context.repo; + const subject_id = '${{ inputs.subject_id }}'; + const comment_type = '${{ inputs.comment_type }}'; + const baseBody = ` + We appreciate your input and will review it soon. + + > [!WARNING] + > A friendly reminder that this is a public forum. Please be cautious when clicking links, downloading files, or running scripts posted by others. + > + > - Always verify the credibility of links and code. + > - Avoid running scripts or installing files from untrusted sources. + > - If you're unsure, ask for clarification before proceeding. + + Stay safe and happy SLEAPing! + + Best regards, + The Team + `; + const body = `${{ inputs.body_prefix }}\n\n${baseBody}`; + + const mutation = comment_type === 'discussion' + ? ` + mutation($discussionId: ID!, $body: String!) { + addDiscussionComment(input: {discussionId: $discussionId, body: $body}) { + comment { + id + } + } + } + ` + : ` + mutation($issueId: ID!, $body: String!) { + addComment(input: {subjectId: $issueId, body: $body}) { + commentEdge { + node { + id + body + } + } + } + } + `; + + const variables = comment_type === 'discussion' + ? { discussionId: subject_id, body: body.trim() } + : { issueId: subject_id, body: body.trim() }; + + await github.graphql(mutation, variables); diff --git a/.github/workflows/archive/comment.yml b/.github/workflows/archive/comment.yml new file mode 100644 index 000000000..a24df018f --- /dev/null +++ b/.github/workflows/archive/comment.yml @@ -0,0 +1,24 @@ +name: Comment on New Discussions and Issues + +on: + discussion: + types: [created] + issues: + types: [opened] + +jobs: + comment_on_discussion: + if: github.event_name == 'discussion' + uses: ./.github/workflows/comment-template.yml + with: + subject_id: ${{ github.event.discussion.node_id }} + body_prefix: "Thank you for starting a new discussion!" + comment_type: "discussion" + + comment_on_issue: + if: github.event_name == 'issues' + uses: ./.github/workflows/comment-template.yml + with: + subject_id: ${{ github.event.issue.node_id }} + body_prefix: "Thank you for opening a new issue!" + comment_type: "issue" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 02bc8798b..74203245c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,39 +13,37 @@ jobs: strategy: fail-fast: false matrix: - os: ["ubuntu-22.04", "windows-2022", "macos-latest"] + os: ["ubuntu-22.04", "windows-2022", "macos-14"] # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategymatrixinclude include: # Use this condarc as default - condarc: .conda/condarc.yaml + - pyver: "3.7" # Use special condarc if macos - - os: "macos-latest" + - os: "macos-14" condarc: .conda_mac/condarc.yaml + pyver: "3.9" steps: # Setup - - uses: actions/checkout@v2 - - name: Cache conda - uses: actions/cache@v1 - env: - # Increase this value to reset cache if environment_build.yml has not changed - CACHE_NUMBER: 0 - with: - path: ~/conda_pkgs_dir - key: ${{ runner.os }}-conda-${{ env.CACHE_NUMBER }}-${{ hashFiles('environment_build.yml', 'requirements.txt') }} + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Miniconda - # https://github.com/conda-incubator/setup-miniconda - uses: conda-incubator/setup-miniconda@v2.0.1 + uses: conda-incubator/setup-miniconda@v3.0.3 with: - python-version: 3.7 - use-only-tar-bz2: true # IMPORTANT: This needs to be set for caching to work properly! - environment-file: environment_build.yml + miniforge-version: latest condarc-file: ${{ matrix.condarc }} + python-version: ${{ matrix.pyver }} + environment-file: environment_build.yml activate-environment: sleap_ci + conda-solver: "libmamba" + - name: Print environment info shell: bash -l {0} run: | which python conda info + conda list # Build pip wheel (Ubuntu) - name: Build pip wheel (Ubuntu) @@ -69,21 +67,92 @@ jobs: shell: bash -l {0} run: | conda build .conda --output-folder build + echo "BUILD_PATH=$(pwd)/build" >> "$GITHUB_ENV" - # Build conda package (Windows) + # Build conda package (Windows) - name: Build conda package (Windows) if: matrix.os == 'windows-2022' shell: powershell run: | conda build .conda --output-folder build + echo "BUILD_PATH=\$(pwd)\build" >> "$env:GITHUB_ENV" # Build conda package (Mac) - name: Build conda package (Mac) - if: matrix.os == 'macos-latest' + if: matrix.os == 'macos-14' shell: bash -l {0} run: | conda build .conda_mac --output-folder build + echo "BUILD_PATH=$(pwd)/build" >> "$GITHUB_ENV" + + # Test built conda package (Ubuntu and Windows) + - name: Test built conda package (Ubuntu and Windows) + if: matrix.os != 'macos-14' + shell: bash -l {0} + run: | + echo "Current build path: $BUILD_PATH" + conda deactivate + + echo "Python executable before activating environment:" + which python + echo "Python version before activating environment:" + python --version + echo "Conda info before activating environment:" + conda info + + echo "Creating and testing conda environment with sleap package..." + conda create -y -n sleap_test -c file://$BUILD_PATH -c sleap/label/dev -c conda-forge -c nvidia -c anaconda sleap + conda activate sleap_test + echo "Python executable after activating sleap_test environment:" + which python + echo "Python version after activating sleap_test environment:" + python --version + echo "Conda info after activating sleap_test environment:" + conda info + echo "List of installed conda packages in the sleap_test environment:" + conda list + echo "List of installed pip packages in the sleap_test environment:" + pip list + + echo "Testing sleap package installation..." + sleap_version=$(python -c "import sleap; print(sleap.__version__)") + echo "Test completed using sleap version: $sleap_version" + + # Test built conda package (Mac) + - name: Test built conda package (Mac) + if: matrix.os == 'macos-14' + shell: bash -l {0} + run: | + echo "Current build path: $BUILD_PATH" + conda deactivate + + echo "Python executable before activating environment:" + which python + echo "Python version before activating environment:" + python --version + echo "Conda info before activating environment:" + conda info + + echo "Creating and testing conda environment with sleap package..." + conda create -y -n sleap_test -c file://$BUILD_PATH -c conda-forge -c anaconda sleap + conda activate sleap_test + + echo "Python executable after activating sleap_test environment:" + which python + echo "Python version after activating sleap_test environment:" + python --version + echo "Conda info after activating sleap_test environment:" + conda info + echo "List of installed conda packages in the sleap_test environment:" + conda list + echo "List of installed pip packages in the sleap_test environment:" + pip list + + echo "Testing sleap package installation..." + sleap_version=$(python -c "import sleap; print(sleap.__version__)") + echo "Test completed using sleap version: $sleap_version" + # Login to conda (Ubuntu) - name: Login to Anaconda (Ubuntu) if: matrix.os == 'ubuntu-22.04' @@ -95,7 +164,7 @@ jobs: # Login to conda (Windows) - name: Login to Anaconda (Windows) - if: matrix.os == 'windows-2019' + if: matrix.os == 'windows-2022' env: ANACONDA_LOGIN: ${{ secrets.ANACONDA_LOGIN }} shell: powershell @@ -104,7 +173,7 @@ jobs: # Login to conda (Mac) - name: Login to Anaconda (Mac) - if: matrix.os == 'macos-latest' + if: matrix.os == 'macos-14' env: ANACONDA_LOGIN: ${{ secrets.ANACONDA_LOGIN }} shell: bash -l {0} @@ -113,12 +182,12 @@ jobs: # Upload conda package (Windows) - name: Upload conda package (Windows/main) - if: matrix.os == 'windows-2019' && !github.event.release.prerelease + if: matrix.os == 'windows-2022' && !github.event.release.prerelease shell: powershell run: | anaconda -v upload "build\win-64\*.tar.bz2" - name: Upload conda package (Windows/dev) - if: matrix.os == 'windows-2019' && github.event.release.prerelease + if: matrix.os == 'windows-2022' && github.event.release.prerelease shell: powershell run: | anaconda -v upload "build\win-64\*.tar.bz2" --label dev @@ -137,15 +206,15 @@ jobs: # Upload conda package (Mac) - name: Upload conda package (Mac/main) - if: matrix.os == 'macos-latest' && !github.event.release.prerelease + if: matrix.os == 'macos-14' && !github.event.release.prerelease shell: bash -l {0} run: | - anaconda -v upload build/osx-64/*.tar.bz2 --label dev + anaconda -v upload build/osx-arm64/*.tar.bz2 --label dev - name: Upload conda package (Mac/dev) - if: matrix.os == 'macos-latest' && github.event.release.prerelease + if: matrix.os == 'macos-14' && github.event.release.prerelease shell: bash -l {0} run: | - anaconda -v upload build/osx-64/*.tar.bz2 --label dev + anaconda -v upload build/osx-arm64/*.tar.bz2 --label dev # Logout - name: Logout from Anaconda diff --git a/.github/workflows/build_conda_ci.yml b/.github/workflows/build_conda_ci.yml new file mode 100644 index 000000000..3fd3d2b92 --- /dev/null +++ b/.github/workflows/build_conda_ci.yml @@ -0,0 +1,179 @@ +# Run tests using built conda packages. +name: Build Conda CI (no upload) + +# Run when changes to pip wheel +on: + push: + paths: + - ".conda/meta.yaml" + - ".conda_mac/meta.yaml" + - "setup.py" + - "requirements.txt" + - "dev_requirements.txt" + - "environment_build.yml" + - ".github/workflows/build_conda_ci.yml" # Run! + +# If RUN_BUILD_JOB is set to true, then RUN_ID will be overwritten to the current run id +env: + RUN_BUILD_JOB: true + RUN_ID: 10713717594 # Only used if RUN_BUILD_JOB is false (to dowload build artifact) + +jobs: + build: + name: Build package from push (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ["windows-2022", "ubuntu-22.04", "macos-14"] + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategymatrixinclude + include: + # Use these variables as defaults + - condarc: .conda/condarc.yaml + - conda-folder: .conda + - pyver: "3.10" + - build-prefix: win + - os: "ubuntu-22.04" + build-prefix: linux + # Use special condarc if macos + - os: "macos-14" + condarc: .conda_mac/condarc.yaml + conda-folder: .conda_mac + build-prefix: osx + + steps: + # Setup + - name: Checkout + if: env.RUN_BUILD_JOB == 'true' + uses: actions/checkout@v4 + + - name: Setup Miniconda + if: env.RUN_BUILD_JOB == 'true' + uses: conda-incubator/setup-miniconda@v3.0.4 + with: + miniforge-version: latest + condarc-file: ${{ matrix.condarc }} + python-version: ${{ matrix.pyver }} + environment-file: environment_build.yml + activate-environment: sleap_ci + conda-solver: "libmamba" + + - name: Print build environment info + if: env.RUN_BUILD_JOB == 'true' + shell: bash -l {0} + run: | + which python + conda list + pip freeze + + # Build conda package + - name: Build conda package + if: env.RUN_BUILD_JOB == 'true' + shell: bash -l {0} + run: | + conda build ${{ matrix.conda-folder }} --output-folder build + + # Upload artifact "tests" can use it + - name: Upload conda package artifact + if: env.RUN_BUILD_JOB == 'true' + uses: actions/upload-artifact@v4 + with: + name: sleap-build-${{ matrix.build-prefix }} + path: build # Upload entire build directory + retention-days: 1 + overwrite: true + + tests: + name: Run tests using package (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: build # Ensure the build job has completed before starting this job. + strategy: + fail-fast: false + matrix: + os: ["windows-2022", "ubuntu-22.04", "macos-14"] + # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategymatrixinclude + include: + # Default values + - build-prefix: win + - build-suffix: 64 + - test_args: pytest --durations=-1 tests/ + - condarc: .conda/condarc.yaml + - pyver: "3.10" + - conda-channels: -c conda-forge -c nvidia -c anaconda + # Ubuntu specific values + - os: ubuntu-22.04 + build-prefix: linux + # Otherwise core dumped in github actions + test_args: | + sudo apt install xvfb libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xinerama0 libxcb-xfixes0 + sudo Xvfb :1 -screen 0 1024x768x24 > $GITHUB_ENV + + # https://github.com/actions/download-artifact?tab=readme-ov-file#usage + - name: Download conda package artifact + uses: actions/download-artifact@v4 + id: download + with: + name: sleap-build-${{ matrix.build-prefix }} + path: build + run-id: ${{ env.RUN_ID }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: List items in current directory + run: | + ls . + ls -R build + + - name: Setup Miniconda + uses: conda-incubator/setup-miniconda@v3.0.4 + with: + miniforge-version: latest + condarc-file: ${{ matrix.condarc }} + python-version: ${{ matrix.pyver }} + conda-solver: "libmamba" + + - name: Create conda environment + shell: bash -l {0} + run: conda create sleap -y -n sleap_ci -c ./build ${{ matrix.conda-channels }} + + - name: Install packages for testing + shell: bash -l {0} + run: | + conda activate sleap_ci + pip install -r "dev_requirements.txt" + + # Note: "conda activate" does not persist across steps + - name: Print environment info + shell: bash -l {0} + run: | + conda activate sleap_ci + conda info + conda list + pip freeze + + - name: Test package + shell: bash -l {0} + run: | + conda activate sleap_ci + ${{ matrix.test_args}} diff --git a/.github/workflows/build_manual.yml b/.github/workflows/build_manual.yml index ab689342d..7cba65d67 100644 --- a/.github/workflows/build_manual.yml +++ b/.github/workflows/build_manual.yml @@ -8,8 +8,10 @@ on: paths: - '.conda/meta.yaml' - '.conda_mac/meta.yaml' + - '.github/workflows/build_manual.yml' branches: - - develop + # - develop + - fakebranch jobs: build: @@ -18,39 +20,37 @@ jobs: strategy: fail-fast: false matrix: - os: ["ubuntu-22.04", "windows-2022", "macos-latest"] + os: ["ubuntu-22.04", "windows-2022", "macos-14"] # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategymatrixinclude include: # Use this condarc as default - condarc: .conda/condarc.yaml + - pyver: "3.7" # Use special condarc if macos - - os: "macos-latest" + - os: "macos-14" condarc: .conda_mac/condarc.yaml + pyver: "3.9" steps: # Setup - - uses: actions/checkout@v2 - - name: Cache conda - uses: actions/cache@v1 - env: - # Increase this value to reset cache if environment_build.yml has not changed - CACHE_NUMBER: 0 - with: - path: ~/conda_pkgs_dir - key: ${{ runner.os }}-conda-${{ env.CACHE_NUMBER }}-${{ hashFiles('environment_build.yml', 'requirements.txt') }} + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Miniconda - # https://github.com/conda-incubator/setup-miniconda - uses: conda-incubator/setup-miniconda@v2.0.1 + uses: conda-incubator/setup-miniconda@v3.0.3 with: - python-version: 3.7 - use-only-tar-bz2: true # IMPORTANT: This needs to be set for caching to work properly! - environment-file: environment_build.yml + miniforge-version: latest condarc-file: ${{ matrix.condarc }} + python-version: ${{ matrix.pyver }} + environment-file: environment_build.yml activate-environment: sleap_ci + conda-solver: "libmamba" + - name: Print environment info shell: bash -l {0} run: | which python conda info + conda list # Build pip wheel (Not Windows) - name: Build pip wheel (Not Windows) @@ -59,14 +59,14 @@ jobs: run: | python setup.py bdist_wheel - # Upload pip wheel (Ubuntu) - - name: Upload pip wheel (Ubuntu) - if: matrix.os == 'ubuntu-22.04' - env: - PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} - shell: bash -l {0} - run: | - twine upload -u __token__ -p "$PYPI_TOKEN" dist/* --non-interactive --skip-existing --disable-progress-bar + # # Upload pip wheel (Ubuntu) + # - name: Upload pip wheel (Ubuntu) + # if: matrix.os == 'ubuntu-22.04' + # env: + # PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} + # shell: bash -l {0} + # run: | + # twine upload -u __token__ -p "$PYPI_TOKEN" dist/* --non-interactive --skip-existing --disable-progress-bar # Build conda package (Ubuntu) - name: Build conda package (Ubuntu) @@ -74,70 +74,141 @@ jobs: shell: bash -l {0} run: | conda build .conda --output-folder build + echo "BUILD_PATH=$(pwd)/build" >> "$GITHUB_ENV" - # Build conda package (Windows) + # Build conda package (Windows) - name: Build conda package (Windows) if: matrix.os == 'windows-2022' shell: powershell run: | conda build .conda --output-folder build + echo "BUILD_PATH=\$(pwd)\build" >> "$env:GITHUB_ENV" # Build conda package (Mac) - name: Build conda package (Mac) - if: matrix.os == 'macos-latest' + if: matrix.os == 'macos-14' shell: bash -l {0} run: | conda build .conda_mac --output-folder build + echo "BUILD_PATH=$(pwd)/build" >> "$GITHUB_ENV" - # Login to conda (Ubuntu) - - name: Login to Anaconda (Ubuntu) - if: matrix.os == 'ubuntu-22.04' - env: - ANACONDA_LOGIN: ${{ secrets.ANACONDA_LOGIN }} + # Test built conda package (Ubuntu and Windows) + - name: Test built conda package (Ubuntu and Windows) + if: matrix.os != 'macos-14' shell: bash -l {0} run: | - yes 2>/dev/null | anaconda login --username sleap --password "$ANACONDA_LOGIN" || true - - # Login to conda (Windows) - - name: Login to Anaconda (Windows) - if: matrix.os == 'windows-2022' - env: - ANACONDA_LOGIN: ${{ secrets.ANACONDA_LOGIN }} - shell: powershell - run: | - echo "yes" | anaconda login --username sleap --password "$env:ANACONDA_LOGIN" - - # Login to conda (Mac) - - name: Login to Anaconda (Mac) - if: matrix.os == 'macos-latest' - env: - ANACONDA_LOGIN: ${{ secrets.ANACONDA_LOGIN }} + echo "Current build path: $BUILD_PATH" + conda deactivate + + echo "Python executable before activating environment:" + which python + echo "Python version before activating environment:" + python --version + echo "Conda info before activating environment:" + conda info + + echo "Creating and testing conda environment with sleap package..." + conda create -y -n sleap_test -c file://$BUILD_PATH -c sleap/label/dev -c conda-forge -c nvidia -c anaconda sleap + conda activate sleap_test + + echo "Python executable after activating sleap_test environment:" + which python + echo "Python version after activating sleap_test environment:" + python --version + echo "Conda info after activating sleap_test environment:" + conda info + echo "List of installed conda packages in the sleap_test environment:" + conda list + echo "List of installed pip packages in the sleap_test environment:" + pip list + + echo "Testing sleap package installation..." + sleap_version=$(python -c "import sleap; print(sleap.__version__)") + echo "Test completed using sleap version: $sleap_version" + + # Test built conda package (Mac) + - name: Test built conda package (Mac) + if: matrix.os == 'macos-14' shell: bash -l {0} run: | - yes 2>/dev/null | anaconda login --username sleap --password "$ANACONDA_LOGIN" || true + echo "Current build path: $BUILD_PATH" + conda deactivate + + echo "Python executable before activating environment:" + which python + echo "Python version before activating environment:" + python --version + echo "Conda info before activating environment:" + conda info + + echo "Creating and testing conda environment with sleap package..." + conda create -y -n sleap_test -c file://$BUILD_PATH -c conda-forge -c anaconda sleap + conda activate sleap_test + + echo "Python executable after activating sleap_test environment:" + which python + echo "Python version after activating sleap_test environment:" + python --version + echo "Conda info after activating sleap_test environment:" + conda info + echo "List of installed conda packages in the sleap_test environment:" + conda list + echo "List of installed pip packages in the sleap_test environment:" + pip list + + echo "Testing sleap package installation..." + sleap_version=$(python -c "import sleap; print(sleap.__version__)") + echo "Test completed using sleap version: $sleap_version" + + # # Login to conda (Ubuntu) + # - name: Login to Anaconda (Ubuntu) + # if: matrix.os == 'ubuntu-22.04' + # env: + # ANACONDA_LOGIN: ${{ secrets.ANACONDA_LOGIN }} + # shell: bash -l {0} + # run: | + # yes 2>/dev/null | anaconda login --username sleap --password "$ANACONDA_LOGIN" || true - # Upload conda package (Windows) - - name: Upload conda package (Windows/dev) - if: matrix.os == 'windows-2022' - shell: powershell - run: | - anaconda -v upload "build\win-64\*.tar.bz2" --label dev + # # Login to conda (Windows) + # - name: Login to Anaconda (Windows) + # if: matrix.os == 'windows-2022' + # env: + # ANACONDA_LOGIN: ${{ secrets.ANACONDA_LOGIN }} + # shell: powershell + # run: | + # echo "yes" | anaconda login --username sleap --password "$env:ANACONDA_LOGIN" - # Upload conda package (Ubuntu) - - name: Upload conda package (Ubuntu/dev) - if: matrix.os == 'ubuntu-22.04' - shell: bash -l {0} - run: | - anaconda -v upload build/linux-64/*.tar.bz2 --label dev + # # Login to conda (Mac) + # - name: Login to Anaconda (Mac) + # if: matrix.os == 'macos-14' + # env: + # ANACONDA_LOGIN: ${{ secrets.ANACONDA_LOGIN }} + # shell: bash -l {0} + # run: | + # yes 2>/dev/null | anaconda login --username sleap --password "$ANACONDA_LOGIN" || true - # Upload conda package (Mac) - - name: Upload conda package (Mac/dev) - if: matrix.os == 'macos-latest' - shell: bash -l {0} - run: | - anaconda -v upload build/osx-64/*.tar.bz2 --label dev + # # Upload conda package (Windows) + # - name: Upload conda package (Windows/dev) + # if: matrix.os == 'windows-2022' + # shell: powershell + # run: | + # anaconda -v upload "build\win-64\*.tar.bz2" --label dev - - name: Logout from Anaconda - shell: bash -l {0} - run: | - anaconda logout + # # Upload conda package (Ubuntu) + # - name: Upload conda package (Ubuntu/dev) + # if: matrix.os == 'ubuntu-22.04' + # shell: bash -l {0} + # run: | + # anaconda -v upload build/linux-64/*.tar.bz2 --label dev + + # # Upload conda package (Mac) + # - name: Upload conda package (Mac/dev) + # if: matrix.os == 'macos-14' + # shell: bash -l {0} + # run: | + # anaconda -v upload build/osx-arm64/*.tar.bz2 --label dev + + # - name: Logout from Anaconda + # shell: bash -l {0} + # run: | + # anaconda logout diff --git a/.github/workflows/build_ci.yml b/.github/workflows/build_pypi_ci.yml similarity index 71% rename from .github/workflows/build_ci.yml rename to .github/workflows/build_pypi_ci.yml index baf046295..68142b288 100644 --- a/.github/workflows/build_ci.yml +++ b/.github/workflows/build_pypi_ci.yml @@ -1,17 +1,17 @@ -# Run tests using built conda packages and wheels. -name: Build CI (no upload) +# Run tests using built wheels. +name: Build PyPI CI (no upload) # Run when changes to pip wheel on: push: paths: - - 'setup.py' - - 'requirements.txt' - - 'dev_requirements.txt' - - 'jupyter_requirements.txt' - - 'pypi_requirements.txt' - - 'environment_build.yml' - - '.github/workflows/build_ci.yml' + - "setup.py" + - "requirements.txt" + - "dev_requirements.txt" + - "jupyter_requirements.txt" + - "pypi_requirements.txt" + - "environment_build.yml" + - ".github/workflows/build_pypi_ci.yml" # Run! jobs: build: @@ -26,28 +26,21 @@ jobs: # Use this condarc as default - condarc: .conda/condarc.yaml - wheel_name: sleap-wheel-linux + - pyver: "3.7" steps: # Setup - - uses: actions/checkout@v2 + - name: Checkout + uses: actions/checkout@v4 - - name: Cache conda - uses: actions/cache@v1 - env: - # Increase this value to reset cache if environment_build.yml has not changed - CACHE_NUMBER: 0 + - name: Setup Miniconda + uses: conda-incubator/setup-miniconda@v3.0.3 with: - path: ~/conda_pkgs_dir - key: ${{ runner.os }}-conda-${{ env.CACHE_NUMBER }}-${{ hashFiles('environment_build.yml', 'pyproject.toml') }} - - - name: Setup Miniconda for Build - # https://github.com/conda-incubator/setup-miniconda - uses: conda-incubator/setup-miniconda@v2.0.1 - with: - python-version: 3.7 - use-only-tar-bz2: true # IMPORTANT: This needs to be set for caching to work properly! - environment-file: environment_build.yml + miniforge-version: latest condarc-file: ${{ matrix.condarc }} + python-version: ${{ matrix.pyver }} + environment-file: environment_build.yml activate-environment: sleap_ci + conda-solver: "libmamba" - name: Print build environment info shell: bash -l {0} @@ -61,10 +54,10 @@ jobs: shell: bash -l {0} run: | python setup.py bdist_wheel - + # Upload artifact "tests" can use it - name: Upload wheel artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ matrix.wheel_name }} path: dist/*.whl @@ -73,11 +66,12 @@ jobs: tests: name: Run tests using wheel (${{ matrix.os }}) runs-on: ${{ matrix.os }} - needs: build # Ensure the build job has completed before starting this job. + needs: build # Ensure the build job has completed before starting this job. strategy: fail-fast: false matrix: - os: ["ubuntu-22.04", "windows-2022", "macos-latest"] + os: ["ubuntu-22.04", "windows-2022"] + # os: ["ubuntu-22.04", "windows-2022", "macos-14"] # removing macos-14 for now since the setup-python action only support py>=3.10, which is breaking this CI. # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategymatrixinclude include: # Default values @@ -89,9 +83,11 @@ jobs: pip install '$wheel_path'[dev] - test_args: pytest --durations=-1 tests/ - condarc: .conda/condarc.yaml + - pyver: "3.7" # Use special condarc if macos - - os: "macos-latest" + - os: "macos-14" condarc: .conda_mac/condarc.yaml + pyver: "3.10" # Ubuntu specific values - os: ubuntu-22.04 # Otherwise core dumped in github actions @@ -106,16 +102,16 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v3 - - - name: Set up Python 3.7 - uses: actions/setup-python@v4 + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 with: - python-version: 3.7 - + python-version: ${{ matrix.pyver }} + # Download wheel - name: Download wheel artifact - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 id: download with: name: ${{ matrix.wheel_name }} @@ -148,8 +144,8 @@ jobs: run: | which python pip freeze - + # Install and test the wheel - name: Test the built wheel run: | - ${{ matrix.test_args}} \ No newline at end of file + ${{ matrix.test_args}} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1d193724..84b028fc3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,45 +11,28 @@ on: - "environment_no_cuda.yml" - "requirements.txt" - "dev_requirements.txt" - push: - branches: - - master - - develop - paths: - - "sleap/**" - - "tests/**" - - ".github/workflows/ci.yml" - - "environment_no_cuda.yml" - - "requirements.txt" - - "dev_requirements.txt" + # push: + # branches: + # - main + # - develop + # paths: + # - "sleap/**" + # - "tests/**" + # - ".github/workflows/ci.yml" + # - "environment_no_cuda.yml" + # - "requirements.txt" + # - "dev_requirements.txt" jobs: - type_check: - name: Type Check - runs-on: "ubuntu-22.04" - steps: - - name: Checkout repo - uses: actions/checkout@v3 - - name: Set up Python 3.7 - uses: actions/setup-python@v4 - with: - python-version: 3.7 - - name: Install Dependencies - run: | - pip install mypy - - name: Run MyPy - # TODO: remove this once all MyPy errors get fixed - continue-on-error: true - run: | - mypy --follow-imports=skip --ignore-missing-imports sleap tests + # Lint lint: name: Lint runs-on: "ubuntu-22.04" steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python 3.7 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.7 - name: Install Dependencies @@ -59,58 +42,52 @@ jobs: - name: Run Black run: | black --check sleap tests + + # Tests tests: name: Tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: ["ubuntu-22.04", "windows-2022", "macos-latest"] + os: ["ubuntu-22.04", "windows-2022", "macos-14"] include: # Default values - env_file: environment_no_cuda.yml - - test_args: --durations=-1 tests/ # Mac specific values - - os: macos-latest + - os: macos-14 env_file: environment_mac.yml - # Ubuntu specific values - - os: ubuntu-22.04 - test_args: --cov=sleap --cov-report=xml --durations=-1 tests/ + steps: - name: Checkout repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 - - name: Setup Micromamba - # https://github.com/mamba-org/setup-micromamba - uses: mamba-org/setup-micromamba@v1 + - name: Setup Conda + uses: conda-incubator/setup-miniconda@v3.0.3 with: - micromamba-version: '1.4.6-0' + miniforge-version: latest + conda-solver: "libmamba" environment-file: ${{ matrix.env_file }} - environment-name: sleap_ci - init-shell: >- - bash - powershell - post-cleanup: all + activate-environment: sleap_ci # Print environment info - name: Print environment info shell: bash -l {0} run: | which python - micromamba info - micromamba list + conda info + conda list pip freeze # Test environment - name: Test with pytest shell: bash -l {0} run: | - pytest ${{ matrix.test_args }} + pytest --cov=sleap --cov-report=xml --durations=-1 tests/ # Upload coverage - name: Upload coverage - uses: codecov/codecov-action@v1 - if: matrix.os == 'ubuntu-22.04' + uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 7db6b4d74..36c1d6ad7 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -7,8 +7,8 @@ on: branches: # 'main' triggers updates to 'sleap.ai', all others to 'sleap.ai/develop' - main - - develop - - liezl/add-pip-extras + - develop # Run + - liezl/bump-to-1.4.1 paths: - "docs/**" - "README.rst" @@ -20,21 +20,13 @@ jobs: steps: # Setup - name: Checkout - uses: actions/checkout@v2 - - name: Cache conda - uses: actions/cache@v1 - env: - # Increase this value to reset cache if environment_build.yml has not changed - CACHE_NUMBER: 0 - with: - path: ~/conda_pkgs_dir - key: ${{ runner.os }}-conda-${{ env.CACHE_NUMBER }}-${{ hashFiles('environment_build.yml', 'requirements.txt') }} + uses: actions/checkout@v4 + - name: Setup Miniconda # https://github.com/conda-incubator/setup-miniconda - uses: conda-incubator/setup-miniconda@v2.0.1 + uses: conda-incubator/setup-miniconda@v3.0.3 with: python-version: 3.7 - use-only-tar-bz2: true # IMPORTANT: This needs to be set for caching to work properly! environment-file: environment_no_cuda.yml activate-environment: sleap_ci - name: Print environment info @@ -51,7 +43,7 @@ jobs: make html - name: Deploy (sleap.ai) - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@v4 if: github.ref == 'refs/heads/main' with: github_token: ${{ secrets.GITHUB_TOKEN }} @@ -60,10 +52,10 @@ jobs: keep_files: true - name: Deploy (test) - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@v4 if: github.ref != 'refs/heads/main' with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_branch: gh-pages publish_dir: docs/build/html - destination_dir: develop \ No newline at end of file + destination_dir: develop diff --git a/AUTHORS b/AUTHORS index e6a78d2ba..11e40e839 100644 --- a/AUTHORS +++ b/AUTHORS @@ -11,3 +11,5 @@ John Smith Example Inc. Jeremy Delahanty The Salk Institute for Biological Studies + +Lili Karashchuk Allen Institute of Neural Dynamics diff --git a/README.rst b/README.rst index dbc5a7cac..f7a5acd6c 100644 --- a/README.rst +++ b/README.rst @@ -69,7 +69,7 @@ Quick install .. code-block:: bash - conda create -y -n sleap -c conda-forge -c nvidia -c sleap -c anaconda sleap + conda create -y -n sleap -c conda-forge -c nvidia -c sleap/label/dev -c sleap -c anaconda sleap `pip` **(any OS except Apple silicon)**: @@ -84,7 +84,7 @@ Learn to SLEAP -------------- - **Learn step-by-step**: `Tutorial `_ - **Learn more advanced usage**: `Guides `__ and `Notebooks `__ -- **Learn by watching**: `MIT CBMM Tutorial `_ +- **Learn by watching**: `ABL:AOC 2023 Workshop `_ and `MIT CBMM Tutorial `_ - **Learn by reading**: `Paper (Pereira et al., Nature Methods, 2022) `__ and `Review on behavioral quantification (Pereira et al., Nature Neuroscience, 2020) `_ - **Learn from others**: `Discussions on Github `_ diff --git a/dev_requirements.txt b/dev_requirements.txt index f7bb23643..709fb48fd 100644 --- a/dev_requirements.txt +++ b/dev_requirements.txt @@ -5,13 +5,14 @@ pytest-qt>=4.0.0 pytest-cov<=3.0.0 pytest-xvfb ipython -sphinx +sphinx>=5.0 # sphinxcontrib.applehelp extension needs at least Sphinx v5.0 # furo sphinx-book-theme sphinx-copybutton +sphinx-tabs nbformat==5.1.3 -myst-nb==0.13.2 -myst-parser==0.15.2 +myst-nb>=0.16.0 # sphinx>=5.0 needs myst-nb>=0.16.0 +myst-parser linkify-it-py sphinx-autobuild black==21.6b0 diff --git a/docs/_static/bonsai-connection.jpg b/docs/_static/bonsai-connection.jpg new file mode 100644 index 0000000000000000000000000000000000000000..32b725416e2ed32f12977cdf50c73b53ce9b3d9c GIT binary patch literal 113805 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�cX1A%>%q4Iiw6@2LunvjivU|pj-_AMge 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¢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 NQe(RCf$PWc{{rTTu?heH literal 0 HcmV?d00001 diff --git a/docs/_static/bonsai-filecapture.jpg b/docs/_static/bonsai-filecapture.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7a809d67ac10f043046af7dc5960e00b5640a2d0 GIT binary patch literal 68990 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 KW5R>m9sX~}(9XkrG0W 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
e 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 z z@2twD1hZT;4Fu{gj2-3LQa4jw)2}$W-ZbRVUkWKX&0vi?yEQrMR6=Ohd4ML#Mz6yZ zp7Te83CDX4gSlC}u^!wwZlOr7H>%|RFli;yPTjRDf&^W<1#KjiEthP?x(O^PB)#AymlQQRHK*vPA0GTG0Irafuw-^068 zhgW$c6hv0dgGy)ao0qzXJXdS7>I)$ZbjWf;L5`&TCNg82+;6XOd0Tay#M@1<%o8?e z$CUZe0*8+>c?&t58ZhTAv;oCWPfWzYu!c-7vhVZUa_?O4yJdDzs=u<>pS! z!lp2wwCoqTX-066_dil?cuv0iwc3&2WT4#Q-lx+60t>LhtXN`(P+Eb6GVLNyca!Z6 z-?vYKp7xFerCkt;T>?onjJ)ggipQ=mR8V>>BdGAX5D1&+drF8oX|QHw*Yulu>cN{c zLHX|oWZX_&w7r`ndMP?QJ0Ld-F|tF2z)!B9%^CAYguv_vnxgVij9d z!u=-v6E99f17?ofG#TrLm+oaMb8ouZaxcB7%P&=w%X;w{XM?9|72#I`D#1ZPkigR+ zY$HGGJ?vCd70pG(^)7a_)z4mXP6=2rQZC?d=mTmzCI`6Q4 z^83@|-X}H(L06IOvay=N$nPW=u8v4~*~T@)REzrdotzdggHm{Q6q~m5HVJ3fI5kyz zmgRkmes71%t;a{8THHYJmJS><{`L9g5In+dw_ z9SlAQe_?O9TlK_KZ`+K9G};N?R|vN->T@`ni8)LrWXC8XfO={fXY<^^C2jI8b^Ve_ ziML#4nE(!~8T=_t{~PQvSMPCJJ{BX*bpJVTxo4Am^W0O&^bCiFOUi zBldC8*rs*NFm$>?uE~bpau+kJA=k=+-WBL|c&fL2j>15c;AC>)-+Bt7XXUWI#hzgN_-M88wv7QEm)kBR&2G3d z3$lFD<5NA>OIj-;VpmQy_$(7^nvW!Dtng!h^n;_d|D{^6HtAp=*`D~$j`MXXrLukh z$N`s@09)Sed$HKJU=OyQQFE2Qw@P?oEp+PJkHfH)r;6$dLq;xBX=Ghf+y8z8>T?f;VnkM|75Ly?Amri zkTPc57evtly5%@_0;AL+{@%yT6pQxY{>sWJhrS$7L{KSi=3S+IFEnK#b8@$= z6}dOd5m3RJzsh2*Bv=*{g0)SLmN6Dsf1tJJN3aP-GuajB0cj&GCN>1mcET^!1gJ4v>_?C^iyOy3@KAmoI%h2zHZ~5Tgepuv z^zkZMNCjH&7X*qh@Xm#u{i$y4pBhU5h5wx0JGwUml4J+Gveo~)S)MoL5joL`>v`32s zQ<`d%o}+-{g#7$<%orsxA38F(6^j;{p@=*fRLYbvsts>DQbv?`@BD5q%%d+@PqSX( z`_dyLaV(tV_?PmhmA-4W4H651D`4=!c2O8c!b)VzB-?Em69(3jaci8^!K*uy+gj0# zWWao*F=~Hfv07>l6XM9;7Mxn@@_KrRude`A`q?9&PL6RwX@sKRfzwZ*s<~Gzs|>B) zw!)=G5^vh5J}EDF#9(cG#_-#j`+~3ZBo`rc(5?;)wrq=^1br$xe4P_*PPsG1{G)!=*+1sHVdXt-cLP)}$`XWv-9W zoU{&S8y@MLQR<8)ZQ?uXBTd<6*pzhk*ZWOBAFxc!^?mQafTsOA24b8v8WOF@3!No% z%=X%p*Pfoytoghbntf(J1{Qs8(BgVRPA3XtLq(#?$7e@4p8Yba;SPuCG2w57fb2jvqjiAABFCDL4HH{qPi+)gf;pL6_SG@m~_PG6qp&5#71-p%yQX0s?_=l*vnq~}HERK_8 zaMMD)?Ox4?B*EV@ck}A$`o|=$Ff`WG;i3sKXmV<@cWm55#7TL-ofZ6Sk3Fu?6xeyalZ{Ui(`#1wly}ImKZ_+WrP;4btnmdYSudfmerf)EO+G`{?N7IPd}n6k(46{j`H@=yC?kpYLY=R z6L!}39)4}GdT7GkP)IwQngYb# zcVAgbiz`#$e|vj-n zj@+(zBBAQBDU@P%DAM;%^pooJD>(xG?;nx(_e=a@iPSMhBoTg*$mJO0h_#b zvwvGao&WdN;(z*CbpL@j=6~U0xb%Rh7aNUP7tB5~fHiwoT)V@^!7%{)L$`y290OK7 zbNT~+=rjreQTYH(@vp%lhl784{V%_EhmZL`{|s27|Gk!E)CNReQ#p>Oma#1Tq01V9 z%%L9AoRbHh`6M^1x6&&+ovj4tOTEe0@ zTpD!afoR-Kk4Ve;Bs@^J@O()cCiM{-0~}pKBxhpL^>6#CrdG#+$!`WGB9V3%Yp- lsySKf-EKqehQ7tI?w3i}Puc+6(uFH8N?9JN4Kn_j_%D@$`o;hN literal 0 HcmV?d00001 diff --git a/docs/_static/bonsai-predictposeidentities.jpg b/docs/_static/bonsai-predictposeidentities.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8582fd70727fbfff46555a0c463506627fa05ab4 GIT binary patch literal 85845 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

IT1W}zMTC&shZ4S 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 below for more instructions. +````{tabs} + ```{group-tab} Windows and Linux + ```bash + conda create -y -n sleap -c conda-forge -c nvidia -c sleap/label/dev -c sleap -c anaconda sleap=1.4.1 + ``` + ``` + ```{group-tab} Mac OS + ```bash + conda create -y -n sleap -c conda-forge -c anaconda -c sleap sleap=1.4.1 + ``` + ``` +```` -The newest version of SLEAP can always be found in the [Releases page](https://github.com/talmolab/sleap/releases). +. For more in-depth installation instructions, see the [installation methods](installation-methods). The newest version of SLEAP can always be found in the [Releases page](https://github.com/talmolab/sleap/releases). ```{contents} Contents --- @@ -12,66 +23,30 @@ local: --- ``` -````{hint} -Installation requires entering commands in a terminal. To open one: - -**Windows:** Open the *Start menu* and search for the *Miniforge Prompt* (if using Mambaforge) or the *Command Prompt* if not. -```{note} -On Windows, our personal preference is to use alternative terminal apps like [Cmder](https://cmder.net) or [Windows Terminal](https://aka.ms/terminal). -``` - -**Linux:** Launch a new terminal by pressing Ctrl + Alt + T. - -**Mac:** Launch a new terminal by pressing Cmd + Space and searching for _Terminal_. - -```` - -(apple-silicon)= - -### Macs Pre-M1 (Pre-Installation) - -SLEAP can be installed on Macs by following these instructions: - -1. Make sure you're on **macOS Monterey** or later, i.e., version 12+. - -2. If you don't have it yet, [install **homebrew**](https://brew.sh/), a convenient package manager for Macs (skip this if you can run `brew` from the terminal): - - ```bash - /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" - ``` - - This might take a little while since it'll also install Xcode (which we'll need later). Once it's finished, your terminal should give you two extra commands to run listed under **Next Steps**. - - ````{note} - We recommend running the commands given in your terminal which will be similar to (but may differ slightly) from the commands below: - ```bash - echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile - ``` - - ```bash - eval "$(/opt/homebrew/bin/brew shellenv)" - ``` - +`````{hint} + Installation requires entering commands in a terminal. To open one: + ````{tabs} + ```{tab} Windows + Open the *Start menu* and search for the *Anaconda Prompt* (if using Miniconda) or the *Command Prompt* if not. + ```{note} + On Windows, our personal preference is to use alternative terminal apps like [Cmder](https://cmder.net) or [Windows Terminal](https://aka.ms/terminal). + ``` + ``` + ```{tab} Linux + Launch a new terminal by pressing Ctrl + Alt + T. + ``` + ```{group-tab} Mac OS + Launch a new terminal by pressing Cmd + Space and searching for _Terminal_. + ``` ```` +````` - Then, close and re-open the terminal for it to take effect. +## Package Manager -3. Install wget, a CLI downloading utility (also makes sure your homebrew setup worked): - - ```bash - brew install wget - ``` - -(mambaforge)= - -## Installing Mambaforge - -**Anaconda** is a Python environment manager that makes it easy to install SLEAP and its necessary dependencies without affecting other Python software on your computer. - -[**Mambaforge**](https://mamba.readthedocs.io/en/latest/installation.html) is a lightweight installer of Anaconda with speedy package resolution that we recommend. +SLEAP requires many complex dependencies, so we **strongly** recommend using a package manager such as [Miniforge](https://github.com/conda-forge/miniforge) or [Miniconda](https://docs.anaconda.com/free/miniconda/) to install SLEAP in its own isolated environment. ````{note} -If you already have Anaconda on your computer, then you can [set the solver to `libmamba`](https://www.anaconda.com/blog/a-faster-conda-for-a-growing-community) in the `base` environment (and skip the Mambaforge installation): +If you already have Anaconda on your computer (and it is an [older installation](https://conda.org/blog/2023-11-06-conda-23-10-0-release/)), then make sure to [set the solver to `libmamba`](https://www.anaconda.com/blog/a-faster-conda-for-a-growing-community) in the `base` environment. ```bash conda update -n base conda @@ -80,195 +55,220 @@ conda config --set solver libmamba ``` ```{warning} -Any subsequent `mamba` commands in the docs will need to be replaced with `conda` if you choose to use your existing Anaconda installation. +Any subsequent `conda` commands in the docs will need to be replaced with `mamba` if you have [Mamba](https://mamba.readthedocs.io/en/latest/) installed instead of Anaconda or Miniconda. ``` ```` -Otherwise, to install Mamba: - -**On Windows**, just click through the installation steps. - -1. Go to: https://github.com/conda-forge/miniforge#mambaforge -2. Download the latest version for your OS. -3. Follow the installer instructions. - -We recommend using the following settings: - -- Install for: All Users (requires admin privileges) -- Destination folder: `C:\mambaforge` -- Advanced Options: Add MambaForge to the system PATH environment variable -- Advanced Options: Register MambaForge as the system Python 3.X - These will make sure that MambaForge is easily accessible from most places on your computer. - -**On Linux**, it might be easier to do this straight from the terminal (Ctrl + Alt + T) with this one-liner: - -```bash -wget -nc https://github.com/conda-forge/miniforge/releases/latest/download/Mambaforge-Linux-x86_64.sh && bash Mambaforge-Linux-x86_64.sh -b && ~/mambaforge/bin/conda init bash -``` - -Restart the terminal after running this command. - -```{note} -For other Linux architectures (arm64 and POWER8/9), replace the `.sh` filenames above with the correct installer name for your architecture. See the Download column in [this table](https://github.com/conda-forge/miniforge#mambaforge) for the correct filename. - -``` - -**On Macs (pre-M1)**, you can run the installer using this terminal command: +If you don't have a `conda` package manager installation, here are some quick install options: -```bash -wget -nc https://github.com/conda-forge/miniforge/releases/latest/download/Mambaforge-MacOSX-x86_64.sh && bash Mambaforge-MacOSX-x86_64.sh -b && ~/mambaforge/bin/conda init zsh -``` +### Miniforge (recommended) -**On Macs (Apple Silicon)**, use this terminal command: +Miniforge is a minimal installer for conda that includes the `conda` package manager and is maintained by the [conda-forge](https://conda-forge.org) community. The only difference between Miniforge and Miniconda is that Miniforge uses the `conda-forge` channel by default, which provides a much wider selection of community-maintained packages. -```bash -curl -fsSL --compressed https://github.com/conda-forge/miniforge/releases/latest/download/Mambaforge-MacOSX-arm64.sh -o Mambaforge3-MacOSX-arm64.sh && chmod +x Mambaforge3-MacOSX-arm64.sh && ./Mambaforge3-MacOSX-arm64.sh -b -p ~/mambaforge3 && rm Mambaforge3-MacOSX-arm64.sh && ~/mambaforge3/bin/conda init "$(basename "${SHELL}")" && source "$HOME/.$(basename "${SHELL}")rc" -``` +````{tabs} + ```{group-tab} Windows + Open a new PowerShell terminal (does not need to be admin) and enter: -## Installation methods - -SLEAP can be installed three different ways: via {ref}`conda package`, {ref}`conda from source`, or {ref}`pip package`. Select one of the methods below to install SLEAP. We recommend {ref}`conda package`. - -(condapackage)= - -### `conda` package - -**Windows** and **Linux** - -```bash -mamba create -y -n sleap -c conda-forge -c nvidia -c sleap -c anaconda sleap=1.3.3 -``` - -**Mac OS X** and **Apple Silicon** - -```bash -mamba create -y -n sleap -c conda-forge -c anaconda -c sleap sleap=1.3.3 -``` - -**This is the recommended installation method**. - -```{note} -- This comes with CUDA to enable GPU support. All you need is to have an NVIDIA GPU and [updated drivers](https://nvidia.com/drivers). -- If you already have CUDA installed on your system, this will not conflict with it. -- This will also work in CPU mode if you don't have a GPU on your machine. -``` - -(condasource)= - -### `conda` from source - -1. First, ensure git is installed: - - ```bash - git --version + ```bash + Invoke-WebRequest -Uri "https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Windows-x86_64.exe" -OutFile "$env:UserProfile/Downloads/Miniforge3-Windows-x86_64.exe"; Start-Process -FilePath "$env:UserProfile/Downloads/Miniforge3-Windows-x86_64.exe" -ArgumentList "/InstallationType=JustMe /RegisterPython=1 /S" -Wait; Remove-Item -Path "$env:UserProfile/Downloads/Miniforge3-Windows-x86_64.exe" + ``` ``` + ```{group-tab} Linux + Open a new terminal and enter: - If 'git' is not recognized, then [install git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git). - -2. Then, clone the repository: - - ```bash - git clone https://github.com/talmolab/sleap && cd sleap + ```bash + curl -fsSL --compressed https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Linux-x86_64.sh -o "~/Downloads/Miniforge3-Linux-x86_64.sh" && chmod +x "~/Downloads/Miniforge3-Linux-x86_64.sh" && "~/Downloads/Miniforge3-Linux-x86_64.sh" -b -p ~/miniforge3 && rm "~/Downloads/Miniforge3-Linux-x86_64.sh" && ~/miniforge3/bin/conda init "$(basename "${SHELL}")" && source "$HOME/.$(basename "${SHELL}")rc" + ``` ``` + ```{group-tab} Mac (Apple Silicon) + Open a new terminal and enter: -3. Finally, install from the environment file (differs based on OS and GPU): - - **Windows** and **Linux** - - ```bash - mamba env create -f environment.yml -n sleap - ``` - - If you do not have a NVIDIA GPU, then you should use the no CUDA environment file: - - ```bash - mamba env create -f environment_no_cuda.yml -n sleap + ```bash + curl -fsSL --compressed https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-MacOSX-arm64.sh -o "~/Downloads/Miniforge3-MacOSX-arm64.sh" && chmod +x "~/Downloads/Miniforge3-MacOSX-arm64.sh" && "~/Downloads/Miniforge3-MacOSX-arm64.sh" -b -p ~/miniforge3 && rm "~/Downloads/Miniforge3-MacOSX-arm64.sh" && ~/miniforge3/bin/conda init "$(basename "${SHELL}")" && source "$HOME/.$(basename "${SHELL}")rc" + ``` ``` + ```{group-tab} Mac (Intel) + Open a new terminal and enter: - **Mac OS X** and **Apple Silicon** - - ```bash - mamba env create -f environment_mac.yml -n sleap + ```bash + curl -fsSL --compressed https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-MacOSX-x86_64.sh -o "~/Downloads/Miniforge3-MacOSX-x86_64.sh" && chmod +x "~/Downloads/Miniforge3-MacOSX-x86_64.sh" && "~/Downloads/Miniforge3-MacOSX-x86_64.sh" -b -p ~/miniforge3 && rm "~/Downloads/Miniforge3-MacOSX-x86_64.sh" && ~/miniforge3/bin/conda init "$(basename "${SHELL}")" && source "$HOME/.$(basename "${SHELL}")rc" + ``` ``` +```` - This is the **recommended method for development**. - -```{note} -- This installs SLEAP in development mode, which means that edits to the source code will be applied the next time you run SLEAP. -- Change the `-n sleap` in the command to create an environment with a different name (e.g., `-n sleap_develop`). -``` - -(pippackage)= - -### `pip` package - -Although you do not need Mambaforge installed to perform a `pip install`, we recommend {ref}`installing Mambaforge` to create a new environment where we can isolate the `pip install`. Alternatively, you can use a venv if you have an existing python installation. If you are working on **Google Colab**, skip to step 3 to perform the `pip install` without using a conda environment. +### Miniconda -1. Otherwise, create a new conda environment where we will `pip install sleap`: +This is a minimal installer for conda that includes the `conda` package manager and is maintained by the [Anaconda](https://www.anaconda.com) company. - either without GPU support: +````{tabs} + ```{group-tab} Windows + Open a new PowerShell terminal (does not need to be admin) and enter: - ```bash - mamba create --name sleap pip python=3.7.12 + ```bash + curl https://repo.anaconda.com/miniconda/Miniconda3-latest-Windows-x86_64.exe -o miniconda.exe; Start-Process -FilePath ".\miniconda.exe" -ArgumentList "/S" -Wait; del miniconda.exe + ``` ``` + ```{group-tab} Linux + Open a new terminal and enter: - or with GPU support: - - ```bash - mamba create --name sleap pip python=3.7.12 cudatoolkit=11.3 cudnn=8.2 + ```bash + mkdir -p ~/miniconda3 && wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O ~/miniconda3/miniconda.sh && bash ~/miniconda3/miniconda.sh -b -u -p ~/miniconda3 && rm ~/miniconda3/miniconda.sh && ~/miniconda3/bin/conda init "$(basename "${SHELL}")" && source "$HOME/.$(basename "${SHELL}")rc" + ``` ``` + ```{group-tab} Mac (Apple Silicon) + Open a new terminal and enter: -2. Then activate the environment to isolate the `pip install` from other environments on your computer: - - ```bash - mamba activate sleap + ```bash + curl -fsSL --compressed https://repo.anaconda.com/miniconda/Miniconda3-latest-MacOSX-arm64.sh -o "~/Downloads/Miniconda3-latest-MacOSX-arm64.sh" && chmod +x "~/Downloads/Miniconda3-latest-MacOSX-arm64.sh" && "~/Downloads/Miniconda3-latest-MacOSX-arm64.sh" -b -u -p ~/miniconda3 && rm "~/Downloads/Miniconda3-latest-MacOSX-arm64.sh" && ~/miniconda3/bin/conda init "$(basename "${SHELL}")" && source "$HOME/.$(basename "${SHELL}")rc" + ``` ``` + ```{group-tab} Mac (Intel) + Open a new terminal and enter: - ```{warning} - Refrain from installing anything into the `base` environment. Always create a new environment to install new packages. + ```bash + curl -fsSL --compressed https://repo.anaconda.com/miniconda/Miniconda3-latest-MacOSX-x86_64.sh -o "~/Downloads/Miniconda3-latest-MacOSX-x86_64.sh" && chmod +x "~/Downloads/Miniconda3-latest-MacOSX-x86_64.sh" && "~/Downloads/Miniconda3-latest-MacOSX-x86_64.sh" -b -u -p ~/miniconda3 && rm "~/Downloads/Miniconda3-latest-MacOSX-x86_64.sh" && ~/miniconda3/bin/conda init "$(basename "${SHELL}")" && source "$HOME/.$(basename "${SHELL}")rc" + ``` ``` +```` -3. Finally, we can perform the `pip install`: - - ```bash - pip install sleap[pypi]==1.3.3 - ``` +See the [Miniconda website](https://docs.anaconda.com/free/miniconda/) for up-to-date installation instructions if the above instructions don't work for your system. - This works on **any OS except Apple silicon** and on **Google Colab**. +(installation-methods)= - ```{note} - The pypi distributed package of SLEAP ships with the following extras: - - **pypi**: For installation without an mamba environment file. All dependencies come from PyPI. - - **jupyter**: This installs all *pypi* and jupyter lab dependencies. - - **dev**: This installs all *jupyter* dependencies and developement tools for testing and building docs. - - **conda_jupyter**: For installation using a mamba environment file included in the source code. Most dependencies are listed as conda packages in the environment file and only a few come from PyPI to allow jupyter lab support. - - **conda_dev**: For installation using [a mamba environment](https://github.com/search?q=repo%3Atalmolab%2Fsleap+path%3Aenvironment*.yml&type=code) with a few PyPI dependencies for development tools. - ``` +## Installation methods - ```{note} - - Requires Python 3.7 - - To enable GPU support, make sure that you have **CUDA Toolkit v11.3** and **cuDNN v8.2** installed. - ``` +SLEAP can be installed three different ways: via {ref}`conda package`, {ref}`conda from source`, or {ref}`pip package`. Select one of the methods below to install SLEAP. We recommend {ref}`conda package`. - ```{warning} - This will uninstall existing libraries and potentially install conflicting ones. +`````{tabs} + ```{tab} conda package + **This is the recommended installation method**. + ````{tabs} + ```{group-tab} Windows and Linux + ```bash + conda create -y -n sleap -c conda-forge -c nvidia -c sleap/label/dev -c sleap -c anaconda sleap=1.4.1 + ``` + ```{note} + - This comes with CUDA to enable GPU support. All you need is to have an NVIDIA GPU and [updated drivers](https://nvidia.com/drivers). + - If you already have CUDA installed on your system, this will not conflict with it. + - This will also work in CPU mode if you don't have a GPU on your machine. + ``` + ``` + ```{group-tab} Mac OS + ```bash + conda create -y -n sleap -c conda-forge -c anaconda -c sleap sleap=1.4.1 + ``` + ```{note} + This will also work in CPU mode if you don't have a GPU on your machine. + ``` + ``` + ```` - We strongly recommend that you **only use this method if you know what you're doing**! ``` + ```{tab} conda from source + This is the **recommended method for development**. + 1. First, ensure git is installed: + ```bash + git --version + ``` + If `git` is not recognized, then [install git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git). + 2. Then, clone the repository: + ```bash + git clone https://github.com/talmolab/sleap && cd sleap + ``` + 3. Finally, install SLEAP from the environment file: + ````{tabs} + ```{group-tab} Windows and Linux + ````{tabs} + ```{group-tab} NVIDIA GPU + ```bash + conda env create -f environment.yml -n sleap + ``` + ``` + ```{group-tab} CPU or other GPU + ```bash + conda env create -f environment_no_cuda.yml -n sleap + ``` + ``` + ```` + ``` + ```{group-tab} Mac OS + ```bash + conda env create -f environment_mac.yml -n sleap + ``` + ``` + ```` + ```{note} + - This installs SLEAP in development mode, which means that edits to the source code will be applied the next time you run SLEAP. + - Change the `-n sleap` in the command to create an environment with a different name (e.g., `-n sleap_develop`). + ``` + ``` + ```{tab} pip package + This is the **recommended method for Google Colab only**. + ```{warning} + This will uninstall existing libraries and potentially install conflicting ones. + + We strongly recommend that you **only use this method if you know what you're doing**! + ``` + ````{tabs} + ```{group-tab} Windows and Linux + ```{note} + - Requires Python 3.7 + - To enable GPU support, make sure that you have **CUDA Toolkit v11.3** and **cuDNN v8.2** installed. + ``` + Although you do not need Miniconda installed to perform a `pip install`, we recommend [installing Miniconda](https://docs.anaconda.com/free/miniconda/) to create a new environment where we can isolate the `pip install`. Alternatively, you can use a venv if you have an existing Python 3.7 installation. If you are working on **Google Colab**, skip to step 3 to perform the `pip install` without using a conda environment. + 1. Otherwise, create a new conda environment where we will `pip install sleap`: + ````{tabs} + ```{group-tab} NVIDIA GPU + ```bash + conda create --name sleap pip python=3.7.12 cudatoolkit=11.3 cudnn=8.2 -c conda-forge -c nvidia + ``` + ``` + ```{group-tab} CPU or other GPU + ```bash + conda create --name sleap pip python=3.7.12 + ``` + ``` + ```` + 2. Then activate the environment to isolate the `pip install` from other environments on your computer: + ```bash + conda activate sleap + ``` + ```{warning} + Refrain from installing anything into the `base` environment. Always create a new environment to install new packages. + ``` + 3. Finally, we can perform the `pip install`: + ```bash + pip install sleap[pypi]==1.4.1 + ``` + ```{note} + The pypi distributed package of SLEAP ships with the following extras: + - **pypi**: For installation without an conda environment file. All dependencies come from PyPI. + - **jupyter**: This installs all *pypi* and jupyter lab dependencies. + - **dev**: This installs all *jupyter* dependencies and developement tools for testing and building docs. + - **conda_jupyter**: For installation using a conda environment file included in the source code. Most dependencies are listed as conda packages in the environment file and only a few come from PyPI to allow jupyter lab support. + - **conda_dev**: For installation using [a conda environment](https://github.com/search?q=repo%3Atalmolab%2Fsleap+path%3Aenvironment*.yml&type=code) with a few PyPI dependencies for development tools. + ``` + ``` + ```{group-tab} Mac OS + Not supported. + ``` + ```` + ``` +````` ## Testing that things are working -If you installed using `mamba`, first activate the `sleap` environment by opening a terminal and typing: +If you installed using `conda`, first activate the `sleap` environment by opening a terminal and typing: ```bash -mamba activate sleap +conda activate sleap ``` ````{hint} -Not sure what `mamba` environments you already installed? You can get a list of the environments on your system with: +Not sure what `conda` environments you already installed? You can get a list of the environments on your system with: ``` -mamba env list +conda env list ``` ```` @@ -301,7 +301,7 @@ python -c "import sleap; sleap.versions()" ### GPU support -Assuming you installed using either of the `mamba`-based methods on Windows or Linux, SLEAP should automatically have GPU support enabled. +Assuming you installed using either of the `conda`-based methods on Windows or Linux, SLEAP should automatically have GPU support enabled. To check, verify that SLEAP can detect the GPUs on your system: @@ -362,7 +362,7 @@ file: No such file or directory then activate the environment: ```bash -mamba activate sleap +conda activate sleap ``` and run the commands: @@ -391,13 +391,13 @@ We **strongly recommend** installing SLEAP in a fresh environment when updating. To uninstall an existing environment named `sleap`: ```bash -mamba env remove -n sleap +conda env remove -n sleap ``` ````{hint} -Not sure what `mamba` environments you already installed? You can get a list of the environments on your system with: +Not sure what `conda` environments you already installed? You can get a list of the environments on your system with: ```bash -mamba env list +conda env list ``` ```` @@ -413,10 +413,10 @@ If you get any errors or the GUI fails to launch, try running the diagnostics to sleap-diagnostic ``` -If you were not able to get SLEAP installed, activate the mamba environment it is in and generate a list of the package versions installed: +If you were not able to get SLEAP installed, activate the conda environment it is in and generate a list of the package versions installed: ```bash -mamba list +conda list ``` Then, [open a new Issue](https://github.com/talmolab/sleap/issues) providing the versions from either command above, as well as any errors you saw in the console during the installation. Or [start a discussion](https://github.com/talmolab/sleap/discussions) to get help from the community. diff --git a/docs/notebooks/Training_and_inference_on_an_example_dataset.ipynb b/docs/notebooks/Training_and_inference_on_an_example_dataset.ipynb index b0211bbca..4e26cb286 100644 --- a/docs/notebooks/Training_and_inference_on_an_example_dataset.ipynb +++ b/docs/notebooks/Training_and_inference_on_an_example_dataset.ipynb @@ -335,7 +335,7 @@ " \"runs_folder\": \"models\",\n", " \"tags\": [],\n", " \"save_visualizations\": true,\n", - " \"delete_viz_images\": true,\n", + " \"keep_viz_images\": true,\n", " \"zip_outputs\": false,\n", " \"log_to_csv\": true,\n", " \"checkpointing\": {\n", @@ -727,7 +727,7 @@ " \"runs_folder\": \"models\",\n", " \"tags\": [],\n", " \"save_visualizations\": true,\n", - " \"delete_viz_images\": true,\n", + " \"keep_viz_images\": true,\n", " \"zip_outputs\": false,\n", " \"log_to_csv\": true,\n", " \"checkpointing\": {\n", diff --git a/environment.yml b/environment.yml index 67ed39d01..d8f752759 100644 --- a/environment.yml +++ b/environment.yml @@ -10,14 +10,15 @@ channels: dependencies: # Packages SLEAP uses directly - - conda-forge::attrs >=21.2.0 #,<=21.4.0 + - conda-forge::attrs >=21.2.0 - conda-forge::cattrs ==1.1.1 - - conda-forge::imgaug ==0.4.0 + - conda-forge::imageio-ffmpeg # Required for imageio to read/write videos with ffmpeg - conda-forge::jsmin - conda-forge::jsonpickle ==1.2 - conda-forge::networkx - anaconda::numpy >=1.19.5,<1.23.0 - - conda-forge::opencv + - conda-forge::opencv <4.9.0 + - conda-forge::h5py <=3.7.0 - conda-forge::pandas - conda-forge::pip - conda-forge::pillow #>=8.3.1,<=8.4.0 @@ -35,8 +36,11 @@ dependencies: - conda-forge::scikit-learn ==1.0 - conda-forge::scikit-video - conda-forge::seaborn - - sleap::tensorflow >=2.6.3,<2.11 # No windows GPU support for >2.10 + - sleap/label/dev::tensorflow ==2.7.0 # TODO: Switch to main label when updated - conda-forge::tensorflow-hub # Pinned in meta.yml, but no problems here... yet + - conda-forge::qudida + - conda-forge::albumentations + - conda-forge::ndx-pose <0.2.0 # Packages required by tensorflow to find/use GPUs - conda-forge::cudatoolkit ==11.3.1 @@ -46,4 +50,3 @@ dependencies: - pip: - "--editable=.[conda_dev]" - \ No newline at end of file diff --git a/environment_mac.yml b/environment_mac.yml index 85ef7d3b9..2026154fa 100644 --- a/environment_mac.yml +++ b/environment_mac.yml @@ -9,13 +9,14 @@ channels: dependencies: # Packages SLEAP uses directly - conda-forge::attrs >=21.2.0 + - conda-forge::importlib-metadata <7.1.0 - conda-forge::cattrs ==1.1.1 - conda-forge::h5py - - conda-forge::imgaug ==0.4.0 + - conda-forge::imageio-ffmpeg # Required for imageio to read/write videos with ffmpeg - conda-forge::jsmin - conda-forge::jsonpickle ==1.2 - conda-forge::keras <2.10.0,>=2.9.0rc0 # Required by tensorflow-macos - - conda-forge::networkx + - conda-forge::networkx <3.3 - anaconda::numpy >=1.19.5,<1.23.0 - conda-forge::opencv - conda-forge::pandas @@ -35,6 +36,9 @@ dependencies: - conda-forge::scikit-learn ==1.0 - conda-forge::scikit-video - conda-forge::seaborn - - conda-forge::tensorflow-hub + # - conda-forge::tensorflow-hub # pulls in tensorflow cpu from conda-forge + - conda-forge::qudida + - conda-forge::albumentations + - conda-forge::ndx-pose <0.2.0 - pip: - - "--editable=.[conda_dev]" + - "--editable=.[conda_dev]" \ No newline at end of file diff --git a/environment_no_cuda.yml b/environment_no_cuda.yml index 7e384b5f9..721c27fca 100644 --- a/environment_no_cuda.yml +++ b/environment_no_cuda.yml @@ -11,14 +11,14 @@ channels: dependencies: # Packages SLEAP uses directly - - conda-forge::attrs >=21.2.0 #,<=21.4.0 + - conda-forge::attrs >=21.2.0 - conda-forge::cattrs ==1.1.1 - - conda-forge::imgaug ==0.4.0 + - conda-forge::imageio-ffmpeg # Required for imageio to read/write videos with ffmpeg - conda-forge::jsmin - conda-forge::jsonpickle ==1.2 - conda-forge::networkx - anaconda::numpy >=1.19.5,<1.23.0 - - conda-forge::opencv + - conda-forge::opencv <4.9.0 - conda-forge::pandas - conda-forge::pip - conda-forge::pillow #>=8.3.1,<=8.4.0 @@ -36,8 +36,12 @@ dependencies: - conda-forge::scikit-learn ==1.0 - conda-forge::scikit-video - conda-forge::seaborn - - sleap::tensorflow >=2.6.3,<2.11 # No windows GPU support for >2.10 + # - sleap::tensorflow >=2.6.3,<2.11 # No windows GPU support for >2.10 + - sleap/label/dev::tensorflow ==2.7.0 - conda-forge::tensorflow-hub + - conda-forge::qudida + - conda-forge::albumentations + - conda-forge::ndx-pose <0.2.0 - pip: - - "--editable=.[conda_dev]" + - "--editable=.[conda_dev]" \ No newline at end of file diff --git a/pypi_requirements.txt b/pypi_requirements.txt index 33f419c9c..775ce584e 100644 --- a/pypi_requirements.txt +++ b/pypi_requirements.txt @@ -3,15 +3,17 @@ # setup.py, the packages in requirements.txt will also be installed when running # pip install sleap[pypi]. -# These are also distrubuted through conda and not pip installed when using conda. +# These are also distributed through conda and not pip installed when using conda. attrs>=21.2.0,<=21.4.0 cattrs==1.1.1 +imageio +imageio-ffmpeg # certifi>=2017.4.17,<=2021.10.8 jsmin jsonpickle==1.2 networkx numpy>=1.19.5,<1.23.0 -opencv-python>=4.2.0,<=4.6.0 +opencv-python>=4.2.0,<=4.7.0 pandas pillow>=8.3.1,<=8.4.0 psutil @@ -32,7 +34,10 @@ scikit-learn ==1.0.* scikit-video seaborn tensorflow>=2.6.3,<2.9; platform_machine != 'arm64' +# tensorflow ==2.7.4; platform_machine != 'arm64' tensorflow-hub<=0.14.0 +albumentations +ndx-pose<0.2.0 # These dependencies are untested since we do not offer a wheel for apple silicon atm. tensorflow-macos==2.9.2; sys_platform == 'darwin' and platform_machine == 'arm64' tensorflow-metal==0.5.0; sys_platform == 'darwin' and platform_machine == 'arm64' diff --git a/requirements.txt b/requirements.txt index cb0ef45c5..5db435ec8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,12 @@ # This file contains the minimal requirements to be installed via pip when using conda. # No conda packages for these -imgstore<0.3.0 # 0.3.3 results in https://github.com/O365/python-o365/issues/591 -ndx-pose +imgstore<0.3.0 # 0.3.3 results in https://github.com/O365/python-o365/issues/591 which is from https://github.com/regebro/tzlocal/issues/112 when tzlocal is v3.0 nixio>=1.5.3 # Constrain put on by @jgrewe from G-Node qimage2ndarray # ==1.9.0 segmentation-models tensorflow-macos==2.9.2; sys_platform == 'darwin' and platform_machine == 'arm64' tensorflow-metal==0.5.0; sys_platform == 'darwin' and platform_machine == 'arm64' +tensorflow-hub==0.12.0; sys_platform == 'darwin' and platform_machine == 'arm64' -# Conda installing results in https://github.com/h5py/h5py/issues/2037 -h5py<3.2; sys_platform == 'win32' # Newer versions result in error above, linking issue in Linux pynwb>=2.3.3 # 2.0.0 required by ndx-pose, 2.3.3 fixes importlib-metadata incompatibility diff --git a/sleap/config/frame_range_form.yaml b/sleap/config/frame_range_form.yaml new file mode 100644 index 000000000..3f01eade4 --- /dev/null +++ b/sleap/config/frame_range_form.yaml @@ -0,0 +1,13 @@ +main: + + - name: min_frame_idx + label: Minimum frame index + type: int + range: 1,1000000 + default: 1 + + - name: max_frame_idx + label: Maximum frame index + type: int + range: 1,1000000 + default: 1000 \ No newline at end of file diff --git a/sleap/config/labeled_clip_form.yaml b/sleap/config/labeled_clip_form.yaml index be0d64829..9236ad42b 100644 --- a/sleap/config/labeled_clip_form.yaml +++ b/sleap/config/labeled_clip_form.yaml @@ -18,6 +18,10 @@ main: label: Use GUI Visual Settings (colors, line widths) type: bool default: true + - name: background + label: Video Background + type: list + options: original,black,white,grey - name: open_when_done label: Open When Done Saving type: bool diff --git a/sleap/config/pipeline_form.yaml b/sleap/config/pipeline_form.yaml index cbcea2be5..1bb930e58 100644 --- a/sleap/config/pipeline_form.yaml +++ b/sleap/config/pipeline_form.yaml @@ -52,7 +52,7 @@ training: This pipeline uses two models: a "centroid" model to locate and crop around each animal in the frame, and a "centered-instance confidence map" model for predicted node locations - for each individual animal predicted by the centroid model.' + for each individual animal predicted by the centroid model.' - label: Max Instances name: max_instances type: optional_int @@ -211,6 +211,21 @@ training: options: ',RGB,grayscale' type: list +- type: text + text: 'ZMQ Options' + +- name: controller_port + label: Controller Port + type: int + default: 9000 + range: 1024,65535 + +- name: publish_port + label: Publish Port + type: int + default: 9001 + range: 1024,65535 + - type: text text: 'Output Options' @@ -271,6 +286,11 @@ training: type: bool default: true +- name: _keep_viz + label: Keep Prediction Visualization Images After Training + type: bool + default: false + - name: _predict_frames label: Predict On type: list @@ -287,7 +307,7 @@ inference: label: Training/Inference Pipeline Type type: stacked default: "multi-animal bottom-up " - options: "multi-animal bottom-up,multi-animal top-down,multi-animal bottom-up-id,multi-animal top-down-id,single animal,movenet-lightning,movenet-thunder,none" + options: "multi-animal bottom-up,multi-animal top-down,multi-animal bottom-up-id,multi-animal top-down-id,single animal,movenet-lightning,movenet-thunder,tracking-only" multi-animal bottom-up: - type: text @@ -365,7 +385,13 @@ inference: Note that this model is intended for human pose estimation. There is no support for videos containing more than one instance' - none: + tracking-only: + +- name: batch_size + label: Batch Size + type: int + default: 4 + range: 1,512 - name: tracking.tracker label: Tracker (cross-frame identity) Method @@ -413,7 +439,7 @@ inference: label: Similarity Method type: list default: instance - options: instance,centroid,iou + options: "instance,normalized_instance,centroid,iou,object keypoint" - name: tracking.match label: Matching Method type: list @@ -452,6 +478,22 @@ inference: label: Nodes to use for Tracking type: string default: 0,1,2 + - type: text + text: 'Object keypoint similarity options:
+ Only used if this similarity method is selected.' + - name: tracking.oks_errors + label: Keypoints errors in pixels + help: 'Standard error in pixels of the distance for each keypoint. + If the list is empty, defaults to 1. If singleton list, each keypoint has + the same error. Otherwise, the length should be the same as the number of + keypoints in the skeleton.' + type: string + default: + - name: tracking.oks_score_weighting + label: Use prediction score for weighting + help: 'Use prediction scores to weight the similarity of each keypoint' + type: bool + default: false - type: text text: 'Post-tracker data cleaning:' - name: tracking.post_connect_single_breaks @@ -495,8 +537,8 @@ inference: - name: tracking.similarity label: Similarity Method type: list - default: iou - options: instance,centroid,iou + default: instance + options: "instance,normalized_instance,centroid,iou,object keypoint" - name: tracking.match label: Matching Method type: list @@ -531,6 +573,22 @@ inference: label: Nodes to use for Tracking type: string default: 0,1,2 + - type: text + text: 'Object keypoint similarity options:
+ Only used if this similarity method is selected.' + - name: tracking.oks_errors + label: Keypoints errors in pixels + help: 'Standard error in pixels of the distance for each keypoint. + If the list is empty, defaults to 1. If singleton list, each keypoint has + the same error. Otherwise, the length should be the same as the number of + keypoints in the skeleton.' + type: string + default: + - name: tracking.oks_score_weighting + label: Use prediction score for weighting + help: 'Use prediction scores to weight the similarity of each keypoint' + type: bool + default: false - type: text text: 'Post-tracker data cleaning:' - name: tracking.post_connect_single_breaks diff --git a/sleap/config/suggestions.yaml b/sleap/config/suggestions.yaml index 8cf89728a..1440530fc 100644 --- a/sleap/config/suggestions.yaml +++ b/sleap/config/suggestions.yaml @@ -3,7 +3,7 @@ main: label: Method type: stacked default: " " - options: " ,image features,sample,prediction score,velocity,frame chunk" + options: " ,image features,sample,prediction score,velocity,frame chunk,max point displacement" " ": sample: @@ -175,6 +175,13 @@ main: type: double default: 0.1 range: 0.1,1.0 + + "max point displacement": + - name: displacement_threshold + label: Maximum Displacement Threshold + type: int + default: 10 + range: 0,999 - name: target label: Target diff --git a/sleap/config/training_editor_form.yaml b/sleap/config/training_editor_form.yaml index d10b840a0..7d7972892 100644 --- a/sleap/config/training_editor_form.yaml +++ b/sleap/config/training_editor_form.yaml @@ -44,7 +44,7 @@ model: label: Max Stride name: model.backbone.hourglass.max_stride type: list - options: 1,2,4,8,16,32,64 + options: 1,2,4,8,16,32,64,128 # - default: 4 # help: Determines the number of upsampling blocks in the network. # label: Output Stride @@ -81,7 +81,7 @@ model: label: Max Stride name: model.backbone.leap.max_stride type: list - options: 2,4,8,16,32,64 + options: 2,4,8,16,32,64,128 # - default: 1 # help: Determines the number of upsampling blocks in the network. # label: Output Stride @@ -190,7 +190,7 @@ model: label: Max Stride name: model.backbone.resnet.max_stride type: list - options: 2,4,8,16,32,64 + options: 2,4,8,16,32,64,128 # - default: 4 # help: Stride of the final output. If the upsampling branch is not defined, the # output stride is controlled via dilated convolutions or reduced pooling in the @@ -250,7 +250,7 @@ model: label: Max Stride name: model.backbone.unet.max_stride type: list - options: 2,4,8,16,32,64 + options: 2,4,8,16,32,64,128 # - default: 1 # help: Determines the number of upsampling blocks in the network. # label: Output Stride @@ -661,6 +661,7 @@ optimization: label: Batch Size name: optimization.batch_size type: int + range: 1,512 - default: 100 help: Maximum number of epochs to train for. Training can be stopped manually or automatically if early stopping is enabled and a plateau is detected. label: Epochs diff --git a/sleap/gui/app.py b/sleap/gui/app.py index de6ce9fbf..8b711c806 100644 --- a/sleap/gui/app.py +++ b/sleap/gui/app.py @@ -44,13 +44,16 @@ frame and instances listed in data view table. """ - import os import platform import random import re +import traceback +from logging import getLogger from pathlib import Path from typing import Callable, List, Optional, Tuple +import sys +import subprocess from qtpy import QtCore, QtGui from qtpy.QtCore import QEvent, Qt @@ -82,7 +85,10 @@ from sleap.io.video import available_video_exts from sleap.prefs import prefs from sleap.skeleton import Skeleton -from sleap.util import parse_uri_path +from sleap.util import parse_uri_path, get_config_file + + +logger = getLogger(__name__) class MainWindow(QMainWindow): @@ -101,6 +107,7 @@ class MainWindow(QMainWindow): def __init__( self, labels_path: Optional[str] = None, + labels: Optional[Labels] = None, reset: bool = False, no_usage_data: bool = False, *args, @@ -118,7 +125,7 @@ def __init__( self.setAcceptDrops(True) self.state = GuiState() - self.labels = Labels() + self.labels = labels or Labels() self.commands = CommandContext( state=self.state, app=self, update_callback=self.on_data_update @@ -145,6 +152,7 @@ def __init__( self.state["edge style"] = prefs["edge style"] self.state["fit"] = False self.state["color predicted"] = prefs["color predicted"] + self.state["trail_length"] = prefs["trail length"] self.state["trail_shade"] = prefs["trail shade"] self.state["marker size"] = prefs["marker size"] self.state["propagate track labels"] = prefs["propagate track labels"] @@ -175,8 +183,10 @@ def __init__( print("Restoring GUI state...") self.restoreState(prefs["window state"]) - if labels_path: + 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 @@ -213,6 +223,7 @@ def closeEvent(self, event): 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 shade"] = self.state["trail_shade"] prefs["share usage data"] = self.state["share usage data"] @@ -254,7 +265,6 @@ def dragEnterEvent(self, event): event.acceptProposedAction() def dropEvent(self, event): - # Parse filenames filenames = event.mimeData().data("text/uri-list").data().decode() filenames = [parse_uri_path(f.strip()) for f in filenames.strip().split("\n")] @@ -367,7 +377,9 @@ def add_menu_item(menu, key: str, name: str, action: Callable): def connect_check(key): self._menu_actions[key].setCheckable(True) self._menu_actions[key].setChecked(self.state[key]) - self.state.connect(key, self._menu_actions[key].setChecked) + 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): @@ -506,6 +518,13 @@ def add_submenu_choices(menu, title, options, key): 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) @@ -637,17 +656,18 @@ def prev_vid(): key="edge style", ) + # XXX add_submenu_choices( menu=viewMenu, title="Node Marker Size", - options=(1, 2, 4, 6, 8, 12), + options=prefs["node marker sizes"], key="marker size", ) add_submenu_choices( menu=viewMenu, title="Node Label Size", - options=(6, 12, 18, 24, 36), + options=prefs["node label sizes"], key="node label size", ) @@ -686,13 +706,17 @@ def prev_vid(): ) 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]) + self.commands.newInstance(init_method=method_key[0], offset=10) labelMenu = self.menuBar().addMenu("Labels") add_menu_item( @@ -735,12 +759,12 @@ def new_instance_menu_action(): labelMenu.addAction( "Copy Instance", self.commands.copyInstance, - Qt.CTRL + Qt.Key_C, + Qt.CTRL | Qt.Key_C, ) labelMenu.addAction( "Paste Instance", self.commands.pasteInstance, - Qt.CTRL + Qt.Key_V, + Qt.CTRL | Qt.Key_V, ) labelMenu.addSeparator() @@ -775,6 +799,12 @@ def new_instance_menu_action(): "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", @@ -834,12 +864,12 @@ def new_instance_menu_action(): tracksMenu.addAction( "Copy Instance Track", self.commands.copyInstanceTrack, - Qt.CTRL + Qt.SHIFT + Qt.Key_C, + Qt.CTRL | Qt.SHIFT | Qt.Key_C, ) tracksMenu.addAction( "Paste Instance Track", self.commands.pasteInstanceTrack, - Qt.CTRL + Qt.SHIFT + Qt.Key_V, + Qt.CTRL | Qt.SHIFT | Qt.Key_V, ) tracksMenu.addSeparator() @@ -850,6 +880,8 @@ def new_instance_menu_action(): "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)", @@ -1018,6 +1050,7 @@ def _load_overlays(self): labels=self.labels, player=self.player, trail_shade=self.state["trail_shade"], + trail_length=self.state["trail_length"], ) self.overlays["instance"] = InstanceOverlay( labels=self.labels, player=self.player, state=self.state @@ -1307,7 +1340,7 @@ def updateStatusMessage(self, message: Optional[str] = None): message += f" [Hidden] Press '{hide_key}' to toggle." self.statusBar().setStyleSheet("color: red") else: - self.statusBar().setStyleSheet("color: black") + self.statusBar().setStyleSheet("") self.statusBar().showMessage(message) @@ -1320,14 +1353,42 @@ def resetPrefs(self): ) 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)]) + 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 + Qt.Key_0 + self.labels.tracks.index(track) + 1 + key_command = Qt.CTRL | key_mapping[track_ind] self.track_menu.addAction( f"{track.name}", lambda x=track: self.commands.setInstanceTrack(x), @@ -1337,7 +1398,7 @@ def _update_track_menu(self): f"{track.name}", lambda x=track: self.commands.deleteTrack(x) ) self.track_menu.addAction( - "New Track", self.commands.addTrack, Qt.CTRL + Qt.Key_0 + "New Track", self.commands.addTrack, Qt.CTRL | Qt.Key_0 ) def _update_seekbar_marks(self): @@ -1354,6 +1415,8 @@ def _set_seekbar_header(self, graph_name: str): "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, @@ -1367,7 +1430,7 @@ def _set_seekbar_header(self, graph_name: str): else: if graph_name in header_functions: kwargs = dict(video=self.state["video"]) - reduction_name = re.search("\\((sum|max|min)\\)", graph_name) + 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) @@ -1594,8 +1657,12 @@ def _show_keyboard_shortcuts_window(self): ShortcutDialog().exec_() -def main(args: Optional[list] = None): - """Starts new instance of app.""" +def create_sleap_label_parser(): + """Creates parser for `sleap-label` command line arguments. + + Returns: + argparse.ArgumentParser: The parser. + """ import argparse @@ -1635,6 +1702,23 @@ def main(args: Optional[list] = None): default=False, ) + return parser + + +def create_app(): + """Creates Qt application.""" + + app = QApplication([]) + app.setApplicationName(f"SLEAP v{sleap.version.__version__}") + app.setWindowIcon(QtGui.QIcon(sleap.util.get_package_file("gui/icon.png"))) + + return app + + +def main(args: Optional[list] = None, labels: Optional[Labels] = None): + """Starts new instance of app.""" + + parser = create_sleap_label_parser() args = parser.parse_args(args) if args.nonnative: @@ -1646,17 +1730,26 @@ def main(args: Optional[list] = None): # https://stackoverflow.com/q/64818879 os.environ["QT_MAC_WANTS_LAYER"] = "1" - app = QApplication([]) - app.setApplicationName(f"SLEAP v{sleap.version.__version__}") - app.setWindowIcon(QtGui.QIcon(sleap.util.get_package_file("gui/icon.png"))) + app = create_app() window = MainWindow( - labels_path=args.labels_path, reset=args.reset, no_usage_data=args.no_usage_data + labels_path=args.labels_path, + labels=labels, + reset=args.reset, + no_usage_data=args.no_usage_data, ) window.showMaximized() # Disable GPU in GUI process. This does not affect subprocesses. - sleap.use_cpu_only() + try: + sleap.use_cpu_only() + except RuntimeError: # Visible devices cannot be modified after being initialized + logger.warning( + "Running processes on the GPU. Restarting your GUI should allow switching " + "back to CPU-only mode.\n" + "Received the following error when trying to switch back to CPU-only mode:" + ) + traceback.print_exc() # Print versions. print() diff --git a/sleap/gui/commands.py b/sleap/gui/commands.py index 698eed756..fca982327 100644 --- a/sleap/gui/commands.py +++ b/sleap/gui/commands.py @@ -36,7 +36,7 @@ class which inherits from `AppCommand` (or a more specialized class such as from enum import Enum from glob import glob from pathlib import Path, PurePath -from typing import Callable, Dict, Iterator, List, Optional, Tuple, Type +from typing import Callable, Dict, Iterator, List, Optional, Tuple, Type, Union, cast import attr import cv2 @@ -49,7 +49,7 @@ class which inherits from `AppCommand` (or a more specialized class such as from sleap.gui.dialogs.merge import MergeDialog, ReplaceSkeletonTableDialog from sleap.gui.dialogs.message import MessageDialog from sleap.gui.dialogs.missingfiles import MissingFilesDialog -from sleap.gui.dialogs.query import QueryDialog +from sleap.gui.dialogs.frame_range import FrameRangeDialog from sleap.gui.state import GuiState from sleap.gui.suggestions import VideoFrameSuggestions from sleap.instance import Instance, LabeledFrame, Point, PredictedInstance, Track @@ -260,16 +260,15 @@ def loadLabelsObject(self, labels: Labels, filename: Optional[str] = None): """ self.execute(LoadLabelsObject, labels=labels, filename=filename) - def loadProjectFile(self, filename: str): + def loadProjectFile(self, filename: Union[str, Labels]): """Loads given labels file into GUI. Args: - filename: The path to the saved labels dataset. If None, - then don't do anything. + filename: The path to the saved labels dataset or the `Labels` object. + If None, then don't do anything. Returns: None - """ self.execute(LoadProjectFile, filename=filename) @@ -492,8 +491,12 @@ def deleteLowScorePredictions(self): """Gui for deleting instances below some score threshold.""" self.execute(DeleteLowScorePredictions) - def deleteFrameLimitPredictions(self): + def deleteInstanceLimitPredictions(self): """Gui for deleting instances beyond some number in each frame.""" + self.execute(DeleteInstanceLimitPredictions) + + def deleteFrameLimitPredictions(self): + """Gui for deleting instances beyond some frame number.""" self.execute(DeleteFrameLimitPredictions) def completeInstanceNodes(self, instance: Instance): @@ -506,6 +509,7 @@ def newInstance( init_method: str = "best", location: Optional[QtCore.QPoint] = None, mark_complete: bool = False, + offset: int = 0, ): """Creates a new instance, copying node coordinates as appropriate. @@ -515,6 +519,8 @@ def newInstance( init_method: Method to use for positioning nodes. location: The location where instance should be added (if node init method supports custom location). + mark_complete: Whether to mark the instance as complete. + offset: Offset to apply to the location if given. """ self.execute( AddInstance, @@ -522,6 +528,7 @@ def newInstance( init_method=init_method, location=location, mark_complete=mark_complete, + offset=offset, ) def setPointLocations( @@ -647,9 +654,8 @@ def do_action(context: "CommandContext", params: dict): Returns: None. - """ - filename = params["filename"] + filename = params.get("filename", None) # If called with just a Labels object labels: Labels = params["labels"] context.state["labels"] = labels @@ -669,7 +675,9 @@ def do_action(context: "CommandContext", params: dict): context.state["video"] = labels.videos[0] context.state["project_loaded"] = True - context.state["has_changes"] = params.get("changed_on_load", False) + context.state["has_changes"] = params.get("changed_on_load", False) or ( + filename is None + ) # This is not listed as an edit command since we want a clean changestack context.app.on_data_update([UpdateTopic.project, UpdateTopic.all]) @@ -683,17 +691,16 @@ def ask(context: "CommandContext", params: dict): if len(filename) == 0: return - gui_video_callback = Labels.make_gui_video_callback( - search_paths=[os.path.dirname(filename)], context=params - ) - has_loaded = False labels = None - if type(filename) == Labels: + if isinstance(filename, Labels): labels = filename filename = None has_loaded = True else: + gui_video_callback = Labels.make_gui_video_callback( + search_paths=[os.path.dirname(filename)], context=params + ) try: labels = Labels.load_file(filename, video_search=gui_video_callback) has_loaded = True @@ -751,7 +758,6 @@ def ask(context: "CommandContext", params: dict) -> bool: class ImportAlphaTracker(AppCommand): @staticmethod def do_action(context: "CommandContext", params: dict): - video_path = params["video_path"] if "video_path" in params else None labels = Labels.load_alphatracker( @@ -791,7 +797,6 @@ def ask(context: "CommandContext", params: dict) -> bool: class ImportNWB(AppCommand): @staticmethod def do_action(context: "CommandContext", params: dict): - labels = Labels.load_nwb(filename=params["filename"]) new_window = context.app.__class__() @@ -824,7 +829,6 @@ def ask(context: "CommandContext", params: dict) -> bool: class ImportDeepPoseKit(AppCommand): @staticmethod def do_action(context: "CommandContext", params: dict): - labels = Labels.from_deepposekit( filename=params["filename"], video_path=params["video_path"], @@ -873,7 +877,6 @@ def ask(context: "CommandContext", params: dict) -> bool: class ImportLEAP(AppCommand): @staticmethod def do_action(context: "CommandContext", params: dict): - labels = Labels.load_leap_matlab( filename=params["filename"], ) @@ -904,7 +907,6 @@ def ask(context: "CommandContext", params: dict) -> bool: class ImportCoco(AppCommand): @staticmethod def do_action(context: "CommandContext", params: dict): - labels = Labels.load_coco( filename=params["filename"], img_dir=params["img_dir"], use_missing_gui=True ) @@ -936,7 +938,6 @@ def ask(context: "CommandContext", params: dict) -> bool: class ImportDeepLabCut(AppCommand): @staticmethod def do_action(context: "CommandContext", params: dict): - labels = Labels.load_deeplabcut(filename=params["filename"]) new_window = context.app.__class__() @@ -1295,6 +1296,7 @@ def do_action(context: CommandContext, params: dict): frames=list(params["frames"]), fps=params["fps"], color_manager=params["color_manager"], + background=params["background"], show_edges=params["show edges"], edge_is_wedge=params["edge_is_wedge"], marker_size=params["marker size"], @@ -1309,7 +1311,6 @@ def do_action(context: CommandContext, params: dict): @staticmethod def ask(context: CommandContext, params: dict) -> bool: - from sleap.gui.dialogs.export_clip import ExportClipDialog dialog = ExportClipDialog() @@ -1333,17 +1334,15 @@ def ask(context: CommandContext, params: dict) -> bool: # makes mp4's that most programs can't open (VLC can). default_out_filename = context.state["filename"] + ".avi" - # But if we can write mpegs using sci-kit video, use .mp4 - # since it has trouble writing .avi files. - if VideoWriter.can_use_skvideo(): + if VideoWriter.can_use_ffmpeg(): default_out_filename = context.state["filename"] + ".mp4" - # Ask where use wants to save video file + # Ask where user wants to save video file filename, _ = FileDialog.save( context.app, caption="Save Video As...", dir=default_out_filename, - filter="Video (*.avi *mp4)", + filter="Video (*.avi *.mp4)", ) # Check if user hit cancel @@ -1354,6 +1353,7 @@ def ask(context: CommandContext, params: dict) -> bool: params["fps"] = export_options["fps"] params["scale"] = export_options["scale"] params["open_when_done"] = export_options["open_when_done"] + params["background"] = export_options["background"] params["crop"] = None @@ -1584,7 +1584,6 @@ class GoNextSuggestedFrame(NavCommand): @classmethod def do_action(cls, context: CommandContext, params: dict): - next_suggestion_frame = context.labels.get_next_suggestion( context.state["video"], context.state["frame_idx"], cls.seek_direction ) @@ -1770,7 +1769,6 @@ class ReplaceVideo(EditCommand): @staticmethod def do_action(context: CommandContext, params: dict) -> bool: - import_list = params["import_list"] for import_item, video in import_list: @@ -1899,7 +1897,6 @@ def ask(context: CommandContext, params: dict) -> bool: video_file_names = [] total_num_labeled_frames = 0 for idx in row_idxs: - video = videos[idx] if video is None: return False @@ -1944,7 +1941,6 @@ def load_skeleton(filename: str): def compare_skeletons( skeleton: Skeleton, new_skeleton: Skeleton ) -> Tuple[List[str], List[str], List[str]]: - delete_nodes = [] add_nodes = [] if skeleton.node_names != new_skeleton.node_names: @@ -2076,7 +2072,7 @@ def try_and_skip_if_error(func, *args, **kwargs): func(*args, **kwargs) except Exception as e: tb_str = traceback.format_exception( - etype=type(e), value=e, tb=e.__traceback__ + type(e), value=e, tb=e.__traceback__ ) logger.warning( f"Recieved the following error while replacing skeleton:\n" @@ -2307,6 +2303,8 @@ def _do_deletion(context: CommandContext, lf_inst_list: List[int]): lfs_to_remove = [] for lf, inst in lf_inst_list: context.labels.remove_instance(lf, inst, in_transaction=True) + if context.state["instance"] == inst: + context.state["instance"] = None if len(lf.instances) == 0: lfs_to_remove.append(lf) @@ -2449,7 +2447,7 @@ def ask(cls, context: CommandContext, params: dict) -> bool: return super().ask(context, params) -class DeleteFrameLimitPredictions(InstanceDeleteCommand): +class DeleteInstanceLimitPredictions(InstanceDeleteCommand): @staticmethod def get_frame_instance_list(context: CommandContext, params: dict): count_thresh = params["count_threshold"] @@ -2479,6 +2477,36 @@ def ask(cls, context: CommandContext, params: dict) -> bool: return super().ask(context, params) +class DeleteFrameLimitPredictions(InstanceDeleteCommand): + @staticmethod + def get_frame_instance_list(context: CommandContext, params: Dict): + """Called from the parent `InstanceDeleteCommand.ask` method. + + Returns: + List of instances to be deleted. + """ + instances = [] + # Select the instances to be deleted + for lf in context.labels.labeled_frames: + if lf.frame_idx < (params["min_frame_idx"] - 1) or lf.frame_idx > ( + params["max_frame_idx"] - 1 + ): + instances.extend([(lf, inst) for inst in lf.instances]) + return instances + + @classmethod + def ask(cls, context: CommandContext, params: Dict) -> bool: + current_video = context.state["video"] + dialog = FrameRangeDialog( + title="Delete Instances in Frame Range...", max_frame_idx=len(current_video) + ) + results = dialog.get_results() + if results: + params["min_frame_idx"] = results["min_frame_idx"] + params["max_frame_idx"] = results["max_frame_idx"] + return super().ask(context, params) + + class TransposeInstances(EditCommand): topics = [UpdateTopic.project_instances, UpdateTopic.tracks] @@ -2492,7 +2520,16 @@ def do_action(cls, context: CommandContext, params: dict): # Swap tracks for current and subsequent frames when we have tracks old_track, new_track = instances[0].track, instances[1].track if old_track is not None and new_track is not None: - frame_range = (context.state["frame_idx"], context.state["video"].frames) + if context.state["propagate track labels"]: + frame_range = ( + context.state["frame_idx"], + context.state["video"].frames, + ) + else: + frame_range = ( + context.state["frame_idx"], + context.state["frame_idx"] + 1, + ) context.labels.track_swap( context.state["video"], new_track, old_track, frame_range ) @@ -2535,6 +2572,7 @@ def do_action(context: CommandContext, params: dict): return context.labels.remove_instance(context.state["labeled_frame"], selected_inst) + context.state["instance"] = None class DeleteSelectedInstanceTrack(EditCommand): @@ -2552,6 +2590,7 @@ def do_action(context: CommandContext, params: dict): track = selected_inst.track context.labels.remove_instance(context.state["labeled_frame"], selected_inst) + context.state["instance"] = None if track is not None: # remove any instance on this track @@ -2723,7 +2762,6 @@ class GenerateSuggestions(EditCommand): @classmethod def do_action(cls, context: CommandContext, params: dict): - if len(context.labels.videos) == 0: print("Error: no videos to generate suggestions for") return @@ -2851,27 +2889,13 @@ def ask_and_do(cls, context: CommandContext, params: dict): class AddInstance(EditCommand): topics = [UpdateTopic.frame, UpdateTopic.project_instances, UpdateTopic.suggestions] - @staticmethod - def get_previous_frame_index(context: CommandContext) -> Optional[int]: - frames = context.labels.frames( - context.state["video"], - from_frame_idx=context.state["frame_idx"], - reverse=True, - ) - - try: - next_idx = next(frames).frame_idx - except: - return - - return next_idx - @classmethod def do_action(cls, context: CommandContext, params: dict): copy_instance = params.get("copy_instance", None) init_method = params.get("init_method", "best") location = params.get("location", None) mark_complete = params.get("mark_complete", False) + offset = params.get("offset", 0) if context.state["labeled_frame"] is None: return @@ -2879,6 +2903,250 @@ def do_action(cls, context: CommandContext, params: dict): if len(context.state["skeleton"]) == 0: return + ( + copy_instance, + from_predicted, + from_prev_frame, + ) = AddInstance.find_instance_to_copy_from( + context, copy_instance=copy_instance, init_method=init_method + ) + + new_instance = AddInstance.create_new_instance( + context=context, + from_predicted=from_predicted, + copy_instance=copy_instance, + mark_complete=mark_complete, + init_method=init_method, + location=location, + from_prev_frame=from_prev_frame, + offset=offset, + ) + + # Add the instance + context.labels.add_instance(context.state["labeled_frame"], new_instance) + + if context.state["labeled_frame"] not in context.labels.labels: + context.labels.append(context.state["labeled_frame"]) + + @staticmethod + def create_new_instance( + context: CommandContext, + from_predicted: Optional[PredictedInstance], + copy_instance: Optional[Union[Instance, PredictedInstance]], + mark_complete: bool, + init_method: str, + location: Optional[QtCore.QPoint], + from_prev_frame: bool, + offset: int = 0, + ) -> Instance: + """Create new instance.""" + + # Now create the new instance + new_instance = Instance( + skeleton=context.state["skeleton"], + from_predicted=from_predicted, + frame=context.state["labeled_frame"], + ) + + has_missing_nodes = AddInstance.set_visible_nodes( + context=context, + copy_instance=copy_instance, + new_instance=new_instance, + mark_complete=mark_complete, + init_method=init_method, + location=location, + offset=offset, + ) + + if has_missing_nodes: + AddInstance.fill_missing_nodes( + context=context, + copy_instance=copy_instance, + init_method=init_method, + new_instance=new_instance, + location=location, + ) + + # If we're copying a predicted instance or from another frame, copy the track + if hasattr(copy_instance, "score") or from_prev_frame: + copy_instance = cast(Union[PredictedInstance, Instance], copy_instance) + new_instance.track = copy_instance.track + + return new_instance + + @staticmethod + def fill_missing_nodes( + context: CommandContext, + copy_instance: Optional[Union[Instance, PredictedInstance]], + init_method: str, + new_instance: Instance, + location: Optional[QtCore.QPoint], + ): + """Fill in missing nodes for new instance. + + Args: + context: The command context. + copy_instance: The instance to copy from. + init_method: The initialization method. + new_instance: The new instance. + location: The location of the instance. + + Returns: + None + """ + + # mark the node as not "visible" if we're copying from a predicted instance without this node + is_visible = copy_instance is None or (not hasattr(copy_instance, "score")) + + if init_method == "force_directed": + AddMissingInstanceNodes.add_force_directed_nodes( + context=context, + instance=new_instance, + visible=is_visible, + center_point=location, + ) + elif init_method == "random": + AddMissingInstanceNodes.add_random_nodes( + context=context, instance=new_instance, visible=is_visible + ) + elif init_method == "template": + AddMissingInstanceNodes.add_nodes_from_template( + context=context, + instance=new_instance, + visible=is_visible, + center_point=location, + ) + else: + AddMissingInstanceNodes.add_best_nodes( + context=context, instance=new_instance, visible=is_visible + ) + + @staticmethod + def set_visible_nodes( + context: CommandContext, + copy_instance: Optional[Union[Instance, PredictedInstance]], + new_instance: Instance, + mark_complete: bool, + init_method: str, + location: Optional[QtCore.QPoint] = None, + offset: int = 0, + ) -> bool: + """Sets visible nodes for new instance. + + Args: + context: The command context. + copy_instance: The instance to copy from. + new_instance: The new instance. + mark_complete: Whether to mark the instance as complete. + init_method: The initialization method. + location: The location of the mouse click if any. + offset: The offset to apply to all nodes. + + Returns: + Whether the new instance has missing nodes. + """ + + if copy_instance is None: + return True + + has_missing_nodes = False + + # Calculate scale factor for getting new x and y values. + old_size_width = copy_instance.frame.video.shape[2] + old_size_height = copy_instance.frame.video.shape[1] + new_size_width = new_instance.frame.video.shape[2] + new_size_height = new_instance.frame.video.shape[1] + scale_width = new_size_width / old_size_width + scale_height = new_size_height / old_size_height + + # The offset is 0, except when using Ctrl + I or Add Instance button. + offset_x = offset + offset_y = offset + + # Using right click and context menu with option "best" + if (init_method == "best") and (location is not None): + reference_node = next( + (node for node in copy_instance if not node.isnan()), None + ) + reference_x = reference_node.x + reference_y = reference_node.y + offset_x = location.x() - (reference_x * scale_width) + offset_y = location.y() - (reference_y * scale_height) + + # Go through each node in skeleton. + for node in context.state["skeleton"].node_names: + # If we're copying from a skeleton that has this node. + if node in copy_instance and not copy_instance[node].isnan(): + # Ensure x, y inside current frame, then copy x, y, and visible. + # We don't want to copy a PredictedPoint or score attribute. + x_old = copy_instance[node].x + y_old = copy_instance[node].y + + # Copy the instance without scale or offset if predicted + if isinstance(copy_instance, PredictedInstance): + x_new = x_old + y_new = y_old + else: + x_new = x_old * scale_width + y_new = y_old * scale_height + + # Apply offset if in bounds + x_new_offset = x_new + offset_x + y_new_offset = y_new + offset_y + + # Default visibility is same as copied instance. + visible = copy_instance[node].visible + + # If the node is offset to outside the frame, mark as not visible. + if x_new_offset < 0: + x_new = 0 + visible = False + elif x_new_offset > new_size_width: + x_new = new_size_width + visible = False + else: + x_new = x_new_offset + if y_new_offset < 0: + y_new = 0 + visible = False + elif y_new_offset > new_size_height: + y_new = new_size_height + visible = False + else: + y_new = y_new_offset + + # Update the new instance with the new x, y, and visibility. + new_instance[node] = Point( + x=x_new, + y=y_new, + visible=visible, + complete=mark_complete, + ) + else: + has_missing_nodes = True + + return has_missing_nodes + + @staticmethod + def find_instance_to_copy_from( + context: CommandContext, + copy_instance: Optional[Union[Instance, PredictedInstance]], + init_method: bool, + ) -> Tuple[ + Optional[Union[Instance, PredictedInstance]], Optional[PredictedInstance], bool + ]: + """Find instance to copy from. + + Args: + context: The command context. + copy_instance: The `Instance` to copy from. + init_method: The initialization method. + + Returns: + The instance to copy from, the predicted instance (if it is from a predicted + instance, else None), and whether it's from a previous frame. + """ + from_predicted = copy_instance from_prev_frame = False @@ -2904,7 +3172,7 @@ def do_action(cls, context: CommandContext, params: dict): ) or init_method == "prior_frame": # Otherwise, if there are instances in previous frames, # copy the points from one of those instances. - prev_idx = cls.get_previous_frame_index(context) + prev_idx = AddInstance.get_previous_frame_index(context) if prev_idx is not None: prev_instances = context.labels.find( @@ -2929,71 +3197,26 @@ def do_action(cls, context: CommandContext, params: dict): from_prev_frame = True from_predicted = from_predicted if hasattr(from_predicted, "score") else None + from_predicted = cast(Optional[PredictedInstance], from_predicted) - # Now create the new instance - new_instance = Instance( - skeleton=context.state["skeleton"], - from_predicted=from_predicted, - frame=context.state["labeled_frame"], - ) + return copy_instance, from_predicted, from_prev_frame - has_missing_nodes = False - - # go through each node in skeleton - for node in context.state["skeleton"].node_names: - # if we're copying from a skeleton that has this node - if ( - copy_instance is not None - and node in copy_instance - and not copy_instance[node].isnan() - ): - # just copy x, y, and visible - # we don't want to copy a PredictedPoint or score attribute - new_instance[node] = Point( - x=copy_instance[node].x, - y=copy_instance[node].y, - visible=copy_instance[node].visible, - complete=mark_complete, - ) - else: - has_missing_nodes = True - - if has_missing_nodes: - # mark the node as not "visible" if we're copying from a predicted instance without this node - is_visible = copy_instance is None or (not hasattr(copy_instance, "score")) - - if init_method == "force_directed": - AddMissingInstanceNodes.add_force_directed_nodes( - context=context, - instance=new_instance, - visible=is_visible, - center_point=location, - ) - elif init_method == "random": - AddMissingInstanceNodes.add_random_nodes( - context=context, instance=new_instance, visible=is_visible - ) - elif init_method == "template": - AddMissingInstanceNodes.add_nodes_from_template( - context=context, - instance=new_instance, - visible=is_visible, - center_point=location, - ) - else: - AddMissingInstanceNodes.add_best_nodes( - context=context, instance=new_instance, visible=is_visible - ) + @staticmethod + def get_previous_frame_index(context: CommandContext) -> Optional[int]: + """Returns index of previous frame.""" - # If we're copying a predicted instance or from another frame, copy the track - if hasattr(copy_instance, "score") or from_prev_frame: - new_instance.track = copy_instance.track + frames = context.labels.frames( + context.state["video"], + from_frame_idx=context.state["frame_idx"], + reverse=True, + ) - # Add the instance - context.labels.add_instance(context.state["labeled_frame"], new_instance) + try: + next_idx = next(frames).frame_idx + except: + return - if context.state["labeled_frame"] not in context.labels.labels: - context.labels.append(context.state["labeled_frame"]) + return next_idx class SetInstancePointLocations(EditCommand): diff --git a/sleap/gui/dataviews.py b/sleap/gui/dataviews.py index 0a008bea7..721bdc321 100644 --- a/sleap/gui/dataviews.py +++ b/sleap/gui/dataviews.py @@ -15,20 +15,17 @@ """ -from qtpy import QtCore, QtWidgets, QtGui - -import numpy as np import os - from operator import itemgetter +from pathlib import Path +from typing import Any, Callable, List, Optional -from typing import Any, Callable, Dict, List, Optional, Type +import numpy as np +from qtpy import QtCore, QtGui, QtWidgets -from sleap.gui.state import GuiState from sleap.gui.commands import CommandContext -from sleap.gui.color import ColorManager -from sleap.io.dataset import Labels -from sleap.instance import LabeledFrame, Instance +from sleap.gui.state import GuiState +from sleap.instance import LabeledFrame from sleap.skeleton import Skeleton @@ -386,10 +383,25 @@ def getSelectedRowItem(self) -> Any: class VideosTableModel(GenericTableModel): - properties = ("filename", "frames", "height", "width", "channels") - - def item_to_data(self, obj, item): - return {key: getattr(item, key) for key in self.properties} + properties = ( + "name", + "filepath", + "frames", + "height", + "width", + "channels", + ) + + def item_to_data(self, obj, item: "Video"): + data = {} + for property in self.properties: + if property == "name": + data[property] = Path(item.filename).name + elif property == "filepath": + data[property] = str(Path(item.filename).parent) + else: + data[property] = getattr(item, property) + return data class SkeletonNodesTableModel(GenericTableModel): @@ -413,13 +425,6 @@ def set_item(self, item, key, value): elif key == "symmetry": self.context.setNodeSymmetry(skeleton=self.obj, node=item, symmetry=value) - def get_item_color(self, item: Any, key: str): - if self.skeleton: - color = self.context.app.color_manager.get_item_color( - item, parent_skeleton=self.skeleton - ) - return QtGui.QColor(*color) - class SkeletonEdgesTableModel(GenericTableModel): """Table model for skeleton edges.""" @@ -436,14 +441,6 @@ def object_to_items(self, skeleton: Skeleton): ] return items - def get_item_color(self, item: Any, key: str): - if self.skeleton: - edge_pair = (item["source"], item["destination"]) - color = self.context.app.color_manager.get_item_color( - edge_pair, parent_skeleton=self.skeleton - ) - return QtGui.QColor(*color) - class LabeledFrameTableModel(GenericTableModel): """Table model for listing instances in labeled frame. diff --git a/sleap/gui/dialogs/export_clip.py b/sleap/gui/dialogs/export_clip.py index 312f9a807..f84766d18 100644 --- a/sleap/gui/dialogs/export_clip.py +++ b/sleap/gui/dialogs/export_clip.py @@ -11,16 +11,16 @@ def __init__(self): super().__init__(form_name="labeled_clip_form") - can_use_skvideo = VideoWriter.can_use_skvideo() + can_use_ffmpeg = VideoWriter.can_use_ffmpeg() - if can_use_skvideo: + if can_use_ffmpeg: message = ( "MP4 file will be encoded using " - "system ffmpeg via scikit-video (preferred option)." + "system ffmpeg via imageio (preferred option).
" ) else: message = ( - "Unable to use ffpmeg via scikit-video. " + "Unable to use ffpmeg via imageio. " "AVI file will be encoding using OpenCV." ) diff --git a/sleap/gui/dialogs/filedialog.py b/sleap/gui/dialogs/filedialog.py index 930c71b0d..ff394d191 100644 --- a/sleap/gui/dialogs/filedialog.py +++ b/sleap/gui/dialogs/filedialog.py @@ -29,7 +29,8 @@ def set_dialog_type(cls, *args, **kwargs): if cls.is_non_native: kwargs["options"] = kwargs.get("options", 0) - kwargs["options"] |= QtWidgets.QFileDialog.DontUseNativeDialog + if not kwargs["options"]: + kwargs["options"] = QtWidgets.QFileDialog.DontUseNativeDialog # Make sure we don't send empty options argument if "options" in kwargs and not kwargs["options"]: diff --git a/sleap/gui/dialogs/frame_range.py b/sleap/gui/dialogs/frame_range.py new file mode 100644 index 000000000..7165dd939 --- /dev/null +++ b/sleap/gui/dialogs/frame_range.py @@ -0,0 +1,42 @@ +"""Frame range dialog.""" +from qtpy import QtWidgets +from sleap.gui.dialogs.formbuilder import FormBuilderModalDialog +from typing import Optional + + +class FrameRangeDialog(FormBuilderModalDialog): + def __init__(self, max_frame_idx: Optional[int] = None, title: str = "Frame Range"): + + super().__init__(form_name="frame_range_form") + min_frame_idx_field = self.form_widget.fields["min_frame_idx"] + max_frame_idx_field = self.form_widget.fields["max_frame_idx"] + + if max_frame_idx is not None: + min_frame_idx_field.setRange(1, max_frame_idx) + min_frame_idx_field.setValue(1) + + max_frame_idx_field.setRange(1, max_frame_idx) + max_frame_idx_field.setValue(max_frame_idx) + + min_frame_idx_field.valueChanged.connect(self._update_max_frame_range) + max_frame_idx_field.valueChanged.connect(self._update_min_frame_range) + + self.setWindowTitle(title) + + def _update_max_frame_range(self, value): + min_frame_idx_field = self.form_widget.fields["min_frame_idx"] + max_frame_idx_field = self.form_widget.fields["max_frame_idx"] + + max_frame_idx_field.setRange(value, max_frame_idx_field.maximum()) + + def _update_min_frame_range(self, value): + min_frame_idx_field = self.form_widget.fields["min_frame_idx"] + max_frame_idx_field = self.form_widget.fields["max_frame_idx"] + + min_frame_idx_field.setRange(min_frame_idx_field.minimum(), value) + + +if __name__ == "__main__": + app = QtWidgets.QApplication([]) + dialog = FrameRangeDialog(max_frame_idx=100) + print(dialog.get_results()) diff --git a/sleap/gui/dialogs/metrics.py b/sleap/gui/dialogs/metrics.py index 864a6adf0..884b373a9 100644 --- a/sleap/gui/dialogs/metrics.py +++ b/sleap/gui/dialogs/metrics.py @@ -120,10 +120,11 @@ def _show_model_params( if cfg_info is None: cfg_info = self.table_view.getSelectedRowItem() + cfg_getter = self._cfg_getter key = cfg_info.path if key not in model_detail_widgets: model_detail_widgets[key] = TrainingEditorWidget.from_trained_config( - cfg_info + cfg_info, cfg_getter ) model_detail_widgets[key].show() diff --git a/sleap/gui/learning/dialog.py b/sleap/gui/learning/dialog.py index d9f872fda..bc26d826c 100644 --- a/sleap/gui/learning/dialog.py +++ b/sleap/gui/learning/dialog.py @@ -1,24 +1,20 @@ """ Dialogs for running training and/or inference in GUI. """ -import cattr -import os +import json import shutil -import atexit import tempfile from pathlib import Path +from typing import Dict, List, Optional, Text, cast + +import cattr +from qtpy import QtCore, QtGui, QtWidgets import sleap from sleap import Labels, Video from sleap.gui.dialogs.filedialog import FileDialog from sleap.gui.dialogs.formbuilder import YamlFormWidget -from sleap.gui.learning import runners, scopedkeydict, configs, datagen, receptivefield - -from typing import Dict, List, Optional, Text, Optional, cast - -from qtpy import QtWidgets, QtCore - -import json +from sleap.gui.learning import configs, datagen, receptivefield, runners, scopedkeydict # List of fields which should show list of skeleton nodes NODE_LIST_FIELDS = [ @@ -128,12 +124,25 @@ def __init__( self.message_widget = QtWidgets.QLabel("") # Layout for entire dialog - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.tab_widget) - layout.addWidget(self.message_widget) - layout.addWidget(buttons_layout_widget) + content_widget = QtWidgets.QWidget() + content_layout = QtWidgets.QVBoxLayout(content_widget) - self.setLayout(layout) + content_layout.addWidget(self.tab_widget) + content_layout.addWidget(self.message_widget) + content_layout.addWidget(buttons_layout_widget) + + # Create the QScrollArea. + scroll_area = QtWidgets.QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setWidget(content_widget) + + scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(scroll_area) + + self.adjust_initial_size() # Default to most recently trained pipeline (if there is one) self.set_default_pipeline_tab() @@ -157,6 +166,20 @@ def __init__( self.view_datagen ) + def adjust_initial_size(self): + # Get screen size + screen = QtGui.QGuiApplication.primaryScreen().availableGeometry() + + max_width = 1860 + max_height = 1150 + margin = 0.10 + + # Calculate target width and height + target_width = min(screen.width() - screen.width() * margin, max_width) + target_height = min(screen.height() - screen.height() * margin, max_height) + # Set the dialog's dimensions + self.resize(target_width, target_height) + def update_file_lists(self): self._cfg_getter.update() for tab in self.tabs.values(): @@ -579,6 +602,7 @@ def get_selected_frames_to_predict( def get_items_for_inference(self, pipeline_form_data) -> runners.ItemsForInference: predict_frames_choice = pipeline_form_data.get("_predict_frames", "") + batch_size = pipeline_form_data.get("batch_size") frame_selection = self.get_selected_frames_to_predict(pipeline_form_data) frame_count = self.count_total_frames_for_selection_option(frame_selection) @@ -591,6 +615,7 @@ def get_items_for_inference(self, pipeline_form_data) -> runners.ItemsForInferen ) ], total_frame_count=frame_count, + batch_size=batch_size, ) elif predict_frames_choice.startswith("suggested"): items_for_inference = runners.ItemsForInference( @@ -600,6 +625,7 @@ def get_items_for_inference(self, pipeline_form_data) -> runners.ItemsForInferen ) ], total_frame_count=frame_count, + batch_size=batch_size, ) else: items_for_inference = runners.ItemsForInference.from_video_frames_dict( @@ -607,9 +633,24 @@ def get_items_for_inference(self, pipeline_form_data) -> runners.ItemsForInferen total_frame_count=frame_count, labels_path=self.labels_filename, labels=self.labels, + batch_size=batch_size, ) return items_for_inference + def _validate_id_model(self) -> bool: + """Make sure we have instances with tracks set for ID models.""" + if not self.labels.tracks: + message = "Cannot run ID model training without tracks." + return False + + found_tracks = False + for inst in self.labels.instances(): + if type(inst) == sleap.Instance and inst.track is not None: + found_tracks = True + break + + return found_tracks + def _validate_pipeline(self): can_run = True message = "" @@ -628,6 +669,15 @@ def _validate_pipeline(self): f"({', '.join(untrained)})." ) + # Make sure we have instances with tracks set for ID models. + if self.mode == "training" and self.current_pipeline in ( + "top-down-id", + "bottom-up-id", + ): + can_run = self.validate_id_model() + if not can_run: + message = "Cannot run ID model training without tracks." + # Make sure skeleton will be valid for bottom-up inference. if self.mode == "training" and self.current_pipeline == "bottom-up": skeleton = self.labels.skeletons[0] @@ -1088,8 +1138,12 @@ def __init__( self.setLayout(layout) @classmethod - def from_trained_config(cls, cfg_info: configs.ConfigFileInfo): - widget = cls(require_trained=True, head=cfg_info.head_name) + def from_trained_config( + cls, cfg_info: configs.ConfigFileInfo, cfg_getter: configs.TrainingConfigsGetter + ): + widget = cls( + require_trained=True, head=cfg_info.head_name, cfg_getter=cfg_getter + ) widget.acceptSelectedConfigInfo(cfg_info) widget.setWindowTitle(cfg_info.path_dir) return widget diff --git a/sleap/gui/learning/runners.py b/sleap/gui/learning/runners.py index ca60c4127..d0bb1f3ba 100644 --- a/sleap/gui/learning/runners.py +++ b/sleap/gui/learning/runners.py @@ -1,4 +1,5 @@ """Run training/inference in background process via CLI.""" + import abc import attr import os @@ -151,6 +152,7 @@ class ItemsForInference: items: List[ItemForInference] total_frame_count: int + batch_size: int def __len__(self): return len(self.items) @@ -160,6 +162,7 @@ def from_video_frames_dict( cls, video_frames_dict: Dict[Video, List[int]], total_frame_count: int, + batch_size: int, labels: Labels, labels_path: Optional[str] = None, ): @@ -174,7 +177,9 @@ def from_video_frames_dict( video_idx=labels.videos.index(video), ) ) - return cls(items=items, total_frame_count=total_frame_count) + return cls( + items=items, total_frame_count=total_frame_count, batch_size=batch_size + ) @attr.s(auto_attribs=True) @@ -255,12 +260,20 @@ def make_predict_cli_call( "tracking.max_tracking", "tracking.post_connect_single_breaks", "tracking.save_shifted_instances", + "tracking.oks_score_weighting", ) for key in bool_items_as_ints: if key in self.inference_params: self.inference_params[key] = int(self.inference_params[key]) + remove_spaces_items = ("tracking.similarity",) + + for key in remove_spaces_items: + if key in self.inference_params: + value = self.inference_params[key] + self.inference_params[key] = value.replace(" ", "_") + for key, val in self.inference_params.items(): if not key.startswith(("_", "outputs.", "model.", "data.")): cli_args.extend((f"--{key}", str(val))) @@ -496,9 +509,11 @@ def write_pipeline_files( "data_path": os.path.basename(data_path), "models": [Path(p).as_posix() for p in new_cfg_filenames], "output_path": prediction_output_path, - "type": "labels" - if type(item_for_inference) == DatasetItemForInference - else "video", + "type": ( + "labels" + if type(item_for_inference) == DatasetItemForInference + else "video" + ), "only_suggested_frames": only_suggested_frames, "tracking": tracking_args, } @@ -540,6 +555,7 @@ def run_learning_pipeline( """ save_viz = inference_params.get("_save_viz", False) + keep_viz = inference_params.get("_keep_viz", False) if "movenet" in inference_params["_pipeline"]: trained_job_paths = [inference_params["_pipeline"]] @@ -550,8 +566,10 @@ def run_learning_pipeline( labels_filename=labels_filename, labels=labels, config_info_list=config_info_list, + inference_params=inference_params, gui=True, save_viz=save_viz, + keep_viz=keep_viz, ) # Check that all the models were trained @@ -577,8 +595,10 @@ def run_gui_training( labels_filename: str, labels: Labels, config_info_list: List[ConfigFileInfo], + inference_params: Dict[str, Any], gui: bool = True, save_viz: bool = False, + keep_viz: bool = False, ) -> Dict[Text, Text]: """ Runs training for each training job. @@ -588,19 +608,28 @@ def run_gui_training( config_info_list: List of ConfigFileInfo with configs for training. gui: Whether to show gui windows and process gui events. save_viz: Whether to save visualizations from training. + keep_viz: Whether to keep prediction visualization images after training. Returns: Dictionary, keys are head name, values are path to trained config. """ trained_job_paths = dict() - + zmq_ports = None if gui: from sleap.gui.widgets.monitor import LossViewer from sleap.gui.widgets.imagedir import QtImageDirectoryWidget - # open training monitor window - win = LossViewer() + zmq_ports = dict() + zmq_ports["controller_port"] = inference_params.get("controller_port", 9000) + zmq_ports["publish_port"] = inference_params.get("publish_port", 9001) + + # Open training monitor window + win = LossViewer(zmq_ports=zmq_ports) + + # Reassign the values in the inference parameters in case the ports were changed + inference_params["controller_port"] = win.zmq_ports["controller_port"] + inference_params["publish_port"] = win.zmq_ports["publish_port"] win.resize(600, 400) win.show() @@ -664,10 +693,12 @@ def waiting(): # Run training trained_job_path, ret = train_subprocess( job_config=job, + inference_params=inference_params, labels_filename=labels_filename, video_paths=video_path_list, waiting_callback=waiting, save_viz=save_viz, + keep_viz=keep_viz, ) if ret == "success": @@ -806,9 +837,11 @@ def waiting_item(**kwargs): def train_subprocess( job_config: TrainingJobConfig, labels_filename: str, + inference_params: Dict[str, Any], video_paths: Optional[List[Text]] = None, waiting_callback: Optional[Callable] = None, save_viz: bool = False, + keep_viz: bool = False, ): """Runs training inside subprocess.""" run_path = job_config.outputs.run_path @@ -829,10 +862,16 @@ def train_subprocess( training_job_path, labels_filename, "--zmq", + "--controller_port", + str(inference_params["controller_port"]), + "--publish_port", + str(inference_params["publish_port"]), ] if save_viz: cli_args.append("--save_viz") + if keep_viz: + cli_args.append("--keep_viz") # Use cli arg since cli ignores setting in config if job_config.outputs.tensorboard.write_logs: diff --git a/sleap/gui/overlays/base.py b/sleap/gui/overlays/base.py index d27b069ac..879d12810 100644 --- a/sleap/gui/overlays/base.py +++ b/sleap/gui/overlays/base.py @@ -71,7 +71,6 @@ def remove_from_scene(self): except RuntimeError as e: # Internal C++ object (PySide2.QtWidgets.QGraphicsPathItem) already deleted. logger.debug(e) - pass # Stop tracking the items after they been removed from the scene self.items = [] diff --git a/sleap/gui/overlays/tracks.py b/sleap/gui/overlays/tracks.py index 361585719..c5f091658 100644 --- a/sleap/gui/overlays/tracks.py +++ b/sleap/gui/overlays/tracks.py @@ -1,17 +1,16 @@ """Track trail and track list overlays.""" +from typing import Dict, Iterable, List, Optional, Tuple + +import attr +from qtpy import QtCore, QtGui + from sleap.gui.overlays.base import BaseOverlay +from sleap.gui.widgets.video import QtTextWithBackground from sleap.instance import Track from sleap.io.dataset import Labels from sleap.io.video import Video from sleap.prefs import prefs -from sleap.gui.widgets.video import QtTextWithBackground - -import attr - -from typing import Iterable, List, Optional, Dict - -from qtpy import QtCore, QtGui @attr.s(auto_attribs=True) @@ -48,7 +47,9 @@ def __attrs_post_init__(self): @classmethod def get_length_options(cls): - return (0, 10, 50, 100, 250) + if prefs["trail length"] != 0: + return (0, 10, 50, 100, 250, 500, prefs["trail length"]) + return (0, 10, 50, 100, 250, 500) @classmethod def get_shade_options(cls): @@ -56,7 +57,9 @@ def get_shade_options(cls): return {"Dark": 0.6, "Normal": 1.0, "Light": 1.25} - def get_track_trails(self, frame_selection: Iterable["LabeledFrame"]): + def get_track_trails( + self, frame_selection: Iterable["LabeledFrame"] + ) -> Optional[Dict[Track, List[List[Tuple[float, float]]]]]: """Get data needed to draw track trail. Args: @@ -152,6 +155,8 @@ def add_to_scene(self, video: Video, frame_idx: int): frame_selection = self.get_frame_selection(video, frame_idx) all_track_trails = self.get_track_trails(frame_selection) + if all_track_trails is None: + return for track, trails in all_track_trails.items(): trail_color = tuple( diff --git a/sleap/gui/suggestions.py b/sleap/gui/suggestions.py index 48b916437..b85d6ac32 100644 --- a/sleap/gui/suggestions.py +++ b/sleap/gui/suggestions.py @@ -61,6 +61,7 @@ def suggest(cls, params: dict, labels: "Labels" = None) -> List[SuggestionFrame] prediction_score=cls.prediction_score, velocity=cls.velocity, frame_chunk=cls.frame_chunk, + max_point_displacement=cls.max_point_displacement, ) method = str.replace(params["method"], " ", "_") @@ -213,6 +214,7 @@ def _prediction_score_video( ): lfs = labels.find(video) frames = len(lfs) + # initiate an array filled with -1 to store frame index (starting from 0). idxs = np.full((frames), -1, dtype="int") @@ -291,6 +293,56 @@ def _velocity_video( return cls.idx_list_to_frame_list(frame_idxs, video) + @classmethod + def max_point_displacement( + cls, + labels: "Labels", + videos: List[Video], + displacement_threshold: float, + **kwargs, + ): + """Finds frames with maximum point displacement above a threshold.""" + + proposed_suggestions = [] + for video in videos: + proposed_suggestions.extend( + cls._max_point_displacement_video(video, labels, displacement_threshold) + ) + + suggestions = VideoFrameSuggestions.filter_unique_suggestions( + labels, videos, proposed_suggestions + ) + + return suggestions + + @classmethod + def _max_point_displacement_video( + cls, video: Video, labels: "Labels", displacement_threshold: float + ): + # Get numpy of shape (frames, tracks, nodes, x, y) + labels_numpy = labels.numpy(video=video, all_frames=True, untracked=False) + + # Return empty list if not enough frames + n_frames, n_tracks, n_nodes, _ = labels_numpy.shape + + if n_frames < 2: + return [] + + # Calculate displacements + diff = labels_numpy[1:] - labels_numpy[:-1] # (frames - 1, tracks, nodes, x, y) + euc_norm = np.linalg.norm(diff, axis=-1) # (frames - 1, tracks, nodes) + mean_euc_norm = np.nanmean(euc_norm, axis=-1) # (frames - 1, tracks) + + # Find frames where mean displacement is above threshold + threshold_mask = np.any( + mean_euc_norm > displacement_threshold, axis=-1 + ) # (frames - 1,) + frame_idxs = list( + np.argwhere(threshold_mask).flatten() + 1 + ) # [0, len(frames - 1)] + + return cls.idx_list_to_frame_list(frame_idxs, video) + @classmethod def frame_chunk( cls, diff --git a/sleap/gui/utils.py b/sleap/gui/utils.py new file mode 100644 index 000000000..4f8215706 --- /dev/null +++ b/sleap/gui/utils.py @@ -0,0 +1,28 @@ +"""Generic module containing utilities used for the GUI.""" + +import zmq +from typing import Optional + + +def is_port_free(port: int, zmq_context: Optional[zmq.Context] = None) -> bool: + """Checks if a port is free.""" + ctx = zmq.Context.instance() if zmq_context is None else zmq_context + socket = ctx.socket(zmq.REP) + address = f"tcp://127.0.0.1:{port}" + try: + socket.bind(address) + socket.unbind(address) + return True + except zmq.error.ZMQError: + return False + finally: + socket.close() + + +def select_zmq_port(zmq_context: Optional[zmq.Context] = None) -> int: + """Select a port that is free to connect within the given context.""" + ctx = zmq.Context.instance() if zmq_context is None else zmq_context + socket = ctx.socket(zmq.REP) + port = socket.bind_to_random_port("tcp://127.0.0.1") + socket.close() + return port diff --git a/sleap/gui/widgets/docks.py b/sleap/gui/widgets/docks.py index 43e218adb..bd20bf79a 100644 --- a/sleap/gui/widgets/docks.py +++ b/sleap/gui/widgets/docks.py @@ -30,10 +30,8 @@ ) from sleap.gui.dialogs.formbuilder import YamlFormWidget from sleap.gui.widgets.views import CollapsibleWidget -from sleap.skeleton import Skeleton -from sleap.util import decode_preview_image, find_files_by_suffix, get_package_file - -# from sleap.gui.app import MainWindow +from sleap.skeleton import Skeleton, SkeletonDecoder +from sleap.util import find_files_by_suffix, get_package_file class DockWidget(QDockWidget): @@ -365,7 +363,7 @@ def create_templates_groupbox(self) -> QGroupBox: def updatePreviewImage(preview_image_bytes: bytes): # Decode the preview image - preview_image = decode_preview_image(preview_image_bytes) + preview_image = SkeletonDecoder.decode_preview_image(preview_image_bytes) # Create a QImage from the Image preview_image = QtGui.QImage( @@ -557,7 +555,7 @@ def create_table_edit_buttons(self) -> QWidget: hb = QHBoxLayout() self.add_button( - hb, "New Instance", lambda x: main_window.commands.newInstance() + hb, "New Instance", lambda x: main_window.commands.newInstance(offset=10) ) self.add_button( hb, "Delete Instance", main_window.commands.deleteSelectedInstance diff --git a/sleap/gui/widgets/monitor.py b/sleap/gui/widgets/monitor.py index 93bc483e9..fff8a0327 100644 --- a/sleap/gui/widgets/monitor.py +++ b/sleap/gui/widgets/monitor.py @@ -1,20 +1,590 @@ """GUI for monitoring training progress interactively.""" -import numpy as np -from time import perf_counter -from sleap.nn.config.training_job import TrainingJobConfig -import zmq -import jsonpickle import logging -from typing import Optional -from qtpy import QtCore, QtWidgets, QtGui -from qtpy.QtCharts import QtCharts +from time import perf_counter +from typing import Dict, Optional, Tuple + import attr +import jsonpickle +import numpy as np +import zmq +from matplotlib.collections import PathCollection +import matplotlib.transforms as mtransforms +from qtpy import QtCore, QtWidgets +from sleap.gui.utils import is_port_free, select_zmq_port +from sleap.gui.widgets.mpl import MplCanvas +from sleap.nn.config.training_job import TrainingJobConfig logger = logging.getLogger(__name__) +class LossPlot(MplCanvas): + """Matplotlib canvas for diplaying training and validation loss curves.""" + + def __init__( + self, + width: int = 5, + height: int = 4, + dpi: int = 100, + log_scale: bool = True, + ignore_outliers: bool = False, + ): + super().__init__(width=width, height=height, dpi=dpi) + + self._log_scale: bool = log_scale + + self.ignore_outliers = ignore_outliers + + # Initialize the series for the plot + self.series: dict = {} + COLOR_TRAIN = (18, 158, 220) + COLOR_VAL = (248, 167, 52) + COLOR_BEST_VAL = (151, 204, 89) + + # Initialize scatter series for batch training loss + self.series["batch"] = self._init_series( + series_type=self.axes.scatter, + name="Batch Training Loss", + color=COLOR_TRAIN + (48,), + border_color=(255, 255, 255, 25), + ) + + # Initialize line series for epoch training loss + self.series["epoch_loss"] = self._init_series( + series_type=self.axes.plot, + name="Epoch Training Loss", + color=COLOR_TRAIN + (255,), + line_width=3.0, + ) + + # Initialize line series for epoch validation loss + self.series["val_loss"] = self._init_series( + series_type=self.axes.plot, + name="Epoch Validation Loss", + color=COLOR_VAL + (255,), + line_width=3.0, + zorder=4, # Below best validation loss series + ) + + # Initialize scatter series for best epoch validation loss + self.series["val_loss_best"] = self._init_series( + series_type=self.axes.scatter, + name="Best Validation Loss", + color=COLOR_BEST_VAL + (255,), + border_color=(255, 255, 255, 25), + zorder=5, # Above epoch validation loss series + ) + + # Set the x and y positions for the xy labels (as fraction of figure size) + self.ypos_xlabel = 0.1 + self.xpos_ylabel = 0.05 + + # Padding between the axes and the xy labels + self.xpos_padding = 0.2 + self.ypos_padding = 0.1 + + # Set up the major gridlines + self._setup_major_gridlines() + + # Set up the x-axis + self._setup_x_axis() + + # Set up the y-axis + self._set_up_y_axis() + + # Set up the legend + self.legend_width, legend_height = self._setup_legend() + + # Set up the title space + self.ypos_title = None + title_height = self._set_title_space() + self.ypos_title = 1 - title_height - self.ypos_padding + + # Determine the top height of the plot + top_height = max(title_height, legend_height) + + # Adjust the figure layout + self.xpos_left_plot = self.xpos_ylabel + self.xpos_padding + self.xpos_right_plot = 0.97 + self.ypos_bottom_plot = self.ypos_xlabel + self.ypos_padding + self.ypos_top_plot = 1 - top_height - self.ypos_padding + + # Adjust the top parameters as needed + self.fig.subplots_adjust( + left=self.xpos_left_plot, + right=self.xpos_right_plot, + top=self.ypos_top_plot, + bottom=self.ypos_bottom_plot, + ) + + @property + def log_scale(self): + """Returns True if the plot has a log scale for y-axis.""" + + return self._log_scale + + @log_scale.setter + def log_scale(self, val): + """Sets the scale of the y axis to log if True else linear.""" + + if isinstance(val, bool): + self._log_scale = val + + y_scale = "log" if self._log_scale else "linear" + self.axes.set_yscale(y_scale) + self.redraw_plot() + + def set_data_on_scatter(self, xs, ys, which): + """Set data on a scatter plot. + + Not to be used with line plots. + + Args: + xs: The x-coordinates of the data points. + ys: The y-coordinates of the data points. + which: The type of data point. Possible values are: + * "batch" + * "val_loss_best" + """ + + offsets = np.column_stack((xs, ys)) + self.series[which].set_offsets(offsets) + + def add_data_to_plot(self, x, y, which): + """Add data to a line plot. + + Not to be used with scatter plots. + + Args: + x: The x-coordinate of the data point. + y: The y-coordinate of the data point. + which: The type of data point. Possible values are: + * "epoch_loss" + * "val_loss" + """ + + x_data, y_data = self.series[which].get_data() + self.series[which].set_data(np.append(x_data, x), np.append(y_data, y)) + + def resize_axes(self, x, y): + """Resize axes to fit data. + + This is only called when plotting batches. + + Args: + x: The x-coordinates of the data points. + y: The y-coordinates of the data points. + """ + + # Set X scale to show all points + x_min, x_max = self._calculate_xlim(x) + self.axes.set_xlim(x_min, x_max) + + # Set Y scale, ensuring that y_min and y_max do not lead to sngular transform + y_min, y_max = self._calculate_ylim(y) + y_min, y_max = self.axes.yaxis.get_major_locator().nonsingular(y_min, y_max) + self.axes.set_ylim(y_min, y_max) + + # Add gridlines at midpoint between major ticks (major gridlines are automatic) + self._add_midpoint_gridlines() + + # Redraw the plot + self.redraw_plot() + + def redraw_plot(self): + """Redraw the plot.""" + + self.fig.canvas.draw_idle() + + def set_title(self, title, color=None): + """Set the title of the plot. + + Args: + title: The title text to display. + """ + + if color is None: + color = "black" + + self.axes.set_title( + title, fontweight="light", fontsize="small", color=color, x=0.55, y=1.03 + ) + + def update_runtime_title( + self, + epoch: int, + dt_min: int, + dt_sec: int, + last_epoch_val_loss: float = None, + penultimate_epoch_val_loss: float = None, + mean_epoch_time_min: int = None, + mean_epoch_time_sec: int = None, + eta_ten_epochs_min: int = None, + epochs_in_plateau: int = None, + plateau_patience: int = None, + epoch_in_plateau_flag: bool = False, + best_val_x: int = None, + best_val_y: float = None, + epoch_size: int = None, + ): + + # Add training epoch and runtime info + title = self._get_training_epoch_and_runtime_text(epoch, dt_min, dt_sec) + + if last_epoch_val_loss is not None: + + if penultimate_epoch_val_loss is not None: + # Add mean epoch time and ETA for next 10 epochs + eta_text = self._get_eta_text( + mean_epoch_time_min, mean_epoch_time_sec, eta_ten_epochs_min + ) + title = self._add_with_newline(title, eta_text) + + # Add epochs in plateau if flag is set + if epoch_in_plateau_flag: + plateau_text = self._get_epochs_in_plateau_text( + epochs_in_plateau, plateau_patience + ) + title = self._add_with_newline(title, plateau_text) + + # Add last epoch validation loss + last_val_text = self._get_last_validation_loss_text(last_epoch_val_loss) + title = self._add_with_newline(title, last_val_text) + + # Add best epoch validation loss if available + if best_val_x is not None: + best_epoch = (best_val_x // epoch_size) + 1 + best_val_text = self._get_best_validation_loss_text( + best_val_y, best_epoch + ) + title = self._add_with_newline(title, best_val_text) + + self.set_title(title) + + @staticmethod + def _get_training_epoch_and_runtime_text(epoch: int, dt_min: int, dt_sec: int): + """Get the training epoch and runtime text to display in the plot. + + Args: + epoch: The current epoch. + dt_min: The number of minutes since training started. + dt_sec: The number of seconds since training started. + """ + + runtime_text = ( + r"Training Epoch $\mathbf{" + str(epoch + 1) + r"}$ / " + r"Runtime: $\mathbf{" + f"{int(dt_min):02}:{int(dt_sec):02}" + r"}$" + ) + + return runtime_text + + @staticmethod + def _get_eta_text(mean_epoch_time_min, mean_epoch_time_sec, eta_ten_epochs_min): + """Get the mean time and ETA text to display in the plot. + + Args: + mean_epoch_time_min: The mean time per epoch in minutes. + mean_epoch_time_sec: The mean time per epoch in seconds. + eta_ten_epochs_min: The estimated time for the next ten epochs in minutes. + """ + + runtime_text = ( + r"Mean Time per Epoch: $\mathbf{" + + f"{int(mean_epoch_time_min):02}:{int(mean_epoch_time_sec):02}" + + r"}$ / " + r"ETA Next 10 Epochs: $\mathbf{" + f"{int(eta_ten_epochs_min)}" + r"}$ min" + ) + + return runtime_text + + @staticmethod + def _get_epochs_in_plateau_text(epochs_in_plateau, plateau_patience): + """Get the epochs in plateau text to display in the plot. + + Args: + epochs_in_plateau: The number of epochs in plateau. + plateau_patience: The number of epochs to wait before stopping training. + """ + + plateau_text = ( + r"Epochs in Plateau: $\mathbf{" + f"{epochs_in_plateau}" + r"}$ / " + r"$\mathbf{" + f"{plateau_patience}" + r"}$" + ) + + return plateau_text + + @staticmethod + def _get_last_validation_loss_text(last_epoch_val_loss): + """Get the last epoch validation loss text to display in the plot. + + Args: + last_epoch_val_loss: The validation loss from the last epoch. + """ + + last_val_loss_text = ( + "Last Epoch Validation Loss: " + r"$\mathbf{" + f"{last_epoch_val_loss:.3e}" + r"}$" + ) + + return last_val_loss_text + + @staticmethod + def _get_best_validation_loss_text(best_val_y, best_epoch): + """Get the best epoch validation loss text to display in the plot. + + Args: + best_val_x: The epoch number of the best validation loss. + best_val_y: The best validation loss. + """ + + best_val_loss_text = ( + r"Best Epoch Validation Loss: $\mathbf{" + + f"{best_val_y:.3e}" + + r"}$ (epoch $\mathbf{" + + str(best_epoch) + + r"}$)" + ) + + return best_val_loss_text + + @staticmethod + def _add_with_newline(old_text: str, new_text: str): + """Add a new line to the text. + + Args: + old_text: The existing text. + new_text: The text to add on a new line. + """ + + return old_text + "\n" + new_text + + @staticmethod + def _calculate_xlim(x: np.ndarray, dx: float = 0.5): + """Calculates x-axis limits. + + Args: + x: Array of x data to fit the limits to. + dx: The padding to add to the limits. + + Returns: + Tuple of the minimum and maximum x-axis limits. + """ + + x_min = min(x) - dx + x_min = x_min if x_min > 0 else 0 + x_max = max(x) + dx + + return x_min, x_max + + def _calculate_ylim(self, y: np.ndarray, dy: float = 0.02): + """Calculates y-axis limits. + + Args: + y: Array of y data to fit the limits to. + dy: The padding to add to the limits. + + Returns: + Tuple of the minimum and maximum y-axis limits. + """ + + if self.ignore_outliers: + dy = np.ptp(y) * 0.02 + # Set Y scale to exclude outliers + q1, q3 = np.quantile(y, (0.25, 0.75)) + iqr = q3 - q1 # Interquartile range + y_min = q1 - iqr * 1.5 + y_max = q3 + iqr * 1.5 + + # Keep within range of data + y_min = max(y_min, min(y) - dy) + y_max = min(y_max, max(y) + dy) + else: + # Set Y scale to show all points + dy = np.ptp(y) * 0.02 + y_min = min(y) - dy + y_max = max(y) + dy + + # For log scale, low cannot be 0 + if self.log_scale: + y_min = max(y_min, 1e-8) + + return y_min, y_max + + def _set_title_space(self): + """Set up the title space. + + Returns: + The height of the title space as a decimal fraction of the total figure height. + """ + + # Set a dummy title of the plot + n_lines = 5 # Number of lines in the title + title_str = "\n".join( + [r"Number: $\mathbf{" + str(n) + r"}$" for n in range(n_lines + 1)] + ) + self.set_title( + title_str, color="white" + ) # Set the title color to white so it's not visible + + # Draw the canvas to ensure the title is created + self.fig.canvas.draw() + + # Get the title Text object + title = self.axes.title + + # Get the bounding box of the title in display coordinates + bbox = title.get_window_extent() + + # Transform the bounding box to figure coordinates + bbox = bbox.transformed(self.fig.transFigure.inverted()) + + # Calculate the height of the title as a percentage of the total figure height + title_height = bbox.height + + return title_height + + def _setup_x_axis(self): + """Set up the x axis. + + This includes setting the label, limits, and bottom/right adjustment. + """ + + self.axes.set_xlim(0, 1) + self.axes.set_xlabel("Batches", fontweight="bold", fontsize="small") + + # Set the x-label in the center of the axes and some amount above the bottom of the figure + blended_transform = mtransforms.blended_transform_factory( + self.axes.transAxes, self.fig.transFigure + ) + self.axes.xaxis.set_label_coords( + 0.5, self.ypos_xlabel, transform=blended_transform + ) + + def _set_up_y_axis(self): + """Set up the y axis. + + This includes setting the label, limits, scaling, and left adjustment. + """ + + # Set the minimum value of the y-axis depending on scaling + if self.log_scale: + yscale = "log" + y_min = 0.001 + else: + yscale = "linear" + y_min = 0 + self.axes.set_ylim(bottom=y_min) + self.axes.set_yscale(yscale) + + # Set the y-label name, size, wight, and position + self.axes.set_ylabel("Loss", fontweight="bold", fontsize="small") + self.axes.yaxis.set_label_coords( + self.xpos_ylabel, 0.5, transform=self.fig.transFigure + ) + + def _setup_legend(self): + """Set up the legend. + + Returns: + Tuple of the width and height of the legend as a decimal fraction of the total figure width and height. + """ + + # Move the legend outside the plot on the upper left + legend = self.axes.legend( + loc="upper left", + fontsize="small", + bbox_to_anchor=(0, 1), + bbox_transform=self.fig.transFigure, + ) + + # Draw the canvas to ensure the legend is created + self.fig.canvas.draw() + + # Get the bounding box of the legend in display coordinates + bbox = legend.get_window_extent() + + # Transform the bounding box to figure coordinates + bbox = bbox.transformed(self.fig.transFigure.inverted()) + + # Calculate the width and height of the legend as a percentage of the total figure width and height + return bbox.width, bbox.height + + def _setup_major_gridlines(self): + + # Set the outline color of the plot to gray + for spine in self.axes.spines.values(): + spine.set_edgecolor("#d3d3d3") # Light gray color + + # Remove the top and right axis spines + self.axes.spines["top"].set_visible(False) + self.axes.spines["right"].set_visible(False) + + # Set the tick markers color to light gray, but not the tick labels + self.axes.tick_params( + axis="both", which="both", color="#d3d3d3", labelsize="small" + ) + + # Add gridlines at the tick labels + self.axes.grid(True, which="major", linewidth=0.5, color="#d3d3d3") + + def _add_midpoint_gridlines(self): + # Clear existing minor vertical lines + for line in self.axes.get_lines(): + if line.get_linestyle() == ":": + line.remove() + + # Add gridlines at midpoint between major ticks + major_ticks = self.axes.yaxis.get_majorticklocs() + if len(major_ticks) > 1: + prev_major_tick = major_ticks[0] + for major_tick in major_ticks[:-1]: + midpoint = (major_tick + prev_major_tick) / 2 + self.axes.axhline( + midpoint, linestyle=":", linewidth=0.5, color="#d3d3d3" + ) + prev_major_tick = major_tick + + def _init_series( + self, + series_type, + color, + name: Optional[str] = None, + line_width: Optional[float] = None, + border_color: Optional[Tuple[int, int, int]] = None, + zorder: Optional[int] = None, + ): + + # Set the color + color = [c / 255.0 for c in color] # Normalize color values to [0, 1] + + # Create the series + series = series_type( + [], + [], + color=color, + label=name, + marker="o", + zorder=zorder, + ) + + # ax.plot returns a list of PathCollections, so we need to get the first one + if not isinstance(series, PathCollection): + series = series[0] + + if line_width is not None: + series.set_linewidth(line_width) + + # Set the border color (edge color) + if border_color is not None: + border_color = [ + c / 255.0 for c in border_color + ] # Normalize color values to [0, 1] + series.set_edgecolor(border_color) + + return series + + class LossViewer(QtWidgets.QMainWindow): """Qt window for showing in-progress training metrics sent over ZMQ.""" @@ -22,6 +592,7 @@ class LossViewer(QtWidgets.QMainWindow): def __init__( self, + zmq_ports: Dict = None, zmq_context: Optional[zmq.Context] = None, show_controller=True, parent=None, @@ -33,41 +604,62 @@ def __init__( self.cancel_button = None self.canceled = False + # Set up ZMQ ports for communication. + zmq_ports = zmq_ports or dict() + zmq_ports["publish_port"] = zmq_ports.get("publish_port", 9001) + zmq_ports["controller_port"] = zmq_ports.get("controller_port", 9000) + self.zmq_ports = zmq_ports + self.batches_to_show = -1 # -1 to show all - self.ignore_outliers = False - self.log_scale = True + self._ignore_outliers = False + self._log_scale = True self.message_poll_time_ms = 20 # ms self.redraw_batch_time_ms = 500 # ms self.last_redraw_batch = None + self.canvas = None self.reset() - self.setup_zmq(zmq_context) + self._setup_zmq(zmq_context) def __del__(self): - self.unbind() + self._unbind() - def close(self): - """Disconnect from ZMQ ports and close the window.""" - self.unbind() - super().close() + @property + def is_timer_running(self) -> bool: + """Return True if the timer has started.""" + return self.t0 is not None and self.is_running - def unbind(self): - """Disconnect from all ZMQ sockets.""" - if self.sub is not None: - self.sub.unbind(self.sub.LAST_ENDPOINT) - self.sub.close() - self.sub = None + @property + def log_scale(self): + """Returns True if the plot has a log scale for y-axis.""" - if self.zmq_ctrl is not None: - url = self.zmq_ctrl.LAST_ENDPOINT - self.zmq_ctrl.unbind(url) - self.zmq_ctrl.close() - self.zmq_ctrl = None + return self._log_scale - # If we started out own zmq context, terminate it. - if not self.ctx_given and self.ctx is not None: - self.ctx.term() - self.ctx = None + @log_scale.setter + def log_scale(self, val): + """Sets the scale of the y axis to log if True else linear.""" + + if isinstance(val, bool): + self._log_scale = val + + # Set the log scale on the canvas + self.canvas.log_scale = self._log_scale + + @property + def ignore_outliers(self): + """Returns True if the plot ignores outliers.""" + + return self._ignore_outliers + + @ignore_outliers.setter + def ignore_outliers(self, val): + """Sets whether to ignore outliers in the plot.""" + + if isinstance(val, bool): + self._ignore_outliers = val + + # Set the ignore_outliers on the canvas + self.canvas.ignore_outliers = self._ignore_outliers def reset( self, @@ -80,112 +672,34 @@ def reset( what: String identifier indicating which job type the current run corresponds to. """ - self.chart = QtCharts.QChart() - - self.series = dict() + self.canvas = LossPlot( + width=5, + height=4, + dpi=100, + log_scale=self.log_scale, + ignore_outliers=self.ignore_outliers, + ) - COLOR_TRAIN = (18, 158, 220) - COLOR_VAL = (248, 167, 52) - COLOR_BEST_VAL = (151, 204, 89) + self.mp_series = dict() + self.mp_series["batch"] = self.canvas.series["batch"] + self.mp_series["epoch_loss"] = self.canvas.series["epoch_loss"] + self.mp_series["val_loss"] = self.canvas.series["val_loss"] + self.mp_series["val_loss_best"] = self.canvas.series["val_loss_best"] - self.series["batch"] = QtCharts.QScatterSeries() - self.series["batch"].setName("Batch Training Loss") - self.series["batch"].setColor(QtGui.QColor(*COLOR_TRAIN, 48)) - self.series["batch"].setMarkerSize(8.0) - self.series["batch"].setBorderColor(QtGui.QColor(255, 255, 255, 25)) - self.chart.addSeries(self.series["batch"]) - - self.series["epoch_loss"] = QtCharts.QLineSeries() - self.series["epoch_loss"].setName("Epoch Training Loss") - self.series["epoch_loss"].setColor(QtGui.QColor(*COLOR_TRAIN, 255)) - pen = self.series["epoch_loss"].pen() - pen.setWidth(4) - self.series["epoch_loss"].setPen(pen) - self.chart.addSeries(self.series["epoch_loss"]) - - self.series["epoch_loss_scatter"] = QtCharts.QScatterSeries() - self.series["epoch_loss_scatter"].setColor(QtGui.QColor(*COLOR_TRAIN, 255)) - self.series["epoch_loss_scatter"].setMarkerSize(12.0) - self.series["epoch_loss_scatter"].setBorderColor( - QtGui.QColor(255, 255, 255, 25) - ) - self.chart.addSeries(self.series["epoch_loss_scatter"]) - - self.series["val_loss"] = QtCharts.QLineSeries() - self.series["val_loss"].setName("Epoch Validation Loss") - self.series["val_loss"].setColor(QtGui.QColor(*COLOR_VAL, 255)) - pen = self.series["val_loss"].pen() - pen.setWidth(4) - self.series["val_loss"].setPen(pen) - self.chart.addSeries(self.series["val_loss"]) - - self.series["val_loss_scatter"] = QtCharts.QScatterSeries() - self.series["val_loss_scatter"].setColor(QtGui.QColor(*COLOR_VAL, 255)) - self.series["val_loss_scatter"].setMarkerSize(12.0) - self.series["val_loss_scatter"].setBorderColor(QtGui.QColor(255, 255, 255, 25)) - self.chart.addSeries(self.series["val_loss_scatter"]) - - self.series["val_loss_best"] = QtCharts.QScatterSeries() - self.series["val_loss_best"].setName("Best Validation Loss") - self.series["val_loss_best"].setColor(QtGui.QColor(*COLOR_BEST_VAL, 255)) - self.series["val_loss_best"].setMarkerSize(12.0) - self.series["val_loss_best"].setBorderColor(QtGui.QColor(32, 32, 32, 25)) - self.chart.addSeries(self.series["val_loss_best"]) - - axisX = QtCharts.QValueAxis() - axisX.setLabelFormat("%d") - axisX.setTitleText("Batches") - self.chart.addAxis(axisX, QtCore.Qt.AlignBottom) - - # Create the different Y axes that can be used. - self.axisY = dict() - - self.axisY["log"] = QtCharts.QLogValueAxis() - self.axisY["log"].setBase(10) - - self.axisY["linear"] = QtCharts.QValueAxis() - - # Apply settings that apply to all Y axes. - for axisY in self.axisY.values(): - axisY.setLabelFormat("%f") - axisY.setLabelsVisible(True) - axisY.setMinorTickCount(1) - axisY.setTitleText("Loss") - - # Use the default Y axis. - axisY = self.axisY["log"] if self.log_scale else self.axisY["linear"] - - # Add axes to chart and series. - self.chart.addAxis(axisY, QtCore.Qt.AlignLeft) - for series in self.chart.series(): - series.attachAxis(axisX) - series.attachAxis(axisY) - - # Setup legend. - self.chart.legend().setVisible(True) - self.chart.legend().setAlignment(QtCore.Qt.AlignTop) - self.chart.legend().setMarkerShape(QtCharts.QLegend.MarkerShapeCircle) - - # Hide scatters for epoch and val loss from legend. - for s in ("epoch_loss_scatter", "val_loss_scatter"): - self.chart.legend().markers(self.series[s])[0].setVisible(False) - - self.chartView = QtCharts.QChartView(self.chart) - self.chartView.setRenderHint(QtGui.QPainter.Antialiasing) layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.chartView) + layout.addWidget(self.canvas) if self.show_controller: control_layout = QtWidgets.QHBoxLayout() field = QtWidgets.QCheckBox("Log Scale") field.setChecked(self.log_scale) - field.stateChanged.connect(self.toggle_log_scale) + field.stateChanged.connect(self._toggle_log_scale) control_layout.addWidget(field) field = QtWidgets.QCheckBox("Ignore Outliers") field.setChecked(self.ignore_outliers) - field.stateChanged.connect(self.toggle_ignore_outliers) + field.stateChanged.connect(self._toggle_ignore_outliers) control_layout.addWidget(field) control_layout.addWidget(QtWidgets.QLabel("Batches to Show:")) @@ -203,7 +717,7 @@ def reset( # Set connection action for when user selects another option. field.currentIndexChanged.connect( - lambda x: self.set_batches_to_show(self.batch_options[x]) + lambda x: self._set_batches_to_show(self.batch_options[x]) ) # Store field as property and add to layout. @@ -213,10 +727,10 @@ def reset( control_layout.addStretch(1) self.stop_button = QtWidgets.QPushButton("Stop Early") - self.stop_button.clicked.connect(self.stop) + self.stop_button.clicked.connect(self._stop) control_layout.addWidget(self.stop_button) self.cancel_button = QtWidgets.QPushButton("Cancel Training") - self.cancel_button.clicked.connect(self.cancel) + self.cancel_button.clicked.connect(self._cancel) control_layout.addWidget(self.cancel_button) widget = QtWidgets.QWidget() @@ -248,48 +762,16 @@ def reset( self.last_batch_number = 0 self.is_running = False - def toggle_ignore_outliers(self): - """Toggles whether to ignore outliers in chart scaling.""" - self.ignore_outliers = not self.ignore_outliers - - def toggle_log_scale(self): - """Toggle whether to use log-scaled y-axis.""" - self.log_scale = not self.log_scale - self.update_y_axis() - - def set_batches_to_show(self, batches: str): - """Set the number of batches to show on the x-axis. + def set_message(self, text: str): + """Set the chart title text.""" + self.canvas.set_title(text) - Args: - batches: Number of batches as a string. If numeric, this will be converted - to an integer. If non-numeric string (e.g., "All"), then all batches - will be shown. - """ - if batches.isdigit(): - self.batches_to_show = int(batches) - else: - self.batches_to_show = -1 + def close(self): + """Disconnect from ZMQ ports and close the window.""" + self._unbind() + super().close() - def update_y_axis(self): - """Update the y-axis when scale changes.""" - to = "log" if self.log_scale else "linear" - - # Remove other axes. - for name, axisY in self.axisY.items(): - if name != to: - if axisY in self.chart.axes(): - self.chart.removeAxis(axisY) - for series in self.chart.series(): - if axisY in series.attachedAxes(): - series.detachAxis(axisY) - - # Add axis. - axisY = self.axisY[to] - self.chart.addAxis(axisY, QtCore.Qt.AlignLeft) - for series in self.chart.series(): - series.attachAxis(axisY) - - def setup_zmq(self, zmq_context: Optional[zmq.Context] = None): + def _setup_zmq(self, zmq_context: Optional[zmq.Context] = None): """Connect to ZMQ ports that listen to commands and updates. Args: @@ -305,112 +787,69 @@ def setup_zmq(self, zmq_context: Optional[zmq.Context] = None): # Progress monitoring, SUBSCRIBER self.sub = self.ctx.socket(zmq.SUB) self.sub.subscribe("") - self.sub.bind("tcp://127.0.0.1:9001") + + def find_free_port(port: int, zmq_context: zmq.Context): + """Find free port to bind to. + + Args: + port: The port to start searching from. + zmq_context: The ZMQ context to use. + + Returns: + The free port. + """ + attempts = 0 + max_attempts = 10 + while not is_port_free(port=port, zmq_context=zmq_context): + if attempts >= max_attempts: + raise RuntimeError( + f"Could not find free port to display training progress after " + f"{max_attempts} attempts. Please check your network settings " + "or use the CLI `sleap-train` command." + ) + port = select_zmq_port(zmq_context=self.ctx) + attempts += 1 + + return port + + # Find a free port and bind to it. + self.zmq_ports["publish_port"] = find_free_port( + port=self.zmq_ports["publish_port"], zmq_context=self.ctx + ) + publish_address = f"tcp://127.0.0.1:{self.zmq_ports['publish_port']}" + self.sub.bind(publish_address) # Controller, PUBLISHER self.zmq_ctrl = None if self.show_controller: self.zmq_ctrl = self.ctx.socket(zmq.PUB) - self.zmq_ctrl.bind("tcp://127.0.0.1:9000") + + # Find a free port and bind to it. + self.zmq_ports["controller_port"] = find_free_port( + port=self.zmq_ports["controller_port"], zmq_context=self.ctx + ) + controller_address = f"tcp://127.0.0.1:{self.zmq_ports['controller_port']}" + self.zmq_ctrl.bind(controller_address) # Set timer to poll for messages. self.timer = QtCore.QTimer() - self.timer.timeout.connect(self.check_messages) + self.timer.timeout.connect(self._check_messages) self.timer.start(self.message_poll_time_ms) - def cancel(self): - """Set the cancel flag.""" - self.canceled = True - if self.cancel_button is not None: - self.cancel_button.setText("Canceling...") - self.cancel_button.setEnabled(False) - - def stop(self): - """Send command to stop training.""" - if self.zmq_ctrl is not None: - # Send command to stop training. - logger.info("Sending command to stop training.") - self.zmq_ctrl.send_string(jsonpickle.encode(dict(command="stop"))) - - # Disable the button to prevent double messages. - if self.stop_button is not None: - self.stop_button.setText("Stopping...") - self.stop_button.setEnabled(False) - - def add_datapoint(self, x: int, y: float, which: str): - """Add a data point to graph. + def _set_batches_to_show(self, batches: str): + """Set the number of batches to show on the x-axis. Args: - x: The batch number (out of all epochs, not just current), or epoch. - y: The loss value. - which: Type of data point we're adding. Possible values are: - * "batch" (loss for the batch) - * "epoch_loss" (loss for the entire epoch) - * "val_loss" (validation loss for the epoch) + batches: Number of batches as a string. If numeric, this will be converted + to an integer. If non-numeric string (e.g., "All"), then all batches + will be shown. """ - if which == "batch": - self.X.append(x) - self.Y.append(y) - - # Redraw batch at intervals (faster than plotting every batch). - draw_batch = False - if self.last_redraw_batch is None: - draw_batch = True - else: - dt = perf_counter() - self.last_redraw_batch - draw_batch = (dt * 1000) >= self.redraw_batch_time_ms - - if draw_batch: - self.last_redraw_batch = perf_counter() - if self.batches_to_show < 0 or len(self.X) < self.batches_to_show: - xs, ys = self.X, self.Y - else: - xs, ys = ( - self.X[-self.batches_to_show :], - self.Y[-self.batches_to_show :], - ) - - points = [QtCore.QPointF(x, y) for x, y in zip(xs, ys) if y > 0] - self.series["batch"].replace(points) - - # Set X scale to show all points - dx = 0.5 - self.chart.axisX().setRange(min(xs) - dx, max(xs) + dx) - - if self.ignore_outliers: - dy = np.ptp(ys) * 0.02 - # Set Y scale to exclude outliers - q1, q3 = np.quantile(ys, (0.25, 0.75)) - iqr = q3 - q1 # interquartile range - low = q1 - iqr * 1.5 - high = q3 + iqr * 1.5 - - low = max(low, min(ys) - dy) # keep within range of data - high = min(high, max(ys) + dy) - else: - # Set Y scale to show all points - dy = np.ptp(ys) * 0.02 - low = min(ys) - dy - high = max(ys) + dy - - if self.log_scale: - low = max(low, 1e-8) # for log scale, low cannot be 0 - - self.chart.axisY().setRange(low, high) - + if batches.isdigit(): + self.batches_to_show = int(batches) else: - if which == "epoch_loss": - self.series["epoch_loss"].append(x, y) - self.series["epoch_loss_scatter"].append(x, y) - elif which == "val_loss": - self.series["val_loss"].append(x, y) - self.series["val_loss_scatter"].append(x, y) - if self.best_val_y is None or y < self.best_val_y: - self.best_val_x = x - self.best_val_y = y - self.series["val_loss_best"].replace([QtCore.QPointF(x, y)]) + self.batches_to_show = -1 - def set_start_time(self, t0: float): + def _set_start_time(self, t0: float): """Mark the start flag and time of the run. Args: @@ -419,52 +858,31 @@ def set_start_time(self, t0: float): self.t0 = t0 self.is_running = True - def set_end(self): - """Mark the end of the run.""" - self.is_running = False - - def update_runtime(self): + def _update_runtime(self): """Update the title text with the current running time.""" + if self.is_timer_running: dt = perf_counter() - self.t0 dt_min, dt_sec = divmod(dt, 60) - title = f"Training Epoch {self.epoch + 1} / " - title += f"Runtime: {int(dt_min):02}:{int(dt_sec):02}" - if self.last_epoch_val_loss is not None: - if self.penultimate_epoch_val_loss is not None: - title += ( - f"
Mean Time per Epoch: " - f"{int(self.mean_epoch_time_min):02}:{int(self.mean_epoch_time_sec):02} / " - f"ETA Next 10 Epochs: {int(self.eta_ten_epochs_min)} min" - ) - if self.epoch_in_plateau_flag: - title += ( - f"
Epochs in Plateau: " - f"{self.epochs_in_plateau} / " - f"{self.config.optimization.early_stopping.plateau_patience}" - ) - title += ( - f"
Last Epoch Validation Loss: " - f"{self.last_epoch_val_loss:.3e}" - ) - if self.best_val_x is not None: - best_epoch = (self.best_val_x // self.epoch_size) + 1 - title += ( - f"
Best Epoch Validation Loss: " - f"{self.best_val_y:.3e} (epoch {best_epoch})" - ) - self.set_message(title) - - @property - def is_timer_running(self) -> bool: - """Return True if the timer has started.""" - return self.t0 is not None and self.is_running - def set_message(self, text: str): - """Set the chart title text.""" - self.chart.setTitle(text) + self.canvas.update_runtime_title( + epoch=self.epoch, + dt_min=dt_min, + dt_sec=dt_sec, + last_epoch_val_loss=self.last_epoch_val_loss, + penultimate_epoch_val_loss=self.penultimate_epoch_val_loss, + mean_epoch_time_min=self.mean_epoch_time_min, + mean_epoch_time_sec=self.mean_epoch_time_sec, + eta_ten_epochs_min=self.eta_ten_epochs_min, + epochs_in_plateau=self.epochs_in_plateau, + plateau_patience=self.config.optimization.early_stopping.plateau_patience, + epoch_in_plateau_flag=self.epoch_in_plateau_flag, + best_val_x=self.best_val_x, + best_val_y=self.best_val_y, + epoch_size=self.epoch_size, + ) - def check_messages( + def _check_messages( self, timeout: int = 10, times_to_check: int = 10, do_update: bool = True ): """Poll for ZMQ messages and adds any received data to graph. @@ -496,7 +914,7 @@ def check_messages( msg = jsonpickle.decode(self.sub.recv_string()) if msg["event"] == "train_begin": - self.set_start_time(perf_counter()) + self._set_start_time(perf_counter()) self.current_job_output_type = msg["what"] # Make sure message matches current training job. @@ -504,15 +922,15 @@ def check_messages( if not self.is_timer_running: # We must have missed the train_begin message, so start timer now. - self.set_start_time(perf_counter()) + self._set_start_time(perf_counter()) if msg["event"] == "train_end": - self.set_end() + self._set_end() elif msg["event"] == "epoch_begin": self.epoch = msg["epoch"] elif msg["event"] == "epoch_end": self.epoch_size = max(self.epoch_size, self.last_batch_number + 1) - self.add_datapoint( + self._add_datapoint( (self.epoch + 1) * self.epoch_size, msg["logs"]["loss"], "epoch_loss", @@ -521,7 +939,7 @@ def check_messages( # update variables and add points to plot self.penultimate_epoch_val_loss = self.last_epoch_val_loss self.last_epoch_val_loss = msg["logs"]["val_loss"] - self.add_datapoint( + self._add_datapoint( (self.epoch + 1) * self.epoch_size, msg["logs"]["val_loss"], "val_loss", @@ -552,7 +970,7 @@ def check_messages( self.on_epoch.emit() elif msg["event"] == "batch_end": self.last_batch_number = msg["batch"] - self.add_datapoint( + self._add_datapoint( (self.epoch * self.epoch_size) + msg["batch"], msg["logs"]["loss"], "batch", @@ -560,9 +978,155 @@ def check_messages( # Check for messages again (up to times_to_check times). if times_to_check > 0: - self.check_messages( + self._check_messages( timeout=timeout, times_to_check=times_to_check - 1, do_update=False ) if do_update: - self.update_runtime() + self._update_runtime() + + def _add_datapoint(self, x: int, y: float, which: str): + """Add a data point to graph. + + Args: + x: The batch number (out of all epochs, not just current), or epoch. + y: The loss value. + which: Type of data point we're adding. Possible values are: + * "batch" (loss for the batch) + * "epoch_loss" (loss for the entire epoch) + * "val_loss" (validation loss for the epoch) + """ + if which == "batch": + self.X.append(x) + self.Y.append(y) + + # Redraw batch at intervals (faster than plotting every batch). + draw_batch = False + if self.last_redraw_batch is None: + draw_batch = True + else: + dt = perf_counter() - self.last_redraw_batch + draw_batch = (dt * 1000) >= self.redraw_batch_time_ms + + if draw_batch: + self.last_redraw_batch = perf_counter() + if self.batches_to_show < 0 or len(self.X) < self.batches_to_show: + xs, ys = self.X, self.Y + else: + xs, ys = ( + self.X[-self.batches_to_show :], + self.Y[-self.batches_to_show :], + ) + + # Set data, resize and redraw the plot + self._set_data_on_scatter(xs, ys, which) + self._resize_axes(xs, ys) + + else: + + if which == "val_loss": + if self.best_val_y is None or y < self.best_val_y: + self.best_val_x = x + self.best_val_y = y + self._set_data_on_scatter([x], [y], "val_loss_best") + + # Add data and redraw the plot + self._add_data_to_plot(x, y, which) + self._redraw_plot() + + def _set_data_on_scatter(self, xs, ys, which): + """Add data to a scatter plot. + + Not to be used with line plots. + + Args: + xs: The x-coordinates of the data points. + ys: The y-coordinates of the data points. + which: The type of data point. Possible values are: + * "batch" + * "val_loss_best" + """ + + self.canvas.set_data_on_scatter(xs, ys, which) + + def _add_data_to_plot(self, x, y, which): + """Add data to a line plot. + + Not to be used with scatter plots. + + Args: + x: The x-coordinate of the data point. + y: The y-coordinate of the data point. + which: The type of data point. Possible values are: + * "epoch_loss" + * "val_loss" + """ + + self.canvas.add_data_to_plot(x, y, which) + + def _redraw_plot(self): + """Redraw the plot.""" + + self.canvas.redraw_plot() + + def _resize_axes(self, x, y): + """Resize axes to fit data. + + This is only called when plotting batches. + + Args: + x: The x-coordinates of the data points. + y: The y-coordinates of the data points. + """ + self.canvas.resize_axes(x, y) + + def _toggle_ignore_outliers(self): + """Toggles whether to ignore outliers in chart scaling.""" + + self.ignore_outliers = not self.ignore_outliers + + def _toggle_log_scale(self): + """Toggle whether to use log-scaled y-axis.""" + + self.log_scale = not self.log_scale + + def _stop(self): + """Send command to stop training.""" + if self.zmq_ctrl is not None: + # Send command to stop training. + logger.info("Sending command to stop training.") + self.zmq_ctrl.send_string(jsonpickle.encode(dict(command="stop"))) + + # Disable the button to prevent double messages. + if self.stop_button is not None: + self.stop_button.setText("Stopping...") + self.stop_button.setEnabled(False) + + def _cancel(self): + """Set the cancel flag.""" + self.canceled = True + if self.cancel_button is not None: + self.cancel_button.setText("Canceling...") + self.cancel_button.setEnabled(False) + + def _unbind(self): + """Disconnect from all ZMQ sockets.""" + if self.sub is not None: + self.sub.unbind(self.sub.LAST_ENDPOINT) + self.sub.close() + self.sub = None + + if self.zmq_ctrl is not None: + url = self.zmq_ctrl.LAST_ENDPOINT + self.zmq_ctrl.unbind(url) + self.zmq_ctrl.close() + self.zmq_ctrl = None + + # If we started out own zmq context, terminate it. + if not self.ctx_given and self.ctx is not None: + self.ctx.term() + self.ctx = None + + def _set_end(self): + """Mark the end of the run.""" + self.is_running = False diff --git a/sleap/gui/widgets/mpl.py b/sleap/gui/widgets/mpl.py index a9b7fc838..890c1a67a 100644 --- a/sleap/gui/widgets/mpl.py +++ b/sleap/gui/widgets/mpl.py @@ -6,11 +6,10 @@ from qtpy import QtWidgets from matplotlib.figure import Figure -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as Canvas +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as Canvas import matplotlib -# Ensure using PyQt5 backend -matplotlib.use("QT5Agg") +matplotlib.use("QtAgg") class MplCanvas(Canvas): diff --git a/sleap/gui/widgets/training_monitor.py b/sleap/gui/widgets/training_monitor.py deleted file mode 100644 index ed405a747..000000000 --- a/sleap/gui/widgets/training_monitor.py +++ /dev/null @@ -1,566 +0,0 @@ -"""GUI for monitoring training progress interactively.""" - -import numpy as np -from time import perf_counter -from sleap.nn.config.training_job import TrainingJobConfig -import zmq -import jsonpickle -import logging -from typing import Optional -from qtpy import QtCore, QtWidgets, QtGui, QtCharts -import attr - -logger = logging.getLogger(__name__) - - -class LossViewer(QtWidgets.QMainWindow): - """Qt window for showing in-progress training metrics sent over ZMQ.""" - - on_epoch = QtCore.Signal() - - def __init__( - self, - zmq_context: Optional[zmq.Context] = None, - show_controller=True, - parent=None, - ): - super().__init__(parent) - - self.show_controller = show_controller - self.stop_button = None - self.cancel_button = None - self.canceled = False - - self.batches_to_show = -1 # -1 to show all - self.ignore_outliers = False - self.log_scale = True - self.message_poll_time_ms = 20 # ms - self.redraw_batch_time_ms = 500 # ms - self.last_redraw_batch = None - - self.reset() - self.setup_zmq(zmq_context) - - def __del__(self): - self.unbind() - - def close(self): - """Disconnect from ZMQ ports and close the window.""" - self.unbind() - super().close() - - def unbind(self): - """Disconnect from all ZMQ sockets.""" - if self.sub is not None: - self.sub.unbind(self.sub.LAST_ENDPOINT) - self.sub.close() - self.sub = None - - if self.zmq_ctrl is not None: - url = self.zmq_ctrl.LAST_ENDPOINT - self.zmq_ctrl.unbind(url) - self.zmq_ctrl.close() - self.zmq_ctrl = None - - # If we started out own zmq context, terminate it. - if not self.ctx_given and self.ctx is not None: - self.ctx.term() - self.ctx = None - - def reset( - self, - what: str = "", - config: TrainingJobConfig = attr.ib(factory=TrainingJobConfig), - ): - """Reset all chart series. - - Args: - what: String identifier indicating which job type the current run - corresponds to. - """ - self.chart = QtCharts.QChart() - - self.series = dict() - - COLOR_TRAIN = (18, 158, 220) - COLOR_VAL = (248, 167, 52) - COLOR_BEST_VAL = (151, 204, 89) - - self.series["batch"] = QtCharts.QScatterSeries() - self.series["batch"].setName("Batch Training Loss") - self.series["batch"].setColor(QtGui.QColor(*COLOR_TRAIN, 48)) - self.series["batch"].setMarkerSize(8.0) - self.series["batch"].setBorderColor(QtGui.QColor(255, 255, 255, 25)) - self.chart.addSeries(self.series["batch"]) - - self.series["epoch_loss"] = QtCharts.QLineSeries() - self.series["epoch_loss"].setName("Epoch Training Loss") - self.series["epoch_loss"].setColor(QtGui.QColor(*COLOR_TRAIN, 255)) - pen = self.series["epoch_loss"].pen() - pen.setWidth(4) - self.series["epoch_loss"].setPen(pen) - self.chart.addSeries(self.series["epoch_loss"]) - - self.series["epoch_loss_scatter"] = QtCharts.QScatterSeries() - self.series["epoch_loss_scatter"].setColor(QtGui.QColor(*COLOR_TRAIN, 255)) - self.series["epoch_loss_scatter"].setMarkerSize(12.0) - self.series["epoch_loss_scatter"].setBorderColor( - QtGui.QColor(255, 255, 255, 25) - ) - self.chart.addSeries(self.series["epoch_loss_scatter"]) - - self.series["val_loss"] = QtCharts.QLineSeries() - self.series["val_loss"].setName("Epoch Validation Loss") - self.series["val_loss"].setColor(QtGui.QColor(*COLOR_VAL, 255)) - pen = self.series["val_loss"].pen() - pen.setWidth(4) - self.series["val_loss"].setPen(pen) - self.chart.addSeries(self.series["val_loss"]) - - self.series["val_loss_scatter"] = QtCharts.QScatterSeries() - self.series["val_loss_scatter"].setColor(QtGui.QColor(*COLOR_VAL, 255)) - self.series["val_loss_scatter"].setMarkerSize(12.0) - self.series["val_loss_scatter"].setBorderColor(QtGui.QColor(255, 255, 255, 25)) - self.chart.addSeries(self.series["val_loss_scatter"]) - - self.series["val_loss_best"] = QtCharts.QScatterSeries() - self.series["val_loss_best"].setName("Best Validation Loss") - self.series["val_loss_best"].setColor(QtGui.QColor(*COLOR_BEST_VAL, 255)) - self.series["val_loss_best"].setMarkerSize(12.0) - self.series["val_loss_best"].setBorderColor(QtGui.QColor(32, 32, 32, 25)) - self.chart.addSeries(self.series["val_loss_best"]) - - axisX = QtCharts.QValueAxis() - axisX.setLabelFormat("%d") - axisX.setTitleText("Batches") - self.chart.addAxis(axisX, QtCore.Qt.AlignBottom) - - # Create the different Y axes that can be used. - self.axisY = dict() - - self.axisY["log"] = QtCharts.QLogValueAxis() - self.axisY["log"].setBase(10) - - self.axisY["linear"] = QtCharts.QValueAxis() - - # Apply settings that apply to all Y axes. - for axisY in self.axisY.values(): - axisY.setLabelFormat("%f") - axisY.setLabelsVisible(True) - axisY.setMinorTickCount(1) - axisY.setTitleText("Loss") - - # Use the default Y axis. - axisY = self.axisY["log"] if self.log_scale else self.axisY["linear"] - - # Add axes to chart and series. - self.chart.addAxis(axisY, QtCore.Qt.AlignLeft) - for series in self.chart.series(): - series.attachAxis(axisX) - series.attachAxis(axisY) - - # Setup legend. - self.chart.legend().setVisible(True) - self.chart.legend().setAlignment(QtCore.Qt.AlignTop) - self.chart.legend().setMarkerShape(QtCharts.QLegend.MarkerShapeCircle) - - # Hide scatters for epoch and val loss from legend. - for s in ("epoch_loss_scatter", "val_loss_scatter"): - self.chart.legend().markers(self.series[s])[0].setVisible(False) - - self.chartView = QtCharts.QChartView(self.chart) - self.chartView.setRenderHint(QtGui.QPainter.Antialiasing) - layout = QtWidgets.QVBoxLayout() - layout.addWidget(self.chartView) - - if self.show_controller: - control_layout = QtWidgets.QHBoxLayout() - - field = QtWidgets.QCheckBox("Log Scale") - field.setChecked(self.log_scale) - field.stateChanged.connect(self.toggle_log_scale) - control_layout.addWidget(field) - - field = QtWidgets.QCheckBox("Ignore Outliers") - field.setChecked(self.ignore_outliers) - field.stateChanged.connect(self.toggle_ignore_outliers) - control_layout.addWidget(field) - - control_layout.addWidget(QtWidgets.QLabel("Batches to Show:")) - - # Add field for how many batches to show in chart. - field = QtWidgets.QComboBox() - self.batch_options = "200,1000,5000,All".split(",") - for opt in self.batch_options: - field.addItem(opt) - cur_opt_str = ( - "All" if self.batches_to_show < 0 else str(self.batches_to_show) - ) - if cur_opt_str in self.batch_options: - field.setCurrentText(cur_opt_str) - - # Set connection action for when user selects another option. - field.currentIndexChanged.connect( - lambda x: self.set_batches_to_show(self.batch_options[x]) - ) - - # Store field as property and add to layout. - self.batches_to_show_field = field - control_layout.addWidget(self.batches_to_show_field) - - control_layout.addStretch(1) - - self.stop_button = QtWidgets.QPushButton("Stop Early") - self.stop_button.clicked.connect(self.stop) - control_layout.addWidget(self.stop_button) - self.cancel_button = QtWidgets.QPushButton("Cancel Training") - self.cancel_button.clicked.connect(self.cancel) - control_layout.addWidget(self.cancel_button) - - widget = QtWidgets.QWidget() - widget.setLayout(control_layout) - layout.addWidget(widget) - - wid = QtWidgets.QWidget() - wid.setLayout(layout) - self.setCentralWidget(wid) - - self.config = config - self.X = [] - self.Y = [] - self.best_val_x = None - self.best_val_y = None - - self.t0 = None - self.mean_epoch_time_min = None - self.mean_epoch_time_sec = None - self.eta_ten_epochs_min = None - - self.current_job_output_type = what - self.epoch = 0 - self.epoch_size = 1 - self.epochs_in_plateau = 0 - self.last_epoch_val_loss = None - self.penultimate_epoch_val_loss = None - self.epoch_in_plateau_flag = False - self.last_batch_number = 0 - self.is_running = False - - def toggle_ignore_outliers(self): - """Toggles whether to ignore outliers in chart scaling.""" - self.ignore_outliers = not self.ignore_outliers - - def toggle_log_scale(self): - """Toggle whether to use log-scaled y-axis.""" - self.log_scale = not self.log_scale - self.update_y_axis() - - def set_batches_to_show(self, batches: str): - """Set the number of batches to show on the x-axis. - - Args: - batches: Number of batches as a string. If numeric, this will be converted - to an integer. If non-numeric string (e.g., "All"), then all batches - will be shown. - """ - if batches.isdigit(): - self.batches_to_show = int(batches) - else: - self.batches_to_show = -1 - - def update_y_axis(self): - """Update the y-axis when scale changes.""" - to = "log" if self.log_scale else "linear" - - # Remove other axes. - for name, axisY in self.axisY.items(): - if name != to: - if axisY in self.chart.axes(): - self.chart.removeAxis(axisY) - for series in self.chart.series(): - if axisY in series.attachedAxes(): - series.detachAxis(axisY) - - # Add axis. - axisY = self.axisY[to] - self.chart.addAxis(axisY, QtCore.Qt.AlignLeft) - for series in self.chart.series(): - series.attachAxis(axisY) - - def setup_zmq(self, zmq_context: Optional[zmq.Context] = None): - """Connect to ZMQ ports that listen to commands and updates. - - Args: - zmq_context: The `zmq.Context` object to use for connections. A new one is - created if not specified and will be closed when the monitor exits. If - an existing one is provided, it will NOT be closed. - """ - # Keep track of whether we're using an existing context (which we won't close - # when done) or are creating our own (which we should close). - self.ctx_given = zmq_context is not None - self.ctx = zmq.Context() if zmq_context is None else zmq_context - - # Progress monitoring, SUBSCRIBER - self.sub = self.ctx.socket(zmq.SUB) - self.sub.subscribe("") - self.sub.bind("tcp://127.0.0.1:9001") - - # Controller, PUBLISHER - self.zmq_ctrl = None - if self.show_controller: - self.zmq_ctrl = self.ctx.socket(zmq.PUB) - self.zmq_ctrl.bind("tcp://127.0.0.1:9000") - - # Set timer to poll for messages. - self.timer = QtCore.QTimer() - self.timer.timeout.connect(self.check_messages) - self.timer.start(self.message_poll_time_ms) - - def cancel(self): - """Set the cancel flag.""" - self.canceled = True - if self.cancel_button is not None: - self.cancel_button.setText("Canceling...") - self.cancel_button.setEnabled(False) - - def stop(self): - """Send command to stop training.""" - if self.zmq_ctrl is not None: - # Send command to stop training. - logger.info("Sending command to stop training.") - self.zmq_ctrl.send_string(jsonpickle.encode(dict(command="stop"))) - - # Disable the button to prevent double messages. - if self.stop_button is not None: - self.stop_button.setText("Stopping...") - self.stop_button.setEnabled(False) - - def add_datapoint(self, x: int, y: float, which: str): - """Add a data point to graph. - - Args: - x: The batch number (out of all epochs, not just current), or epoch. - y: The loss value. - which: Type of data point we're adding. Possible values are: - * "batch" (loss for the batch) - * "epoch_loss" (loss for the entire epoch) - * "val_loss" (validation loss for the epoch) - """ - if which == "batch": - self.X.append(x) - self.Y.append(y) - - # Redraw batch at intervals (faster than plotting every batch). - draw_batch = False - if self.last_redraw_batch is None: - draw_batch = True - else: - dt = perf_counter() - self.last_redraw_batch - draw_batch = (dt * 1000) >= self.redraw_batch_time_ms - - if draw_batch: - self.last_redraw_batch = perf_counter() - if self.batches_to_show < 0 or len(self.X) < self.batches_to_show: - xs, ys = self.X, self.Y - else: - xs, ys = ( - self.X[-self.batches_to_show :], - self.Y[-self.batches_to_show :], - ) - - points = [QtCore.QPointF(x, y) for x, y in zip(xs, ys) if y > 0] - self.series["batch"].replace(points) - - # Set X scale to show all points - dx = 0.5 - self.chart.axisX().setRange(min(xs) - dx, max(xs) + dx) - - if self.ignore_outliers: - dy = np.ptp(ys) * 0.02 - # Set Y scale to exclude outliers - q1, q3 = np.quantile(ys, (0.25, 0.75)) - iqr = q3 - q1 # interquartile range - low = q1 - iqr * 1.5 - high = q3 + iqr * 1.5 - - low = max(low, min(ys) - dy) # keep within range of data - high = min(high, max(ys) + dy) - else: - # Set Y scale to show all points - dy = np.ptp(ys) * 0.02 - low = min(ys) - dy - high = max(ys) + dy - - if self.log_scale: - low = max(low, 1e-8) # for log scale, low cannot be 0 - - self.chart.axisY().setRange(low, high) - - else: - if which == "epoch_loss": - self.series["epoch_loss"].append(x, y) - self.series["epoch_loss_scatter"].append(x, y) - elif which == "val_loss": - self.series["val_loss"].append(x, y) - self.series["val_loss_scatter"].append(x, y) - if self.best_val_y is None or y < self.best_val_y: - self.best_val_x = x - self.best_val_y = y - self.series["val_loss_best"].replace([QtCore.QPointF(x, y)]) - - def set_start_time(self, t0: float): - """Mark the start flag and time of the run. - - Args: - t0: Start time in seconds. - """ - self.t0 = t0 - self.is_running = True - - def set_end(self): - """Mark the end of the run.""" - self.is_running = False - - def update_runtime(self): - """Update the title text with the current running time.""" - if self.is_timer_running: - dt = perf_counter() - self.t0 - dt_min, dt_sec = divmod(dt, 60) - title = f"Training Epoch {self.epoch + 1} / " - title += f"Runtime: {int(dt_min):02}:{int(dt_sec):02}" - if self.last_epoch_val_loss is not None: - if self.penultimate_epoch_val_loss is not None: - title += ( - f"
Mean Time per Epoch: " - f"{int(self.mean_epoch_time_min):02}:{int(self.mean_epoch_time_sec):02} / " - f"ETA Next 10 Epochs: {int(self.eta_ten_epochs_min)} min" - ) - if self.epoch_in_plateau_flag: - title += ( - f"
Epochs in Plateau: " - f"{self.epochs_in_plateau} / " - f"{self.config.optimization.early_stopping.plateau_patience}" - ) - title += ( - f"
Last Epoch Validation Loss: " - f"{self.last_epoch_val_loss:.3e}" - ) - if self.best_val_x is not None: - best_epoch = (self.best_val_x // self.epoch_size) + 1 - title += ( - f"
Best Epoch Validation Loss: " - f"{self.best_val_y:.3e} (epoch {best_epoch})" - ) - self.set_message(title) - - @property - def is_timer_running(self) -> bool: - """Return True if the timer has started.""" - return self.t0 is not None and self.is_running - - def set_message(self, text: str): - """Set the chart title text.""" - self.chart.setTitle(text) - - def check_messages( - self, timeout: int = 10, times_to_check: int = 10, do_update: bool = True - ): - """Poll for ZMQ messages and adds any received data to graph. - - The message is a dictionary encoded as JSON: - * event - options include - * train_begin - * train_end - * epoch_begin - * epoch_end - * batch_end - * what - this should match the type of model we're training and - ensures that we ignore old messages when we start monitoring - a new training session (when we're training multiple types - of models in a sequence, as for the top-down pipeline). - * logs - dictionary with data relevant for plotting, can include - * loss - * val_loss - - Args: - timeout: Message polling timeout in milliseconds. This is how often we will - check for new command messages. - times_to_check: How many times to check for new messages in the queue before - going back to polling with a timeout. Helps to clear backlogs of - messages if necessary. - do_update: If True (the default), update the GUI text. - """ - if self.sub and self.sub.poll(timeout, zmq.POLLIN): - msg = jsonpickle.decode(self.sub.recv_string()) - - if msg["event"] == "train_begin": - self.set_start_time(perf_counter()) - self.current_job_output_type = msg["what"] - - # Make sure message matches current training job. - if msg.get("what", "") == self.current_job_output_type: - - if not self.is_timer_running: - # We must have missed the train_begin message, so start timer now. - self.set_start_time(perf_counter()) - - if msg["event"] == "train_end": - self.set_end() - elif msg["event"] == "epoch_begin": - self.epoch = msg["epoch"] - elif msg["event"] == "epoch_end": - self.epoch_size = max(self.epoch_size, self.last_batch_number + 1) - self.add_datapoint( - (self.epoch + 1) * self.epoch_size, - msg["logs"]["loss"], - "epoch_loss", - ) - if "val_loss" in msg["logs"].keys(): - # update variables and add points to plot - self.penultimate_epoch_val_loss = self.last_epoch_val_loss - self.last_epoch_val_loss = msg["logs"]["val_loss"] - self.add_datapoint( - (self.epoch + 1) * self.epoch_size, - msg["logs"]["val_loss"], - "val_loss", - ) - # calculate timing and flags at new epoch - if self.penultimate_epoch_val_loss is not None: - mean_epoch_time = (perf_counter() - self.t0) / ( - self.epoch + 1 - ) - self.mean_epoch_time_min, self.mean_epoch_time_sec = divmod( - mean_epoch_time, 60 - ) - self.eta_ten_epochs_min = (mean_epoch_time * 10) // 60 - - val_loss_delta = ( - self.penultimate_epoch_val_loss - - self.last_epoch_val_loss - ) - self.epoch_in_plateau_flag = ( - val_loss_delta - < self.config.optimization.early_stopping.plateau_min_delta - ) or (self.best_val_y < self.last_epoch_val_loss) - self.epochs_in_plateau = ( - self.epochs_in_plateau + 1 - if self.epoch_in_plateau_flag - else 0 - ) - self.on_epoch.emit() - elif msg["event"] == "batch_end": - self.last_batch_number = msg["batch"] - self.add_datapoint( - (self.epoch * self.epoch_size) + msg["batch"], - msg["logs"]["loss"], - "batch", - ) - - # Check for messages again (up to times_to_check times). - if times_to_check > 0: - self.check_messages( - timeout=timeout, times_to_check=times_to_check - 1, do_update=False - ) - - if do_update: - self.update_runtime() diff --git a/sleap/gui/widgets/video.py b/sleap/gui/widgets/video.py index 502ea388e..08ee5bf36 100644 --- a/sleap/gui/widgets/video.py +++ b/sleap/gui/widgets/video.py @@ -62,6 +62,7 @@ QShortcut, QVBoxLayout, QWidget, + QPinchGesture, ) import sleap @@ -240,6 +241,8 @@ def __init__( self._register_shortcuts() + self.context_menu = None + self._menu_actions = dict() if self.context: self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.show_contextual_menu) @@ -358,41 +361,54 @@ def add_shortcut(key, step): def setSeekbarSelection(self, a: int, b: int): self.seekbar.setSelection(a, b) - def show_contextual_menu(self, where: QtCore.QPoint): - if not self.is_menu_enabled: - return + def create_contextual_menu(self, scene_pos: QtCore.QPointF) -> QtWidgets.QMenu: + """Create the context menu for the viewer. - scene_pos = self.view.mapToScene(where) - menu = QtWidgets.QMenu() + This is called when the user right-clicks in the viewer. This function also + stores the menu actions in the `_menu_actions` attribute so that they can be + accessed later and stores the context menu in the `context_menu` attribute. - menu.addAction("Add Instance:").setEnabled(False) + Args: + scene_pos: The position in the scene where the menu was requested. - menu.addAction("Default", lambda: self.context.newInstance(init_method="best")) + Returns: + The created context menu. + """ - menu.addAction( - "Average", - lambda: self.context.newInstance( - init_method="template", location=scene_pos - ), - ) + self.context_menu = QtWidgets.QMenu() + self.context_menu.addAction("Add Instance:").setEnabled(False) + + self._menu_actions = dict() + params_by_action_name = { + "Default": {"init_method": "best", "location": scene_pos}, + "Average": {"init_method": "template", "location": scene_pos}, + "Force Directed": {"init_method": "force_directed", "location": scene_pos}, + "Copy Prior Frame": {"init_method": "prior_frame"}, + "Random": {"init_method": "random", "location": scene_pos}, + } + for action_name, params in params_by_action_name.items(): + self._menu_actions[action_name] = self.context_menu.addAction( + action_name, lambda params=params: self.context.newInstance(**params) + ) - menu.addAction( - "Force Directed", - lambda: self.context.newInstance( - init_method="force_directed", location=scene_pos - ), - ) + return self.context_menu - menu.addAction( - "Copy Prior Frame", - lambda: self.context.newInstance(init_method="prior_frame"), - ) + def show_contextual_menu(self, where: QtCore.QPoint): + """Show the context menu at the given position in the viewer. - menu.addAction( - "Random", - lambda: self.context.newInstance(init_method="random", location=scene_pos), - ) + This is called when the user right-clicks in the viewer. This function calls + `create_contextual_menu` to create the menu and then shows the menu at the + given position. + Args: + where: The position in the viewer where the menu was requested. + """ + + if not self.is_menu_enabled: + return + + scene_pos = self.view.mapToScene(where) + menu = self.create_contextual_menu(scene_pos) menu.exec_(self.mapToGlobal(where)) def load_video(self, video: Video, plot=True): @@ -808,6 +824,8 @@ def __init__(self, state=None, player=None, *args, **kwargs): # Set icon as default background. self.setImage(QImage(sleap.util.get_package_file("gui/background.png"))) + self.grabGesture(Qt.GestureType.PinchGesture) + def dragEnterEvent(self, event): if self.parentWidget(): self.parentWidget().dragEnterEvent(event) @@ -1147,8 +1165,13 @@ def mouseDoubleClickEvent(self, event: QMouseEvent): QGraphicsView.mouseDoubleClickEvent(self, event) def wheelEvent(self, event): - """Custom event handler. Zoom in/out based on scroll wheel change.""" - # zoom on wheel when no mouse buttons are pressed + """Custom event handler to zoom in/out based on scroll wheel change. + + We cannot use the default QGraphicsView.wheelEvent behavior since that will + scroll the view. + """ + + # Zoom on wheel when no mouse buttons are pressed if event.buttons() == Qt.NoButton: angle = event.angleDelta().y() factor = 1.1 if angle > 0 else 0.9 @@ -1156,20 +1179,10 @@ def wheelEvent(self, event): self.zoomFactor = max(factor * self.zoomFactor, 1) self.updateViewer() - # Trigger wheelEvent for all child elements. This is a bit of a hack. - # We can't use QGraphicsView.wheelEvent(self, event) since that will scroll - # view. - # We want to trigger for all children, since wheelEvent should continue rotating - # an skeleton even if the skeleton node/node label is no longer under the - # cursor. - # Note that children expect a QGraphicsSceneWheelEvent event, which is why we're - # explicitly ignoring TypeErrors. Everything seems to work fine since we don't - # care about the mouse position; if we did, we'd need to map pos to scene. + # Trigger only for rotation-relevant children (otherwise GUI crashes) for child in self.items(): - try: + if isinstance(child, (QtNode, QtNodeLabel)): child.wheelEvent(event) - except TypeError: - pass def keyPressEvent(self, event): """Custom event hander, disables default QGraphicsView behavior.""" @@ -1179,6 +1192,23 @@ def keyReleaseEvent(self, event): """Custom event hander, disables default QGraphicsView behavior.""" event.ignore() # Kicks the event up to parent + def event(self, event): + if event.type() == QtCore.QEvent.Gesture: + return self.handleGestureEvent(event) + return super().event(event) + + def handleGestureEvent(self, event): + gesture = event.gesture(Qt.GestureType.PinchGesture) + if gesture: + self.handlePinchGesture(gesture) + return True + + def handlePinchGesture(self, gesture: QPinchGesture): + if gesture.state() == Qt.GestureState.GestureUpdated: + factor = gesture.scaleFactor() + self.zoomFactor = max(factor * self.zoomFactor, 1) + self.updateViewer() + class QtNodeLabel(QGraphicsTextItem): """ @@ -1560,7 +1590,6 @@ def mousePressEvent(self, event): def mouseMoveEvent(self, event): """Custom event handler for mouse move.""" - # print(event) if self.dragParent: self.parentObject().mouseMoveEvent(event) else: @@ -1571,7 +1600,6 @@ def mouseMoveEvent(self, event): def mouseReleaseEvent(self, event): """Custom event handler for mouse release.""" - # print(event) self.unsetCursor() if self.dragParent: self.parentObject().mouseReleaseEvent(event) @@ -1587,7 +1615,9 @@ def mouseReleaseEvent(self, event): def wheelEvent(self, event): """Custom event handler for mouse scroll wheel.""" if self.dragParent: - angle = event.delta() / 20 + self.parentObject().rotation() + angle = ( + event.angleDelta().x() + event.angleDelta().y() + ) / 20 + self.parentObject().rotation() self.parentObject().setRotation(angle) event.accept() @@ -1598,6 +1628,10 @@ def mouseDoubleClickEvent(self, event: QMouseEvent): view = scene.views()[0] view.instanceDoubleClicked.emit(self.parentObject().instance, event) + def hoverEnterEvent(self, event): + """Custom event handler for mouse hover enter.""" + return super().hoverEnterEvent(event) + class QtEdge(QGraphicsPolygonItem): """ @@ -1797,6 +1831,7 @@ def __init__( self.labels = {} self.labels_shown = True self._selected = False + self._is_hovering = False self._bounding_rect = QRectF() # Show predicted instances behind non-predicted ones @@ -1818,6 +1853,7 @@ def __init__( box_pen.setStyle(Qt.DashLine) box_pen.setCosmetic(True) self.box.setPen(box_pen) + self.setAcceptHoverEvents(True) # Add label for highlighted instance self.highlight_label = QtTextWithBackground(parent=self) @@ -1979,7 +2015,12 @@ def updateBox(self, *args, **kwargs): select this instance. """ # Only show box if instance is selected - op = 0.7 if self._selected else 0 + op = 0 + if self._selected: + op = 0.8 + elif self._is_hovering: + op = 0.4 + self.box.setOpacity(op) # Update the position for the box rect = self.getPointsBoundingRect() @@ -2073,6 +2114,16 @@ def paint(self, painter, option, widget=None): """Method required by Qt.""" pass + def hoverEnterEvent(self, event): + self._is_hovering = True + self.updateBox() + return super().hoverEnterEvent(event) + + def hoverLeaveEvent(self, event): + self._is_hovering = False + self.updateBox() + return super().hoverLeaveEvent(event) + class VisibleBoundingBox(QtWidgets.QGraphicsRectItem): """QGraphicsRectItem for user instance bounding boxes. @@ -2263,7 +2314,7 @@ def mouseReleaseEvent(self, event): self.parent.nodes[node_key].setPos(new_x, new_y) # Update the instance - self.parent.updatePoints(complete=True, user_change=True) + self.parent.updatePoints(complete=False, user_change=True) self.resizing = None diff --git a/sleap/info/feature_suggestions.py b/sleap/info/feature_suggestions.py index 51f9038a5..a5f773fa7 100644 --- a/sleap/info/feature_suggestions.py +++ b/sleap/info/feature_suggestions.py @@ -644,7 +644,7 @@ class ParallelFeaturePipeline(object): def get(self, video_idx): """Apply pipeline to single video by idx. Can be called in process.""" video_dict = self.videos_as_dicts[video_idx] - video = cattr.structure(video_dict, Video) + video = Video.cattr().structure(video_dict, Video) group_offset = video_idx * self.pipeline.n_clusters # t0 = time() diff --git a/sleap/info/metrics.py b/sleap/info/metrics.py index 2ac61d339..5bec077e4 100644 --- a/sleap/info/metrics.py +++ b/sleap/info/metrics.py @@ -10,75 +10,6 @@ from sleap.io.dataset import Labels -def matched_instance_distances( - labels_gt: Labels, - labels_pr: Labels, - match_lists_function: Callable, - frame_range: Optional[range] = None, -) -> Tuple[List[int], np.ndarray, np.ndarray, np.ndarray]: - - """ - Distances between ground truth and predicted nodes over a set of frames. - - Args: - labels_gt: the `Labels` object with ground truth data - labels_pr: the `Labels` object with predicted data - match_lists_function: function for determining corresponding instances - Takes two lists of instances and returns "sorted" lists. - frame_range (optional): range of frames for which to compare data - If None, we compare every frame in labels_gt with corresponding - frame in labels_pr. - Returns: - Tuple: - * frame indices map: instance idx (for other matrices) -> frame idx - * distance matrix: (instances * nodes) - * ground truth points matrix: (instances * nodes * 2) - * predicted points matrix: (instances * nodes * 2) - """ - - frame_idxs = [] - points_gt = [] - points_pr = [] - for lf_gt in labels_gt.find(labels_gt.videos[0]): - frame_idx = lf_gt.frame_idx - - # Get instances from ground truth/predicted labels - instances_gt = lf_gt.instances - lfs_pr = labels_pr.find(labels_pr.videos[0], frame_idx=frame_idx) - if len(lfs_pr): - instances_pr = lfs_pr[0].instances - else: - instances_pr = [] - - # Sort ground truth and predicted instances. - # We'll then compare points between corresponding items in lists. - # We can use different "match" functions depending on what we want. - sorted_gt, sorted_pr = match_lists_function(instances_gt, instances_pr) - - # Convert lists of instances to (instances, nodes, 2) matrices. - # This allows match_lists_function to return data as either - # a list of Instances or a (instances, nodes, 2) matrix. - if type(sorted_gt[0]) != np.ndarray: - sorted_gt = list_points_array(sorted_gt) - if type(sorted_pr[0]) != np.ndarray: - sorted_pr = list_points_array(sorted_pr) - - points_gt.append(sorted_gt) - points_pr.append(sorted_pr) - frame_idxs.extend([frame_idx] * len(sorted_gt)) - - # Convert arrays to numpy matrixes - # instances * nodes * (x,y) - points_gt = np.concatenate(points_gt) - points_pr = np.concatenate(points_pr) - - # Calculate distances between corresponding nodes for all corresponding - # ground truth and predicted instances. - D = np.linalg.norm(points_gt - points_pr, axis=2) - - return frame_idxs, D, points_gt, points_pr - - def match_instance_lists( instances_a: List[Union[Instance, PredictedInstance]], instances_b: List[Union[Instance, PredictedInstance]], @@ -165,6 +96,75 @@ def match_instance_lists_nodewise( return instances_a, best_points_array +def matched_instance_distances( + labels_gt: Labels, + labels_pr: Labels, + match_lists_function: Callable = match_instance_lists_nodewise, + frame_range: Optional[range] = None, +) -> Tuple[List[int], np.ndarray, np.ndarray, np.ndarray]: + + """ + Distances between ground truth and predicted nodes over a set of frames. + + Args: + labels_gt: the `Labels` object with ground truth data + labels_pr: the `Labels` object with predicted data + match_lists_function: function for determining corresponding instances + Takes two lists of instances and returns "sorted" lists. + frame_range (optional): range of frames for which to compare data + If None, we compare every frame in labels_gt with corresponding + frame in labels_pr. + Returns: + Tuple: + * frame indices map: instance idx (for other matrices) -> frame idx + * distance matrix: (instances * nodes) + * ground truth points matrix: (instances * nodes * 2) + * predicted points matrix: (instances * nodes * 2) + """ + + frame_idxs = [] + points_gt = [] + points_pr = [] + for lf_gt in labels_gt.find(labels_gt.videos[0]): + frame_idx = lf_gt.frame_idx + + # Get instances from ground truth/predicted labels + instances_gt = lf_gt.instances + lfs_pr = labels_pr.find(labels_pr.videos[0], frame_idx=frame_idx) + if len(lfs_pr): + instances_pr = lfs_pr[0].instances + else: + instances_pr = [] + + # Sort ground truth and predicted instances. + # We'll then compare points between corresponding items in lists. + # We can use different "match" functions depending on what we want. + sorted_gt, sorted_pr = match_lists_function(instances_gt, instances_pr) + + # Convert lists of instances to (instances, nodes, 2) matrices. + # This allows match_lists_function to return data as either + # a list of Instances or a (instances, nodes, 2) matrix. + if type(sorted_gt[0]) != np.ndarray: + sorted_gt = list_points_array(sorted_gt) + if type(sorted_pr[0]) != np.ndarray: + sorted_pr = list_points_array(sorted_pr) + + points_gt.append(sorted_gt) + points_pr.append(sorted_pr) + frame_idxs.extend([frame_idx] * len(sorted_gt)) + + # Convert arrays to numpy matrixes + # instances * nodes * (x,y) + points_gt = np.concatenate(points_gt) + points_pr = np.concatenate(points_pr) + + # Calculate distances between corresponding nodes for all corresponding + # ground truth and predicted instances. + D = np.linalg.norm(points_gt - points_pr, axis=2) + + return frame_idxs, D, points_gt, points_pr + + def point_dist( inst_a: Union[Instance, PredictedInstance], inst_b: Union[Instance, PredictedInstance], @@ -238,46 +238,3 @@ def point_match_count(dist_array: np.ndarray, thresh: float = 5) -> int: def point_nonmatch_count(dist_array: np.ndarray, thresh: float = 5) -> int: """Given an array of distances, returns number which are not <= threshold.""" return dist_array.shape[0] - point_match_count(dist_array, thresh) - - -if __name__ == "__main__": - - labels_gt = Labels.load_json("tests/data/json_format_v1/centered_pair.json") - labels_pr = Labels.load_json( - "tests/data/json_format_v2/centered_pair_predictions.json" - ) - - # OPTION 1 - - # Match each ground truth instance node to the closest corresponding node - # from any predicted instance in the same frame. - - nodewise_matching_func = match_instance_lists_nodewise - - # OPTION 2 - - # Match each ground truth instance to a distinct predicted instance: - # We want to maximize the number of "matching" points between instances, - # where "match" means the points are within some threshold distance. - # Note that each sorted list will be as long as the shorted input list. - - instwise_matching_func = lambda gt_list, pr_list: match_instance_lists( - gt_list, pr_list, point_nonmatch_count - ) - - # PICK THE FUNCTION - - inst_matching_func = nodewise_matching_func - # inst_matching_func = instwise_matching_func - - # Calculate distances - frame_idxs, D, points_gt, points_pr = matched_instance_distances( - labels_gt, labels_pr, inst_matching_func - ) - - # Show mean difference for each node - node_names = labels_gt.skeletons[0].node_names - - for node_idx, node_name in enumerate(node_names): - mean_d = np.nanmean(D[..., node_idx]) - print(f"{node_name}\t\t{mean_d}") diff --git a/sleap/info/summary.py b/sleap/info/summary.py index c6a6af60e..0cad1617e 100644 --- a/sleap/info/summary.py +++ b/sleap/info/summary.py @@ -21,7 +21,7 @@ class StatisticSeries: are frame index and value are some numerical value for the frame. Args: - labels: The :class:`Labels` for which to calculate series. + labels: The `Labels` for which to calculate series. """ labels: Labels @@ -41,7 +41,7 @@ def get_point_score_series( """Get series with statistic of point scores in each frame. Args: - video: The :class:`Video` for which to calculate statistic. + video: The `Video` for which to calculate statistic. reduction: name of function applied to scores: * sum * min @@ -67,7 +67,7 @@ def get_instance_score_series(self, video, reduction="sum") -> Dict[int, float]: """Get series with statistic of instance scores in each frame. Args: - video: The :class:`Video` for which to calculate statistic. + video: The `Video` for which to calculate statistic. reduction: name of function applied to scores: * sum * min @@ -93,7 +93,7 @@ def get_point_displacement_series(self, video, reduction="sum") -> Dict[int, flo same track) from the closest earlier labeled frame. Args: - video: The :class:`Video` for which to calculate statistic. + video: The `Video` for which to calculate statistic. reduction: name of function applied to point scores: * sum * mean @@ -121,7 +121,7 @@ def get_primary_point_displacement_series( Get sum of displacement for single node of each instance per frame. Args: - video: The :class:`Video` for which to calculate statistic. + video: The `Video` for which to calculate statistic. reduction: name of function applied to point scores: * sum * mean @@ -226,7 +226,7 @@ def _calculate_frame_velocity( Calculate total point displacement between two given frames. Args: - lf: The :class:`LabeledFrame` for which we want velocity + lf: The `LabeledFrame` for which we want velocity last_lf: The frame from which to calculate displacement. reduce_function: Numpy function (e.g., np.sum, np.nanmean) is applied to *point* displacement, and then those @@ -246,3 +246,35 @@ def _calculate_frame_velocity( inst_dist = reduce_function(point_dist) val += inst_dist if not np.isnan(inst_dist) else 0 return val + + def get_tracking_score_series( + self, video: Video, reduction: str = "min" + ) -> Dict[int, float]: + """Get series with statistic of tracking scores in each frame. + + Args: + video: The `Video` for which to calculate statistic. + reduction: name of function applied to scores: + * mean + * min + + Returns: + The series dictionary (see class docs for details) + """ + reduce_fn = { + "min": np.nanmin, + "mean": np.nanmean, + }[reduction] + + series = dict() + + for lf in self.labels.find(video): + vals = [ + inst.tracking_score for inst in lf if hasattr(inst, "tracking_score") + ] + if vals: + val = reduce_fn(vals) + if not np.isnan(val): + series[lf.frame_idx] = val + + return series diff --git a/sleap/instance.py b/sleap/instance.py index c14038552..382ececf2 100644 --- a/sleap/instance.py +++ b/sleap/instance.py @@ -364,7 +364,7 @@ class Instance: from_predicted: Optional["PredictedInstance"] = attr.ib(default=None) _points: PointArray = attr.ib(default=None) _nodes: List = attr.ib(default=None) - frame: Union["LabeledFrame", None] = attr.ib(default=None) + frame: Union["LabeledFrame", None] = attr.ib(default=None) # TODO(LM): Make private # The underlying Point array type that this instances point array should be. _point_array_type = PointArray @@ -1049,7 +1049,9 @@ def scores(self) -> np.ndarray: return self.points_and_scores_array[:, 2] @classmethod - def from_instance(cls, instance: Instance, score: float) -> "PredictedInstance": + def from_instance( + cls, instance: Instance, score: float, tracking_score: float = 0.0 + ) -> "PredictedInstance": """Create a `PredictedInstance` from an `Instance`. The fields are copied in a shallow manner with the exception of points. For each @@ -1059,6 +1061,7 @@ def from_instance(cls, instance: Instance, score: float) -> "PredictedInstance": Args: instance: The `Instance` object to shallow copy data from. score: The score for this instance. + tracking_score: The tracking score for this instance. Returns: A `PredictedInstance` for the given `Instance`. @@ -1070,6 +1073,7 @@ def from_instance(cls, instance: Instance, score: float) -> "PredictedInstance": ) kw_args["points"] = PredictedPointArray.from_array(instance._points) kw_args["score"] = score + kw_args["tracking_score"] = tracking_score return cls(**kw_args) @classmethod @@ -1080,6 +1084,7 @@ def from_arrays( instance_score: float, skeleton: Skeleton, track: Optional[Track] = None, + tracking_score: float = 0.0, ) -> "PredictedInstance": """Create a predicted instance from data arrays. @@ -1094,6 +1099,7 @@ def from_arrays( skeleton: A sleap.Skeleton instance with n_nodes nodes to associate with the predicted instance. track: Optional `sleap.Track` to associate with the instance. + tracking_score: Optional float representing the track matching score. Returns: A new `PredictedInstance`. @@ -1114,6 +1120,7 @@ def from_arrays( skeleton=skeleton, score=instance_score, track=track, + tracking_score=tracking_score, ) @classmethod @@ -1124,6 +1131,7 @@ def from_pointsarray( instance_score: float, skeleton: Skeleton, track: Optional[Track] = None, + tracking_score: float = 0.0, ) -> "PredictedInstance": """Create a predicted instance from data arrays. @@ -1138,12 +1146,18 @@ def from_pointsarray( skeleton: A sleap.Skeleton instance with n_nodes nodes to associate with the predicted instance. track: Optional `sleap.Track` to associate with the instance. + tracking_score: Optional float representing the track matching score. Returns: A new `PredictedInstance`. """ return cls.from_arrays( - points, point_confidences, instance_score, skeleton, track=track + points, + point_confidences, + instance_score, + skeleton, + track=track, + tracking_score=tracking_score, ) @classmethod @@ -1154,6 +1168,7 @@ def from_numpy( instance_score: float, skeleton: Skeleton, track: Optional[Track] = None, + tracking_score: float = 0.0, ) -> "PredictedInstance": """Create a predicted instance from data arrays. @@ -1168,12 +1183,18 @@ def from_numpy( skeleton: A sleap.Skeleton instance with n_nodes nodes to associate with the predicted instance. track: Optional `sleap.Track` to associate with the instance. + tracking_score: Optional float representing the track matching score. Returns: A new `PredictedInstance`. """ return cls.from_arrays( - points, point_confidences, instance_score, skeleton, track=track + points, + point_confidences, + instance_score, + skeleton, + track=track, + tracking_score=tracking_score, ) @@ -1214,6 +1235,9 @@ def unstructure_instance(x: Instance): converter.register_unstructure_hook(Instance, unstructure_instance) converter.register_unstructure_hook(PredictedInstance, unstructure_instance) + converter.register_unstructure_hook( + InstancesList, lambda x: [converter.unstructure(inst) for inst in x] + ) ## STRUCTURE HOOKS @@ -1229,35 +1253,37 @@ def structure_points(x, type): def structure_instances_list(x, type): inst_list = [] for inst_data in x: - if "score" in inst_data.keys(): - inst = converter.structure(inst_data, PredictedInstance) - else: - if ( - "from_predicted" in inst_data - and inst_data["from_predicted"] is not None - ): - inst_data["from_predicted"] = converter.structure( - inst_data["from_predicted"], PredictedInstance - ) - inst = converter.structure(inst_data, Instance) + inst = structure_instance(inst_data, type) inst_list.append(inst) return inst_list + def structure_instance(inst_data, type): + """Structure hook for Instance and PredictedInstance objects.""" + from_predicted = None + + if "score" in inst_data.keys(): + inst = converter.structure(inst_data, PredictedInstance) + else: + if ( + "from_predicted" in inst_data + and inst_data["from_predicted"] is not None + ): + from_predicted = converter.structure( + inst_data["from_predicted"], PredictedInstance + ) + # Remove the from_predicted key. We'll add it back afterwards. + inst_data["from_predicted"] = None + + # Structure the instance data, then add the from_predicted attribute. + inst = converter.structure(inst_data, Instance) + inst.from_predicted = from_predicted + return inst + converter.register_structure_hook( Union[List[Instance], List[PredictedInstance]], structure_instances_list ) - - # Structure forward reference for PredictedInstance for the Instance.from_predicted - # attribute. - converter.register_structure_hook_func( - lambda t: t.__class__ is ForwardRef, - lambda v, t: converter.structure(v, t.__forward_value__), - ) - # converter.register_structure_hook( - # ForwardRef("PredictedInstance"), - # lambda x, _: converter.structure(x, PredictedInstance), - # ) + converter.register_structure_hook(InstancesList, structure_instances_list) # We can register structure hooks for point arrays that do nothing # because Instance can have a dict of points passed to it in place of @@ -1278,6 +1304,127 @@ def structure_point_array(x, t): return converter +class InstancesList(list): + """A list of `Instance`s associated with a `LabeledFrame`. + + This class should only be used for the `LabeledFrame.instances` attribute. + """ + + def __init__(self, *args, labeled_frame: Optional["LabeledFrame"] = None): + super(InstancesList, self).__init__(*args) + + # Set the labeled frame for each instance + self.labeled_frame = labeled_frame + + @property + def labeled_frame(self) -> "LabeledFrame": + """Return the `LabeledFrame` associated with this list of instances.""" + + return self._labeled_frame + + @labeled_frame.setter + def labeled_frame(self, labeled_frame: "LabeledFrame"): + """Set the `LabeledFrame` associated with this list of instances. + + This updates the `frame` attribute on each instance. + + Args: + labeled_frame: The `LabeledFrame` to associate with this list of instances. + """ + + try: + # If the labeled frame is the same as the one we're setting, then skip + if self._labeled_frame == labeled_frame: + return + except AttributeError: + # Only happens on init and updates each instance.frame (even if None) + pass + + # Otherwise, update the frame for each instance + self._labeled_frame = labeled_frame + for instance in self: + instance.frame = labeled_frame + + def append(self, instance: Union[Instance, PredictedInstance]): + """Append an `Instance` or `PredictedInstance` to the list, setting the frame. + + Args: + item: The `Instance` or `PredictedInstance` to append to the list. + """ + + if not isinstance(instance, (Instance, PredictedInstance)): + raise ValueError( + f"InstancesList can only contain Instance or PredictedInstance objects," + f" but got {type(instance)}." + ) + instance.frame = self.labeled_frame + super().append(instance) + + def extend(self, instances: List[Union[PredictedInstance, Instance]]): + """Extend the list with a list of `Instance`s or `PredictedInstance`s. + + Args: + instances: A list of `Instance` or `PredictedInstance` objects to add to the + list. + + Returns: + None + """ + for instance in instances: + self.append(instance) + + def __delitem__(self, index): + """Remove instance (by index), and set instance.frame to None.""" + + instance: Instance = self.__getitem__(index) + super().__delitem__(index) + + # Modify the instance to remove reference to the frame + instance.frame = None + + def insert(self, index: int, instance: Union[Instance, PredictedInstance]) -> None: + super().insert(index, instance) + instance.frame = self.labeled_frame + + def __setitem__(self, index, instance: Union[Instance, PredictedInstance]): + """Set nth instance in frame to the given instance. + + Args: + index: The index of instance to replace with new instance. + value: The new instance to associate with frame. + + Returns: + None. + """ + super().__setitem__(index, instance) + instance.frame = self.labeled_frame + + def pop(self, index: int) -> Union[Instance, PredictedInstance]: + """Remove and return instance at index, setting instance.frame to None.""" + + instance = super().pop(index) + instance.frame = None + return instance + + def remove(self, instance: Union[Instance, PredictedInstance]) -> None: + """Remove instance from list, setting instance.frame to None.""" + super().remove(instance) + instance.frame = None + + def clear(self) -> None: + """Remove all instances from list, setting instance.frame to None.""" + for instance in self: + instance.frame = None + super().clear() + + def copy(self) -> list: + """Return a shallow copy of the list of instances as a list. + + Note: This will not return an `InstancesList` object, but a normal list. + """ + return list(self) + + @attr.s(auto_attribs=True, eq=False, repr=False, str=False) class LabeledFrame: """Holds labeled data for a single frame of a video. @@ -1290,9 +1437,7 @@ class LabeledFrame: video: Video = attr.ib() frame_idx: int = attr.ib(converter=int) - _instances: Union[List[Instance], List[PredictedInstance]] = attr.ib( - default=attr.Factory(list) - ) + _instances: InstancesList = attr.ib(default=attr.Factory(InstancesList)) def __attrs_post_init__(self): """Called by attrs. @@ -1302,8 +1447,7 @@ def __attrs_post_init__(self): """ # Make sure all instances have a reference to this frame - for instance in self.instances: - instance.frame = self + self.instances = self._instances def __len__(self) -> int: """Return number of instances associated with frame.""" @@ -1319,13 +1463,8 @@ def index(self, value: Instance) -> int: def __delitem__(self, index): """Remove instance (by index) from frame.""" - value = self.instances.__getitem__(index) - self.instances.__delitem__(index) - # Modify the instance to remove reference to this frame - value.frame = None - def __repr__(self) -> str: """Return a readable representation of the LabeledFrame.""" return ( @@ -1348,9 +1487,6 @@ def insert(self, index: int, value: Instance): """ self.instances.insert(index, value) - # Modify the instance to have a reference back to this frame - value.frame = self - def __setitem__(self, index, value: Instance): """Set nth instance in frame to the given instance. @@ -1363,9 +1499,6 @@ def __setitem__(self, index, value: Instance): """ self.instances.__setitem__(index, value) - # Modify the instance to have a reference back to this frame - value.frame = self - def find( self, track: Optional[Union[Track, int]] = -1, user: bool = False ) -> List[Instance]: @@ -1393,7 +1526,7 @@ def instances(self) -> List[Instance]: return self._instances @instances.setter - def instances(self, instances: List[Instance]): + def instances(self, instances: Union[InstancesList, List[Instance]]): """Set the list of instances associated with this frame. Updates the `frame` attribute on each instance to the @@ -1408,9 +1541,11 @@ def instances(self, instances: List[Instance]): None """ - # Make sure to set the frame for each instance to this LabeledFrame - for instance in instances: - instance.frame = self + # Make sure to set the LabeledFrame for each instance to this frame + if isinstance(instances, InstancesList): + instances.labeled_frame = self + else: + instances = InstancesList(instances, labeled_frame=self) self._instances = instances @@ -1685,22 +1820,20 @@ def complex_frame_merge( * list of conflicting instances from base * list of conflicting instances from new """ - merged_instances = [] - redundant_instances = [] - extra_base_instances = copy(base_frame.instances) - extra_new_instances = [] + merged_instances: List[Instance] = [] # Only used for informing user + redundant_instances: List[Instance] = [] + extra_base_instances: List[Instance] = list(base_frame.instances) + extra_new_instances: List[Instance] = [] for new_inst in new_frame: redundant = False for base_inst in base_frame.instances: if new_inst.matches(base_inst): - base_inst.frame = None extra_base_instances.remove(base_inst) redundant_instances.append(base_inst) redundant = True continue if not redundant: - new_inst.frame = None extra_new_instances.append(new_inst) conflict = False @@ -1732,7 +1865,7 @@ def complex_frame_merge( else: # No conflict, so include all instances in base base_frame.instances.extend(extra_new_instances) - merged_instances = copy(extra_new_instances) + merged_instances: List[Instance] = copy(extra_new_instances) extra_base_instances = [] extra_new_instances = [] diff --git a/sleap/io/asyncvideo.py b/sleap/io/asyncvideo.py deleted file mode 100644 index c48d21a8b..000000000 --- a/sleap/io/asyncvideo.py +++ /dev/null @@ -1,218 +0,0 @@ -""" -Support for loading video frames (by chunk) in background process. -""" - -from sleap import Video -from sleap.message import PairedSender, PairedReceiver - -import cattr -import logging -import time -import numpy as np -from math import ceil -from multiprocessing import Process -from typing import Iterable, Iterator, List, Optional, Tuple - - -logger = logging.getLogger(__name__) - - -class AsyncVideo: - """Supports fetching chunks from video in background process.""" - - def __init__(self, base_port: int = 9010): - self.base_port = base_port - - # Spawn the server as a background process - self.server = AsyncVideoServer(self.base_port) - self.server.start() - - # Create sender/receiver for sending requests and receiving data via ZMQ - sender = PairedSender.from_tcp_ports(self.base_port, self.base_port + 1) - result_receiver = PairedReceiver.from_tcp_ports( - send_port=self.base_port + 2, rec_port=self.base_port + 3 - ) - - sender.setup() - result_receiver.setup() - - self.sender = sender - self.receiver = result_receiver - - # Use "handshake" to ensure that initial messages aren't dropped - self.handshake_success = sender.send_handshake() - - def close(self): - """Close the async video server and communication ports.""" - if self.sender and self.server: - self.sender.send_dict(dict(stop=True)) - self.server.join() - - self.server = None - - if self.sender: - self.sender.close() - self.sender = None - - if self.receiver: - self.receiver.close() - self.receiver = None - - def __del__(self): - self.close() - - @classmethod - def from_video( - cls, - video: Video, - frame_idxs: Optional[Iterable[int]] = None, - frames_per_chunk: int = 64, - ) -> "AsyncVideo": - """Create object and start loading frames in background process.""" - obj = cls() - obj.load_by_chunk( - video=video, frame_idxs=frame_idxs, frames_per_chunk=frames_per_chunk - ) - return obj - - def load_by_chunk( - self, - video: Video, - frame_idxs: Optional[Iterable[int]] = None, - frames_per_chunk: int = 64, - ): - """ - Sends request for loading video in background process. - - Args: - video: The :py:class:`Video` to load - frame_idxs: Frame indices we want to load; if None, then full video - is loaded. - frames_per_chunk: How many frames to load per chunk. - - Returns: - None, data should be accessed via :py:method:`chunks`. - """ - # prime the video since this seems to make frames load faster (!?) - video.test_frame - - request_dict = dict( - video=cattr.unstructure(video), frames_per_chunk=frames_per_chunk - ) - # if no frames are specified, whole video will be loaded - if frame_idxs is not None: - request_dict["frame_idxs"] = list(frame_idxs) - - # send the request - self.sender.send_dict(request_dict) - - @property - def chunks(self) -> Iterator[Tuple[List[int], np.ndarray]]: - """ - Generator for fetching chunks of frames. - - When all chunks are loaded, closes the server and communication ports. - - Yields: - Tuple with (list of frame indices, ndarray of frames) - """ - done = False - while not done: - results = self.receiver.check_messages() - if results: - for result in results: - yield result["frame_idxs"], result["ndarray"] - - if result["chunk"] == result["last_chunk"]: - done = True - - # automatically close when all chunks have been received - self.close() - - -class AsyncVideoServer(Process): - """ - Class which loads video frames in background on request. - - All interactions with video server should go through :py:class:`AsyncVideo` - which runs in local thread. - """ - - def __init__(self, base_port: int): - super(AsyncVideoServer, self).__init__() - - self.video = None - self.base_port = base_port - - def run(self): - receiver = PairedReceiver.from_tcp_ports(self.base_port + 1, self.base_port) - receiver.setup() - - result_sender = PairedSender.from_tcp_ports( - send_port=self.base_port + 3, rec_port=self.base_port + 2 - ) - result_sender.setup() - - running = True - while running: - requests = receiver.check_messages() - if requests: - - for request in requests: - - if "stop" in request: - running = False - logger.debug("stopping async video server") - break - - if "video" in request: - self.video = cattr.structure(request["video"], Video) - logger.debug(f"loaded video: {self.video.filename}") - - if self.video is not None: - if "frames_per_chunk" in request: - - load_time = 0 - send_time = 0 - - per_chunk = request["frames_per_chunk"] - - frame_idxs = request.get( - "frame_idxs", list(range(self.video.frames)) - ) - - frame_count = len(frame_idxs) - chunks = ceil(frame_count / per_chunk) - - for chunk_idx in range(chunks): - start = per_chunk * chunk_idx - end = min(per_chunk * (chunk_idx + 1), frame_count) - chunk_frame_idxs = frame_idxs[start:end] - - # load the frames - t0 = time.time() - frames = self.video[chunk_frame_idxs] - t1 = time.time() - load_time += t1 - t0 - - metadata = dict( - chunk=chunk_idx, - last_chunk=chunks - 1, - frame_idxs=chunk_frame_idxs, - ) - - # send back results - t0 = time.time() - result_sender.send_array(metadata, frames) - t1 = time.time() - send_time += t1 - t0 - - logger.debug(f"returned chunk: {chunk_idx+1}/{chunks}") - - logger.debug(f"total load time: {load_time}") - logger.debug(f"total send time: {send_time}") - else: - logger.warning( - "unable to process message since no video loaded" - ) - logger.warning(request) diff --git a/sleap/io/convert.py b/sleap/io/convert.py index 3353a169b..7045ed71f 100644 --- a/sleap/io/convert.py +++ b/sleap/io/convert.py @@ -70,6 +70,7 @@ def create_parser(): help="Output format. Default ('slp') is SLEAP dataset; " "'analysis' results in analysis.h5 file; " "'analysis.nix' results in an analysis nix file;" + "'analysis.csv' results in an analysis csv file;" "'h5' or 'json' results in SLEAP dataset " "with specified file format.", ) @@ -135,7 +136,12 @@ def main(args: list = None): outnames = [path for path in args.outputs] if len(outnames) < len(vids): # if there are less outnames provided than videos to convert... - out_suffix = "nix" if "nix" in args.format else "h5" + if "nix" in args.format: + out_suffix = "nix" + elif "csv" in args.format: + out_suffix = "csv" + else: + out_suffix = "h5" fn = args.input_path fn = re.sub("(\.json(\.zip)?|\.h5|\.slp)$", "", fn) fn = PurePath(fn) @@ -158,6 +164,20 @@ def main(args: list = None): NixAdaptor.write(outname, labels, args.input_path, video) except ValueError as e: print(e.args[0]) + + elif "csv" in args.format: + from sleap.info.write_tracking_h5 import main as write_analysis + + for video, output_path in zip(vids, outnames): + write_analysis( + labels, + output_path=output_path, + labels_path=args.input_path, + all_frames=True, + video=video, + csv=True, + ) + else: from sleap.info.write_tracking_h5 import main as write_analysis diff --git a/sleap/io/dataset.py b/sleap/io/dataset.py index 45280cc54..1b894089f 100644 --- a/sleap/io/dataset.py +++ b/sleap/io/dataset.py @@ -2055,6 +2055,19 @@ def export(self, filename: str): SleapAnalysisAdaptor.write(filename, self) + def export_csv(self, filename: str): + """Export labels to CSV format. + + Args: + filename: Output path for the CSV format file. + + Notes: + This will write the contents of the labels out as a CSV file. + """ + from sleap.io.format.csv import CSVAdaptor + + CSVAdaptor.write(filename, self) + def export_nwb( self, filename: str, diff --git a/sleap/io/format/coco.py b/sleap/io/format/coco.py index 25122e4d0..44e7fb84a 100644 --- a/sleap/io/format/coco.py +++ b/sleap/io/format/coco.py @@ -180,6 +180,9 @@ def read( if flag == 0: # node not labeled for this instance + if (x, y) != (0, 0): + # If labeled but invisible, place the node at the coord + points[node] = Point(x, y, False) continue is_visible = flag == 2 diff --git a/sleap/io/format/deeplabcut.py b/sleap/io/format/deeplabcut.py index bb5dc3410..5892dba1a 100644 --- a/sleap/io/format/deeplabcut.py +++ b/sleap/io/format/deeplabcut.py @@ -19,10 +19,10 @@ import numpy as np import pandas as pd -from typing import List, Optional +from typing import List, Optional, Dict from sleap import Labels, Video, Skeleton -from sleap.instance import Instance, LabeledFrame, Point +from sleap.instance import Instance, LabeledFrame, Point, Track from sleap.util import find_files_by_suffix from .adaptor import Adaptor, SleapObjectType @@ -119,11 +119,12 @@ def read_frames( # Pull out animal and node names from the columns. start_col = 3 if is_new_format else 1 - animal_names = [] + tracks: Dict[str, Optional[Track]] = {} node_names = [] for animal_name, node_name, _ in data.columns[start_col:][::2]: - if animal_name not in animal_names: - animal_names.append(animal_name) + # Keep the starting frame index for each individual/track + if animal_name not in tracks.keys(): + tracks[animal_name] = None if node_name not in node_names: node_names.append(node_name) @@ -177,23 +178,33 @@ def read_frames( instances = [] if is_multianimal: - for animal_name in animal_names: + for animal_name in tracks.keys(): any_not_missing = False # Get points for each node. instance_points = dict() for node in node_names: - x, y = ( - data[(animal_name, node, "x")][i], - data[(animal_name, node, "y")][i], - ) + if (animal_name, node) in data.columns: + x, y = ( + data[(animal_name, node, "x")][i], + data[(animal_name, node, "y")][i], + ) + else: + x, y = np.nan, np.nan instance_points[node] = Point(x, y) if ~(np.isnan(x) and np.isnan(y)): any_not_missing = True if any_not_missing: + # Create track + if tracks[animal_name] is None: + tracks[animal_name] = Track(spawned_on=i, name=animal_name) # Create instance with points. instances.append( - Instance(skeleton=skeleton, points=instance_points) + Instance( + skeleton=skeleton, + points=instance_points, + track=tracks[animal_name], + ) ) else: # Get points for each node. @@ -270,6 +281,8 @@ def read( skeleton = Skeleton() if project_data.get("multianimalbodyparts", False): skeleton.add_nodes(project_data["multianimalbodyparts"]) + if "uniquebodyparts" in project_data: + skeleton.add_nodes(project_data["uniquebodyparts"]) else: skeleton.add_nodes(project_data["bodyparts"]) @@ -298,13 +311,24 @@ def read( # If subdirectory is foo, we look for foo.mp4 in videos dir. shortname = os.path.split(data_subdir)[-1] - video_path = os.path.join(videos_dir, f"{shortname}.mp4") - - if os.path.exists(video_path): + video_path = None + if os.path.exists(videos_dir): + with os.scandir(videos_dir) as file_iterator: + for file in file_iterator: + if not file.is_file(): + continue + if os.path.splitext(file.name)[0] != shortname: + continue + video_path = os.path.join(videos_dir, file.name) + break + + if video_path is not None and os.path.exists(video_path): video = Video.from_filename(video_path) else: # When no video is found, the individual frame images # stored in the labeled data subdir will be used. + if video_path is None: + video_path = os.path.join(videos_dir, f"{shortname}.mp4") print( f"Unable to find {video_path} so using individual frame images." ) diff --git a/sleap/io/format/hdf5.py b/sleap/io/format/hdf5.py index 353f88e3a..55a30d74f 100644 --- a/sleap/io/format/hdf5.py +++ b/sleap/io/format/hdf5.py @@ -81,7 +81,10 @@ def read_headers( # Extract the Labels JSON metadata and create Labels object with just this # metadata. - dicts = json_loads(f.require_group("metadata").attrs["json"].tobytes().decode()) + json = f.require_group("metadata").attrs["json"] + if not isinstance(json, str): + json = json.tobytes().decode() + dicts = json_loads(json) # These items are stored in separate lists because the metadata group got to be # too big. @@ -151,6 +154,45 @@ def read( points_dset[:]["x"] -= 0.5 points_dset[:]["y"] -= 0.5 + def cast_as_compound(arr, dtype): + out = np.empty(shape=(len(arr),), dtype=dtype) + if out.size == 0: + return out + for i, (name, _) in enumerate(dtype): + out[name] = arr[:, i] + return out + + # cast points, instances, and frames into complex dtype if not already + dtype_points = [("x", " np.ndarray: def get_frames_safely(self, idxs: Iterable[int]) -> Tuple[List[int], np.ndarray]: """Return list of frame indices and frames which were successfully loaded. + Args: + idxs: An iterable object that contains the indices of frames. - idxs: An iterable object that contains the indices of frames. Returns: A tuple of (frame indices, frames), where * frame indices is a subset of the specified idxs, and @@ -1442,19 +1443,31 @@ def to_hdf5( def encode(img): _, encoded = cv2.imencode("." + format, img) - return np.squeeze(encoded) + return np.squeeze(encoded).astype("int8") + + # pad with zeroes to guarantee int8 type in hdf5 file + frames = [] + for i in range(len(frame_numbers)): + frames.append(encode(frame_data[i])) + + max_frame_size = ( + max([len(x) if len(x) else 0 for x in frames]) if len(frames) else 0 + ) - dtype = h5.special_dtype(vlen=np.dtype("int8")) dset = f.create_dataset( - dataset + "/video", (len(frame_numbers),), dtype=dtype + dataset + "/video", + (len(frame_numbers), max_frame_size), + dtype="int8", + compression="gzip", ) dset.attrs["format"] = format dset.attrs["channels"] = self.channels dset.attrs["height"] = self.height dset.attrs["width"] = self.width - for i in range(len(frame_numbers)): - dset[i] = encode(frame_data[i]) + for i, frame in enumerate(frames): + dset[i, 0 : len(frame)] = frame + else: f.create_dataset( dataset + "/video", @@ -1532,22 +1545,17 @@ def cattr(): A cattr converter. """ - # When we are structuring video backends, try to fixup the video file paths - # in case they are coming from a different computer or the file has been moved. - def fixup_video(x, cl): - if "filename" in x: - x["filename"] = Video.fixup_path(x["filename"]) - if "file" in x: - x["file"] = Video.fixup_path(x["file"]) + # Use from_filename to fixup the video path and determine backend + def fixup_video(x: dict, cl: Video): + backend_dict = x.pop("backend") + filename = backend_dict.pop("filename", None) or backend_dict.pop( + "file", None + ) - return Video.make_specific_backend(cl, x) + return Video.from_filename(filename, **backend_dict) vid_cattr = cattr.Converter() - - # Check the type hint for backend and register the video path - # fixup hook for each type in the Union. - for t in attr.fields(Video).backend.type.__args__: - vid_cattr.register_structure_hook(t, fixup_video) + vid_cattr.register_structure_hook(Video, fixup_video) return vid_cattr diff --git a/sleap/io/videowriter.py b/sleap/io/videowriter.py index 510fad739..cd710c9d5 100644 --- a/sleap/io/videowriter.py +++ b/sleap/io/videowriter.py @@ -12,6 +12,7 @@ from abc import ABC, abstractmethod import cv2 import numpy as np +import imageio.v2 as iio class VideoWriter(ABC): @@ -32,22 +33,26 @@ def close(self): @staticmethod def safe_builder(filename, height, width, fps): """Builds VideoWriter based on available dependencies.""" - if VideoWriter.can_use_skvideo(): - return VideoWriterSkvideo(filename, height, width, fps) + if VideoWriter.can_use_ffmpeg(): + return VideoWriterImageio(filename, height, width, fps) else: return VideoWriterOpenCV(filename, height, width, fps) @staticmethod - def can_use_skvideo(): - # See if we can import skvideo + def can_use_ffmpeg(): + """Check if ffmpeg is available for writing videos.""" try: - import skvideo + import imageio_ffmpeg as ffmpeg except ImportError: return False - # See if skvideo can find FFMPEG - if skvideo.getFFmpegVersion() != "0.0.0": - return True + try: + # Try to get the version of the ffmpeg plugin + ffmpeg_version = ffmpeg.get_ffmpeg_version() + if ffmpeg_version: + return True + except Exception: + return False return False @@ -68,11 +73,11 @@ def close(self): self._writer.release() -class VideoWriterSkvideo(VideoWriter): - """Writes video using scikit-video as wrapper for ffmpeg. +class VideoWriterImageio(VideoWriter): + """Writes video using imageio as a wrapper for ffmpeg. Attributes: - filename: Path to mp4 file to save to. + filename: Path to video file to save to. height: Height of movie frames. width: Width of movie frames. fps: Playback framerate to save at. @@ -85,28 +90,38 @@ class VideoWriterSkvideo(VideoWriter): def __init__( self, filename, height, width, fps, crf: int = 21, preset: str = "superfast" ): - import skvideo.io - - fps = str(fps) - self._writer = skvideo.io.FFmpegWriter( + self.filename = filename + self.height = height + self.width = width + self.fps = fps + self.crf = crf + self.preset = preset + + import imageio_ffmpeg as ffmpeg + + # Imageio's ffmpeg writer parameters + # https://imageio.readthedocs.io/en/stable/examples.html#writing-videos-with-ffmpeg-and-vaapi + # Use `ffmpeg -h encoder=libx264`` to see all options for libx264 output_params + # output_params must be a list of strings + # iio.help(name='FFMPEG') to test + self.writer = iio.get_writer( filename, - inputdict={ - "-r": fps, - }, - outputdict={ - "-c:v": "libx264", - "-preset": preset, - "-vf": "scale=trunc(iw/2)*2:trunc(ih/2)*2", # Need even dims for libx264 - "-framerate": fps, - "-crf": str(crf), - "-pix_fmt": "yuv420p", - }, + fps=fps, + codec="libx264", + format="FFMPEG", + pixelformat="yuv420p", + output_params=[ + "-preset", + preset, + "-crf", + str(crf), + ], ) def add_frame(self, img, bgr: bool = False): if bgr: img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) - self._writer.writeFrame(img) + self.writer.append_data(img) def close(self): - self._writer.close() + self.writer.close() diff --git a/sleap/io/visuals.py b/sleap/io/visuals.py index 2018ce0bf..f2dde0be3 100644 --- a/sleap/io/visuals.py +++ b/sleap/io/visuals.py @@ -27,7 +27,13 @@ _sentinel = object() -def reader(out_q: Queue, video: Video, frames: List[int], scale: float = 1.0): +def reader( + out_q: Queue, + video: Video, + frames: List[int], + scale: float = 1.0, + background: str = "original", +): """Read frame images from video and send them into queue. Args: @@ -36,11 +42,13 @@ def reader(out_q: Queue, video: Video, frames: List[int], scale: float = 1.0): video: The `Video` object to read. frames: Full list frame indexes we want to read. scale: Output scale for frame images. + background: output video background. Either original, black, white, grey Returns: None. """ + background = background.lower() cv2.setNumThreads(usable_cpu_count()) total_count = len(frames) @@ -64,6 +72,16 @@ def reader(out_q: Queue, video: Video, frames: List[int], scale: float = 1.0): loaded_chunk_idxs, video_frame_images = video.get_frames_safely( frames_idx_chunk ) + if background != "original": + # fill the frame with the color + fill_values = {"black": 0, "grey": 127, "white": 255} + try: + fill = fill_values[background] + except KeyError: + raise ValueError( + f"Invalid background color: {background}. Options include: {', '.join(fill_values.keys())}" + ) + video_frame_images = video_frame_images * 0 + fill if not loaded_chunk_idxs: print(f"No frames could be loaded from chunk {chunk_i}") @@ -497,6 +515,7 @@ def save_labeled_video( fps: int = 15, scale: float = 1.0, crop_size_xy: Optional[Tuple[int, int]] = None, + background: str = "original", show_edges: bool = True, edge_is_wedge: bool = False, marker_size: int = 4, @@ -515,6 +534,7 @@ def save_labeled_video( fps: Frames per second for output video. scale: scale of image (so we can scale point locations to match) crop_size_xy: size of crop around instances, or None for full images + background: output video background. Either original, black, white, grey show_edges: whether to draw lines between nodes edge_is_wedge: whether to draw edges as wedges (draw as line if False) marker_size: Size of marker in pixels before scaling by `scale` @@ -537,7 +557,7 @@ def save_labeled_video( q2 = Queue(maxsize=10) progress_queue = Queue() - thread_read = Thread(target=reader, args=(q1, video, frames, scale)) + thread_read = Thread(target=reader, args=(q1, video, frames, scale, background)) thread_mark = VideoMarkerThread( in_q=q1, out_q=q2, @@ -695,6 +715,15 @@ def main(args: list = None): "and 'nodes' (default: 'nodes')" ), ) + parser.add_argument( + "--background", + type=str, + default="original", + help=( + "Specify the type of background to be used to save the videos." + "Options for background: original, black, white and grey" + ), + ) args = parser.parse_args(args=args) labels = Labels.load_file( args.data_path, video_search=[os.path.dirname(args.data_path)] @@ -730,6 +759,7 @@ def main(args: list = None): marker_size=args.marker_size, palette=args.palette, distinctly_color=args.distinctly_color, + background=args.background, ) print(f"Video saved as: {filename}") diff --git a/sleap/nn/config/outputs.py b/sleap/nn/config/outputs.py index ffb0d76e4..ccb6077b1 100644 --- a/sleap/nn/config/outputs.py +++ b/sleap/nn/config/outputs.py @@ -151,8 +151,8 @@ class OutputsConfig: save_visualizations: If True, will render and save visualizations of the model predictions as PNGs to "{run_folder}/viz/{split}.{epoch:04d}.png", where the split is one of "train", "validation", "test". - delete_viz_images: If True, delete the saved visualizations after training - completes. This is useful to reduce the model folder size if you do not need + keep_viz_images: If True, keep the saved visualization images after training + completes. This is useful unchecked to reduce the model folder size if you do not need to keep the visualization images. zip_outputs: If True, compress the run folder to a zip file. This will be named "{run_folder}.zip". @@ -170,7 +170,7 @@ class OutputsConfig: runs_folder: Text = "models" tags: List[Text] = attr.ib(factory=list) save_visualizations: bool = True - delete_viz_images: bool = True + keep_viz_images: bool = False zip_outputs: bool = False log_to_csv: bool = True checkpointing: CheckpointingConfig = attr.ib(factory=CheckpointingConfig) diff --git a/sleap/nn/data/augmentation.py b/sleap/nn/data/augmentation.py index 21dfb29e6..b754c0fe9 100644 --- a/sleap/nn/data/augmentation.py +++ b/sleap/nn/data/augmentation.py @@ -1,19 +1,11 @@ """Transformers for applying data augmentation.""" -# Monkey patch for: https://github.com/aleju/imgaug/issues/537 -# TODO: Fix when PyPI/conda packages are available for version fencing. -import numpy - -if hasattr(numpy.random, "_bit_generator"): - numpy.random.bit_generator = numpy.random._bit_generator - import sleap import numpy as np import tensorflow as tf import attr from typing import List, Text, Optional -import imgaug as ia -import imgaug.augmenters as iaa +import albumentations as A from sleap.nn.config import AugmentationConfig from sleap.nn.data.instance_cropping import crop_bboxes @@ -111,15 +103,15 @@ def flip_instances_ud( @attr.s(auto_attribs=True) -class ImgaugAugmenter: - """Data transformer based on the `imgaug` library. +class AlbumentationsAugmenter: + """Data transformer based on the `albumentations` library. This class can generate a `tf.data.Dataset` from an existing one that generates image and instance data. Element of the output dataset will have a set of augmentation transformations applied. Attributes: - augmenter: An instance of `imgaug.augmenters.Sequential` that will be applied to + augmenter: An instance of `albumentations.Compose` that will be applied to each element of the input dataset. image_key: Name of the example key where the image is stored. Defaults to "image". @@ -127,7 +119,7 @@ class ImgaugAugmenter: Defaults to "instances". """ - augmenter: iaa.Sequential + augmenter: A.Compose image_key: str = "image" instances_key: str = "instances" @@ -137,7 +129,7 @@ def from_config( config: AugmentationConfig, image_key: Text = "image", instances_key: Text = "instances", - ) -> "ImgaugAugmenter": + ) -> "AlbumentationsAugmenter": """Create an augmenter from a set of configuration parameters. Args: @@ -148,52 +140,64 @@ def from_config( Defaults to "instances". Returns: - An instance of `ImgaugAugmenter` with the specified augmentation + An instance of `AlbumentationsAugmenter` with the specified augmentation configuration. """ aug_stack = [] if config.rotate: aug_stack.append( - iaa.Affine( - rotate=(config.rotation_min_angle, config.rotation_max_angle) + A.Rotate( + limit=(config.rotation_min_angle, config.rotation_max_angle), p=1.0 ) ) if config.translate: aug_stack.append( - iaa.Affine( + A.Affine( translate_px={ "x": (config.translate_min, config.translate_max), "y": (config.translate_min, config.translate_max), - } + }, + p=1.0, ) ) if config.scale: - aug_stack.append(iaa.Affine(scale=(config.scale_min, config.scale_max))) - if config.uniform_noise: aug_stack.append( - iaa.AddElementwise( - value=(config.uniform_noise_min_val, config.uniform_noise_max_val) - ) + A.Affine(scale=(config.scale_min, config.scale_max), p=1.0) ) + if config.uniform_noise: + + def uniform_noise(image, **kwargs): + return image + np.random.uniform( + config.uniform_noise_min_val, config.uniform_noise_max_val + ) + + aug_stack.append(A.Lambda(image=uniform_noise)) if config.gaussian_noise: aug_stack.append( - iaa.AdditiveGaussianNoise( - loc=config.gaussian_noise_mean, scale=config.gaussian_noise_stddev + A.GaussNoise( + mean=config.gaussian_noise_mean, + var_limit=config.gaussian_noise_stddev, ) ) if config.contrast: aug_stack.append( - iaa.GammaContrast( - gamma=(config.contrast_min_gamma, config.contrast_max_gamma) + A.RandomGamma( + gamma_limit=(config.contrast_min_gamma, config.contrast_max_gamma), + p=1.0, ) ) if config.brightness: aug_stack.append( - iaa.Add(value=(config.brightness_min_val, config.brightness_max_val)) + A.RandomBrightness( + limit=(config.brightness_min_val, config.brightness_max_val), p=1.0 + ) ) return cls( - augmenter=iaa.Sequential(aug_stack), + augmenter=A.Compose( + aug_stack, + keypoint_params=A.KeypointParams(format="xy", remove_invisible=False), + ), image_key=image_key, instances_key=instances_key, ) @@ -226,22 +230,16 @@ def transform_dataset(self, input_ds: tf.data.Dataset) -> tf.data.Dataset: # Define augmentation function to map over each sample. def py_augment(image, instances): """Local processing function that will not be autographed.""" - # Ensure that the transformations applied to all data within this - # example are kept consistent. - aug_det = self.augmenter.to_deterministic() + # Convert to numpy arrays. + img = image.numpy() + kps = instances.numpy() + original_shape = kps.shape + kps = kps.reshape(-1, 2) - # Augment the image. - aug_img = aug_det.augment_image(image.numpy()) - - # This will get converted to a rank 3 tensor (n_instances, n_nodes, 2). - aug_instances = np.full_like(instances, np.nan) - - # Augment each set of points for each instance. - for i, instance in enumerate(instances): - kps = ia.KeypointsOnImage.from_xy_array( - instance.numpy(), tuple(image.shape) - ) - aug_instances[i] = aug_det.augment_keypoints(kps).to_xy_array() + # Augment. + augmented = self.augmenter(image=img, keypoints=kps) + aug_img = augmented["image"] + aug_instances = np.array(augmented["keypoints"]).reshape(original_shape) return aug_img, aug_instances @@ -258,7 +256,6 @@ def augment(frame_data): return frame_data # Apply the augmentation to each element. - # Note: We map sequentially since imgaug gets slower with tf.data parallelism. output_ds = input_ds.map(augment) return output_ds diff --git a/sleap/nn/data/pipelines.py b/sleap/nn/data/pipelines.py index b0892f8a1..2e334456a 100644 --- a/sleap/nn/data/pipelines.py +++ b/sleap/nn/data/pipelines.py @@ -18,7 +18,7 @@ from sleap.nn.data.providers import LabelsReader, VideoReader from sleap.nn.data.augmentation import ( AugmentationConfig, - ImgaugAugmenter, + AlbumentationsAugmenter, RandomCropper, RandomFlipper, ) @@ -68,7 +68,7 @@ PROVIDERS = (LabelsReader, VideoReader) TRANSFORMERS = ( - ImgaugAugmenter, + AlbumentationsAugmenter, RandomCropper, Normalizer, Resizer, @@ -406,7 +406,7 @@ def make_training_pipeline(self, data_provider: Provider) -> Pipeline: self.data_config.labels.skeletons[0], horizontal=self.optimization_config.augmentation_config.flip_horizontal, ) - pipeline += ImgaugAugmenter.from_config( + pipeline += AlbumentationsAugmenter.from_config( self.optimization_config.augmentation_config ) if self.optimization_config.augmentation_config.random_crop: @@ -550,7 +550,7 @@ def make_training_pipeline(self, data_provider: Provider) -> Pipeline: self.data_config.labels.skeletons[0], horizontal=self.optimization_config.augmentation_config.flip_horizontal, ) - pipeline += ImgaugAugmenter.from_config( + pipeline += AlbumentationsAugmenter.from_config( self.optimization_config.augmentation_config ) if self.optimization_config.augmentation_config.random_crop: @@ -713,7 +713,7 @@ def make_training_pipeline(self, data_provider: Provider) -> Pipeline: self.data_config.labels.skeletons[0], horizontal=self.optimization_config.augmentation_config.flip_horizontal, ) - pipeline += ImgaugAugmenter.from_config( + pipeline += AlbumentationsAugmenter.from_config( self.optimization_config.augmentation_config ) pipeline += Normalizer.from_config(self.data_config.preprocessing) @@ -863,7 +863,7 @@ def make_training_pipeline(self, data_provider: Provider) -> Pipeline: self.data_config.labels.skeletons[0], horizontal=aug_config.flip_horizontal, ) - pipeline += ImgaugAugmenter.from_config(aug_config) + pipeline += AlbumentationsAugmenter.from_config(aug_config) if aug_config.random_crop: pipeline += RandomCropper( crop_height=aug_config.random_crop_height, @@ -1028,7 +1028,7 @@ def make_training_pipeline(self, data_provider: Provider) -> Pipeline: horizontal=aug_config.flip_horizontal, ) - pipeline += ImgaugAugmenter.from_config(aug_config) + pipeline += AlbumentationsAugmenter.from_config(aug_config) if aug_config.random_crop: pipeline += RandomCropper( crop_height=aug_config.random_crop_height, @@ -1186,7 +1186,7 @@ def make_training_pipeline(self, data_provider: Provider) -> Pipeline: config=self.data_config.preprocessing, provider=data_provider, ) - pipeline += ImgaugAugmenter.from_config( + pipeline += AlbumentationsAugmenter.from_config( self.optimization_config.augmentation_config ) pipeline += Normalizer.from_config(self.data_config.preprocessing) diff --git a/sleap/nn/data/providers.py b/sleap/nn/data/providers.py index 16f439d10..9e93d0b18 100644 --- a/sleap/nn/data/providers.py +++ b/sleap/nn/data/providers.py @@ -394,9 +394,7 @@ def make_dataset(self) -> tf.data.Dataset: grid in order to properly map points to image coordinates. """ # Grab an image to test for the dtype. - test_image = tf.convert_to_tensor( - self.video.get_frame(self.video.last_frame_idx) - ) + test_image = tf.convert_to_tensor(self.video.get_frame(0)) image_dtype = test_image.dtype def py_fetch_frame(ind): diff --git a/sleap/nn/inference.py b/sleap/nn/inference.py index 6d7d24f8c..3f01a1c3c 100644 --- a/sleap/nn/inference.py +++ b/sleap/nn/inference.py @@ -33,6 +33,7 @@ import atexit import subprocess import rich.progress +import pandas as pd from rich.pretty import pprint from collections import deque import json @@ -1142,6 +1143,7 @@ def export_model( info["frozen_model_inputs"] = frozen_func.inputs info["frozen_model_outputs"] = frozen_func.outputs + info["unragged_outputs"] = unrag_outputs with (Path(save_path) / "info.json").open("w") as fp: json.dump( @@ -1582,6 +1584,15 @@ def _object_builder(): try: for ex in generator: prediction_queue.put(ex) + + except KeyError as e: + # Gracefully handle seeking errors by early termination. + if "Unable to load frame" in str(e): + pass # TODO: Print warning obeying verbosity? (This code path is also + # called for interactive prediction where we don't want any spam.) + else: + raise + finally: prediction_queue.put(None) object_builder.join() @@ -2611,6 +2622,7 @@ def _object_builder(): # Set tracks for predicted instances in this frame. predicted_instances = self.tracker.track( untracked_instances=predicted_instances, + img_hw=ex["image"].shape[-3:-1], img=image, t=frame_ind, ) @@ -2632,6 +2644,15 @@ def _object_builder(): try: for ex in generator: prediction_queue.put(ex) + + except KeyError as e: + # Gracefully handle seeking errors by early termination. + if "Unable to load frame" in str(e): + pass # TODO: Print warning obeying verbosity? (This code path is also + # called for interactive prediction where we don't want any spam.) + else: + raise + finally: prediction_queue.put(None) object_builder.join() @@ -3244,6 +3265,7 @@ def _object_builder(): # Set tracks for predicted instances in this frame. predicted_instances = self.tracker.track( untracked_instances=predicted_instances, + img_hw=ex["image"].shape[-3:-1], img=image, t=frame_ind, ) @@ -3265,6 +3287,15 @@ def _object_builder(): try: for ex in generator: prediction_queue.put(ex) + + except KeyError as e: + # Gracefully handle seeking errors by early termination. + if "Unable to load frame" in str(e): + pass # TODO: Print warning obeying verbosity? (This code path is also + # called for interactive prediction where we don't want any spam.) + else: + raise + finally: prediction_queue.put(None) object_builder.join() @@ -3747,9 +3778,10 @@ def _object_builder(): PredictedInstance.from_numpy( points=pts, point_confidences=confs, - instance_score=np.nanmean(score), + instance_score=np.nanmean(confs), skeleton=skeleton, track=track, + tracking_score=np.nanmean(score), ) ) @@ -3770,6 +3802,15 @@ def _object_builder(): try: for ex in generator: prediction_queue.put(ex) + + except KeyError as e: + # Gracefully handle seeking errors by early termination. + if "Unable to load frame" in str(e): + pass # TODO: Print warning obeying verbosity? (This code path is also + # called for interactive prediction where we don't want any spam.) + else: + raise + finally: prediction_queue.put(None) object_builder.join() @@ -4412,18 +4453,27 @@ def _object_builder(): break # Loop over frames. - for image, video_ind, frame_ind, points, confidences, scores in zip( + for ( + image, + video_ind, + frame_ind, + centroid_vals, + points, + confidences, + scores, + ) in zip( ex["image"], ex["video_ind"], ex["frame_ind"], + ex["centroid_vals"], ex["instance_peaks"], ex["instance_peak_vals"], ex["instance_scores"], ): # Loop over instances. predicted_instances = [] - for i, (pts, confs, score) in enumerate( - zip(points, confidences, scores) + for i, (pts, centroid_val, confs, score) in enumerate( + zip(points, centroid_vals, confidences, scores) ): if np.isnan(pts).all(): continue @@ -4434,9 +4484,10 @@ def _object_builder(): PredictedInstance.from_numpy( points=pts, point_confidences=confs, - instance_score=np.nanmean(score), + instance_score=centroid_val, skeleton=skeleton, track=track, + tracking_score=score, ) ) @@ -4457,6 +4508,15 @@ def _object_builder(): try: for ex in generator: prediction_queue.put(ex) + + except KeyError as e: + # Gracefully handle seeking errors by early termination. + if "Unable to load frame" in str(e): + pass # TODO: Print warning obeying verbosity? (This code path is also + # called for interactive prediction where we don't want any spam.) + else: + raise + finally: prediction_queue.put(None) object_builder.join() @@ -4734,6 +4794,15 @@ def _object_builder(): try: for ex in generator: prediction_queue.put(ex) + + except KeyError as e: + # Gracefully handle seeking errors by early termination. + if "Unable to load frame" in str(e): + pass # TODO: Print warning obeying verbosity? (This code path is also + # called for interactive prediction where we don't want any spam.) + else: + raise + finally: prediction_queue.put(None) object_builder.join() @@ -4939,7 +5008,7 @@ def export_cli(args: Optional[list] = None): export_model( args.models, args.export_path, - unrag_outputs=args.unrag, + unrag_outputs=(not args.ragged), max_instances=args.max_instances, ) @@ -4971,13 +5040,13 @@ def _make_export_cli_parser() -> argparse.ArgumentParser: ), ) parser.add_argument( - "-u", - "--unrag", + "-r", + "--ragged", action="store_true", - default=True, + default=False, help=( - "Convert ragged tensors into regular tensors with NaN padding. " - "Defaults to True." + "Keep tensors ragged if present. If ommited, convert ragged tensors" + " into regular tensors with NaN padding." ), ) parser.add_argument( @@ -5230,15 +5299,14 @@ def _make_provider_from_cli(args: argparse.Namespace) -> Tuple[Provider, str]: args: Parsed CLI namespace. Returns: - A tuple of `(provider, data_path)` with the data `Provider` and path to the data - that was specified in the args. + `(provider_list, data_path_list, output_path_list)` where `provider_list` contains the data providers, + `data_path_list` contains the paths to the specified data, and the `output_path_list` contains the list + of output paths if a CSV file with a column of output paths was provided; otherwise, `output_path_list` + defaults to None """ + # Figure out which input path to use. - labels_path = getattr(args, "labels", None) - if labels_path is not None: - data_path = labels_path - else: - data_path = args.data_path + data_path = args.data_path if data_path is None or data_path == "": raise ValueError( @@ -5246,33 +5314,117 @@ def _make_provider_from_cli(args: argparse.Namespace) -> Tuple[Provider, str]: "Run 'sleap-track -h' to see full command documentation." ) - if data_path.endswith(".slp"): - labels = sleap.load_file(data_path) - - if args.only_labeled_frames: - provider = LabelsReader.from_user_labeled_frames(labels) - elif args.only_suggested_frames: - provider = LabelsReader.from_unlabeled_suggestions(labels) - elif getattr(args, "video.index") != "": - provider = VideoReader( - video=labels.videos[int(getattr(args, "video.index"))], - example_indices=frame_list(args.frames), - ) + data_path_obj = Path(data_path) + + # Set output_path_list to None as a default to return later + output_path_list = None + + # Check that input value is valid + if not data_path_obj.exists(): + raise ValueError("Path to data_path does not exist") + + elif data_path_obj.is_file(): + # If the file is a CSV file, check for data_paths and output_paths + if data_path_obj.suffix.lower() == ".csv": + try: + data_path_column = None + # Read the CSV file + df = pd.read_csv(data_path) + + # collect data_paths from column + for col_index in range(df.shape[1]): + path_str = df.iloc[0, col_index] + if Path(path_str).exists(): + data_path_column = df.columns[col_index] + break + if data_path_column is None: + raise ValueError( + f"Column containing valid data_paths does not exist in the CSV file: {data_path}" + ) + raw_data_path_list = df[data_path_column].tolist() + + # optional output_path column to specify multiple output_paths + output_path_column_index = df.columns.get_loc(data_path_column) + 1 + if ( + output_path_column_index < df.shape[1] + and df.iloc[:, output_path_column_index].dtype == object + ): + # Ensure the next column exists + output_path_list = df.iloc[:, output_path_column_index].tolist() + else: + output_path_list = None + + except pd.errors.EmptyDataError as e: + raise ValueError(f"CSV file is empty: {data_path}. Error: {e}") from e + + # If the file is a text file, collect data_paths + elif data_path_obj.suffix.lower() == ".txt": + try: + with open(data_path_obj, "r") as file: + raw_data_path_list = [line.strip() for line in file.readlines()] + except Exception as e: + raise ValueError( + f"Error reading text file: {data_path}. Error: {e}" + ) from e else: - provider = LabelsReader(labels) + raw_data_path_list = [data_path_obj.as_posix()] - else: - print(f"Video: {data_path}") - # TODO: Clean this up. - video_kwargs = dict( - dataset=vars(args).get("video.dataset"), - input_format=vars(args).get("video.input_format"), - ) - provider = VideoReader.from_filepath( - filename=data_path, example_indices=frame_list(args.frames), **video_kwargs - ) + raw_data_path_list = [Path(p) for p in raw_data_path_list] - return provider, data_path + # Check for multiple video inputs + # Compile file(s) into a list for later iteration + elif data_path_obj.is_dir(): + raw_data_path_list = [ + file_path for file_path in data_path_obj.iterdir() if file_path.is_file() + ] + + # Provider list to accomodate multiple video inputs + provider_list = [] + data_path_list = [] + for file_path in raw_data_path_list: + # Create a provider for each file + if file_path.as_posix().endswith(".slp") and len(raw_data_path_list) > 1: + print(f"slp file skipped: {file_path.as_posix()}") + + elif file_path.as_posix().endswith(".slp"): + labels = sleap.load_file(file_path.as_posix()) + + if args.only_labeled_frames: + provider_list.append(LabelsReader.from_user_labeled_frames(labels)) + elif args.only_suggested_frames: + provider_list.append(LabelsReader.from_unlabeled_suggestions(labels)) + elif getattr(args, "video.index") != "": + provider_list.append( + VideoReader( + video=labels.videos[int(getattr(args, "video.index"))], + example_indices=frame_list(args.frames), + ) + ) + else: + provider_list.append(LabelsReader(labels)) + + data_path_list.append(file_path) + + else: + try: + video_kwargs = dict( + dataset=vars(args).get("video.dataset"), + input_format=vars(args).get("video.input_format"), + ) + provider_list.append( + VideoReader.from_filepath( + filename=file_path.as_posix(), + example_indices=frame_list(args.frames), + **video_kwargs, + ) + ) + print(f"Video: {file_path.as_posix()}") + data_path_list.append(file_path) + # TODO: Clean this up. + except Exception: + print(f"Error reading file: {file_path.as_posix()}") + + return provider_list, data_path_list, output_path_list def _make_predictor_from_cli(args: argparse.Namespace) -> Predictor: @@ -5367,8 +5519,6 @@ def main(args: Optional[list] = None): pprint(vars(args)) print() - output_path = args.output - # Setup devices. if args.cpu or not sleap.nn.system.is_gpu_system(): sleap.nn.system.use_cpu_only() @@ -5406,7 +5556,20 @@ def main(args: Optional[list] = None): print() # Setup data loader. - provider, data_path = _make_provider_from_cli(args) + provider_list, data_path_list, output_path_list = _make_provider_from_cli(args) + + output_path = None + + # if output_path has not been extracted from a csv file yet + if output_path_list is None and args.output is not None: + output_path = args.output + output_path_obj = Path(output_path) + + # check if output_path is valid before running inference + if Path(output_path).is_file() and len(data_path_list) > 1: + raise ValueError( + "output_path argument must be a directory if multiple video inputs are given" + ) # Setup tracker. tracker = _make_tracker_from_cli(args) @@ -5414,25 +5577,94 @@ def main(args: Optional[list] = None): if args.models is not None and "movenet" in args.models[0]: args.models = args.models[0] - # Either run inference (and tracking) or just run tracking + # Either run inference (and tracking) or just run tracking (if using an existing prediction where inference has already been run) if args.models is not None: - # Setup models. - predictor = _make_predictor_from_cli(args) - predictor.tracker = tracker - # Run inference! - labels_pr = predictor.predict(provider) + # Run inference on all files inputed + for i, (data_path, provider) in enumerate(zip(data_path_list, provider_list)): + # Setup models. + data_path_obj = Path(data_path) + predictor = _make_predictor_from_cli(args) + predictor.tracker = tracker - if output_path is None: - output_path = data_path + ".predictions.slp" + # Run inference! + labels_pr = predictor.predict(provider) - labels_pr.provenance["model_paths"] = predictor.model_paths - labels_pr.provenance["predictor"] = type(predictor).__name__ + # if output path was not provided, create an output path + if output_path is None: + # if output path was not provided, create an output path + if output_path_list: + output_path = output_path_list[i] + + else: + output_path = data_path_obj.with_suffix(".predictions.slp") + output_path_obj = Path(output_path) + + # if output_path was provided and multiple inputs were provided, create a directory to store outputs + elif len(data_path_list) > 1: + output_path_obj = Path(output_path) + output_path = ( + output_path_obj + / (data_path_obj.with_suffix(".predictions.slp")).name + ) + output_path_obj = Path(output_path) + # Create the containing directory if needed. + output_path_obj.parent.mkdir(exist_ok=True, parents=True) + + labels_pr.provenance["model_paths"] = predictor.model_paths + labels_pr.provenance["predictor"] = type(predictor).__name__ + + if args.no_empty_frames: + # Clear empty frames if specified. + labels_pr.remove_empty_frames() + + finish_timestamp = str(datetime.now()) + total_elapsed = time() - t0 + print("Finished inference at:", finish_timestamp) + print(f"Total runtime: {total_elapsed} secs") + print(f"Predicted frames: {len(labels_pr)}/{len(provider)}") + + # Add provenance metadata to predictions. + labels_pr.provenance["sleap_version"] = sleap.__version__ + labels_pr.provenance["platform"] = platform.platform() + labels_pr.provenance["command"] = " ".join(sys.argv) + labels_pr.provenance["data_path"] = data_path_obj.as_posix() + labels_pr.provenance["output_path"] = output_path_obj.as_posix() + labels_pr.provenance["total_elapsed"] = total_elapsed + labels_pr.provenance["start_timestamp"] = start_timestamp + labels_pr.provenance["finish_timestamp"] = finish_timestamp + + print("Provenance:") + pprint(labels_pr.provenance) + print() + + labels_pr.provenance["args"] = vars(args) + + # Save results. + try: + labels_pr.save(output_path) + except Exception: + print("WARNING: Provided output path invalid.") + fallback_path = data_path_obj.with_suffix(".predictions.slp") + labels_pr.save(fallback_path) + print("Saved output:", output_path) + + if args.open_in_gui: + subprocess.call(["sleap-label", output_path]) + + # Reset output_path for next iteration + output_path = args.output + + # running tracking on existing prediction file elif getattr(args, "tracking.tracker") is not None: + provider = provider_list[0] + data_path = data_path_list[0] + # Load predictions + data_path = args.data_path print("Loading predictions...") - labels_pr = sleap.load_file(args.data_path) + labels_pr = sleap.load_file(data_path) frames = sorted(labels_pr.labeled_frames, key=lambda lf: lf.frame_idx) print("Starting tracker...") @@ -5444,6 +5676,40 @@ def main(args: Optional[list] = None): if output_path is None: output_path = f"{data_path}.{tracker.get_name()}.slp" + if args.no_empty_frames: + # Clear empty frames if specified. + labels_pr.remove_empty_frames() + + finish_timestamp = str(datetime.now()) + total_elapsed = time() - t0 + print("Finished inference at:", finish_timestamp) + print(f"Total runtime: {total_elapsed} secs") + print(f"Predicted frames: {len(labels_pr)}/{len(provider)}") + + # Add provenance metadata to predictions. + labels_pr.provenance["sleap_version"] = sleap.__version__ + labels_pr.provenance["platform"] = platform.platform() + labels_pr.provenance["command"] = " ".join(sys.argv) + labels_pr.provenance["data_path"] = data_path + labels_pr.provenance["output_path"] = output_path + labels_pr.provenance["total_elapsed"] = total_elapsed + labels_pr.provenance["start_timestamp"] = start_timestamp + labels_pr.provenance["finish_timestamp"] = finish_timestamp + + print("Provenance:") + pprint(labels_pr.provenance) + print() + + labels_pr.provenance["args"] = vars(args) + + # Save results. + labels_pr.save(output_path) + + print("Saved output:", output_path) + + if args.open_in_gui: + subprocess.call(["sleap-label", output_path]) + else: raise ValueError( "Neither tracker type nor path to trained models specified. " @@ -5451,36 +5717,3 @@ def main(args: Optional[list] = None): "To retrack on predictions, must specify tracker. " "Use \"sleap-track --tracking.tracker ...' to specify tracker to use." ) - - if args.no_empty_frames: - # Clear empty frames if specified. - labels_pr.remove_empty_frames() - - finish_timestamp = str(datetime.now()) - total_elapsed = time() - t0 - print("Finished inference at:", finish_timestamp) - print(f"Total runtime: {total_elapsed} secs") - print(f"Predicted frames: {len(labels_pr)}/{len(provider)}") - - # Add provenance metadata to predictions. - labels_pr.provenance["sleap_version"] = sleap.__version__ - labels_pr.provenance["platform"] = platform.platform() - labels_pr.provenance["command"] = " ".join(sys.argv) - labels_pr.provenance["data_path"] = data_path - labels_pr.provenance["output_path"] = output_path - labels_pr.provenance["total_elapsed"] = total_elapsed - labels_pr.provenance["start_timestamp"] = start_timestamp - labels_pr.provenance["finish_timestamp"] = finish_timestamp - - print("Provenance:") - pprint(labels_pr.provenance) - print() - - labels_pr.provenance["args"] = vars(args) - - # Save results. - labels_pr.save(output_path) - print("Saved output:", output_path) - - if args.open_in_gui: - subprocess.call(["sleap-label", output_path]) diff --git a/sleap/nn/peak_finding.py b/sleap/nn/peak_finding.py index 84dca00ae..e1fb43a6e 100644 --- a/sleap/nn/peak_finding.py +++ b/sleap/nn/peak_finding.py @@ -221,7 +221,7 @@ def find_global_peaks_rough( channels = tf.cast(tf.shape(cms)[-1], tf.int64) total_peaks = tf.cast(tf.shape(argmax_cols)[0], tf.int64) sample_subs = tf.range(total_peaks, dtype=tf.int64) // channels - channel_subs = tf.range(total_peaks, dtype=tf.int64) % channels + channel_subs = tf.math.mod(tf.range(total_peaks, dtype=tf.int64), channels) # Gather subscripts. peak_subs = tf.stack([sample_subs, argmax_rows, argmax_cols, channel_subs], axis=1) diff --git a/sleap/nn/system.py b/sleap/nn/system.py index eeb3f3ca4..4cc3d1804 100644 --- a/sleap/nn/system.py +++ b/sleap/nn/system.py @@ -48,7 +48,17 @@ def get_current_gpu() -> tf.config.PhysicalDevice: def use_cpu_only(): """Hide GPUs from TensorFlow to ensure only the CPU is available.""" - tf.config.set_visible_devices([], "GPU") + try: + tf.config.set_visible_devices([], "GPU") + except RuntimeError as ex: + if ( + len(ex.args) > 0 + and ex.args[0] + == "Visible devices cannot be modified after being initialized" + ): + print( + "Failed to set visible GPU. Visible devices cannot be modified after being initialized." + ) def use_gpu(device_ind: int): @@ -58,7 +68,17 @@ def use_gpu(device_ind: int): device_ind: Index of the GPU within the list of system GPUs. """ gpus = get_all_gpus() - tf.config.set_visible_devices(gpus[device_ind], "GPU") + try: + tf.config.set_visible_devices(gpus[device_ind], "GPU") + except RuntimeError as ex: + if ( + len(ex.args) > 0 + and ex.args[0] + == "Visible devices cannot be modified after being initialized" + ): + print( + "Failed to set visible GPU. Visible devices cannot be modified after being initialized." + ) def use_first_gpu(): @@ -159,7 +179,7 @@ def summary(): for gpu in all_gpus: print(f" Device: {gpu.name}") print(f" Available: {gpu in gpus}") - print(f" Initalized: {is_initialized(gpu)}") + print(f" Initialized: {is_initialized(gpu)}") print( f" Memory growth: {tf.config.experimental.get_memory_growth(gpu)}" ) diff --git a/sleap/nn/tracker/components.py b/sleap/nn/tracker/components.py index 10b2953b7..0b77f4ac9 100644 --- a/sleap/nn/tracker/components.py +++ b/sleap/nn/tracker/components.py @@ -12,9 +12,11 @@ """ + import operator from collections import defaultdict -from typing import List, Tuple, Optional, TypeVar, Callable +import logging +from typing import List, Tuple, Union, Optional, TypeVar, Callable import attr import numpy as np @@ -23,9 +25,26 @@ from sleap import PredictedInstance, Instance, Track from sleap.nn import utils +logger = logging.getLogger(__name__) + InstanceType = TypeVar("InstanceType", Instance, PredictedInstance) +def normalized_instance_similarity( + ref_instance: InstanceType, query_instance: InstanceType, img_hw: Tuple[int] +) -> float: + """Computes similarity between instances with normalized keypoints.""" + + normalize_factors = np.array((img_hw[1], img_hw[0])) + ref_visible = ~(np.isnan(ref_instance.points_array).any(axis=1)) + normalized_query_keypoints = query_instance.points_array / normalize_factors + normalized_ref_keypoints = ref_instance.points_array / normalize_factors + dists = np.sum((normalized_query_keypoints - normalized_ref_keypoints) ** 2, axis=1) + similarity = np.nansum(np.exp(-dists)) / np.sum(ref_visible) + + return similarity + + def instance_similarity( ref_instance: InstanceType, query_instance: InstanceType ) -> float: @@ -40,6 +59,95 @@ def instance_similarity( return similarity +def factory_object_keypoint_similarity( + keypoint_errors: Optional[Union[List, int, float]] = None, + score_weighting: bool = False, + normalization_keypoints: str = "all", +) -> Callable: + """Factory for similarity function based on object keypoints. + + Args: + keypoint_errors: The standard error of the distance between the predicted + keypoint and the true value, in pixels. + If None or empty list, defaults to 1. + If a scalar or singleton list, every keypoint has the same error. + If a list, defines the error for each keypoint, the length should be equal + to the number of keypoints in the skeleton. + score_weighting: If True, use `score` of `PredictedPoint` to weigh + `keypoint_errors`. If False, do not add a weight to `keypoint_errors`. + normalization_keypoints: Determine how to normalize similarity score. One of + ["all", "ref", "union"]. If "all", similarity score is normalized by number + of reference points. If "ref", similarity score is normalized by number of + visible reference points. If "union", similarity score is normalized by + number of points both visible in query and reference instance. + Default is "all". + + Returns: + Callable that returns object keypoint similarity between two `Instance`s. + + """ + keypoint_errors = 1 if keypoint_errors is None else keypoint_errors + with np.errstate(divide="ignore"): + kp_precision = 1 / (2 * np.array(keypoint_errors) ** 2) + + def object_keypoint_similarity( + ref_instance: InstanceType, query_instance: InstanceType + ) -> float: + nonlocal kp_precision + # Keypoints + ref_points = ref_instance.points_array + query_points = query_instance.points_array + # Keypoint scores + if score_weighting: + ref_scores = getattr(ref_instance, "scores", np.ones(len(ref_points))) + query_scores = getattr(query_instance, "scores", np.ones(len(query_points))) + else: + ref_scores = 1 + query_scores = 1 + # Number of keypoint for normalization + if normalization_keypoints in ("ref", "union"): + ref_visible = ~(np.isnan(ref_points).any(axis=1)) + if normalization_keypoints == "ref": + max_n_keypoints = np.sum(ref_visible) + elif normalization_keypoints == "union": + query_visible = ~(np.isnan(query_points).any(axis=1)) + max_n_keypoints = np.sum(np.logical_and(ref_visible, query_visible)) + else: # if normalization_keypoints == "all": + max_n_keypoints = len(ref_points) + if max_n_keypoints == 0: + return 0 + + # Make sure the sizes of kp_precision and n_points match + if kp_precision.size > 1 and 2 * kp_precision.size != ref_points.size: + # Correct kp_precision size to fit number of points + n_points = ref_points.size // 2 + mess = ( + "keypoint_errors array should have the same size as the number of " + f"keypoints in the instance: {kp_precision.size} != {n_points}" + ) + + if kp_precision.size > n_points: + kp_precision = kp_precision[:n_points] + mess += "\nTruncating keypoint_errors array." + + else: # elif kp_precision.size < n_points: + pad = n_points - kp_precision.size + kp_precision = np.pad(kp_precision, (0, pad), "edge") + mess += "\nPadding keypoint_errors array by repeating the last value." + logger.warning(mess) + + # Compute distances + dists = np.sum((query_points - ref_points) ** 2, axis=1) * kp_precision + + similarity = ( + np.nansum(ref_scores * query_scores * np.exp(-dists)) / max_n_keypoints + ) + + return similarity + + return object_keypoint_similarity + + def centroid_distance( ref_instance: InstanceType, query_instance: InstanceType, cache: dict = dict() ) -> float: diff --git a/sleap/nn/tracking.py b/sleap/nn/tracking.py index 9865b7db5..558aa9309 100644 --- a/sleap/nn/tracking.py +++ b/sleap/nn/tracking.py @@ -5,12 +5,15 @@ import attr import numpy as np import cv2 +import functools from typing import Callable, Deque, Dict, Iterable, List, Optional, Tuple from sleap import Track, LabeledFrame, Skeleton from sleap.nn.tracker.components import ( + factory_object_keypoint_similarity, instance_similarity, + normalized_instance_similarity, centroid_distance, instance_iou, hungarian_matching, @@ -391,6 +394,7 @@ def get_ref_instances( def get_candidates( self, track_matching_queue_dict: Dict[Track, Deque[MatchedFrameInstance]], + max_tracking: bool, t: int, img: np.ndarray, *args, @@ -404,7 +408,7 @@ def get_candidates( tracks = [] for track, matched_items in track_matching_queue_dict.items(): - if len(tracks) <= self.max_tracks: + if not max_tracking or len(tracks) < self.max_tracks: tracks.append(track) for matched_item in matched_items: ref_t, ref_img = ( @@ -466,6 +470,7 @@ class SimpleMaxTracksCandidateMaker(SimpleCandidateMaker): def get_candidates( self, track_matching_queue_dict: Dict, + max_tracking: bool, *args, **kwargs, ) -> List[InstanceType]: @@ -473,7 +478,7 @@ def get_candidates( candidate_instances = [] tracks = [] for track, matched_instances in track_matching_queue_dict.items(): - if len(tracks) <= self.max_tracks: + if not max_tracking or len(tracks) < self.max_tracks: tracks.append(track) for ref_instance in matched_instances: if ref_instance.instance_t.n_visible_points >= self.min_points: @@ -492,6 +497,8 @@ def get_candidates( instance=instance_similarity, centroid=centroid_distance, iou=instance_iou, + normalized_instance=normalized_instance_similarity, + object_keypoint=factory_object_keypoint_similarity, ) match_policies = dict( @@ -598,8 +605,15 @@ def _init_matching_queue(self): """Factory for instantiating default matching queue with specified size.""" return deque(maxlen=self.track_window) + @property + def has_max_tracking(self) -> bool: + return isinstance( + self.candidate_maker, + (SimpleMaxTracksCandidateMaker, FlowMaxTracksCandidateMaker), + ) + def reset_candidates(self): - if self.max_tracking: + if self.has_max_tracking: for track in self.track_matching_queue_dict: self.track_matching_queue_dict[track] = deque(maxlen=self.track_window) else: @@ -610,14 +624,15 @@ def unique_tracks_in_queue(self) -> List[Track]: """Returns the unique tracks in the matching queue.""" unique_tracks = set() - for match_item in self.track_matching_queue: - for instance in match_item.instances_t: - unique_tracks.add(instance.track) - - if self.max_tracking: + if self.has_max_tracking: for track in self.track_matching_queue_dict.keys(): unique_tracks.add(track) + else: + for match_item in self.track_matching_queue: + for instance in match_item.instances_t: + unique_tracks.add(instance.track) + return list(unique_tracks) @property @@ -627,6 +642,7 @@ def uses_image(self): def track( self, untracked_instances: List[InstanceType], + img_hw: Tuple[int], img: Optional[np.ndarray] = None, t: int = None, ) -> List[InstanceType]: @@ -634,19 +650,25 @@ def track( Args: untracked_instances: List of instances to assign to tracks. + img_hw: (height, width) of the image used to normalize the keypoints. img: Image data of the current frame for flow shifting. t: Current timestep. If not provided, increments from the internal queue. Returns: A list of the instances that were tracked. """ + if self.similarity_function == normalized_instance_similarity: + factory_normalized_instance = functools.partial( + normalized_instance_similarity, img_hw=img_hw + ) + self.similarity_function = factory_normalized_instance if self.candidate_maker is None: return untracked_instances # Infer timestep if not provided. if t is None: - if self.max_tracking: + if self.has_max_tracking: if len(self.track_matching_queue_dict) > 0: # Default to last timestep + 1 if available. @@ -684,10 +706,10 @@ def track( self.pre_cull_function(untracked_instances) # Build a pool of matchable candidate instances. - if self.max_tracking: + if self.has_max_tracking: candidate_instances = self.candidate_maker.get_candidates( track_matching_queue_dict=self.track_matching_queue_dict, - max_tracks=self.max_tracks, + max_tracking=self.max_tracking, t=t, img=img, ) @@ -721,13 +743,16 @@ def track( ) # Add the tracked instances to the dictionary of matched instances. - if self.max_tracking: + if self.has_max_tracking: for tracked_instance in tracked_instances: if tracked_instance.track in self.track_matching_queue_dict: self.track_matching_queue_dict[tracked_instance.track].append( MatchedFrameInstance(t, tracked_instance, img) ) - elif len(self.track_matching_queue_dict) < self.max_tracks: + elif ( + not self.max_tracking + or len(self.track_matching_queue_dict) < self.max_tracks + ): self.track_matching_queue_dict[tracked_instance.track] = deque( maxlen=self.track_window ) @@ -773,7 +798,8 @@ def spawn_for_untracked_instances( # Skip if we've reached the maximum number of tracks. if ( - self.max_tracking + self.has_max_tracking + and self.max_tracking and len(self.track_matching_queue_dict) >= self.max_tracks ): break @@ -838,8 +864,17 @@ def make_tracker_by_name( # Max tracking options max_tracks: Optional[int] = None, max_tracking: bool = False, + # Object keypoint similarity options + oks_errors: Optional[list] = None, + oks_score_weighting: bool = False, + oks_normalization: str = "all", **kwargs, ) -> BaseTracker: + # Parse max_tracking arguments, only True if max_tracks is not None and > 0 + max_tracking = max_tracking if max_tracks else False + if max_tracking and tracker in ("simple", "flow"): + # Force a candidate maker of 'maxtracks' type + tracker += "maxtracks" if tracker.lower() == "none": candidate_maker = None @@ -858,7 +893,14 @@ def make_tracker_by_name( raise ValueError(f"{match} is not a valid tracker matching function.") candidate_maker = tracker_policies[tracker](min_points=min_match_points) - similarity_function = similarity_policies[similarity] + if similarity == "object_keypoint": + similarity_function = factory_object_keypoint_similarity( + keypoint_errors=oks_errors, + score_weighting=oks_score_weighting, + normalization_keypoints=oks_normalization, + ) + else: + similarity_function = similarity_policies[similarity] matching_function = match_policies[match] if tracker == "flow": @@ -931,7 +973,10 @@ def get_by_name_factory_options(cls): option = dict(name="max_tracking", default=False) option["type"] = bool - option["help"] = "If true then the tracker will cap the max number of tracks." + option["help"] = ( + "If true then the tracker will cap the max number of tracks. " + "Falls back to false if `max_tracks` is not defined or 0." + ) options.append(option) option = dict(name="max_tracks", default=None) @@ -1054,6 +1099,42 @@ def int_list_func(s): ] = "For Kalman filter: Number of frames to track with other tracker. 0 means no Kalman filters will be used." options.append(option) + def float_list_func(s): + return [float(x.strip()) for x in s.split(",")] if s else None + + option = dict(name="oks_errors", default="1") + option["type"] = float_list_func + option["help"] = ( + "For Object Keypoint similarity: the standard error of the distance " + "between the predicted keypoint and the true value, in pixels.\n" + "If None or empty list, defaults to 1. If a scalar or singleton list, " + "every keypoint has the same error. If a list, defines the error for each " + "keypoint, the length should be equal to the number of keypoints in the " + "skeleton." + ) + options.append(option) + + option = dict(name="oks_score_weighting", default="0") + option["type"] = int + option["help"] = ( + "For Object Keypoint similarity: if 0 (default), only the distance between the reference " + "and query keypoint is used to compute the similarity. If 1, each distance is weighted " + "by the prediction scores of the reference and query keypoint." + ) + options.append(option) + + option = dict(name="oks_normalization", default="all") + option["type"] = str + option["options"] = ["all", "ref", "union"] + option["help"] = ( + "For Object Keypoint similarity: Determine how to normalize similarity score. " + "If 'all', similarity score is normalized by number of reference points. " + "If 'ref', similarity score is normalized by number of visible reference points. " + "If 'union', similarity score is normalized by number of points both visible " + "in query and reference instance." + ) + options.append(option) + return options @classmethod @@ -1449,6 +1530,7 @@ def run_tracker(frames: List[LabeledFrame], tracker: BaseTracker) -> List[Labele track_args["img"] = lf.video[lf.frame_idx] else: track_args["img"] = None + track_args["img_hw"] = lf.image.shape[-3:-1] new_lf = LabeledFrame( frame_idx=lf.frame_idx, diff --git a/sleap/nn/training.py b/sleap/nn/training.py index 16f027175..c3692637c 100644 --- a/sleap/nn/training.py +++ b/sleap/nn/training.py @@ -508,7 +508,7 @@ def setup_visualization( callbacks = [] try: - matplotlib.use("Qt5Agg") + matplotlib.use("QtAgg") except ImportError: print( "Unable to use Qt backend for matplotlib. " @@ -946,7 +946,7 @@ def train(self): if self.config.outputs.save_outputs: if ( self.config.outputs.save_visualizations - and self.config.outputs.delete_viz_images + and not self.config.outputs.keep_viz_images ): self.cleanup() @@ -997,7 +997,7 @@ def cleanup(self): def package(self): """Package model folder into a zip file for portability.""" - if self.config.outputs.delete_viz_images: + if not self.config.outputs.keep_viz_images: self.cleanup() logger.info(f"Packaging results to: {self.run_path}.zip") shutil.make_archive( @@ -1864,6 +1864,14 @@ def create_trainer_using_cli(args: Optional[List] = None): "already specified in the training job config." ), ) + parser.add_argument( + "--keep_viz", + action="store_true", + help=( + "Keep prediction visualization images in the run folder after training when " + "--save_viz is enabled." + ), + ) parser.add_argument( "--zmq", action="store_true", @@ -1872,6 +1880,18 @@ def create_trainer_using_cli(args: Optional[List] = None): "job config." ), ) + parser.add_argument( + "--publish_port", + type=int, + default=9001, + help="Port to set up the publish address while using ZMQ, defaults to 9001.", + ) + parser.add_argument( + "--controller_port", + type=int, + default=9000, + help="Port to set up the controller address while using ZMQ, defaults to 9000.", + ) parser.add_argument( "--run_name", default="", @@ -1926,6 +1946,10 @@ def create_trainer_using_cli(args: Optional[List] = None): job_config.outputs.tensorboard.write_logs |= args.tensorboard job_config.outputs.zmq.publish_updates |= args.zmq job_config.outputs.zmq.subscribe_to_controller |= args.zmq + job_config.outputs.zmq.controller_address = "tcp://127.0.0.1:" + str( + args.controller_port + ) + job_config.outputs.zmq.publish_address = "tcp://127.0.0.1:" + str(args.publish_port) if args.run_name != "": job_config.outputs.run_name = args.run_name if args.prefix != "": @@ -1933,6 +1957,7 @@ def create_trainer_using_cli(args: Optional[List] = None): if args.suffix != "": job_config.outputs.run_name_suffix = args.suffix job_config.outputs.save_visualizations |= args.save_viz + job_config.outputs.keep_viz_images = args.keep_viz if args.labels_path == "": args.labels_path = None args.video_paths = args.video_paths.split(",") diff --git a/sleap/prefs.py b/sleap/prefs.py index 3d5a2113e..e043afc44 100644 --- a/sleap/prefs.py +++ b/sleap/prefs.py @@ -28,6 +28,8 @@ class Preferences(object): "node label size": 12, "show non-visible nodes": True, "share usage data": True, + "node marker sizes": (1, 2, 3, 4, 6, 8, 12), + "node label sizes": (6, 9, 12, 18, 24, 36), } _filename = "preferences.yaml" @@ -43,10 +45,14 @@ def load_(self): """Load preferences from file (regardless of whether loaded already).""" try: self._prefs = util.get_config_yaml(self._filename) - if not hasattr(self._prefs, "get"): - self._prefs = self._defaults except FileNotFoundError: - self._prefs = self._defaults + pass + + self._prefs = self._prefs or {} + + for k, v in self._defaults.items(): + if k not in self._prefs: + self._prefs[k] = v def save(self): """Save preferences to file.""" diff --git a/sleap/skeleton.py b/sleap/skeleton.py index eca393b8e..fbd1b909c 100644 --- a/sleap/skeleton.py +++ b/sleap/skeleton.py @@ -6,24 +6,24 @@ their connection to each other, and needed meta-data. """ -import attr -import cattr -import numpy as np -import jsonpickle -import json -import h5py +import base64 import copy - +import json import operator from enum import Enum +from io import BytesIO from itertools import count -from typing import Any, Dict, Iterable, List, Optional, Tuple, Union, Text +from typing import Any, Dict, Iterable, List, Optional, Text, Tuple, Union +import attr +import cattr +import h5py import networkx as nx +import numpy as np from networkx.readwrite import json_graph +from PIL import Image from scipy.io import loadmat - NodeRef = Union[str, "Node"] H5FileRef = Union[str, h5py.File] @@ -85,6 +85,502 @@ def matches(self, other: "Node") -> bool: return other.name == self.name and other.weight == self.weight +class SkeletonDecoder: + """Replace jsonpickle.decode with our own decoder. + + This function will decode the following from jsonpickle's encoded format: + + `Node` objects from + { + "py/object": "sleap.skeleton.Node", + "py/state": { "py/tuple": ["thorax1", 1.0] } + } + to `Node(name="thorax1", weight=1.0)` + + `EdgeType` objects from + { + "py/reduce": [ + { "py/type": "sleap.skeleton.EdgeType" }, + { "py/tuple": [1] } + ] + } + to `EdgeType(1)` + + `bytes` from + { + "py/b64": "aVZC..." + } + to `b"iVBO..."` + + and any repeated objects from + { + "py/id": 1 + } + to the object with the same reconstruction id (from top to bottom). + """ + + def __init__(self): + self.decoded_objects: List[Union[Node, EdgeType]] = [] + + def _decode_id(self, id: int) -> Union[Node, EdgeType]: + """Decode the object with the given `py/id` value of `id`. + + Args: + id: The `py/id` value to decode (1-indexed). + objects: The dictionary of objects that have already been decoded. + + Returns: + The object with the given `py/id` value. + """ + return self.decoded_objects[id - 1] + + @staticmethod + def _decode_state(state: dict) -> Node: + """Reconstruct the `Node` object from 'py/state' key in the serialized nx_graph. + + We support states in either dictionary or tuple format: + { + "py/state": { "py/tuple": ["thorax1", 1.0] } + } + or + { + "py/state": {"name": "thorax1", "weight": 1.0} + } + + Args: + state: The state to decode, i.e. state = dict["py/state"] + + Returns: + The `Node` object reconstructed from the state. + """ + + if "py/tuple" in state: + return Node(*state["py/tuple"]) + + return Node(**state) + + @staticmethod + def _decode_object_dict(object_dict) -> Node: + """Decode dict containing `py/object` key in the serialized nx_graph. + + Args: + object_dict: The dict to decode, i.e. + object_dict = {"py/object": ..., "py/state":...} + + Raises: + ValueError: If object_dict does not have 'py/object' and 'py/state' keys. + ValueError: If object_dict['py/object'] is not 'sleap.skeleton.Node'. + + Returns: + The decoded `Node` object. + """ + + if object_dict["py/object"] != "sleap.skeleton.Node": + raise ValueError("Only 'sleap.skeleton.Node' objects are supported.") + + node: Node = SkeletonDecoder._decode_state(state=object_dict["py/state"]) + return node + + def _decode_node(self, encoded_node: dict) -> Node: + """Decode an item believed to be an encoded `Node` object. + + Also updates the list of decoded objects. + + Args: + encoded_node: The encoded node to decode. + + Returns: + The decoded node and the updated list of decoded objects. + """ + + if isinstance(encoded_node, int): + # Using index mapping to replace the object (load from Labels) + return encoded_node + elif "py/object" in encoded_node: + decoded_node: Node = SkeletonDecoder._decode_object_dict(encoded_node) + self.decoded_objects.append(decoded_node) + elif "py/id" in encoded_node: + decoded_node: Node = self._decode_id(encoded_node["py/id"]) + + return decoded_node + + def _decode_nodes(self, encoded_nodes: List[dict]) -> List[Dict[str, Node]]: + """Decode the 'nodes' key in the serialized nx_graph. + + The encoded_nodes is a list of dictionary of two types: + - A dictionary with 'py/object' and 'py/state' keys. + - A dictionary with 'py/id' key. + + Args: + encoded_nodes: The list of encoded nodes to decode. + + Returns: + The decoded nodes. + """ + + decoded_nodes: List[Dict[str, Node]] = [] + for e_node_dict in encoded_nodes: + e_node = e_node_dict["id"] + d_node = self._decode_node(e_node) + decoded_nodes.append({"id": d_node}) + + return decoded_nodes + + def _decode_reduce_dict(self, reduce_dict: Dict[str, List[dict]]) -> EdgeType: + """Decode the 'reduce' key in the serialized nx_graph. + + The reduce_dict is a dictionary in the following format: + { + "py/reduce": [ + { "py/type": "sleap.skeleton.EdgeType" }, + { "py/tuple": [1] } + ] + } + + Args: + reduce_dict: The dictionary to decode i.e. reduce_dict = {"py/reduce": ...} + + Returns: + The decoded `EdgeType` object. + """ + + reduce_list = reduce_dict["py/reduce"] + has_py_type = has_py_tuple = False + for reduce_item in reduce_list: + if reduce_item is None: + # Sometimes the reduce list has None values, skip them + continue + if ( + "py/type" in reduce_item + and reduce_item["py/type"] == "sleap.skeleton.EdgeType" + ): + has_py_type = True + elif "py/tuple" in reduce_item: + edge_type: int = reduce_item["py/tuple"][0] + has_py_tuple = True + + if not has_py_type or not has_py_tuple: + raise ValueError( + "Only 'sleap.skeleton.EdgeType' objects are supported. " + "The 'py/reduce' list must have dictionaries with 'py/type' and " + "'py/tuple' keys." + f"\n\tHas py/type: {has_py_type}\n\tHas py/tuple: {has_py_tuple}" + ) + + edge = EdgeType(edge_type) + self.decoded_objects.append(edge) + + return edge + + def _decode_edge_type(self, encoded_edge_type: dict) -> EdgeType: + """Decode the 'type' key in the serialized nx_graph. + + Args: + encoded_edge_type: a dictionary with either 'py/id' or 'py/reduce' key. + + Returns: + The decoded `EdgeType` object. + """ + + if "py/reduce" in encoded_edge_type: + edge_type = self._decode_reduce_dict(encoded_edge_type) + else: + # Expect a "py/id" instead of "py/reduce" + edge_type = self._decode_id(encoded_edge_type["py/id"]) + return edge_type + + def _decode_links( + self, links: List[dict] + ) -> List[Dict[str, Union[int, Node, EdgeType]]]: + """Decode the 'links' key in the serialized nx_graph. + + The links are the edges in the graph and will have the following keys: + - source: The source node of the edge. + - target: The destination node of the edge. + - type: The type of the edge (e.g. BODY, SYMMETRY). + and more. + + Args: + encoded_links: The list of encoded links to decode. + """ + + for link in links: + for key, value in link.items(): + if key == "source": + link[key] = self._decode_node(value) + elif key == "target": + link[key] = self._decode_node(value) + elif key == "type": + link[key] = self._decode_edge_type(value) + + return links + + @staticmethod + def decode_preview_image( + img_b64: bytes, return_bytes: bool = False + ) -> Union[Image.Image, bytes]: + """Decode a skeleton preview image byte string representation to a `PIL.Image` + + Args: + img_b64: a byte string representation of a skeleton preview image + return_bytes: whether to return the decoded image as bytes + + Returns: + Either a PIL.Image of the skeleton preview image or the decoded image as bytes + (if `return_bytes` is True). + """ + bytes = base64.b64decode(img_b64) + if return_bytes: + return bytes + + buffer = BytesIO(bytes) + img = Image.open(buffer) + return img + + def _decode(self, json_str: str): + dicts = json.loads(json_str) + + # Enforce same format across template and non-template skeletons + if "nx_graph" not in dicts: + # Non-template skeletons use the dicts as the "nx_graph" + dicts = {"nx_graph": dicts} + + # Decode the graph + nx_graph = dicts["nx_graph"] + + self.decoded_objects = [] # Reset the decoded objects incase reusing decoder + for key, value in nx_graph.items(): + if key == "nodes": + nx_graph[key] = self._decode_nodes(value) + elif key == "links": + nx_graph[key] = self._decode_links(value) + + # Decode the preview image (if it exists) + preview_image = dicts.get("preview_image", None) + if preview_image is not None: + dicts["preview_image"] = SkeletonDecoder.decode_preview_image( + preview_image["py/b64"], return_bytes=True + ) + + return dicts + + @classmethod + def decode(cls, json_str: str) -> Dict: + """Decode the given json string into a dictionary. + + Returns: + A dict with `Node`s, `EdgeType`s, and `bytes` decoded/reconstructed. + """ + decoder = cls() + return decoder._decode(json_str) + + +class SkeletonEncoder: + """Replace jsonpickle.encode with our own encoder. + + The input is a dictionary containing python objects that need to be encoded as + JSON strings. The output is a JSON string that represents the input dictionary. + + `Node(name='neck', weight=1.0)` => + { + "py/object": "sleap.Skeleton.Node", + "py/state": {"py/tuple" ["neck", 1.0]} + } + + `` => + {"py/reduce": [ + {"py/type": "sleap.Skeleton.EdgeType"}, + {"py/tuple": [1] } + ] + }` + + Where `name` and `weight` are the attributes of the `Node` class; weight is always 1.0. + `EdgeType` is an enum with values `BODY = 1` and `SYMMETRY = 2`. + + See sleap.skeleton.Node and sleap.skeleton.EdgeType. + + If the object has been "seen" before, it will not be encoded as the full JSON string + but referenced by its `py/id`, which starts at 1 and indexes the objects in the + order they are seen so that the second time the first object is used, it will be + referenced as `{"py/id": 1}`. + """ + + def __init__(self): + """Initializes a SkeletonEncoder instance.""" + # Maps object id to py/id + self._encoded_objects: Dict[int, int] = {} + + @classmethod + def encode(cls, data: Dict[str, Any]) -> str: + """Encodes the input dictionary as a JSON string. + + Args: + data: The data to encode. + + Returns: + json_str: The JSON string representation of the data. + """ + + # This is required for backwards compatibility with SLEAP <=1.3.4 + sorted_data = cls._recursively_sort_dict(data) + + encoder = cls() + encoded_data = encoder._encode(sorted_data) + json_str = json.dumps(encoded_data) + return json_str + + @staticmethod + def _recursively_sort_dict(dictionary: Dict[str, Any]) -> Dict[str, Any]: + """Recursively sorts the dictionary by keys.""" + sorted_dict = dict(sorted(dictionary.items())) + for key, value in sorted_dict.items(): + if isinstance(value, dict): + sorted_dict[key] = SkeletonEncoder._recursively_sort_dict(value) + elif isinstance(value, list): + for i, item in enumerate(value): + if isinstance(item, dict): + sorted_dict[key][i] = SkeletonEncoder._recursively_sort_dict( + item + ) + return sorted_dict + + def _encode(self, obj: Any) -> Any: + """Recursively encodes the input object. + + Args: + obj: The object to encode. Can be a dictionary, list, Node, EdgeType or + primitive data type. + + Returns: + The encoded object as a dictionary. + """ + if isinstance(obj, dict): + encoded_obj = {} + for key, value in obj.items(): + if key == "links": + encoded_obj[key] = self._encode_links(value) + else: + encoded_obj[key] = self._encode(value) + return encoded_obj + elif isinstance(obj, list): + return [self._encode(v) for v in obj] + elif isinstance(obj, EdgeType): + return self._encode_edge_type(obj) + elif isinstance(obj, Node): + return self._encode_node(obj) + else: + return obj # Primitive data types + + def _encode_links(self, links: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Encodes the list of links (edges) in the skeleton graph. + + Args: + links: A list of dictionaries, each representing an edge in the graph. + + Returns: + A list of encoded edge dictionaries with keys ordered as specified. + """ + encoded_links = [] + for link in links: + # Use a regular dict (insertion order preserved in Python 3.7+) + encoded_link = {} + + for key, value in link.items(): + if key in ("source", "target"): + encoded_link[key] = self._encode_node(value) + elif key == "type": + encoded_link[key] = self._encode_edge_type(value) + else: + encoded_link[key] = self._encode(value) + encoded_links.append(encoded_link) + + return encoded_links + + def _encode_node(self, node: Union["Node", int]) -> Dict[str, Any]: + """Encodes a Node object. + + Args: + node: The Node object to encode or integer index. The latter requires that + the class has the `idx_to_node` attribute set. + + Returns: + The encoded `Node` object as a dictionary. + """ + if isinstance(node, int): + # We sometimes have the node object already replaced by its index (when + # `node_to_idx` is provided). In this case, the node is already encoded. + return node + + # Check if object has been encoded before + first_encoding = self._is_first_encoding(node) + py_id = self._get_or_assign_id(node, first_encoding) + if first_encoding: + # Full encoding + return { + "py/object": "sleap.skeleton.Node", + "py/state": {"py/tuple": [node.name, node.weight]}, + } + else: + # Reference by py/id + return {"py/id": py_id} + + def _encode_edge_type(self, edge_type: "EdgeType") -> Dict[str, Any]: + """Encodes an EdgeType object. + + Args: + edge_type: The EdgeType object to encode. Either `EdgeType.BODY` or + `EdgeType.SYMMETRY` enum with values 1 and 2 respectively. + + Returns: + The encoded EdgeType object as a dictionary. + """ + # Check if object has been encoded before + first_encoding = self._is_first_encoding(edge_type) + py_id = self._get_or_assign_id(edge_type, first_encoding) + if first_encoding: + # Full encoding + return { + "py/reduce": [ + {"py/type": "sleap.skeleton.EdgeType"}, + {"py/tuple": [edge_type.value]}, + ] + } + else: + # Reference by py/id + return {"py/id": py_id} + + def _get_or_assign_id(self, obj: Any, first_encoding: bool) -> int: + """Gets or assigns a py/id for the object. + + Args: + The object to get or assign a py/id for. + + Returns: + The py/id assigned to the object. + """ + # Object id is unique for each object in the current session + obj_id = id(obj) + # Assign a py/id to the object if it hasn't been assigned one yet + if first_encoding: + py_id = len(self._encoded_objects) + 1 # py/id starts at 1 + # Assign the py/id to the object and store it in _encoded_objects + self._encoded_objects[obj_id] = py_id + return self._encoded_objects[obj_id] + + def _is_first_encoding(self, obj: Any) -> bool: + """Checks if the object is being encoded for the first time. + + Args: + obj: The object to check. + + Returns: + True if this is the first encoding of the object, False otherwise. + """ + obj_id = id(obj) + first_time = obj_id not in self._encoded_objects + return first_time + + class Skeleton: """The main object for representing animal skeletons. @@ -937,7 +1433,7 @@ def to_dict(obj: "Skeleton", node_to_idx: Optional[Dict[Node, int]] = None) -> D # This is a weird hack to serialize the whole _graph into a dict. # I use the underlying to_json and parse it. - return json.loads(obj.to_json(node_to_idx)) + return json.loads(obj.to_json(node_to_idx=node_to_idx)) @classmethod def from_dict(cls, d: Dict, node_to_idx: Dict[Node, int] = None) -> "Skeleton": @@ -999,12 +1495,12 @@ def to_json(self, node_to_idx: Optional[Dict[Node, int]] = None) -> str: Returns: A string containing the JSON representation of the skeleton. """ - jsonpickle.set_encoder_options("simplejson", sort_keys=True, indent=4) + if node_to_idx is not None: - indexed_node_graph = nx.relabel_nodes( - G=self._graph, mapping=node_to_idx - ) # map nodes to int + # Map Nodes to int + indexed_node_graph = nx.relabel_nodes(G=self._graph, mapping=node_to_idx) else: + # Keep graph nodes as Node objects indexed_node_graph = self._graph # Encode to JSON @@ -1023,7 +1519,7 @@ def to_json(self, node_to_idx: Optional[Dict[Node, int]] = None) -> str: else: data = graph - json_str = jsonpickle.encode(data) + json_str = SkeletonEncoder.encode(data) return json_str @@ -1071,7 +1567,7 @@ def from_json( Returns: An instance of the `Skeleton` object decoded from the JSON. """ - dicts = jsonpickle.decode(json_str) + dicts: dict = SkeletonDecoder.decode(json_str) nx_graph = dicts.get("nx_graph", dicts) graph = json_graph.node_link_graph(nx_graph) diff --git a/sleap/training_profiles/baseline.centroid.json b/sleap/training_profiles/baseline.centroid.json index 933989ecf..3a54db25c 100755 --- a/sleap/training_profiles/baseline.centroid.json +++ b/sleap/training_profiles/baseline.centroid.json @@ -116,6 +116,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": true, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/sleap/training_profiles/baseline_large_rf.bottomup.json b/sleap/training_profiles/baseline_large_rf.bottomup.json index ea45c9b25..18fb3104f 100644 --- a/sleap/training_profiles/baseline_large_rf.bottomup.json +++ b/sleap/training_profiles/baseline_large_rf.bottomup.json @@ -125,6 +125,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": true, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/sleap/training_profiles/baseline_large_rf.single.json b/sleap/training_profiles/baseline_large_rf.single.json index 75e97b1a6..3feeccd69 100644 --- a/sleap/training_profiles/baseline_large_rf.single.json +++ b/sleap/training_profiles/baseline_large_rf.single.json @@ -116,6 +116,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": true, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/sleap/training_profiles/baseline_large_rf.topdown.json b/sleap/training_profiles/baseline_large_rf.topdown.json index 9b17f6832..38e96594b 100644 --- a/sleap/training_profiles/baseline_large_rf.topdown.json +++ b/sleap/training_profiles/baseline_large_rf.topdown.json @@ -117,6 +117,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": true, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/sleap/training_profiles/baseline_medium_rf.bottomup.json b/sleap/training_profiles/baseline_medium_rf.bottomup.json index 1cc35330a..61b08515c 100644 --- a/sleap/training_profiles/baseline_medium_rf.bottomup.json +++ b/sleap/training_profiles/baseline_medium_rf.bottomup.json @@ -125,6 +125,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": true, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/sleap/training_profiles/baseline_medium_rf.single.json b/sleap/training_profiles/baseline_medium_rf.single.json index 579f6c8c3..0951bc761 100644 --- a/sleap/training_profiles/baseline_medium_rf.single.json +++ b/sleap/training_profiles/baseline_medium_rf.single.json @@ -116,6 +116,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": true, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/sleap/training_profiles/baseline_medium_rf.topdown.json b/sleap/training_profiles/baseline_medium_rf.topdown.json index 9e3a0bde5..9eccb76c1 100755 --- a/sleap/training_profiles/baseline_medium_rf.topdown.json +++ b/sleap/training_profiles/baseline_medium_rf.topdown.json @@ -117,6 +117,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": true, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/sleap/training_profiles/pretrained.bottomup.json b/sleap/training_profiles/pretrained.bottomup.json index 3e4f3935f..57b7398b5 100644 --- a/sleap/training_profiles/pretrained.bottomup.json +++ b/sleap/training_profiles/pretrained.bottomup.json @@ -122,6 +122,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": true, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/sleap/training_profiles/pretrained.centroid.json b/sleap/training_profiles/pretrained.centroid.json index a5df5e48a..74c43d3e2 100644 --- a/sleap/training_profiles/pretrained.centroid.json +++ b/sleap/training_profiles/pretrained.centroid.json @@ -113,6 +113,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": true, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/sleap/training_profiles/pretrained.single.json b/sleap/training_profiles/pretrained.single.json index 7ca907007..615f0de4d 100644 --- a/sleap/training_profiles/pretrained.single.json +++ b/sleap/training_profiles/pretrained.single.json @@ -113,6 +113,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": true, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/sleap/training_profiles/pretrained.topdown.json b/sleap/training_profiles/pretrained.topdown.json index aeeaebbd8..be0d97de8 100644 --- a/sleap/training_profiles/pretrained.topdown.json +++ b/sleap/training_profiles/pretrained.topdown.json @@ -114,6 +114,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": true, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/sleap/util.py b/sleap/util.py index 5edbf164b..bc3389b7d 100644 --- a/sleap/util.py +++ b/sleap/util.py @@ -3,13 +3,11 @@ Try not to put things in here unless they really have no other place. """ -import base64 import json import os import re import shutil from collections import defaultdict -from io import BytesIO from pathlib import Path from typing import Any, Dict, Hashable, Iterable, List, Optional from urllib.parse import unquote, urlparse @@ -26,7 +24,6 @@ from importlib.resources import files # New in 3.9+ except ImportError: from importlib_resources import files # TODO(LM): Upgrade to importlib.resources. -from PIL import Image import sleap.version as sleap_version @@ -270,30 +267,20 @@ def get_config_file( The full path to the specified config file. """ - desired_path = None # Handle case where get_defaults, but cannot find package_path + desired_path = Path.home() / f".sleap/{sleap_version.__version__}/{shortname}" - if not get_defaults: - desired_path = os.path.expanduser( - f"~/.sleap/{sleap_version.__version__}/{shortname}" - ) + # Make sure there's a ~/.sleap// directory to store user version of the config file. + desired_path.parent.mkdir(parents=True, exist_ok=True) - # Make sure there's a ~/.sleap// directory to store user version of the - # config file. - try: - os.makedirs(os.path.expanduser(f"~/.sleap/{sleap_version.__version__}")) - except FileExistsError: - pass - - # If we don't care whether the file exists, just return the path - if ignore_file_not_found: - return desired_path - - # If we do care whether the file exists, check the package version of the - # config file if we can't find the user version. + # If we don't care whether the file exists, just return the path + if ignore_file_not_found: + return desired_path - if get_defaults or not os.path.exists(desired_path): + # If we do care whether the file exists, check the package version of the config file if we can't find the user version. + if get_defaults or not desired_path.exists(): package_path = get_package_file(f"config/{shortname}") - if not os.path.exists(package_path): + package_path = Path(package_path) + if not package_path.exists(): raise FileNotFoundError( f"Cannot locate {shortname} config file at {desired_path} or {package_path}." ) @@ -384,18 +371,3 @@ def find_files_by_suffix( def parse_uri_path(uri: str) -> str: """Parse a URI starting with 'file:///' to a posix path.""" return Path(url2pathname(urlparse(unquote(uri)).path)).as_posix() - - -def decode_preview_image(img_b64: bytes) -> Image: - """Decode a skeleton preview image byte string representation to a `PIL.Image` - - Args: - img_b64: a byte string representation of a skeleton preview image - - Returns: - A PIL.Image of the skeleton preview - """ - bytes = base64.b64decode(img_b64) - buffer = BytesIO(bytes) - img = Image.open(buffer) - return img diff --git a/sleap/version.py b/sleap/version.py index 437e17fba..698710132 100644 --- a/sleap/version.py +++ b/sleap/version.py @@ -11,8 +11,7 @@ Must be a semver string, "aN" should be appended for alpha releases. """ - -__version__ = "1.3.3" +__version__ = "1.4.1" def versions(): diff --git a/tests/data/dlc/labeled-data/video/CollectedData_LM.csv b/tests/data/dlc/labeled-data/video/CollectedData_LM.csv index f57b667f4..27c86f8af 100644 --- a/tests/data/dlc/labeled-data/video/CollectedData_LM.csv +++ b/tests/data/dlc/labeled-data/video/CollectedData_LM.csv @@ -1,8 +1,8 @@ -scorer,,,LM,LM,LM,LM,LM,LM,LM,LM,LM,LM,LM,LM -individuals,,,individual1,individual1,individual1,individual1,individual1,individual1,individual2,individual2,individual2,individual2,individual2,individual2 -bodyparts,,,A,A,B,B,C,C,A,A,B,B,C,C -coords,,,x,y,x,y,x,y,x,y,x,y,x,y -labeled-data,video,img000.png,0,1,2,3,4,5,6,7,8,9,10,11 -labeled-data,video,img001.png,12,13,,,15,16,17,18,,,20,21 +scorer,,,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer +individuals,,,Animal1,Animal1,Animal1,Animal1,Animal1,Animal1,Animal2,Animal2,Animal2,Animal2,Animal2,Animal2,single,single,single,single +bodyparts,,,A,A,B,B,C,C,A,A,B,B,C,C,D,D,E,E +coords,,,x,y,x,y,x,y,x,y,x,y,x,y,x,y,x,y +labeled-data,video,img000.png,0,1,2,3,4,5,6,7,8,9,10,11,,,, +labeled-data,video,img001.png,12,13,,,15,16,17,18,,,20,21,22,23,24,25 labeled-data,video,img002.png,,,,,,,,,,,, -labeled-data,video,img003.png,22,23,24,25,26,27,,,,,, +labeled-data,video,img003.png,26,27,28,29,30,31,,,,,,,32,33,34,35 diff --git a/tests/data/dlc/labeled-data/video/maudlc_testdata.csv b/tests/data/dlc/labeled-data/video/maudlc_testdata.csv new file mode 100644 index 000000000..4e3e3c28c --- /dev/null +++ b/tests/data/dlc/labeled-data/video/maudlc_testdata.csv @@ -0,0 +1,8 @@ +scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer +individuals,Animal1,Animal1,Animal1,Animal1,Animal1,Animal1,Animal2,Animal2,Animal2,Animal2,Animal2,Animal2,single,single,single,single +bodyparts,A,A,B,B,C,C,A,A,B,B,C,C,D,D,E,E +coords,x,y,x,y,x,y,x,y,x,y,x,y,x,y,x,y +labeled-data/video/img000.png,0,1,2,3,4,5,6,7,8,9,10,11,,,, +labeled-data/video/img001.png,12,13,,,15,16,17,18,,,20,21,22,23,24,25 +labeled-data/video/img002.png,,,,,,,,,,,, +labeled-data/video/img003.png,26,27,28,29,30,31,,,,,,,32,33,34,35 diff --git a/tests/data/dlc/labeled-data/video/maudlc_testdata_v2.csv b/tests/data/dlc/labeled-data/video/maudlc_testdata_v2.csv new file mode 100644 index 000000000..27c86f8af --- /dev/null +++ b/tests/data/dlc/labeled-data/video/maudlc_testdata_v2.csv @@ -0,0 +1,8 @@ +scorer,,,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer,Scorer +individuals,,,Animal1,Animal1,Animal1,Animal1,Animal1,Animal1,Animal2,Animal2,Animal2,Animal2,Animal2,Animal2,single,single,single,single +bodyparts,,,A,A,B,B,C,C,A,A,B,B,C,C,D,D,E,E +coords,,,x,y,x,y,x,y,x,y,x,y,x,y,x,y,x,y +labeled-data,video,img000.png,0,1,2,3,4,5,6,7,8,9,10,11,,,, +labeled-data,video,img001.png,12,13,,,15,16,17,18,,,20,21,22,23,24,25 +labeled-data,video,img002.png,,,,,,,,,,,, +labeled-data,video,img003.png,26,27,28,29,30,31,,,,,,,32,33,34,35 diff --git a/tests/data/dlc/madlc_230_config.yaml b/tests/data/dlc/madlc_230_config.yaml index ae2cbb44b..01e1d32c1 100644 --- a/tests/data/dlc/madlc_230_config.yaml +++ b/tests/data/dlc/madlc_230_config.yaml @@ -1,12 +1,12 @@ # Project definitions (do not edit) -Task: madlc_2.3.0 +Task: maudlc_2.3.0 scorer: LM date: Mar1 multianimalproject: true identity: false # Project path (change when moving around) -project_path: D:\social-leap-estimates-animal-poses\pull-requests\sleap\tests\data\dlc\madlc_testdata_v3 +project_path: D:\social-leap-estimates-animal-poses\pull-requests\sleap\tests\data\dlc\maudlc_testdata_v3 # Annotation data set configuration (and individual video cropping parameters) video_sets: @@ -16,7 +16,9 @@ individuals: - individual1 - individual2 - individual3 -uniquebodyparts: [] +uniquebodyparts: +- D +- E multianimalbodyparts: - A - B diff --git a/tests/data/hdf5_format_v1/small_robot.000_small_robot_3_frame.analysis.h5 b/tests/data/hdf5_format_v1/small_robot.000_small_robot_3_frame.analysis.h5 new file mode 100644 index 0000000000000000000000000000000000000000..d2cec1d1b35d682f2eca7c0088c203145ccfd339 GIT binary patch literal 26408 zcmeI4UuYaf7{F(5ljck+NlvV_ial$Qd$5hrSkm|}+N6n4Y+8f(QZJjky)^W4yWXy8 zq-YvSApsu_5zIrY6?`a&w7%3l_Msq1m4FYGL{SP7pQ=*Syv3RM=G*J-X73Vhn!EB% zxt*Es`(|$Tx3ib;o9)hYU-yCg8@Dwwk)lyn$690&56dbghCzaL)DNnHrt&G3Ux+|K zW8xoUTa~<3_3yVEIMUO@ScGB!n5$;&={hK3KhXnFKaU2K-_r#8dS2=mCpf4k+MGJu z+4TCJw#GKe`3nceGnj#zhBLnO`*p4tn%4M>EGku9ti6e~FY91e&!s{ii9}=vQPrQW z`DZ-X$5b!ExassQld*A7Y|!zwR}Xb-te$n?_m}=$pw!C~sx-C96RlXVmGsiS71f@w zph{DRJ<*B<{GAxDUs1i`!bO&37I*MCnqOAH5w4Y7&%+_{Fu<%QqAoLIQo zUg*$Ip4fJ_ne~6M_vZL~DxKzTzlq|pmcNMm)_^uqnXI>kMI+iIl>P-i4%I3f3S|T zP2?!UJCchi5dk7V1c(3;s8Is{-|uRXe>GdUR|O$@T#^X)Pj@N*3q5oI7xcd{BDlL? zHP!$wj`DLf)+Yy`)qy~XrQ0JE=nY_ANx$m&P={eOvA8T?xT(qbQDbQ{lQmqEJ42A7 z+)gAtQa=}yCD}|kE@EinBe@4TmtF^GTDwQFjwDu`Fq+0Zv&K{EQL_}ARl2S|b8JRA z5g-CYfCvx)B0vO)01+SpM1TlXIf39hKthh#|2zugLVx_P^Z(}{bS?Y?HV`3t>mqB8 z?;%dtBYI*7uoGV?x|ZDsr>4%HGM02oM1xKm>>Y5g-CYfCvzQY9bJfry%~o=Mw&Sz+Yd#_{n=daitT0 zVf-XRkzm}^bw{v<#Zy}<^gizkko=r{>I%J)ng|d9B0vO)01+SpM4;LU)PNt|-wP1$ ziXRR8Iz-u^x^RO}mA`^llzMB07i-Xuj^l%!kZ$|2zsExil=zg&m%7W_^(l2rYk9j% zN_`ETAh-_$ID|z% z5cZXOm~R9i-YE!uuOI^N0~Gg_i>NIEM2lhyuE$Rml~qvc-GAtyJa;v0$If}*Jv9XNt>2) zdq2rN$xPlDHFuBN?Xo?FFWB$Nccs`nyivN{nkT0M5g-CYfCvx)B0vO)01+SpM1Tko z0U{7iVExuZVl}xQQklnE_u<^3IKZx*cqM}CLIqS+W$xyU)bV7FFV#45d^sy^-d1I1 zdN^1g$)&-XG1;D1pdTxhdfPT~sdt!djpW>(Y0V?ApOnOP@k s8}2bV4!NXMtTL$cFIE)oyftR#M2{fA^{r~5U3tF!*Y(IJYx4g47Xv)1M*si- literal 0 HcmV?d00001 diff --git a/tests/data/models/min_tracks_2node.UNet.bottomup_multiclass/initial_config.json b/tests/data/models/min_tracks_2node.UNet.bottomup_multiclass/initial_config.json index 7e52d1703..2ae0e925c 100644 --- a/tests/data/models/min_tracks_2node.UNet.bottomup_multiclass/initial_config.json +++ b/tests/data/models/min_tracks_2node.UNet.bottomup_multiclass/initial_config.json @@ -128,6 +128,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": false, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/tests/data/models/min_tracks_2node.UNet.bottomup_multiclass/training_config.json b/tests/data/models/min_tracks_2node.UNet.bottomup_multiclass/training_config.json index bcb2f26d5..7b6f817aa 100644 --- a/tests/data/models/min_tracks_2node.UNet.bottomup_multiclass/training_config.json +++ b/tests/data/models/min_tracks_2node.UNet.bottomup_multiclass/training_config.json @@ -191,6 +191,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": false, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/tests/data/models/min_tracks_2node.UNet.topdown_multiclass/initial_config.json b/tests/data/models/min_tracks_2node.UNet.topdown_multiclass/initial_config.json index 045890b21..5d8081628 100644 --- a/tests/data/models/min_tracks_2node.UNet.topdown_multiclass/initial_config.json +++ b/tests/data/models/min_tracks_2node.UNet.topdown_multiclass/initial_config.json @@ -141,7 +141,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": false, - "delete_viz_images": true, + "keep_viz_images": false, "zip_outputs": false, "log_to_csv": true, "checkpointing": { diff --git a/tests/data/models/min_tracks_2node.UNet.topdown_multiclass/training_config.json b/tests/data/models/min_tracks_2node.UNet.topdown_multiclass/training_config.json index 070e9d3c0..9591e5b52 100644 --- a/tests/data/models/min_tracks_2node.UNet.topdown_multiclass/training_config.json +++ b/tests/data/models/min_tracks_2node.UNet.topdown_multiclass/training_config.json @@ -208,7 +208,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": false, - "delete_viz_images": true, + "keep_viz_images": false, "zip_outputs": false, "log_to_csv": true, "checkpointing": { diff --git a/tests/data/models/minimal_instance.UNet.bottomup/initial_config.json b/tests/data/models/minimal_instance.UNet.bottomup/initial_config.json index 8e39fea3f..68e4f894e 100644 --- a/tests/data/models/minimal_instance.UNet.bottomup/initial_config.json +++ b/tests/data/models/minimal_instance.UNet.bottomup/initial_config.json @@ -127,6 +127,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": false, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/tests/data/models/minimal_instance.UNet.bottomup/training_config.json b/tests/data/models/minimal_instance.UNet.bottomup/training_config.json index d1fb718ba..e3bfbc5f8 100644 --- a/tests/data/models/minimal_instance.UNet.bottomup/training_config.json +++ b/tests/data/models/minimal_instance.UNet.bottomup/training_config.json @@ -192,6 +192,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": false, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/tests/data/models/minimal_instance.UNet.centered_instance/initial_config.json b/tests/data/models/minimal_instance.UNet.centered_instance/initial_config.json index 739d8e3e7..f4914aae4 100644 --- a/tests/data/models/minimal_instance.UNet.centered_instance/initial_config.json +++ b/tests/data/models/minimal_instance.UNet.centered_instance/initial_config.json @@ -119,6 +119,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": false, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/tests/data/models/minimal_instance.UNet.centered_instance/training_config.json b/tests/data/models/minimal_instance.UNet.centered_instance/training_config.json index 7b6782a68..e747f6862 100644 --- a/tests/data/models/minimal_instance.UNet.centered_instance/training_config.json +++ b/tests/data/models/minimal_instance.UNet.centered_instance/training_config.json @@ -179,6 +179,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": false, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/tests/data/models/minimal_instance.UNet.centroid/initial_config.json b/tests/data/models/minimal_instance.UNet.centroid/initial_config.json index 41d8ac8c3..977654b2e 100644 --- a/tests/data/models/minimal_instance.UNet.centroid/initial_config.json +++ b/tests/data/models/minimal_instance.UNet.centroid/initial_config.json @@ -118,6 +118,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": false, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/tests/data/models/minimal_instance.UNet.centroid/training_config.json b/tests/data/models/minimal_instance.UNet.centroid/training_config.json index 2d2280a31..02e9683e1 100644 --- a/tests/data/models/minimal_instance.UNet.centroid/training_config.json +++ b/tests/data/models/minimal_instance.UNet.centroid/training_config.json @@ -175,6 +175,7 @@ "runs_folder": "models", "tags": [], "save_visualizations": false, + "keep_viz_images": false, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/tests/data/models/minimal_robot.UNet.single_instance/initial_config.json b/tests/data/models/minimal_robot.UNet.single_instance/initial_config.json index cb2e4f353..f2bb907fa 100644 --- a/tests/data/models/minimal_robot.UNet.single_instance/initial_config.json +++ b/tests/data/models/minimal_robot.UNet.single_instance/initial_config.json @@ -120,6 +120,7 @@ "" ], "save_visualizations": false, + "keep_viz_images": true, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/tests/data/models/minimal_robot.UNet.single_instance/training_config.json b/tests/data/models/minimal_robot.UNet.single_instance/training_config.json index 66901c9f0..dffecc1d9 100644 --- a/tests/data/models/minimal_robot.UNet.single_instance/training_config.json +++ b/tests/data/models/minimal_robot.UNet.single_instance/training_config.json @@ -180,6 +180,7 @@ "" ], "save_visualizations": false, + "keep_viz_images": true, "log_to_csv": true, "checkpointing": { "initial_model": false, diff --git a/tests/data/skeleton/fly_skeleton_legs_pystate_dict.json b/tests/data/skeleton/fly_skeleton_legs_pystate_dict.json new file mode 100644 index 000000000..eae83d6bc --- /dev/null +++ b/tests/data/skeleton/fly_skeleton_legs_pystate_dict.json @@ -0,0 +1 @@ +{"directed": true, "graph": {"name": "skeleton_legs.mat", "num_edges_inserted": 23}, "links": [{"edge_insert_idx": 1, "key": 0, "source": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "neck", "weight": 1.0}}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "head", "weight": 1.0}}, "type": {"py/reduce": [{"py/type": "sleap.skeleton.EdgeType"}, {"py/tuple": [1]}]}}, {"edge_insert_idx": 0, "key": 0, "source": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "thorax", "weight": 1.0}}, "target": {"py/id": 1}, "type": {"py/id": 3}}, {"edge_insert_idx": 2, "key": 0, "source": {"py/id": 4}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "abdomen", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 3, "key": 0, "source": {"py/id": 4}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "wingL", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 4, "key": 0, "source": {"py/id": 4}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "wingR", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 5, "key": 0, "source": {"py/id": 4}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "forelegL1", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 8, "key": 0, "source": {"py/id": 4}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "forelegR1", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 11, "key": 0, "source": {"py/id": 4}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "midlegL1", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 14, "key": 0, "source": {"py/id": 4}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "midlegR1", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 17, "key": 0, "source": {"py/id": 4}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "hindlegL1", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 20, "key": 0, "source": {"py/id": 4}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "hindlegR1", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 6, "key": 0, "source": {"py/id": 8}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "forelegL2", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 7, "key": 0, "source": {"py/id": 14}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "forelegL3", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 9, "key": 0, "source": {"py/id": 9}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "forelegR2", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 10, "key": 0, "source": {"py/id": 16}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "forelegR3", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 12, "key": 0, "source": {"py/id": 10}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "midlegL2", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 13, "key": 0, "source": {"py/id": 18}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "midlegL3", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 15, "key": 0, "source": {"py/id": 11}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "midlegR2", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 16, "key": 0, "source": {"py/id": 20}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "midlegR3", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 18, "key": 0, "source": {"py/id": 12}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "hindlegL2", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 19, "key": 0, "source": {"py/id": 22}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "hindlegL3", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 21, "key": 0, "source": {"py/id": 13}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "hindlegR2", "weight": 1.0}}, "type": {"py/id": 3}}, {"edge_insert_idx": 22, "key": 0, "source": {"py/id": 24}, "target": {"py/object": "sleap.skeleton.Node", "py/state": {"name": "hindlegR3", "weight": 1.0}}, "type": {"py/id": 3}}], "multigraph": true, "nodes": [{"id": {"py/id": 2}}, {"id": {"py/id": 1}}, {"id": {"py/id": 4}}, {"id": {"py/id": 5}}, {"id": {"py/id": 6}}, {"id": {"py/id": 7}}, {"id": {"py/id": 8}}, {"id": {"py/id": 14}}, {"id": {"py/id": 15}}, {"id": {"py/id": 9}}, {"id": {"py/id": 16}}, {"id": {"py/id": 17}}, {"id": {"py/id": 10}}, {"id": {"py/id": 18}}, {"id": {"py/id": 19}}, {"id": {"py/id": 11}}, {"id": {"py/id": 20}}, {"id": {"py/id": 21}}, {"id": {"py/id": 12}}, {"id": {"py/id": 22}}, {"id": {"py/id": 23}}, {"id": {"py/id": 13}}, {"id": {"py/id": 24}}, {"id": {"py/id": 25}}]} \ No newline at end of file diff --git a/tests/data/tracks/clip.predictions.slp b/tests/data/tracks/clip.predictions.slp new file mode 100644 index 0000000000000000000000000000000000000000..652e21302043e7eb6064d5663fb70098b9de23e6 GIT binary patch literal 396184 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

6C@5o_(=*8!ATMaYhkj8;1r2D2v=Cd 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 YV;Hw>DCW0e+_qtuBOguu|MTPj0o-wCPyhe` literal 0 HcmV?d00001 diff --git a/tests/fixtures/datasets.py b/tests/fixtures/datasets.py index 801fcc092..c6507caec 100644 --- a/tests/fixtures/datasets.py +++ b/tests/fixtures/datasets.py @@ -41,6 +41,13 @@ def centered_pair_predictions(): return Labels.load_file(TEST_JSON_PREDICTIONS) +@pytest.fixture +def centered_pair_predictions_sorted(centered_pair_predictions): + labels: Labels = centered_pair_predictions + labels.labeled_frames.sort(key=lambda lf: lf.frame_idx) + return labels + + @pytest.fixture def min_labels(): return Labels.load_file(TEST_JSON_MIN_LABELS) @@ -90,6 +97,20 @@ def min_tracks_2node_labels(): ) +@pytest.fixture +def min_tracks_2node_predictions(): + """ + Generated with: + ``` + sleap-track -m "tests/data/models/min_tracks_2node.UNet.bottomup_multiclass" "tests/data/tracks/clip.mp4" + ``` + """ + return Labels.load_file( + "tests/data/tracks/clip.predictions.slp", + video_search=["tests/data/tracks/clip.mp4"], + ) + + @pytest.fixture def min_tracks_13node_labels(): return Labels.load_file( diff --git a/tests/fixtures/skeletons.py b/tests/fixtures/skeletons.py index 311510e6a..b432ca2c7 100644 --- a/tests/fixtures/skeletons.py +++ b/tests/fixtures/skeletons.py @@ -3,14 +3,27 @@ from sleap.skeleton import Skeleton TEST_FLY_LEGS_SKELETON = "tests/data/skeleton/fly_skeleton_legs.json" +TEST_FLY_LEGS_SKELETON_DICT = "tests/data/skeleton/fly_skeleton_legs_pystate_dict.json" @pytest.fixture def fly_legs_skeleton_json(): - """Path to fly_skeleton_legs.json""" + """Path to fly_skeleton_legs.json + + This skeleton json has py/state in tuple format. + """ return TEST_FLY_LEGS_SKELETON +@pytest.fixture +def fly_legs_skeleton_dict_json(): + """Path to fly_skeleton_legs_pystate_dict.json + + This skeleton json has py/state dict format. + """ + return TEST_FLY_LEGS_SKELETON_DICT + + @pytest.fixture def stickman(): diff --git a/tests/fixtures/videos.py b/tests/fixtures/videos.py index b160caedd..08974b3de 100644 --- a/tests/fixtures/videos.py +++ b/tests/fixtures/videos.py @@ -1,12 +1,21 @@ import pytest from sleap.io.video import Video +from sleap.io.format.filehandle import FileHandle TEST_H5_FILE = "tests/data/hdf5_format_v1/training.scale=0.50,sigma=10.h5" TEST_H5_DSET = "/box" TEST_H5_CONFMAPS = "/confmaps" TEST_H5_AFFINITY = "/pafs" TEST_H5_INPUT_FORMAT = "channels_first" +TEST_SMALL_ROBOT3_FRAME_H5 = ( + "tests/data/hdf5_format_v1/small_robot.000_small_robot_3_frame.analysis.h5" +) + + +@pytest.fixture +def small_robot_3_frame_hdf5(): + return FileHandle(filename=TEST_SMALL_ROBOT3_FRAME_H5) @pytest.fixture diff --git a/tests/gui/learning/test_dialog.py b/tests/gui/learning/test_dialog.py index 3d77c891f..389bb48a3 100644 --- a/tests/gui/learning/test_dialog.py +++ b/tests/gui/learning/test_dialog.py @@ -7,6 +7,7 @@ import pytest from qtpy import QtWidgets +import sleap from sleap.gui.learning.dialog import LearningDialog, TrainingEditorWidget from sleap.gui.learning.configs import ( TrainingConfigFilesWidget, @@ -429,3 +430,22 @@ def test_immutablilty_of_trained_config_info( # saving multiple configs from one config info. ld.save(output_dir=tmpdir) ld.save(output_dir=tmpdir) + + +def test_validate_id_model(qtbot, min_labels_slp, min_labels_slp_path): + app = MainWindow(no_usage_data=True) + ld = LearningDialog( + mode="training", + labels_filename=Path(min_labels_slp_path), + labels=min_labels_slp, + ) + assert not ld._validate_id_model() + + # Add track but don't assign it to instances + new_track = sleap.Track(name="new_track") + min_labels_slp.tracks.append(new_track) + assert not ld._validate_id_model() + + # Assign track to instances + min_labels_slp[0][0].track = new_track + assert ld._validate_id_model() diff --git a/tests/gui/test_app.py b/tests/gui/test_app.py index bacda4ae3..def835b6e 100644 --- a/tests/gui/test_app.py +++ b/tests/gui/test_app.py @@ -142,6 +142,7 @@ def assert_frame_chunk_suggestion_ui_updated( # Select and delete instance app.state["instance"] = inst_27_1 app.commands.deleteSelectedInstance() + assert app.state["instance"] is None assert len(app.state["labeled_frame"].instances) == 1 assert app.state["labeled_frame"].instances == [inst_27_0] @@ -179,6 +180,7 @@ def assert_frame_chunk_suggestion_ui_updated( # Delete all instances in track app.commands.deleteSelectedInstanceTrack() + assert app.state["instance"] is None assert len(app.state["labeled_frame"].instances) == 0 app.state["frame_idx"] = 29 @@ -412,6 +414,12 @@ def toggle_and_verify_visibility(expected_visibility: bool = True): window.showNormal() vp = window.player + # Change state and ensure menu-item check updates + color_predicted = window.state["color predicted"] + assert window._menu_actions["color predicted"].isChecked() == color_predicted + window.state["color predicted"] = not color_predicted + assert window._menu_actions["color predicted"].isChecked() == (not color_predicted) + # Enable distinct colors window.state["color predicted"] = True diff --git a/tests/gui/test_commands.py b/tests/gui/test_commands.py index 13aa60e6b..e19e00236 100644 --- a/tests/gui/test_commands.py +++ b/tests/gui/test_commands.py @@ -3,11 +3,15 @@ import sys import time +import numpy as np from pathlib import PurePath, Path +from qtpy import QtCore from typing import List from sleap import Skeleton, Track, PredictedInstance +from sleap.gui.app import MainWindow from sleap.gui.commands import ( + AddInstance, CommandContext, ExportAnalysisFile, ExportDatasetWithImages, @@ -16,6 +20,7 @@ ReplaceVideo, OpenSkeleton, SaveProjectAs, + DeleteFrameLimitPredictions, get_new_version_filename, ) from sleap.instance import Instance, LabeledFrame @@ -65,7 +70,7 @@ def test_import_labels_from_dlc_folder(): assert len(labels.videos) == 2 assert len(labels.skeletons) == 1 assert len(labels.nodes) == 3 - assert len(labels.tracks) == 0 + assert len(labels.tracks) == 3 assert set( [fix_path_separator(l.video.backend.filename) for l in labels.labeled_frames] @@ -847,6 +852,26 @@ def load_and_assert_changes(new_video_path: Path): shutil.move(new_video_path, expected_video_path) +def test_DeleteFrameLimitPredictions( + centered_pair_predictions: Labels, centered_pair_vid: Video +): + """Test deleting instances beyond a certain frame limit.""" + labels = centered_pair_predictions + + # Set-up command context + context = CommandContext.from_labels(labels) + context.state["video"] = centered_pair_vid + + # Set-up params for the command + params = {"min_frame_idx": 900, "max_frame_idx": 1000} + + instances_to_delete = DeleteFrameLimitPredictions.get_frame_instance_list( + context, params + ) + + assert len(instances_to_delete) == 2070 + + @pytest.mark.parametrize("export_extension", [".json.zip", ".slp"]) def test_exportLabelsPackage(export_extension, centered_pair_labels: Labels, tmpdir): def assert_loaded_package_similar(path_to_pkg: Path, sugg=False, pred=False): @@ -922,3 +947,102 @@ def no_gui_ask(cls, context, params): # Case 3: Export all frames and suggested frames with image data. context.exportFullPackage() assert_loaded_package_similar(path_to_pkg, sugg=True, pred=True) + + +def test_newInstance(qtbot, centered_pair_predictions: Labels): + + # Get the data + labels = centered_pair_predictions + lf = labels[0] + pred_inst = lf.instances[0] + video = labels.video + + # Set-up command context + main_window = MainWindow(labels=labels) + context = main_window.commands + context.state["labeled_frame"] = lf + context.state["frame_idx"] = lf.frame_idx + context.state["skeleton"] = labels.skeleton + context.state["video"] = labels.videos[0] + + # Case 1: Double clicking a prediction results in no offset for new instance + + # Double click on prediction + assert len(lf.instances) == 2 + main_window._handle_instance_double_click(instance=pred_inst) + + # Check new instance + assert len(lf.instances) == 3 + new_inst = lf.instances[-1] + assert new_inst.from_predicted is pred_inst + assert np.array_equal(new_inst.numpy(), pred_inst.numpy()) # No offset + + # Case 2: Using Ctrl + I (or menu "Add Instance" button) + + # Connect the action to a slot + add_instance_menu_action = main_window._menu_actions["add instance"] + triggered = False + + def on_triggered(): + nonlocal triggered + triggered = True + + add_instance_menu_action.triggered.connect(on_triggered) + + # Find which instance we are going to copy from + ( + copy_instance, + from_predicted, + from_prev_frame, + ) = AddInstance.find_instance_to_copy_from( + context, copy_instance=None, init_method="best" + ) + + # Click on the menu action + assert len(lf.instances) == 3 + add_instance_menu_action.trigger() + assert triggered, "Action not triggered" + + # Check new instance + assert len(lf.instances) == 4 + new_inst = lf.instances[-1] + offset = 10 + np.nan_to_num(new_inst.numpy() - copy_instance.numpy(), nan=offset) + assert np.all( + np.nan_to_num(new_inst.numpy() - copy_instance.numpy(), nan=offset) == offset + ) + + # Case 3: Using right click and "Default" option + + # Find which instance we are going to copy from + ( + copy_instance, + from_predicted, + from_prev_frame, + ) = AddInstance.find_instance_to_copy_from( + context, copy_instance=None, init_method="best" + ) + + video_player = main_window.player + right_click_location_x = video.shape[2] / 2 + right_click_location_y = video.shape[1] / 2 + right_click_location = QtCore.QPointF( + right_click_location_x, right_click_location_y + ) + video_player.create_contextual_menu(scene_pos=right_click_location) + default_action = video_player._menu_actions["Default"] + default_action.trigger() + + # Check new instance + assert len(lf.instances) == 5 + new_inst = lf.instances[-1] + reference_node_idx = np.where( + np.all( + new_inst.numpy() == [right_click_location_x, right_click_location_y], axis=1 + ) + )[0][0] + offset = ( + new_inst.numpy()[reference_node_idx] - copy_instance.numpy()[reference_node_idx] + ) + diff = np.nan_to_num(new_inst.numpy() - copy_instance.numpy(), nan=offset) + assert np.all(diff == offset) diff --git a/tests/gui/test_dialogs.py b/tests/gui/test_dialogs.py index 4455550fb..611a73c85 100644 --- a/tests/gui/test_dialogs.py +++ b/tests/gui/test_dialogs.py @@ -1,6 +1,5 @@ """Module to test the dialogs of the GUI (contained in sleap/gui/dialogs).""" - import os from pathlib import Path diff --git a/tests/gui/test_monitor.py b/tests/gui/test_monitor.py index 51af0ca92..e0abea692 100644 --- a/tests/gui/test_monitor.py +++ b/tests/gui/test_monitor.py @@ -1,4 +1,3 @@ -from turtle import title from sleap.gui.widgets.monitor import LossViewer from sleap import TrainingJobConfig @@ -12,6 +11,9 @@ def test_monitor_release(qtbot, min_centroid_model_path): win.reset(what="Model Type", config=config) assert win.config.optimization.early_stopping.plateau_patience == 10 + # Ensure zmq port is set correctly + assert win.zmq_ports["controller_port"] == 9000 + assert win.zmq_ports["publish_port"] == 9001 # Ensure all lines of update_runtime() are run error-free win.is_running = True win.t0 = 0 @@ -28,13 +30,17 @@ def test_monitor_release(qtbot, min_centroid_model_path): # Enter "bes_val_x" conditional win.best_val_x = 0 win.best_val_y = win.last_epoch_val_loss - win.update_runtime() + win._update_runtime() win.close() # Make sure the first monitor released its zmq socket - win2 = LossViewer() + controller_port = 9191 + zmq_ports = dict(controller_port=controller_port) + win2 = LossViewer(zmq_ports=zmq_ports) win2.show() + assert win2.zmq_ports["controller_port"] == controller_port + assert win2.zmq_ports["publish_port"] == 9001 # Make sure batches to show field is working correction @@ -47,3 +53,14 @@ def test_monitor_release(qtbot, min_centroid_model_path): assert win2.batches_to_show == 200 win2.close() + + # Ensure zmq port is set correctly + controller_port = 9191 + publish_port = 9101 + zmq_ports = dict(controller_port=controller_port, publish_port=publish_port) + win3 = LossViewer(zmq_ports=zmq_ports) + win3.show() + assert win3.zmq_ports["controller_port"] == controller_port + assert win3.zmq_ports["publish_port"] == publish_port + + win3.close() diff --git a/tests/gui/test_suggestions.py b/tests/gui/test_suggestions.py index bbad73179..196ff2d35 100644 --- a/tests/gui/test_suggestions.py +++ b/tests/gui/test_suggestions.py @@ -24,6 +24,20 @@ def test_velocity_suggestions(centered_pair_predictions): assert suggestions[1].frame_idx == 45 +def test_max_point_displacement_suggestions(centered_pair_predictions): + suggestions = VideoFrameSuggestions.suggest( + labels=centered_pair_predictions, + params=dict( + videos=centered_pair_predictions.videos, + method="max_point_displacement", + displacement_threshold=6, + ), + ) + assert len(suggestions) == 19 + assert suggestions[0].frame_idx == 28 + assert suggestions[1].frame_idx == 82 + + def test_frame_increment(centered_pair_predictions: Labels): # Testing videos that have less frames than desired Samples per Video (stride) # Expected result is there should be n suggestions where n is equal to the frames diff --git a/tests/gui/test_video_player.py b/tests/gui/test_video_player.py index b0661a4e1..c246f0489 100644 --- a/tests/gui/test_video_player.py +++ b/tests/gui/test_video_player.py @@ -3,14 +3,13 @@ from sleap.gui.widgets.video import ( QtVideoPlayer, GraphicsView, - QtInstance, QtVideoPlayer, QtTextWithBackground, VisibleBoundingBox, ) from qtpy import QtCore, QtWidgets -from qtpy.QtGui import QColor +from qtpy.QtGui import QColor, QWheelEvent def test_gui_video(qtbot): @@ -20,10 +19,6 @@ def test_gui_video(qtbot): assert vp.close() - # Click the button 20 times - # for i in range(20): - # qtbot.mouseClick(vp.btn, QtCore.Qt.LeftButton) - def test_gui_video_instances(qtbot, small_robot_mp4_vid, centered_pair_labels): vp = QtVideoPlayer(small_robot_mp4_vid) @@ -144,3 +139,40 @@ def test_VisibleBoundingBox(qtbot, centered_pair_labels): # Check if bounding box scaled appropriately assert inst.box.rect().width() - initial_width == 2 * dx assert inst.box.rect().height() - initial_height == 2 * dy + + +def test_wheelEvent(qtbot): + """Test the wheelEvent method of the GraphicsView class.""" + graphics_view = GraphicsView() + + # Create a QWheelEvent + position = QtCore.QPointF(100, 100) # The position of the wheel event + global_position = QtCore.QPointF(100, 100) # The global position of the wheel event + pixel_delta = QtCore.QPoint(0, 120) # The distance in pixels the wheel is rotated + angle_delta = QtCore.QPoint(0, 120) # The distance in degrees the wheel is rotated + buttons = QtCore.Qt.NoButton # No mouse button is pressed + modifiers = QtCore.Qt.NoModifier # No keyboard modifier is pressed + phase = QtCore.Qt.ScrollUpdate # The phase of the scroll event + inverted = False # The scroll direction is not inverted + source = ( + QtCore.Qt.MouseEventNotSynthesized + ) # The event is not synthesized from a touch or tablet event + + event = QWheelEvent( + position, + global_position, + pixel_delta, + angle_delta, + buttons, + modifiers, + phase, + inverted, + source, + ) + + # Call the wheelEvent method + print( + "Testing GraphicsView.wheelEvent which will result in exit code 127 " + "originating from a segmentation fault if it fails." + ) + graphics_view.wheelEvent(event) diff --git a/tests/gui/widgets/test_docks.py b/tests/gui/widgets/test_docks.py index 69fe56a56..d5c16a763 100644 --- a/tests/gui/widgets/test_docks.py +++ b/tests/gui/widgets/test_docks.py @@ -1,15 +1,17 @@ """Module for testing dock widgets for the `MainWindow`.""" from pathlib import Path -import pytest + +import numpy as np + from sleap import Labels, Video from sleap.gui.app import MainWindow -from sleap.gui.commands import OpenSkeleton +from sleap.gui.commands import AddInstance, OpenSkeleton from sleap.gui.widgets.docks import ( InstancesDock, + SkeletonDock, SuggestionsDock, VideosDock, - SkeletonDock, ) @@ -99,11 +101,35 @@ def test_suggestions_dock(qtbot): assert dock.wgt_layout is dock.widget().layout() -def test_instances_dock(qtbot): +def test_instances_dock(qtbot, centered_pair_predictions: Labels): """Test the `DockWidget` class.""" - main_window = MainWindow() + main_window = MainWindow(labels=centered_pair_predictions) + labels = main_window.labels + context = main_window.commands + lf = context.state["labeled_frame"] dock = InstancesDock(main_window) assert dock.name == "Instances" assert dock.main_window is main_window assert dock.wgt_layout is dock.widget().layout() + + # Test new instance button + + offset = 10 + + # Find instance that we will copy from + ( + copy_instance, + from_predicted, + from_prev_frame, + ) = AddInstance.find_instance_to_copy_from( + context, copy_instance=None, init_method="best" + ) + n_instance = len(lf.instances) + dock.main_window._buttons["new instance"].click() + + # Check that new instance was added with offset + assert len(lf.instances) == n_instance + 1 + new_inst = lf.instances[-1] + diff = np.nan_to_num(new_inst.numpy() - copy_instance.numpy(), nan=offset) + assert np.all(diff == offset) diff --git a/tests/info/test_metrics.py b/tests/info/test_metrics.py new file mode 100644 index 000000000..0d2e097e6 --- /dev/null +++ b/tests/info/test_metrics.py @@ -0,0 +1,55 @@ +import numpy as np + +from sleap import Labels +from sleap.info.metrics import ( + match_instance_lists_nodewise, + matched_instance_distances, +) + + +def test_matched_instance_distances(centered_pair_labels, centered_pair_predictions): + labels_gt = centered_pair_labels + labels_pr = centered_pair_predictions + + # Match each ground truth instance node to the closest corresponding node + # from any predicted instance in the same frame. + + inst_matching_func = match_instance_lists_nodewise + + # Calculate distances + frame_idxs, D, points_gt, points_pr = matched_instance_distances( + labels_gt, labels_pr, inst_matching_func + ) + + # Show mean difference for each node + node_names = labels_gt.skeletons[0].node_names + expected_values = { + "head": 0.872426920709296, + "neck": 0.8016280746914615, + "thorax": 0.8602021363390538, + "abdomen": 1.01012200038258, + "wingL": 1.1297727023475939, + "wingR": 1.0869857897008424, + "forelegL1": 0.780584225081443, + "forelegL2": 1.170805798894702, + "forelegL3": 1.1020486509389473, + "forelegR1": 0.9014698776116817, + "forelegR2": 0.9448001033112047, + "forelegR3": 1.308385214215777, + "midlegL1": 0.9095691623265347, + "midlegL2": 1.2203595627907582, + "midlegL3": 0.9813843358470163, + "midlegR1": 0.9871017182813739, + "midlegR2": 1.0209829335569256, + "midlegR3": 1.0990681234096988, + "hindlegL1": 1.0005335192834348, + "hindlegL2": 1.273539518539708, + "hindlegL3": 1.1752245985832817, + "hindlegR1": 1.1402833959265248, + "hindlegR2": 1.3143221301212737, + "hindlegR3": 1.0441458592503365, + } + + for node_idx, node_name in enumerate(node_names): + mean_d = np.nanmean(D[..., node_idx]) + assert np.isclose(mean_d, expected_values[node_name], atol=1e-6) diff --git a/tests/info/test_summary.py b/tests/info/test_summary.py index 2cf76c166..672d97e63 100644 --- a/tests/info/test_summary.py +++ b/tests/info/test_summary.py @@ -37,6 +37,19 @@ def test_frame_statistics(simple_predictions): x = stats.get_point_displacement_series(video, "max") assert len(x) == 2 - assert len(x) == 2 assert x[0] == 0 assert x[1] == 18.0 + + +def test_get_tracking_score_series(min_tracks_2node_predictions): + + stats = StatisticSeries(min_tracks_2node_predictions) + x = stats.get_tracking_score_series(min_tracks_2node_predictions.video, "min") + assert len(x) == 1500 + assert x[0] == 0.9999966621398926 + assert x[1000] == 0.9998022317886353 + + x = stats.get_tracking_score_series(min_tracks_2node_predictions.video, "mean") + assert len(x) == 1500 + assert x[0] == 0.9999983310699463 + assert x[1000] == 0.9999011158943176 diff --git a/tests/io/test_asyncvideo.py b/tests/io/test_asyncvideo.py deleted file mode 100644 index 1bc3f19c8..000000000 --- a/tests/io/test_asyncvideo.py +++ /dev/null @@ -1,31 +0,0 @@ -import pytest -import sys -from sleap import Video -from sleap.io.asyncvideo import AsyncVideo - - -@pytest.mark.skipif( - sys.platform.startswith("win"), reason="ZMQ testing breaks locally on Windows" -) -def test_async_video(centered_pair_vid, small_robot_mp4_vid): - async_video = AsyncVideo.from_video(centered_pair_vid, frames_per_chunk=23) - - all_idxs = [] - for idxs, frames in async_video.chunks: - assert len(idxs) in (23, 19) # 19 for last chunk - all_idxs.extend(idxs) - - assert frames.shape[0] == len(idxs) - assert frames.shape[1:] == centered_pair_vid.shape[1:] - - assert len(all_idxs) == centered_pair_vid.num_frames - - # make sure we can load another video (i.e., previous video closed) - - async_video = AsyncVideo.from_video( - small_robot_mp4_vid, frame_idxs=range(0, 10, 2), frames_per_chunk=10 - ) - - for idxs, frames in async_video.chunks: - # there should only be single chunk - assert idxs == list(range(0, 10, 2)) diff --git a/tests/io/test_convert.py b/tests/io/test_convert.py index da1971c11..738c3d625 100644 --- a/tests/io/test_convert.py +++ b/tests/io/test_convert.py @@ -8,7 +8,7 @@ import pytest -@pytest.mark.parametrize("format", ["analysis", "analysis.nix"]) +@pytest.mark.parametrize("format", ["analysis", "analysis.nix", "analysis.csv"]) def test_analysis_format( min_labels_slp: Labels, min_labels_slp_path: Labels, @@ -27,7 +27,7 @@ def generate_filenames(paths, format="analysis"): labels_path = str(slp_path) fn = re.sub("(\\.json(\\.zip)?|\\.h5|\\.slp)$", "", labels_path) fn = PurePath(fn) - out_suffix = "nix" if "nix" in format else "h5" + out_suffix = "nix" if "nix" in format else "csv" if "csv" in format else "h5" default_names = [ default_analysis_filename( labels=labels, diff --git a/tests/io/test_dataset.py b/tests/io/test_dataset.py index 5592ae437..d71d4cc83 100644 --- a/tests/io/test_dataset.py +++ b/tests/io/test_dataset.py @@ -1,9 +1,11 @@ import os +import pandas as pd import pytest import numpy as np from pathlib import Path, PurePath import sleap +from sleap.info.write_tracking_h5 import get_nodes_as_np_strings from sleap.skeleton import Skeleton from sleap.instance import Instance, Point, LabeledFrame, PredictedInstance, Track from sleap.io.video import Video, MediaVideo @@ -1234,7 +1236,7 @@ def test_has_frame(): @pytest.fixture def removal_test_labels(): skeleton = Skeleton() - video = Video(backend=MediaVideo(filename="test")) + video = Video(backend=MediaVideo(filename="test.mp4")) lf_user_only = LabeledFrame( video=video, frame_idx=0, instances=[Instance(skeleton=skeleton)] ) @@ -1559,3 +1561,45 @@ def test_export_nwb(centered_pair_predictions: Labels, tmpdir): # Read from NWB file read_labels = NDXPoseAdaptor.read(NDXPoseAdaptor, filehandle.FileHandle(filename)) assert_read_labels_match(centered_pair_predictions, read_labels) + + +@pytest.mark.parametrize( + "labels_fixture_name", + [ + "centered_pair_labels", + "centered_pair_predictions", + "min_labels", + "min_labels_slp", + "min_labels_robot", + ], +) +def test_export_csv(labels_fixture_name, tmpdir, request): + # Retrieve Labels fixture by name + labels_fixture = request.getfixturevalue(labels_fixture_name) + + # Generate the filename for the CSV file + csv_filename = Path(tmpdir) / (labels_fixture_name + "_export.csv") + + # Export to CSV file + labels_fixture.export_csv(str(csv_filename)) + + # Assert that the CSV file was created + assert csv_filename.is_file(), f"CSV file '{csv_filename}' was not created" + + +def test_exported_csv(tmpdir, min_labels_slp, minimal_instance_predictions_csv_path): + # Construct the filename for the CSV file + filename_csv = Path(tmpdir) / "minimal_instance_predictions_export.csv" + labels = min_labels_slp + # Export to CSV file + labels.export_csv(filename_csv) + # Read the CSV file + labels_csv = pd.read_csv(filename_csv) + + # Read the csv file fixture + csv_predictions = pd.read_csv(minimal_instance_predictions_csv_path) + + assert labels_csv.equals(csv_predictions) + + # check number of cols + assert len(labels_csv.columns) - 3 == len(get_nodes_as_np_strings(labels)) * 3 diff --git a/tests/io/test_formats.py b/tests/io/test_formats.py index a89bf60d7..cee754b7c 100644 --- a/tests/io/test_formats.py +++ b/tests/io/test_formats.py @@ -19,6 +19,19 @@ from sleap.gui.app import MainWindow from sleap.gui.state import GuiState from sleap.info.write_tracking_h5 import get_nodes_as_np_strings +from sleap.io.format.sleap_analysis import SleapAnalysisAdaptor + + +def test_sleap_analysis_read(small_robot_3_frame_vid, small_robot_3_frame_hdf5): + + # Single instance hdf5 analysis file test + read_labels = SleapAnalysisAdaptor.read( + file=small_robot_3_frame_hdf5, video=small_robot_3_frame_vid + ) + + assert len(read_labels.videos) == 1 + assert len(read_labels.tracks) == 1 + assert len(read_labels.skeletons) == 1 def test_text_adaptor(tmpdir): @@ -198,7 +211,6 @@ def test_matching_adaptor(centered_pair_predictions_hdf5_path): [ "tests/data/dlc/labeled-data/video/madlc_testdata.csv", "tests/data/dlc/labeled-data/video/madlc_testdata_v2.csv", - "tests/data/dlc/madlc_230_config.yaml", ], ) def test_madlc(test_data): @@ -232,6 +244,78 @@ def test_madlc(test_data): assert labels[2].frame_idx == 3 +@pytest.mark.parametrize( + "test_data", + [ + "tests/data/dlc/labeled-data/video/maudlc_testdata.csv", + "tests/data/dlc/labeled-data/video/maudlc_testdata_v2.csv", + "tests/data/dlc/madlc_230_config.yaml", + ], +) +def test_maudlc(test_data): + labels = read( + test_data, + for_object="labels", + as_format="deeplabcut", + ) + + assert labels.skeleton.node_names == ["A", "B", "C", "D", "E"] + assert len(labels.videos) == 1 + assert len(labels.video.filenames) == 4 + assert labels.videos[0].filenames[0].endswith("img000.png") + assert labels.videos[0].filenames[1].endswith("img001.png") + assert labels.videos[0].filenames[2].endswith("img002.png") + assert labels.videos[0].filenames[3].endswith("img003.png") + + # Assert frames without any coor are not labeled + assert len(labels) == 3 + + # Assert number of instances per frame is correct + assert len(labels[0]) == 2 + assert len(labels[1]) == 3 + assert len(labels[2]) == 2 + + assert_array_equal( + labels[0][0].numpy(), + [[0, 1], [2, 3], [4, 5], [np.nan, np.nan], [np.nan, np.nan]], + ) + assert_array_equal( + labels[0][1].numpy(), + [[6, 7], [8, 9], [10, 11], [np.nan, np.nan], [np.nan, np.nan]], + ) + assert_array_equal( + labels[1][0].numpy(), + [[12, 13], [np.nan, np.nan], [15, 16], [np.nan, np.nan], [np.nan, np.nan]], + ) + assert_array_equal( + labels[1][1].numpy(), + [[17, 18], [np.nan, np.nan], [20, 21], [np.nan, np.nan], [np.nan, np.nan]], + ) + assert_array_equal( + labels[1][2].numpy(), + [[np.nan, np.nan], [np.nan, np.nan], [np.nan, np.nan], [22, 23], [24, 25]], + ) + assert_array_equal( + labels[2][0].numpy(), + [[26, 27], [28, 29], [30, 31], [np.nan, np.nan], [np.nan, np.nan]], + ) + assert_array_equal( + labels[2][1].numpy(), + [[np.nan, np.nan], [np.nan, np.nan], [np.nan, np.nan], [32, 33], [34, 35]], + ) + assert labels[2].frame_idx == 3 + + # Assert tracks are correct + assert len(labels.tracks) == 3 + sorted_animals = sorted(["Animal1", "Animal2", "single"]) + assert sorted([t.name for t in labels.tracks]) == sorted_animals + for t in labels.tracks: + if t.name == "single": + assert t.spawned_on == 1 + else: + assert t.spawned_on == 0 + + @pytest.mark.parametrize( "test_data", [ diff --git a/tests/io/test_videowriter.py b/tests/io/test_videowriter.py index dea193117..35d9bc6df 100644 --- a/tests/io/test_videowriter.py +++ b/tests/io/test_videowriter.py @@ -1,5 +1,7 @@ import os -from sleap.io.videowriter import VideoWriter, VideoWriterOpenCV +import cv2 +from pathlib import Path +from sleap.io.videowriter import VideoWriter, VideoWriterOpenCV, VideoWriterImageio def test_video_writer(tmpdir, small_robot_mp4_vid): @@ -38,3 +40,62 @@ def test_cv_video_writer(tmpdir, small_robot_mp4_vid): writer.close() assert os.path.exists(out_path) + + +def test_imageio_video_writer_avi(tmpdir, small_robot_mp4_vid): + out_path = Path(tmpdir) / "clip.avi" + + # Make sure imageio video writer works + writer = VideoWriterImageio( + out_path, + height=small_robot_mp4_vid.height, + width=small_robot_mp4_vid.width, + fps=small_robot_mp4_vid.fps, + ) + + writer.add_frame(small_robot_mp4_vid[0][0]) + writer.add_frame(small_robot_mp4_vid[1][0]) + + writer.close() + + assert os.path.exists(out_path) + # Check attributes + assert writer.height == small_robot_mp4_vid.height + assert writer.width == small_robot_mp4_vid.width + assert writer.fps == small_robot_mp4_vid.fps + assert writer.filename == out_path + assert writer.crf == 21 + assert writer.preset == "superfast" + + +def test_imageio_video_writer_odd_size(tmpdir, movenet_video): + out_path = Path(tmpdir) / "clip.mp4" + + # Reduce the size of the video frames by 1 pixel in each dimension + reduced_height = movenet_video.height - 1 + reduced_width = movenet_video.width - 1 + + # Initialize the writer with the reduced dimensions + writer = VideoWriterImageio( + out_path, + height=reduced_height, + width=reduced_width, + fps=movenet_video.fps, + ) + + # Resize frames and add them to the video + for i in range(len(movenet_video) - 1): + frame = movenet_video[i][0] # Access the actual frame object + reduced_frame = cv2.resize(frame, (reduced_width, reduced_height)) + writer.add_frame(reduced_frame) + + writer.close() + + # Assertions to validate the test + assert os.path.exists(out_path) + assert writer.height == reduced_height + assert writer.width == reduced_width + assert writer.fps == movenet_video.fps + assert writer.filename == out_path + assert writer.crf == 21 + assert writer.preset == "superfast" diff --git a/tests/io/test_visuals.py b/tests/io/test_visuals.py index d6144e2c1..a1223bfdf 100644 --- a/tests/io/test_visuals.py +++ b/tests/io/test_visuals.py @@ -1,6 +1,7 @@ import numpy as np import os import pytest +import cv2 from sleap.io.dataset import Labels from sleap.io.visuals import ( save_labeled_video, @@ -63,6 +64,46 @@ def test_serial_pipeline(centered_pair_predictions, tmpdir): ) +@pytest.mark.parametrize("background", ["original", "black", "white", "grey"]) +def test_sleap_render_with_different_backgrounds(background): + args = ( + f"-o test_{background}.avi -f 2 --scale 1.2 --frames 1,2 --video-index 0 " + f"--background {background} " + "tests/data/json_format_v2/centered_pair_predictions.json".split() + ) + sleap_render(args) + assert ( + os.path.exists(f"test_{background}.avi") + and os.path.getsize(f"test_{background}.avi") > 0 + ) + + # Check if the background is set correctly if not original background + if background != "original": + saved_video_path = f"test_{background}.avi" + cap = cv2.VideoCapture(saved_video_path) + ret, frame = cap.read() + + # Calculate mean color of the channels + b, g, r = cv2.split(frame) + mean_b = np.mean(b) + mean_g = np.mean(g) + mean_r = np.mean(r) + + # Set threshold values. Color is white if greater than white threshold, black + # if less than grey threshold and grey if in between both threshold values. + white_threshold = 240 + grey_threshold = 40 + + # Check if the average color is white, grey, or black + if all(val > white_threshold for val in [mean_b, mean_g, mean_r]): + background_color = "white" + elif all(val < grey_threshold for val in [mean_b, mean_g, mean_r]): + background_color = "black" + else: + background_color = "grey" + assert background_color == background + + def test_sleap_render(centered_pair_predictions): args = ( "-o testvis.avi -f 2 --scale 1.2 --frames 1,2 --video-index 0 " diff --git a/tests/nn/architectures/test_common.py b/tests/nn/architectures/test_common.py index a40d621ef..96db870ea 100644 --- a/tests/nn/architectures/test_common.py +++ b/tests/nn/architectures/test_common.py @@ -2,7 +2,7 @@ import tensorflow as tf from sleap.nn.system import use_cpu_only -use_cpu_only() # hide GPUs for test +# use_cpu_only() # hide GPUs for test from sleap.nn.architectures import common diff --git a/tests/nn/architectures/test_encoder_decoder.py b/tests/nn/architectures/test_encoder_decoder.py index 3ce019371..8b8f51f0a 100644 --- a/tests/nn/architectures/test_encoder_decoder.py +++ b/tests/nn/architectures/test_encoder_decoder.py @@ -2,7 +2,7 @@ import tensorflow as tf from sleap.nn.system import use_cpu_only -use_cpu_only() # hide GPUs for test +# use_cpu_only() # hide GPUs for test from sleap.nn.architectures import encoder_decoder diff --git a/tests/nn/architectures/test_hourglass.py b/tests/nn/architectures/test_hourglass.py index 4efe79a1c..c45ff1b91 100644 --- a/tests/nn/architectures/test_hourglass.py +++ b/tests/nn/architectures/test_hourglass.py @@ -2,7 +2,7 @@ import tensorflow as tf from sleap.nn.system import use_cpu_only -use_cpu_only() # hide GPUs for test +# use_cpu_only() # hide GPUs for test from sleap.nn.architectures import hourglass from sleap.nn.config import HourglassConfig diff --git a/tests/nn/architectures/test_leap.py b/tests/nn/architectures/test_leap.py index edf07396b..9a73c80d5 100644 --- a/tests/nn/architectures/test_leap.py +++ b/tests/nn/architectures/test_leap.py @@ -2,7 +2,7 @@ import tensorflow as tf from sleap.nn.system import use_cpu_only -use_cpu_only() # hide GPUs for test +# use_cpu_only() # hide GPUs for test from sleap.nn.architectures import leap from sleap.nn.config import LEAPConfig diff --git a/tests/nn/architectures/test_pretrained_encoders.py b/tests/nn/architectures/test_pretrained_encoders.py index f318754ac..b1f7e0af8 100644 --- a/tests/nn/architectures/test_pretrained_encoders.py +++ b/tests/nn/architectures/test_pretrained_encoders.py @@ -3,7 +3,7 @@ import pytest from sleap.nn.system import use_cpu_only -use_cpu_only() # hide GPUs for test +# use_cpu_only() # hide GPUs for test from sleap.nn.architectures import UnetPretrainedEncoder from sleap.nn.config import PretrainedEncoderConfig diff --git a/tests/nn/architectures/test_resnet.py b/tests/nn/architectures/test_resnet.py index 965ea3b72..b0d9d26eb 100644 --- a/tests/nn/architectures/test_resnet.py +++ b/tests/nn/architectures/test_resnet.py @@ -2,7 +2,7 @@ import tensorflow as tf from sleap.nn.system import use_cpu_only -use_cpu_only() # hide GPUs for test +# use_cpu_only() # hide GPUs for test from sleap.nn.architectures import upsampling from sleap.nn.architectures import resnet diff --git a/tests/nn/architectures/test_unet.py b/tests/nn/architectures/test_unet.py index 98b6d7768..1dad7ea05 100644 --- a/tests/nn/architectures/test_unet.py +++ b/tests/nn/architectures/test_unet.py @@ -2,7 +2,7 @@ import tensorflow as tf from sleap.nn.system import use_cpu_only -use_cpu_only() # hide GPUs for test +# use_cpu_only() # hide GPUs for test from sleap.nn.architectures import unet from sleap.nn.config import UNetConfig diff --git a/tests/nn/config/test_config_utils.py b/tests/nn/config/test_config_utils.py index 69e8ddec8..64d83a141 100644 --- a/tests/nn/config/test_config_utils.py +++ b/tests/nn/config/test_config_utils.py @@ -4,7 +4,7 @@ from sleap.nn.system import use_cpu_only -use_cpu_only() # hide GPUs for test +# use_cpu_only() # hide GPUs for test from sleap.nn.config import utils diff --git a/tests/nn/data/test_augmentation.py b/tests/nn/data/test_augmentation.py index d2b468522..2b95a01a3 100644 --- a/tests/nn/data/test_augmentation.py +++ b/tests/nn/data/test_augmentation.py @@ -1,3 +1,4 @@ +import pytest import numpy as np import tensorflow as tf import sleap @@ -9,14 +10,95 @@ from sleap.nn.data import augmentation +@pytest.fixture +def dummy_instances_data_nans(): + return np.full((2, 2), np.nan, dtype=np.float32) + + +@pytest.fixture +def dummy_instances_data_mixed(): + return np.array([[0.1, np.nan], [0.0, 0.8]], dtype=np.float32) + + +@pytest.fixture +def dummy_image_data(): + return np.zeros((100, 100, 3), dtype=np.uint8) + + +@pytest.fixture +def dummy_instances_data_zeros(): + return np.zeros((2, 2), dtype=np.float32) + + +@pytest.fixture +def rotation_min_angle(): + return 90 + + +@pytest.fixture +def rotation_max_angle(): + return 90 + + +@pytest.fixture +def augmentation_config(rotation_min_angle, rotation_max_angle): + return augmentation.AugmentationConfig( + rotate=True, + rotation_min_angle=rotation_min_angle, + rotation_max_angle=rotation_max_angle, + ) + + +@pytest.fixture +def dummy_dataset(dummy_image_data, dummy_instances_data_zeros): + dataset = tf.data.Dataset.from_tensor_slices( + {"image": [dummy_image_data], "instances": [dummy_instances_data_zeros]} + ) + return dataset + + +@pytest.fixture +def augmenter(augmentation_config): + return augmentation.AlbumentationsAugmenter.from_config(augmentation_config) + + +# Test class instantiation and augmentation +@pytest.mark.parametrize( + "dummy_instances_data", + [ + pytest.param("dummy_instances_data_zeros", id="zeros"), + pytest.param("dummy_instances_data_nans", id="nans"), + pytest.param("dummy_instances_data_mixed", id="mixed"), + ], +) +def test_albumentations_augmenter( + dummy_image_data, dummy_instances_data, augmenter, dummy_dataset +): + # Apply augmentation + augmented_dataset = augmenter.transform_dataset(dummy_dataset) + + # Check if augmentation is applied + augmented_example = next(iter(augmented_dataset)) + assert augmented_example["image"].shape == (100, 100, 3) + assert augmented_example["instances"].shape == (2, 2) + + +# Test class method from_config +def test_albumentations_augmenter_from_config(augmentation_config): + augmenter = augmentation.AlbumentationsAugmenter.from_config(augmentation_config) + assert isinstance(augmenter, augmentation.AlbumentationsAugmenter) + assert augmenter.image_key == "image" + assert augmenter.instances_key == "instances" + + def test_augmentation(min_labels): labels_reader = providers.LabelsReader.from_user_instances(min_labels) ds = labels_reader.make_dataset() example_preaug = next(iter(ds)) - augmenter = augmentation.ImgaugAugmenter.from_config( + augmenter = augmentation.AlbumentationsAugmenter.from_config( augmentation.AugmentationConfig( - rotate=True, rotation_min_angle=-90, rotation_max_angle=-90 + rotate=True, rotation_min_angle=90, rotation_max_angle=90 ) ) ds = augmenter.transform_dataset(ds) @@ -52,13 +134,39 @@ def test_augmentation_with_no_instances(min_labels): ) p = min_labels.to_pipeline(user_labeled_only=False) - p += augmentation.ImgaugAugmenter.from_config( + p += augmentation.AlbumentationsAugmenter.from_config( augmentation.AugmentationConfig(rotate=True) ) exs = p.run() assert exs[-1]["instances"].shape[0] == 0 +def test_augmentation_edges(min_labels): + # Tests 1722 + height, width = min_labels[0].video.shape[1:3] + min_labels[0].instances.append( + sleap.Instance.from_numpy( + [[0, 0], [width, height]], + skeleton=min_labels.skeleton, + ) + ) + + labels_reader = providers.LabelsReader.from_user_instances(min_labels) + ds = labels_reader.make_dataset() + example_preaug = next(iter(ds)) + + augmenter = augmentation.AlbumentationsAugmenter.from_config( + augmentation.AugmentationConfig( + rotate=True, rotation_min_angle=90, rotation_max_angle=90 + ) + ) + ds = augmenter.transform_dataset(ds) + + example = next(iter(ds)) + # TODO: check for correctness + assert example["instances"].shape == (3, 2, 2) + + def test_random_cropper(min_labels): cropper = augmentation.RandomCropper(crop_height=64, crop_width=32) assert "image" in cropper.input_keys diff --git a/tests/nn/data/test_data_training.py b/tests/nn/data/test_data_training.py index eb79464e0..c90a29365 100644 --- a/tests/nn/data/test_data_training.py +++ b/tests/nn/data/test_data_training.py @@ -3,7 +3,7 @@ from sleap.nn.data.training import split_labels_train_val -sleap.use_cpu_only() # hide GPUs for test +# sleap.use_cpu_only() # hide GPUs for test def test_split_labels_train_val(): diff --git a/tests/nn/data/test_edge_maps.py b/tests/nn/data/test_edge_maps.py index 295360538..5eb13f9b8 100644 --- a/tests/nn/data/test_edge_maps.py +++ b/tests/nn/data/test_edge_maps.py @@ -2,7 +2,7 @@ import tensorflow as tf from sleap.nn.system import use_cpu_only -use_cpu_only() # hide GPUs for test +# use_cpu_only() # hide GPUs for test from sleap.nn.data import providers from sleap.nn.data import edge_maps diff --git a/tests/nn/data/test_identity.py b/tests/nn/data/test_identity.py index 52d25dd1b..224eff0ba 100644 --- a/tests/nn/data/test_identity.py +++ b/tests/nn/data/test_identity.py @@ -10,7 +10,7 @@ ) -sleap.use_cpu_only() +# sleap.use_cpu_only() def test_make_class_vectors(): diff --git a/tests/nn/data/test_instance_centroids.py b/tests/nn/data/test_instance_centroids.py index 78dee251c..2d8f57627 100644 --- a/tests/nn/data/test_instance_centroids.py +++ b/tests/nn/data/test_instance_centroids.py @@ -3,7 +3,7 @@ import tensorflow as tf from sleap.nn.system import use_cpu_only -use_cpu_only() # hide GPUs for test +# use_cpu_only() # hide GPUs for test import sleap from sleap.nn.data import providers diff --git a/tests/nn/data/test_instance_cropping.py b/tests/nn/data/test_instance_cropping.py index b54fb0e99..688f50dbd 100644 --- a/tests/nn/data/test_instance_cropping.py +++ b/tests/nn/data/test_instance_cropping.py @@ -3,7 +3,7 @@ import tensorflow as tf from sleap.nn.system import use_cpu_only -use_cpu_only() # hide GPUs for test +# use_cpu_only() # hide GPUs for test from sleap.nn.data import providers from sleap.nn.data import instance_centroids diff --git a/tests/nn/data/test_normalization.py b/tests/nn/data/test_normalization.py index 20a1df4ec..d2eb7c290 100644 --- a/tests/nn/data/test_normalization.py +++ b/tests/nn/data/test_normalization.py @@ -3,7 +3,7 @@ import tensorflow as tf from sleap.nn.system import use_cpu_only -use_cpu_only() # hide GPUs for test +# use_cpu_only() # hide GPUs for test from sleap.nn.data import normalization from sleap.nn.data import providers diff --git a/tests/nn/data/test_offset_regression.py b/tests/nn/data/test_offset_regression.py index 31e688839..ce63894d6 100644 --- a/tests/nn/data/test_offset_regression.py +++ b/tests/nn/data/test_offset_regression.py @@ -4,7 +4,7 @@ from sleap.nn.data import offset_regression -sleap.use_cpu_only() # hide GPUs for test +# sleap.use_cpu_only() # hide GPUs for test def test_make_offsets(): diff --git a/tests/nn/data/test_pipelines.py b/tests/nn/data/test_pipelines.py index 30b67e13c..7d442c32d 100644 --- a/tests/nn/data/test_pipelines.py +++ b/tests/nn/data/test_pipelines.py @@ -3,7 +3,7 @@ import tensorflow as tf from sleap.nn.system import use_cpu_only -use_cpu_only() # hide GPUs for test +# use_cpu_only() # hide GPUs for test import sleap from sleap.nn.data import pipelines diff --git a/tests/nn/data/test_providers.py b/tests/nn/data/test_providers.py index 279244ea1..f30216e6a 100644 --- a/tests/nn/data/test_providers.py +++ b/tests/nn/data/test_providers.py @@ -2,7 +2,7 @@ import tensorflow as tf from sleap.nn.system import use_cpu_only -use_cpu_only() # hide GPUs for test +# use_cpu_only() # hide GPUs for test import sleap from sleap.nn.data import providers diff --git a/tests/nn/data/test_resizing.py b/tests/nn/data/test_resizing.py index 440ca66d0..6ef15c2f1 100644 --- a/tests/nn/data/test_resizing.py +++ b/tests/nn/data/test_resizing.py @@ -1,14 +1,10 @@ import pytest import numpy as np import tensorflow as tf -from sleap.nn.system import use_cpu_only - -use_cpu_only() # hide GPUs for test - import sleap from sleap.nn.system import use_cpu_only -use_cpu_only() # hide GPUs for test +# use_cpu_only() # hide GPUs for test from sleap.nn.data import resizing from sleap.nn.data import providers from sleap.nn.data.resizing import SizeMatcher diff --git a/tests/nn/data/test_utils.py b/tests/nn/data/test_utils.py index 213e357e8..7fa98a57a 100644 --- a/tests/nn/data/test_utils.py +++ b/tests/nn/data/test_utils.py @@ -2,7 +2,7 @@ import tensorflow as tf from sleap.nn.system import use_cpu_only -use_cpu_only() # hide GPUs for test +# use_cpu_only() # hide GPUs for test from sleap.nn.data import utils diff --git a/tests/nn/test_evals.py b/tests/nn/test_evals.py index 265994056..48f0d69f8 100644 --- a/tests/nn/test_evals.py +++ b/tests/nn/test_evals.py @@ -20,7 +20,7 @@ from sleap.nn.model import Model -sleap.use_cpu_only() +# sleap.use_cpu_only() def test_compute_oks(): diff --git a/tests/nn/test_heads.py b/tests/nn/test_heads.py index 02fbc2737..a4acbb15f 100644 --- a/tests/nn/test_heads.py +++ b/tests/nn/test_heads.py @@ -21,7 +21,7 @@ ) -sleap.use_cpu_only() +# sleap.use_cpu_only() def test_single_instance_confmaps_head(): diff --git a/tests/nn/test_inference.py b/tests/nn/test_inference.py index fe848bb1c..0a978de0a 100644 --- a/tests/nn/test_inference.py +++ b/tests/nn/test_inference.py @@ -3,12 +3,15 @@ import zipfile from pathlib import Path from typing import cast +import shutil +import csv import numpy as np import pytest +import pandas as pd import tensorflow as tf -import tensorflow_hub as hub from numpy.testing import assert_array_equal, assert_allclose +from sleap.io.video import available_video_exts import sleap from sleap.gui.learning import runners @@ -50,6 +53,7 @@ _make_tracker_from_cli, main as sleap_track, export_cli as sleap_export, + _make_export_cli_parser, ) from sleap.nn.tracking import ( MatchedFrameInstance, @@ -60,7 +64,7 @@ from sleap.instance import Track -sleap.nn.system.use_cpu_only() +# sleap.nn.system.use_cpu_only() @pytest.fixture @@ -925,7 +929,7 @@ def test_load_model(resize_input_shape, model_fixture_name, request): predictor = load_model(model_path, resize_input_layer=resize_input_shape) # Determine predictor type - for (fname, mname, ptype, ishape) in fname_mname_ptype_ishape: + for fname, mname, ptype, ishape in fname_mname_ptype_ishape: if fname in model_fixture_name: expected_model_name = mname expected_predictor_type = ptype @@ -966,7 +970,6 @@ def test_topdown_multi_size_inference( def test_ensure_numpy( min_centroid_model_path, min_centered_instance_model_path, min_labels_slp ): - model = load_model([min_centroid_model_path, min_centered_instance_model_path]) # each frame has same number of instances @@ -1037,7 +1040,6 @@ def test_ensure_numpy( def test_centroid_inference(): - xv, yv = make_grid_vectors(image_height=12, image_width=12, output_stride=1) points = tf.cast([[[1.75, 2.75]], [[3.75, 4.75]], [[5.75, 6.75]]], tf.float32) cms = tf.expand_dims(make_multi_confmaps(points, xv, yv, sigma=1.5), axis=0) @@ -1093,7 +1095,6 @@ def test_centroid_inference(): def export_frozen_graph(model, preds, output_path): - tensors = {} for key, val in preds.items(): @@ -1120,7 +1121,6 @@ def export_frozen_graph(model, preds, output_path): info = json.load(json_file) for tensor_info in info["frozen_model_inputs"] + info["frozen_model_outputs"]: - saved_name = ( tensor_info.split("Tensor(")[1].split(", shape")[0].replace('"', "") ) @@ -1137,7 +1137,6 @@ def export_frozen_graph(model, preds, output_path): def test_single_instance_save(min_single_instance_robot_model_path, tmp_path): - single_instance_model = tf.keras.models.load_model( min_single_instance_robot_model_path + "/best_model.h5", compile=False ) @@ -1152,7 +1151,6 @@ def test_single_instance_save(min_single_instance_robot_model_path, tmp_path): def test_centroid_save(min_centroid_model_path, tmp_path): - centroid_model = tf.keras.models.load_model( min_centroid_model_path + "/best_model.h5", compile=False ) @@ -1171,7 +1169,6 @@ def test_centroid_save(min_centroid_model_path, tmp_path): def test_topdown_save( min_centroid_model_path, min_centered_instance_model_path, min_labels_slp, tmp_path ): - centroid_model = tf.keras.models.load_model( min_centroid_model_path + "/best_model.h5", compile=False ) @@ -1195,7 +1192,6 @@ def test_topdown_save( def test_topdown_id_save( min_centroid_model_path, min_topdown_multiclass_model_path, min_labels_slp, tmp_path ): - centroid_model = tf.keras.models.load_model( min_centroid_model_path + "/best_model.h5", compile=False ) @@ -1217,7 +1213,6 @@ def test_topdown_id_save( def test_single_instance_predictor_save(min_single_instance_robot_model_path, tmp_path): - # directly initialize predictor predictor = SingleInstancePredictor.from_trained_models( min_single_instance_robot_model_path, resize_input_layer=False @@ -1254,10 +1249,33 @@ def test_single_instance_predictor_save(min_single_instance_robot_model_path, tm ) +def test_make_export_cli(): + models_path = r"psuedo/models/path" + export_path = r"psuedo/test/path" + max_instances = 5 + + parser = _make_export_cli_parser() + + # Test default values + args = None + args, _ = parser.parse_known_args(args=args) + assert args.models is None + assert args.export_path == "exported_model" + assert not args.ragged + assert args.max_instances is None + + # Test all arguments + cmd = f"-m {models_path} -e {export_path} -r -n {max_instances}" + args, _ = parser.parse_known_args(args=cmd.split()) + assert args.models == [models_path] + assert args.export_path == export_path + assert args.ragged + assert args.max_instances == max_instances + + def test_topdown_predictor_save( min_centroid_model_path, min_centered_instance_model_path, tmp_path ): - # directly initialize predictor predictor = TopDownPredictor.from_trained_models( centroid_model_path=min_centroid_model_path, @@ -1300,7 +1318,6 @@ def test_topdown_predictor_save( def test_topdown_id_predictor_save( min_centroid_model_path, min_topdown_multiclass_model_path, tmp_path ): - # directly initialize predictor predictor = TopDownMultiClassPredictor.from_trained_models( centroid_model_path=min_centroid_model_path, @@ -1358,7 +1375,7 @@ def test_retracking( # Create sleap-track command cmd = ( f"{slp_path} --tracking.tracker {tracker_method} --video.index 0 --frames 1-3 " - "--cpu" + "--tracking.similarity object_keypoint --cpu" ) if tracker_method == "flow": cmd += " --tracking.save_shifted_instances 1" @@ -1378,6 +1395,8 @@ def test_retracking( parser = _make_cli_parser() args, _ = parser.parse_known_args(args=args) tracker = _make_tracker_from_cli(args) + # Additional check for similarity method + assert tracker.similarity_function.__name__ == "object_keypoint_similarity" output_path = f"{slp_path}.{tracker.get_name()}.slp" # Assert tracked predictions file exists @@ -1433,7 +1452,49 @@ def test_make_predictor_from_cli( assert predictor.max_instances == 5 -def test_sleap_track( +def test_make_predictor_from_cli_mult_input( + centered_pair_predictions: Labels, + min_centroid_model_path: str, + min_centered_instance_model_path: str, + min_bottomup_model_path: str, + tmpdir, +): + slp_path = tmpdir.mkdir("slp_directory") + + slp_file = slp_path / "old_slp.slp" + Labels.save(centered_pair_predictions, slp_file) + + # Copy and paste the video into the temp dir multiple times + num_copies = 3 + for i in range(num_copies): + # Construct the destination path with a unique name for the video + + # Construct the destination path with a unique name for the SLP file + slp_dest_path = slp_path / f"old_slp_copy_{i}.slp" + shutil.copy(slp_file, slp_dest_path) + + # Create sleap-track command + model_args = [ + f"--model {min_centroid_model_path} --model {min_centered_instance_model_path}", + f"--model {min_bottomup_model_path}", + ] + for model_arg in model_args: + args = ( + f"{slp_path} {model_arg} --video.index 0 --frames 1-3 " + "--cpu --max_instances 5" + ).split() + parser = _make_cli_parser() + args, _ = parser.parse_known_args(args=args) + + # Create predictor + predictor = _make_predictor_from_cli(args=args) + if isinstance(predictor, TopDownPredictor): + assert predictor.inference_model.centroid_crop.max_instances == 5 + elif isinstance(predictor, BottomUpPredictor): + assert predictor.max_instances == 5 + + +def test_sleap_track_single_input( centered_pair_predictions: Labels, min_centroid_model_path: str, min_centered_instance_model_path: str, @@ -1452,7 +1513,7 @@ def test_sleap_track( sleap_track(args=args) # Assert predictions file exists - output_path = f"{slp_path}.predictions.slp" + output_path = Path(slp_path).with_suffix(".predictions.slp") assert Path(output_path).exists() # Create invalid sleap-track command @@ -1461,9 +1522,398 @@ def test_sleap_track( sleap_track(args=args) -def test_flow_tracker(centered_pair_predictions: Labels, tmpdir): +@pytest.mark.parametrize("tracking", ["simple", "flow", "None"]) +def test_sleap_track_mult_input_slp( + min_centroid_model_path: str, + min_centered_instance_model_path: str, + tmpdir, + centered_pair_predictions: Labels, + tracking, +): + # Create temporary directory with the structured video files + slp_path = tmpdir.mkdir("slp_directory") + + slp_file = slp_path / "old_slp.slp" + Labels.save(centered_pair_predictions, slp_file) + + slp_path_obj = Path(slp_path) + + # Copy and paste the video into the temp dir multiple times + num_copies = 3 + for i in range(num_copies): + # Construct the destination path with a unique name for the SLP file + slp_dest_path = slp_path / f"old_slp_copy_{i}.slp" + shutil.copy(slp_file, slp_dest_path) + + # Create sleap-track command + args = ( + f"{slp_path} --model {min_centroid_model_path} " + f"--tracking.tracker {tracking} " + f"--model {min_centered_instance_model_path} --video.index 0 --frames 1-3 --cpu" + ).split() + + slp_path_list = [file for file in slp_path_obj.iterdir() if file.is_file()] + + # Run inference + sleap_track(args=args) + + # Assert predictions file exists + expected_extensions = available_video_exts() + + for file_path in slp_path_list: + if file_path.suffix in expected_extensions: + expected_output_file = Path(file_path).with_suffix(".predictions.slp") + assert Path(expected_output_file).exists() + + +@pytest.mark.parametrize("tracking", ["simple", "flow", "None"]) +def test_sleap_track_mult_input_slp_mp4( + min_centroid_model_path: str, + min_centered_instance_model_path: str, + centered_pair_vid_path, + tracking, + tmpdir, + centered_pair_predictions: Labels, +): + # Create temporary directory with the structured video files + slp_path = tmpdir.mkdir("slp_mp4_directory") + + slp_file = slp_path / "old_slp.slp" + Labels.save(centered_pair_predictions, slp_file) + + # Copy and paste the video into temp dir multiple times + num_copies = 3 + for i in range(num_copies): + # Construct the destination path with a unique name + dest_path = slp_path / f"centered_pair_vid_copy_{i}.mp4" + shutil.copy(centered_pair_vid_path, dest_path) + + slp_path_obj = Path(slp_path) + + # Create sleap-track command + args = ( + f"{slp_path} --model {min_centroid_model_path} " + f"--tracking.tracker {tracking} " + f"--model {min_centered_instance_model_path} --video.index 0 --frames 1-3 --cpu" + ).split() + + slp_path_list = [file for file in slp_path_obj.iterdir() if file.is_file()] + + # Run inference + sleap_track(args=args) + + expected_extensions = available_video_exts() + + for file_path in slp_path_list: + if file_path.suffix in expected_extensions: + expected_output_file = Path(file_path).with_suffix(".predictions.slp") + assert Path(expected_output_file).exists() + + +@pytest.mark.parametrize("tracking", ["simple", "flow", "None"]) +def test_sleap_track_mult_input_mp4( + min_centroid_model_path: str, + min_centered_instance_model_path: str, + centered_pair_vid_path, + tracking, + tmpdir, +): + + # Create temporary directory with the structured video files + slp_path = tmpdir.mkdir("mp4_directory") + + # Copy and paste the video into the temp dir multiple times + num_copies = 3 + for i in range(num_copies): + # Construct the destination path with a unique name + dest_path = slp_path / f"centered_pair_vid_copy_{i}.mp4" + shutil.copy(centered_pair_vid_path, dest_path) + + slp_path_obj = Path(slp_path) + + # Create sleap-track command + args = ( + f"{slp_path} --model {min_centroid_model_path} " + f"--tracking.tracker {tracking} " + f"--model {min_centered_instance_model_path} --video.index 0 --frames 1-3 --cpu" + ).split() + + slp_path_list = [file for file in slp_path_obj.iterdir() if file.is_file()] + + # Run inference + sleap_track(args=args) + + # Assert predictions file exists + expected_extensions = available_video_exts() + + for file_path in slp_path_list: + if file_path.suffix in expected_extensions: + expected_output_file = Path(file_path).with_suffix(".predictions.slp") + assert Path(expected_output_file).exists() + + +def test_sleap_track_output_mult( + min_centroid_model_path: str, + min_centered_instance_model_path: str, + centered_pair_vid_path, + tmpdir, +): + + output_path = tmpdir.mkdir("output_directory") + output_path_obj = Path(output_path) + + # Create temporary directory with the structured video files + slp_path = tmpdir.mkdir("mp4_directory") + + # Copy and paste the video into the temp dir multiple times + num_copies = 3 + for i in range(num_copies): + # Construct the destination path with a unique name + dest_path = slp_path / f"centered_pair_vid_copy_{i}.mp4" + shutil.copy(centered_pair_vid_path, dest_path) + + slp_path_obj = Path(slp_path) + + # Create sleap-track command + args = ( + f"{slp_path} --model {min_centroid_model_path} " + f"--tracking.tracker simple " + f"-o {output_path} " + f"--model {min_centered_instance_model_path} --video.index 0 --frames 1-3 --cpu" + ).split() + + slp_path_list = [file for file in slp_path_obj.iterdir() if file.is_file()] + + # Run inference + sleap_track(args=args) + slp_path = Path(slp_path) + + # Check if there are any files in the directory + expected_extensions = available_video_exts() + + for file_path in slp_path_list: + if file_path.suffix in expected_extensions: + expected_output_file = output_path_obj / ( + file_path.stem + ".predictions.slp" + ) + assert Path(expected_output_file).exists() + + +def test_sleap_track_invalid_output( + min_centroid_model_path: str, + min_centered_instance_model_path: str, + centered_pair_vid_path, + centered_pair_predictions: Labels, + tmpdir, +): + + output_path = Path(tmpdir, "output_file.slp").as_posix() + Labels.save(centered_pair_predictions, output_path) + + # Create temporary directory with the structured video files + slp_path = tmpdir.mkdir("mp4_directory") + + # Copy and paste the video into the temp dir multiple times + num_copies = 3 + for i in range(num_copies): + # Construct the destination path with a unique name + dest_path = slp_path / f"centered_pair_vid_copy_{i}.mp4" + shutil.copy(centered_pair_vid_path, dest_path) + + # Create sleap-track command + args = ( + f"{slp_path} --model {min_centroid_model_path} " + f"--tracking.tracker simple " + f"-o {output_path} " + f"--model {min_centered_instance_model_path} --video.index 0 --frames 1-3 --cpu" + ).split() + + # Run inference + with pytest.raises(ValueError): + sleap_track(args=args) + + +def test_sleap_track_invalid_input( + min_centroid_model_path: str, + min_centered_instance_model_path: str, +): + + slp_path = "" + + # Create sleap-track command + args = ( + f"{slp_path} --model {min_centroid_model_path} " + f"--tracking.tracker simple " + f"--model {min_centered_instance_model_path} --video.index 0 --frames 1-3 --cpu" + ).split() + + # Run inference + with pytest.raises(ValueError): + sleap_track(args=args) + + # Test with a non-existent path + slp_path = "/path/to/nonexistent/file.mp4" + + # Create sleap-track command for non-existent path + args = ( + f"{slp_path} --model {min_centroid_model_path} " + f"--tracking.tracker simple " + f"--model {min_centered_instance_model_path} --video.index 0 --frames 1-3 --cpu" + ).split() + + # Run inference and expect a ValueError for non-existent path + with pytest.raises(ValueError): + sleap_track(args=args) + + +def test_sleap_track_csv_input( + min_centroid_model_path: str, + min_centered_instance_model_path: str, + centered_pair_vid_path, + tmpdir, +): + + # Create temporary directory with the structured video files + slp_path = Path(tmpdir.mkdir("mp4_directory")) + + # Copy and paste the video into the temp dir multiple times + num_copies = 3 + file_paths = [] + for i in range(num_copies): + # Construct the destination path with a unique name + dest_path = slp_path / f"centered_pair_vid_copy_{i}.mp4" + shutil.copy(centered_pair_vid_path, dest_path) + file_paths.append(dest_path) + + # Generate output paths for each data_path + output_paths = [ + file_path.with_suffix(".TESTpredictions.slp") for file_path in file_paths + ] + + # Create a CSV file with the file paths + csv_file_path = slp_path / "file_paths.csv" + with open(csv_file_path, mode="w", newline="") as csv_file: + csv_writer = csv.writer(csv_file) + csv_writer.writerow(["data_path", "output_path"]) + for data_path, output_path in zip(file_paths, output_paths): + csv_writer.writerow([data_path, output_path]) + + slp_path_obj = Path(slp_path) + + # Create sleap-track command + args = ( + f"{csv_file_path} --model {min_centroid_model_path} " + f"--tracking.tracker simple " + f"--model {min_centered_instance_model_path} --video.index 0 --frames 1-3 --cpu" + ).split() + + slp_path_list = [file for file in slp_path_obj.iterdir() if file.is_file()] + + # Run inference + sleap_track(args=args) + + # Assert predictions file exists + expected_extensions = available_video_exts() + + for file_path in slp_path_list: + if file_path.suffix in expected_extensions: + expected_output_file = file_path.with_suffix(".TESTpredictions.slp") + assert Path(expected_output_file).exists() + + +def test_sleap_track_invalid_csv( + min_centroid_model_path: str, + min_centered_instance_model_path: str, + tmpdir, +): + + # Create a CSV file with nonexistant data files + csv_nonexistant_files_path = tmpdir / "nonexistant_files.csv" + df_nonexistant_files = pd.DataFrame( + {"data_path": ["video1.mp4", "video2.mp4", "video3.mp4"]} + ) + df_nonexistant_files.to_csv(csv_nonexistant_files_path, index=False) + + # Create an empty CSV file + csv_empty_path = tmpdir / "empty.csv" + open(csv_empty_path, "w").close() + + # Create sleap-track command for missing 'data_path' column + args_missing_column = ( + f"{csv_nonexistant_files_path} --model {min_centroid_model_path} " + f"--tracking.tracker simple " + f"--model {min_centered_instance_model_path} --video.index 0 --frames 1-3 --cpu" + ).split() + + # Run inference and expect ValueError for missing 'data_path' column + with pytest.raises( + ValueError, + ): + sleap_track(args=args_missing_column) + + # Create sleap-track command for empty CSV file + args_empty = ( + f"{csv_empty_path} --model {min_centroid_model_path} " + f"--tracking.tracker simple " + f"--model {min_centered_instance_model_path} --video.index 0 --frames 1-3 --cpu" + ).split() + + # Run inference and expect ValueError for empty CSV file + with pytest.raises(ValueError): + sleap_track(args=args_empty) + + +def test_sleap_track_text_file_input( + min_centroid_model_path: str, + min_centered_instance_model_path: str, + centered_pair_vid_path, + tmpdir, +): + + # Create temporary directory with the structured video files + slp_path = Path(tmpdir.mkdir("mp4_directory")) + + # Copy and paste the video into the temp dir multiple times + num_copies = 3 + file_paths = [] + for i in range(num_copies): + # Construct the destination path with a unique name + dest_path = slp_path / f"centered_pair_vid_copy_{i}.mp4" + shutil.copy(centered_pair_vid_path, dest_path) + file_paths.append(dest_path) + + # Create a text file with the file paths + txt_file_path = slp_path / "file_paths.txt" + with open(txt_file_path, mode="w") as txt_file: + for file_path in file_paths: + txt_file.write(f"{file_path}\n") + + slp_path_obj = Path(slp_path) + + # Create sleap-track command + args = ( + f"{txt_file_path} --model {min_centroid_model_path} " + f"--tracking.tracker simple " + f"--model {min_centered_instance_model_path} --video.index 0 --frames 1-3 --cpu" + ).split() + + slp_path_list = [file for file in slp_path_obj.iterdir() if file.is_file()] + + # Run inference + sleap_track(args=args) + + # Assert predictions file exists + expected_extensions = available_video_exts() + + for file_path in slp_path_list: + if file_path.suffix in expected_extensions: + expected_output_file = Path(file_path).with_suffix(".predictions.slp") + assert Path(expected_output_file).exists() + + +def test_flow_tracker(centered_pair_predictions_sorted: Labels, tmpdir): """Test flow tracker instances are pruned.""" - labels: Labels = centered_pair_predictions + labels: Labels = centered_pair_predictions_sorted track_window = 5 # Setup tracker @@ -1473,17 +1923,20 @@ def test_flow_tracker(centered_pair_predictions: Labels, tmpdir): tracker.candidate_maker = cast(FlowCandidateMaker, tracker.candidate_maker) # Run tracking - frames = sorted(labels.labeled_frames, key=lambda lf: lf.frame_idx) + frames = labels.labeled_frames # Run tracking on subset of frames using psuedo-implementation of # sleap.nn.tracking.run_tracker for lf in frames[:20]: - # Clear the tracks for inst in lf.instances: inst.track = None - track_args = dict(untracked_instances=lf.instances, img=lf.video[lf.frame_idx]) + track_args = dict( + untracked_instances=lf.instances, + img=lf.video[lf.frame_idx], + img_hw=lf.image.shape[-3:-1], + ) tracker.track(**track_args) # Check that saved instances are pruned to track window @@ -1522,12 +1975,15 @@ def test_max_tracks_matching_queue( frames = sorted(labels.labeled_frames, key=lambda lf: lf.frame_idx) for lf in frames[:20]: - # Clear the tracks for inst in lf.instances: inst.track = None - track_args = dict(untracked_instances=lf.instances, img=lf.video[lf.frame_idx]) + track_args = dict( + untracked_instances=lf.instances, + img=lf.video[lf.frame_idx], + img_hw=lf.image.shape[-3:-1], + ) tracker.track(**track_args) if trackername == "flowmaxtracks": @@ -1583,8 +2039,7 @@ def test_movenet_predictor(min_dance_labels, movenet_video): [labels_pr[0][0].numpy(), labels_pr[1][0].numpy()], axis=0 ) - max_diff = np.nanmax(np.abs(points_gt - points_pr)) - assert max_diff < 0.1 + np.testing.assert_allclose(points_gt, points_pr, atol=0.75) @pytest.mark.parametrize( diff --git a/tests/nn/test_inference_identity.py b/tests/nn/test_inference_identity.py index 22be152ea..aaacfef61 100644 --- a/tests/nn/test_inference_identity.py +++ b/tests/nn/test_inference_identity.py @@ -9,7 +9,7 @@ ) -sleap.use_cpu_only() +# sleap.use_cpu_only() def test_group_class_peaks(): diff --git a/tests/nn/test_model.py b/tests/nn/test_model.py index 329e5528f..6c60cb354 100644 --- a/tests/nn/test_model.py +++ b/tests/nn/test_model.py @@ -15,7 +15,7 @@ ModelConfig, ) -sleap.use_cpu_only() +# sleap.use_cpu_only() def test_model_from_config(): diff --git a/tests/nn/test_nn_utils.py b/tests/nn/test_nn_utils.py index 15b9d4bf3..4e8703c05 100644 --- a/tests/nn/test_nn_utils.py +++ b/tests/nn/test_nn_utils.py @@ -6,7 +6,7 @@ from sleap.nn.inference import TopDownPredictor from sleap.nn.utils import tf_linear_sum_assignment, match_points, reset_input_layer -sleap.use_cpu_only() +# sleap.use_cpu_only() def test_tf_linear_sum_assignment(): diff --git a/tests/nn/test_paf_grouping.py b/tests/nn/test_paf_grouping.py index 4856c1fed..d9578bfa9 100644 --- a/tests/nn/test_paf_grouping.py +++ b/tests/nn/test_paf_grouping.py @@ -22,7 +22,7 @@ assign_connections_to_instances, ) -sleap.nn.system.use_cpu_only() +# sleap.nn.system.use_cpu_only() def test_get_connection_candidates(): diff --git a/tests/nn/test_peak_finding.py b/tests/nn/test_peak_finding.py index 93beaa193..243653202 100644 --- a/tests/nn/test_peak_finding.py +++ b/tests/nn/test_peak_finding.py @@ -22,7 +22,7 @@ ) -sleap.nn.system.use_cpu_only() +# sleap.nn.system.use_cpu_only() def test_find_local_offsets(): diff --git a/tests/nn/test_system.py b/tests/nn/test_system.py index fc95bb0ea..7b16f1219 100644 --- a/tests/nn/test_system.py +++ b/tests/nn/test_system.py @@ -4,13 +4,19 @@ be available. """ -from sleap.nn.system import get_gpu_memory -from sleap.nn.system import get_all_gpus +from sleap.nn.system import ( + get_gpu_memory, + get_all_gpus, + use_cpu_only, + use_gpu, + is_gpu_system, +) import os import pytest import subprocess import tensorflow as tf import shutil +import platform def test_get_gpu_memory(): @@ -93,3 +99,17 @@ def test_gpu_device_order(): """Indirectly tests GPU device order by ensuring environment variable is set.""" assert os.environ["CUDA_DEVICE_ORDER"] == "PCI_BUS_ID" + + +@pytest.mark.skipif( + not ("arm64" in platform.platform()), + reason="Only test on macosx-arm64", +) +def test_reinitialize(): + """This test tries to change the devices after they have been initialized.""" + assert is_gpu_system() + use_gpu(0) + tf.zeros((1,)) + tf.ones((1,)) + # The following would normally throw: + # RuntimeError: Visible devices cannot be modified after being initialized + use_cpu_only() diff --git a/tests/nn/test_tracker_components.py b/tests/nn/test_tracker_components.py index f861241ee..0c7ba2b0a 100644 --- a/tests/nn/test_tracker_components.py +++ b/tests/nn/test_tracker_components.py @@ -9,23 +9,82 @@ FrameMatches, greedy_matching, ) +from sleap.io.dataset import Labels from sleap.instance import PredictedInstance from sleap.skeleton import Skeleton +def tracker_by_name(frames=None, **kwargs): + t = Tracker.make_tracker_by_name(**kwargs) + print(kwargs) + print(t.candidate_maker) + if frames is None: + t.track([]) + t.final_pass([]) + return + + for lf in frames: + # Clear the tracks + for inst in lf.instances: + inst.track = None + + track_args = dict(untracked_instances=lf.instances, img=lf.video[lf.frame_idx]) + t.track(**track_args, img_hw=(1, 1)) + t.final_pass(frames) + + @pytest.mark.parametrize( "tracker", ["simple", "flow", "simplemaxtracks", "flowmaxtracks"] ) -@pytest.mark.parametrize("similarity", ["instance", "iou", "centroid"]) +@pytest.mark.parametrize( + "similarity", + ["instance", "normalized_instance", "iou", "centroid", "object_keypoint"], +) @pytest.mark.parametrize("match", ["greedy", "hungarian"]) @pytest.mark.parametrize("count", [0, 2]) -def test_tracker_by_name(tracker, similarity, match, count): - t = Tracker.make_tracker_by_name( - "flow", "instance", "greedy", clean_instance_count=2 +def test_tracker_by_name( + centered_pair_predictions_sorted, + tracker, + similarity, + match, + count, +): + # This is slow, so limit to 5 time points + frames = centered_pair_predictions_sorted[:5] + + tracker_by_name( + frames=frames, + tracker=tracker, + similarity=similarity, + match=match, + max_tracks=count, + ) + + +@pytest.mark.parametrize( + "tracker", ["simple", "flow", "simplemaxtracks", "flowmaxtracks"] +) +@pytest.mark.parametrize("oks_score_weighting", ["True", "False"]) +@pytest.mark.parametrize("oks_normalization", ["all", "ref", "union"]) +def test_oks_tracker_by_name( + centered_pair_predictions_sorted, + tracker, + oks_score_weighting, + oks_normalization, +): + # This is slow, so limit to 5 time points + frames = centered_pair_predictions_sorted[:5] + + tracker_by_name( + frames=frames, + tracker=tracker, + similarity="object_keypoint", + matching="greedy", + oks_score_weighting=oks_score_weighting, + oks_normalization=oks_normalization, + max_tracks=2, ) - t.track([]) - t.final_pass([]) def test_cull_instances(centered_pair_predictions): @@ -232,7 +291,7 @@ def test_max_tracking_large_gap_single_track(): tracked = [] for insts in preds: - tracked_insts = tracker.track(insts) + tracked_insts = tracker.track(insts, img_hw=(1, 1)) tracked.append(tracked_insts) all_tracks = list(set([inst.track for frame in tracked for inst in frame])) @@ -249,7 +308,7 @@ def test_max_tracking_large_gap_single_track(): tracked = [] for insts in preds: - tracked_insts = tracker.track(insts) + tracked_insts = tracker.track(insts, img_hw=(1, 1)) tracked.append(tracked_insts) all_tracks = list(set([inst.track for frame in tracked for inst in frame])) @@ -296,7 +355,7 @@ def test_max_tracking_small_gap_on_both_tracks(): tracked = [] for insts in preds: - tracked_insts = tracker.track(insts) + tracked_insts = tracker.track(insts, img_hw=(1, 1)) tracked.append(tracked_insts) all_tracks = list(set([inst.track for frame in tracked for inst in frame])) @@ -313,7 +372,7 @@ def test_max_tracking_small_gap_on_both_tracks(): tracked = [] for insts in preds: - tracked_insts = tracker.track(insts) + tracked_insts = tracker.track(insts, img_hw=(1, 1)) tracked.append(tracked_insts) all_tracks = list(set([inst.track for frame in tracked for inst in frame])) @@ -365,7 +424,7 @@ def test_max_tracking_extra_detections(): tracked = [] for insts in preds: - tracked_insts = tracker.track(insts) + tracked_insts = tracker.track(insts, img_hw=(1, 1)) tracked.append(tracked_insts) all_tracks = list(set([inst.track for frame in tracked for inst in frame])) @@ -382,7 +441,7 @@ def test_max_tracking_extra_detections(): tracked = [] for insts in preds: - tracked_insts = tracker.track(insts) + tracked_insts = tracker.track(insts, img_hw=(1, 1)) tracked.append(tracked_insts) all_tracks = list(set([inst.track for frame in tracked for inst in frame])) diff --git a/tests/nn/test_tracking_integration.py b/tests/nn/test_tracking_integration.py index a6592dc4d..625302fd0 100644 --- a/tests/nn/test_tracking_integration.py +++ b/tests/nn/test_tracking_integration.py @@ -102,7 +102,7 @@ def run_tracker(frames, tracker): new_lf = LabeledFrame( frame_idx=lf.frame_idx, video=lf.video, - instances=tracker.track(**track_args), + instances=tracker.track(**track_args, img_hw=lf.image.shape[-3:-1]), ) new_lfs.append(new_lf) @@ -138,6 +138,8 @@ def main(f, dir): instance=sleap.nn.tracker.components.instance_similarity, centroid=sleap.nn.tracker.components.centroid_distance, iou=sleap.nn.tracker.components.instance_iou, + normalized_instance=sleap.nn.tracker.components.normalized_instance_similarity, + object_keypoint=sleap.nn.tracker.components.factory_object_keypoint_similarity(), ) scales = ( 1, diff --git a/tests/nn/test_training.py b/tests/nn/test_training.py index 55f404929..72db17bb5 100644 --- a/tests/nn/test_training.py +++ b/tests/nn/test_training.py @@ -25,7 +25,7 @@ create_trainer_using_cli as sleap_train, ) -sleap.use_cpu_only() +# sleap.use_cpu_only() @pytest.fixture @@ -44,7 +44,7 @@ def cfg(): cfg = TrainingJobConfig() cfg.data.instance_cropping.center_on_part = "A" cfg.model.backbone.unet = UNetConfig( - max_stride=8, output_stride=1, filters=8, filters_rate=1.0 + max_stride=8, output_stride=1, filters=2, filters_rate=1.0 ) cfg.optimization.preload_data = False cfg.optimization.batch_size = 1 @@ -123,34 +123,61 @@ def test_train_load_single_instance( assert (w == w2).all() -def test_train_single_instance(min_labels_robot, cfg): +def test_train_single_instance(min_labels_robot, cfg, tmp_path): cfg.model.heads.single_instance = SingleInstanceConfmapsHeadConfig( sigma=1.5, output_stride=1, offset_refinement=False ) + + # Set save directory + cfg.outputs.run_name = "test_run" + cfg.outputs.runs_folder = str(tmp_path / "training_runs") # ensure it's a string + cfg.outputs.save_visualizations = True + cfg.outputs.keep_viz_images = True + cfg.outputs.save_outputs = True # enable saving + trainer = SingleInstanceModelTrainer.from_config( cfg, training_labels=min_labels_robot ) trainer.setup() trainer.train() + + run_path = Path(cfg.outputs.runs_folder, cfg.outputs.run_name) + viz_path = run_path / "viz" + assert trainer.keras_model.output_names[0] == "SingleInstanceConfmapsHead" assert tuple(trainer.keras_model.outputs[0].shape) == (None, 320, 560, 2) + assert viz_path.exists() -def test_train_single_instance_with_offset(min_labels_robot, cfg): +def test_train_single_instance_with_offset(min_labels_robot, cfg, tmp_path): cfg.model.heads.single_instance = SingleInstanceConfmapsHeadConfig( sigma=1.5, output_stride=1, offset_refinement=True ) + + # Set save directory + cfg.outputs.run_name = "test_run" + cfg.outputs.runs_folder = str(tmp_path / "training_runs") # ensure it's a string + cfg.outputs.save_visualizations = False + cfg.outputs.keep_viz_images = False + cfg.outputs.save_outputs = True # enable saving + trainer = SingleInstanceModelTrainer.from_config( cfg, training_labels=min_labels_robot ) trainer.setup() trainer.train() + + run_path = Path(cfg.outputs.runs_folder, cfg.outputs.run_name) + viz_path = run_path / "viz" + assert trainer.keras_model.output_names[0] == "SingleInstanceConfmapsHead" assert tuple(trainer.keras_model.outputs[0].shape) == (None, 320, 560, 2) assert trainer.keras_model.output_names[1] == "OffsetRefinementHead" assert tuple(trainer.keras_model.outputs[1].shape) == (None, 320, 560, 4) + assert not viz_path.exists() + def test_train_centroids(training_labels, cfg): cfg.model.heads.centroid = CentroidsHeadConfig( @@ -251,12 +278,12 @@ def test_train_bottomup_with_offset(training_labels, cfg): def test_train_bottomup_multiclass(min_tracks_2node_labels, cfg): labels = min_tracks_2node_labels - cfg.data.preprocessing.input_scaling = 0.5 + cfg.data.preprocessing.input_scaling = 0.25 cfg.model.heads.multi_class_bottomup = sleap.nn.config.MultiClassBottomUpConfig( confmaps=sleap.nn.config.MultiInstanceConfmapsHeadConfig( - output_stride=2, offset_refinement=False + output_stride=4, offset_refinement=False ), - class_maps=sleap.nn.config.ClassMapsHeadConfig(output_stride=2), + class_maps=sleap.nn.config.ClassMapsHeadConfig(output_stride=4), ) trainer = sleap.nn.training.BottomUpMultiClassModelTrainer.from_config( cfg, training_labels=labels @@ -266,8 +293,8 @@ def test_train_bottomup_multiclass(min_tracks_2node_labels, cfg): assert trainer.keras_model.output_names[0] == "MultiInstanceConfmapsHead" assert trainer.keras_model.output_names[1] == "ClassMapsHead" - assert tuple(trainer.keras_model.outputs[0].shape) == (None, 256, 256, 2) - assert tuple(trainer.keras_model.outputs[1].shape) == (None, 256, 256, 2) + assert tuple(trainer.keras_model.outputs[0].shape) == (None, 64, 64, 2) + assert tuple(trainer.keras_model.outputs[1].shape) == (None, 64, 64, 2) def test_train_topdown_multiclass(min_tracks_2node_labels, cfg): @@ -360,3 +387,26 @@ def test_resume_training_cli( trainer = sleap_train(cli_args) assert trainer.config.model.base_checkpoint == base_checkpoint_path + + +@pytest.mark.parametrize("keep_viz_cli", ["", "--keep_viz"]) +def test_keep_viz_cli( + keep_viz_cli, + min_single_instance_robot_model_path: str, + tmp_path: str, +): + """Test training CLI for --keep_viz option.""" + cfg_dir = min_single_instance_robot_model_path + cfg = TrainingJobConfig.load_json(str(Path(cfg_dir, "training_config.json"))) + + # Save training config to tmp folder + cfg_path = str(Path(tmp_path, "training_config.json")) + cfg.save_json(cfg_path) + + cli_args = [cfg_path, keep_viz_cli] + trainer = sleap_train(cli_args) + + # Check that --keep_viz is set correctly + assert trainer.config.outputs.keep_viz_images == ( + True if keep_viz_cli == "--keep_viz" else False + ) diff --git a/tests/test_instance.py b/tests/test_instance.py index 74a8b192e..58a630a8b 100644 --- a/tests/test_instance.py +++ b/tests/test_instance.py @@ -1,19 +1,21 @@ -import os -import math import copy +import math +import os +from typing import List -import pytest import numpy as np +import pytest -from sleap.skeleton import Skeleton +from sleap import Labels from sleap.instance import ( Instance, - PredictedInstance, + InstancesList, + LabeledFrame, Point, + PredictedInstance, PredictedPoint, - LabeledFrame, ) -from sleap import Labels +from sleap.skeleton import Skeleton def test_instance_node_get_set_item(skeleton): @@ -310,6 +312,8 @@ def test_frame_merge_predicted_and_user(skeleton, centered_pair_vid): # and we want to retain both even though they perfectly match. assert user_inst in user_frame.instances assert pred_inst in user_frame.instances + assert user_inst.frame == user_frame + assert pred_inst.frame == user_frame assert len(user_frame.instances) == 2 @@ -529,3 +533,216 @@ def test_instance_structuring_from_predicted(centered_pair_predictions): # Unstructure -> structure labels_copy = labels.copy() + + +def test_instances_list(centered_pair_predictions): + + labels = centered_pair_predictions + + def test_extend(instances: InstancesList, list_of_instances: List[Instance]): + instances.extend(list_of_instances) + assert len(instances) == len(list_of_instances) + for instance in instances: + assert isinstance(instance, PredictedInstance) + if instances.labeled_frame is None: + assert instance.frame is None + else: + assert instance.frame == instances.labeled_frame + + def test_append(instances: InstancesList, instance: Instance): + prev_len = len(instances) + instances.append(instance) + assert len(instances) == prev_len + 1 + assert instances[-1] == instance + assert instance.frame == instances.labeled_frame + + def test_labeled_frame_setter( + instances: InstancesList, labeled_frame: LabeledFrame + ): + instances.labeled_frame = labeled_frame + for instance in instances: + assert instance.frame == labeled_frame + + # Case 1: Create an empty instances list + labeled_frame = labels.labeled_frames[0] + list_of_instances = list(labeled_frame.instances) + instances = InstancesList() + assert len(instances) == 0 + assert instances._labeled_frame is None + assert instances.labeled_frame is None + + # Extend instances list + assert not isinstance(list_of_instances, InstancesList) + assert isinstance(list_of_instances, list) + test_extend(instances, list_of_instances) + + # Set the labeled frame + test_labeled_frame_setter(instances, labeled_frame) + + # Case 2: Create an empy instances list but initialize the labeled frame + instances = InstancesList(labeled_frame=labeled_frame) + assert len(instances) == 0 + assert instances._labeled_frame == labeled_frame + assert instances.labeled_frame == labeled_frame + + # Extend instances to the list from a different labeled frame + labeled_frame = labels.labeled_frames[1] + list_of_instances = list(labeled_frame.instances) + test_extend(instances, list_of_instances) + + # Add instance to the list + instance = list_of_instances[0] + instance.frame = None + test_append(instances, instance) + + # Set the labeled frame + test_labeled_frame_setter(instances, labeled_frame) + + # Test InstancesList.copy + instances_copy = instances.copy() + assert len(instances_copy) == len(instances) + assert not isinstance(instances_copy, InstancesList) + assert isinstance(instances_copy, list) + + # Test InstancesList.clear + instances_in_instances = list(instances) + instances.clear() + assert len(instances) == 0 + for instance in instances_in_instances: + assert instance.frame is None + + # Case 3: Create an instances list with a list of instances + labeled_frame = labels.labeled_frames[0] + list_of_instances = list(labeled_frame.instances) + instances = InstancesList(list_of_instances) + assert len(instances) == len(list_of_instances) + assert instances._labeled_frame is None + assert instances.labeled_frame is None + for instance in instances: + assert instance.frame is None + + # Add instance to the list + instance = list_of_instances[0] + test_append(instances, instance) + + # Case 4: Create an instances list with a list of instances and initialize the frame + labeled_frame_1 = labels.labeled_frames[0] + labeled_frame_2 = labels.labeled_frames[1] + list_of_instances = list(labeled_frame_2.instances) + instances = InstancesList(list_of_instances, labeled_frame=labeled_frame_1) + assert len(instances) == len(list_of_instances) + assert instances._labeled_frame == labeled_frame + assert instances.labeled_frame == labeled_frame + for instance in instances: + assert instance.frame == labeled_frame + + # Test InstancesList.__delitem__ + instance_to_remove = instances[0] + del instances[0] + assert instance_to_remove not in instances + assert instance_to_remove.frame is None + + # Test InstancesList.insert + instances.insert(0, instance_to_remove) + assert instances[0] == instance_to_remove + assert instance_to_remove.frame == instances.labeled_frame + + # Test InstancesList.__setitem__ + new_instance = labeled_frame_1.instances[0] + new_instance.frame = None + instances[0] = new_instance + assert instances[0] == new_instance + assert new_instance.frame == instances.labeled_frame + + # Test InstancesList.pop + popped_instance = instances.pop(0) + assert popped_instance.frame is None + + # Test InstancesList.remove + instance_to_remove = instances[0] + instances.remove(instance_to_remove) + assert instance_to_remove.frame is None + assert instance_to_remove not in instances + + # Case 5: Create an instances list from an instances list + instances_1 = InstancesList(list_of_instances, labeled_frame=labeled_frame_1) + instances = InstancesList(instances_1) + assert len(instances) == len(instances_1) + assert instances._labeled_frame is None + assert instances.labeled_frame is None + for instance in instances: + assert instance.frame is None + + +def test_instances_list_with_labeled_frame(centered_pair_predictions): + labels: Labels = centered_pair_predictions + labels_lf_0: LabeledFrame = labels.labeled_frames[0] + video = labels_lf_0.video + frame_idx = labels_lf_0.frame_idx + + def test_post_init(labeled_frame: LabeledFrame): + for instance in labeled_frame.instances: + assert instance.frame == labeled_frame + + # Create labeled frame from list of instances + instances = list(labels_lf_0.instances) + for instance in instances: + instance.frame = None # Change frame to None to test if it is set correctly + labeled_frame = LabeledFrame(video=video, frame_idx=frame_idx, instances=instances) + assert isinstance(labeled_frame.instances, InstancesList) + assert len(labeled_frame.instances) == len(instances) + test_post_init(labeled_frame) + + # Create labeled frame from instances list + instances = InstancesList(labels_lf_0.instances) + labeled_frame = LabeledFrame(video=video, frame_idx=frame_idx, instances=instances) + assert isinstance(labeled_frame.instances, InstancesList) + assert len(labeled_frame.instances) == len(instances) + test_post_init(labeled_frame) + + # Test LabeledFrame.__len__ + assert len(labeled_frame.instances) == len(instances) + + # Test LabeledFrame.__getitem__ + assert labeled_frame[0] == instances[0] + + # Test LabeledFrame.index + assert labeled_frame.index(instances[0]) == instances.index(instances[0]) == 0 + + # Test LabeledFrame.__delitem__ + instance_to_remove = labeled_frame[0] + del labeled_frame[0] + assert instance_to_remove not in labeled_frame.instances + assert instance_to_remove.frame is None + + # Test LabeledFrame.__repr__ + print(labeled_frame) + + # Test LabeledFrame.insert + labeled_frame.insert(0, instance_to_remove) + assert labeled_frame[0] == instance_to_remove + assert instance_to_remove.frame == labeled_frame + + # Test LabeledFrame.__setitem__ + new_instance = instances[1] + new_instance.frame = None + labeled_frame[0] = new_instance + assert labeled_frame[0] == new_instance + assert new_instance.frame == labeled_frame + + # Test instances.setter (empty list) + labeled_frame.instances = [] + assert len(labeled_frame.instances) == 0 + assert labeled_frame.instances.labeled_frame == labeled_frame + # Test instances.setter (InstancesList) + labeled_frame.instances = labels.labeled_frames[1].instances + assert len(labeled_frame.instances) == len(labels.labeled_frames[1].instances) + assert labeled_frame.instances.labeled_frame == labeled_frame + for instance in labeled_frame.instances: + assert instance.frame == labeled_frame + # Test instances.setter (populated list) + labeled_frame.instances = list(labels.labeled_frames[1].instances) + assert len(labeled_frame.instances) == len(labels.labeled_frames[1].instances) + assert labeled_frame.instances.labeled_frame == labeled_frame + for instance in labeled_frame.instances: + assert instance.frame == labeled_frame diff --git a/tests/test_skeleton.py b/tests/test_skeleton.py index 1f7c3a853..2320342f6 100644 --- a/tests/test_skeleton.py +++ b/tests/test_skeleton.py @@ -1,10 +1,74 @@ -import os import copy - -import jsonpickle +import os import pytest +import json + +from networkx.readwrite import json_graph +from sleap.skeleton import Skeleton, SkeletonDecoder +from sleap.skeleton import SkeletonEncoder + + +def test_decoded_encoded_Skeleton_from_load_json(fly_legs_skeleton_json): + """ + Test Skeleton decoded from SkeletonEncoder.encode matches the original Skeleton. + """ + # Get the skeleton from the fixture + skeleton = Skeleton.load_json(fly_legs_skeleton_json) + # Get the graph from the skeleton + indexed_node_graph = skeleton._graph + graph = json_graph.node_link_data(indexed_node_graph) + + # Encode the graph as a json string to test .encode method + encoded_json_str = SkeletonEncoder.encode(graph) + + # Get the skeleton from the encoded json string + decoded_skeleton = Skeleton.from_json(encoded_json_str) + + # Check that the decoded skeleton is the same as the original skeleton + assert skeleton.matches(decoded_skeleton) + + +@pytest.mark.parametrize( + "skeleton_fixture_name", ["flies13_skeleton", "skeleton", "stickman"] +) +def test_decoded_encoded_Skeleton(skeleton_fixture_name, request): + """ + Test Skeleton decoded from SkeletonEncoder.encode matches the original Skeleton. + """ + # Use request.getfixturevalue to get the actual fixture value by name + skeleton = request.getfixturevalue(skeleton_fixture_name) + + # Get the graph from the skeleton + indexed_node_graph = skeleton._graph + graph = json_graph.node_link_data(indexed_node_graph) + + # Encode the graph as a json string to test .encode method + encoded_json_str = SkeletonEncoder.encode(graph) + + # Assert that the encoded json has keys in sorted order (backwards compatibility) + encoded_dict = json.loads(encoded_json_str) + sorted_keys = sorted(encoded_dict.keys()) + assert list(encoded_dict.keys()) == sorted_keys + for key, value in encoded_dict.items(): + if isinstance(value, dict): + assert list(value.keys()) == sorted(value.keys()) + elif isinstance(value, list): + for item in value: + if isinstance(item, dict): + assert list(item.keys()) == sorted(item.keys()) + + # Get the skeleton from the encoded json string + decoded_skeleton = Skeleton.from_json(encoded_json_str) + + # Check that the decoded skeleton is the same as the original skeleton + assert skeleton.matches(decoded_skeleton) + + # Now make everything into a JSON string + skeleton_json_str = skeleton.to_json() + decoded_skeleton_json_str = decoded_skeleton.to_json() -from sleap.skeleton import Skeleton + # Check that the JSON strings are the same + assert json.loads(skeleton_json_str) == json.loads(decoded_skeleton_json_str) def test_add_dupe_node(skeleton): @@ -194,9 +258,9 @@ def test_json(skeleton: Skeleton, tmpdir): ) assert skeleton.is_template == False json_str = skeleton.to_json() - json_dict = jsonpickle.decode(json_str) + json_dict = SkeletonDecoder.decode(json_str) json_dict_keys = list(json_dict.keys()) - assert "nx_graph" not in json_dict_keys + assert "nx_graph" in json_dict_keys # SkeletonDecoder adds this key assert "preview_image" not in json_dict_keys assert "description" not in json_dict_keys @@ -208,7 +272,7 @@ def test_json(skeleton: Skeleton, tmpdir): skeleton._is_template = True json_str = skeleton.to_json() - json_dict = jsonpickle.decode(json_str) + json_dict = SkeletonDecoder.decode(json_str) json_dict_keys = list(json_dict.keys()) assert "nx_graph" in json_dict_keys assert "preview_image" in json_dict_keys @@ -224,6 +288,26 @@ def test_json(skeleton: Skeleton, tmpdir): assert skeleton.matches(skeleton_copy) +def test_decode_preview_image(flies13_skeleton: Skeleton): + skeleton = flies13_skeleton + img_b64 = skeleton.preview_image + img = SkeletonDecoder.decode_preview_image(img_b64) + assert img.mode == "RGBA" + + +def test_skeleton_decoder(fly_legs_skeleton_json, fly_legs_skeleton_dict_json): + """Test that SkeletonDecoder can decode both tuple and dict py/state formats.""" + + skeleton_tuple_pystate = Skeleton.load_json(fly_legs_skeleton_json) + assert isinstance(skeleton_tuple_pystate, Skeleton) + + skeleton_dict_pystate = Skeleton.load_json(fly_legs_skeleton_dict_json) + assert isinstance(skeleton_dict_pystate, Skeleton) + + # These are the same skeleton, so they should match + assert skeleton_dict_pystate.matches(skeleton_tuple_pystate) + + def test_hdf5(skeleton, stickman, tmpdir): filename = os.path.join(tmpdir, "skeleton.h5") diff --git a/tests/test_util.py b/tests/test_util.py index a7916d47f..35b41afa8 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,5 +1,4 @@ import pytest -from sleap.skeleton import Skeleton from sleap.util import * @@ -147,10 +146,3 @@ def test_save_dict_to_hdf5(tmpdir): assert f["bar"][-1].decode() == "zop" assert f["cab"]["a"][()] == 2 - - -def test_decode_preview_image(flies13_skeleton: Skeleton): - skeleton = flies13_skeleton - img_b64 = skeleton.preview_image - img = decode_preview_image(img_b64) - assert img.mode == "RGBA"