diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000..ec08fb760 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,92 @@ +version: 2.1 + +jobs: + + # The following job is to run any visual comparison test, and runs on any branch + # or in any pull request. It will generate a summary page for each tox environment + # being run which is accessible through the CircleCI artifacts. + + visual: + parameters: + jobname: + type: string + docker: + - image: cimg/python:3.11 + environment: + TOXENV: << parameters.jobname >> + steps: + - checkout + - run: + name: Install dependencies + command: | + sudo apt update + pip install pip tox --upgrade + - run: + name: Run tests + command: tox -v + - store_artifacts: + path: results + - run: + name: "Image comparison page is available at: " + command: echo "${CIRCLE_BUILD_URL}/artifacts/${CIRCLE_NODE_INDEX}/results/fig_comparison.html" + + # The following job runs only on main - and its main purpose is to update the + # reference images in the glue-core-visual-tests repository. This job needs + # a deploy key. To produce this, go to the glue-core-visual-tests + # repository settings and go to SSH keys, then add your public SSH key. + deploy-reference-images: + parameters: + jobname: + type: string + docker: + - image: cimg/python:3.11 + environment: + TOXENV: << parameters.jobname >> + steps: + - checkout + - run: + name: Install dependencies + command: | + sudo apt update + pip install pip tox --upgrade + - run: ssh-add -D + - add_ssh_keys: + fingerprints: "44:09:69:d7:c6:77:25:e9:46:da:f1:22:7d:d4:38:29" + - run: ssh-keyscan github.com >> ~/.ssh/known_hosts + - run: git config --global user.email "glue@circleci" && git config --global user.name "Glue Circle CI" + - run: git clone git@github.com:glue-viz/glue-core-visual-tests.git --depth 1 ~/glue-core-visual-tests/ + - run: + name: Generate reference images + command: tox -v -- --mpl-generate-path=/home/circleci/glue-core-visual-tests/images/$TOXENV + - run: | + cd ~/glue-core-visual-tests/ + git pull + git status + git add . + git commit -m "Update reference images from ${CIRCLE_BRANCH}" || echo "No changes to reference images to deploy" + git push + +workflows: + version: 2 + + visual-tests: + jobs: + - visual: + name: << matrix.jobname >> + matrix: + parameters: + jobname: + - "py311-test-visual" + + - deploy-reference-images: + name: baseline-<< matrix.jobname >> + matrix: + parameters: + jobname: + - "py311-test-visual" + requires: + - << matrix.jobname >> + filters: + branches: + only: + - main diff --git a/.gitignore b/.gitignore index 37c6fda52..bb776a810 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ glue/_githash.py .vscode # vscode plugin .history + +results diff --git a/.readthedocs.yml b/.readthedocs.yml index 309f6d1bf..2d9052f0f 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,7 +1,9 @@ version: 2 build: - image: latest + os: "ubuntu-22.04" + tools: + python: "3" sphinx: builder: html @@ -9,7 +11,6 @@ sphinx: fail_on_warning: true python: - version: 3.8 install: - method: pip path: . diff --git a/CHANGES.md b/CHANGES.md index 1c8b12b22..d7b798043 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,115 @@ # Full changelog +## v1.18.0 - 2024-03-26 + + +### What's Changed + +#### New Features + +* Preserve alpha while compositing with cmap.set_bad by @bmorris3 in https://github.com/glue-viz/glue/pull/2468 + +#### Other Changes + +* Remove deprecated Qt-related code (and `glue.qglue`) by @astrofrog in https://github.com/glue-viz/glue/pull/2477 + +**Full Changelog**: https://github.com/glue-viz/glue/compare/v1.17.1...v1.18.0 + +## v1.17.1 - 2023-12-07 + + +### What's Changed + +#### Bug Fixes + +* Fix default stretch density by @astrofrog in https://github.com/glue-viz/glue/pull/2467 +* Provide stronger checking for region display. Fix x/y flip. by @jfoster17 in https://github.com/glue-viz/glue/pull/2465 + +#### Other Changes + +* Fix documentation build by @astrofrog in https://github.com/glue-viz/glue/pull/2466 + +**Full Changelog**: https://github.com/glue-viz/glue/compare/v1.17.0...v1.17.1 + +## v1.17.0 - 2023-12-05 + + +### What's Changed + +#### New Features + +* Make stretches be customizable on a layer by layer basis by @astrofrog in https://github.com/glue-viz/glue/pull/2453 + +**Full Changelog**: https://github.com/glue-viz/glue/compare/v1.16.0...v1.17.0 + +## v1.16.0 - 2023-11-16 + + +### What's Changed + +#### New Features + +- Customizable limits percentile and behavior on updating bins when resetting limits by @astrofrog in https://github.com/glue-viz/glue/pull/2455 + +**Full Changelog**: https://github.com/glue-viz/glue/compare/v1.15.0...v1.16.0 + +## v1.15.0 - 2023-11-14 + + +### What's Changed + +#### New Features + +- Support regions in image viewer by @jfoster17 in https://github.com/glue-viz/glue/pull/2456 +- Added 'simple' viewers which can be used for testing or simple use cases by @astrofrog in https://github.com/glue-viz/glue/pull/2458 + +#### Other Changes + +- Added giles configuration by @astrofrog in https://github.com/glue-viz/glue/pull/2460 +- Add visual test for ScatterRegionLayerArtist by @jfoster17 in https://github.com/glue-viz/glue/pull/2461 +- Ignore all test files in coverage calculation by @astrofrog in https://github.com/glue-viz/glue/pull/2462 + +**Full Changelog**: https://github.com/glue-viz/glue/compare/v1.14.1...v1.15.0 + +## v1.14.1 - 2023-10-26 + + +### What's Changed + +#### Bug Fixes + +- Fix bug when changing the number of bins on a histogram with modified/deleted data by @astrofrog in https://github.com/glue-viz/glue/pull/2451 + +#### Documentation + +- Fix docs failure related to theme warning by @astrofrog in https://github.com/glue-viz/glue/pull/2452 + +**Full Changelog**: https://github.com/glue-viz/glue/compare/v1.14.0...v1.14.1 + +## v1.14.0 - 2023-10-23 + + +### What's Changed + +#### New Features + +- Support dynamic components by @jfoster17 in https://github.com/glue-viz/glue/pull/2446 +- Add regiondata by @jfoster17 in https://github.com/glue-viz/glue/pull/2442 + +#### Bug Fixes + +- Render nans as 'bad' in mpl cmaps when mode='colormap' by @bmorris3 in https://github.com/glue-viz/glue/pull/2427 +- Update deprecated call to resize_event in test by @jfoster17 in https://github.com/glue-viz/glue/pull/2443 +- Do not use fancy index for subsets over Dask data by @jfoster17 in https://github.com/glue-viz/glue/pull/2444 +- Fix bug that caused incompatible subsets to be rendered in some contexts rather than be hidden by @jfoster17 in https://github.com/glue-viz/glue/pull/2425 + +#### Other Changes + +- Switch to Sphinx book theme and tidied up Sphinx configuration by @astrofrog in https://github.com/glue-viz/glue/pull/2436 +- Make tests robust to plugins adding new translators by @jfoster17 in https://github.com/glue-viz/glue/pull/2445 + +**Full Changelog**: https://github.com/glue-viz/glue/compare/v1.13.1...v1.14.0 + ## v1.13.1 - 2023-08-17 @@ -683,86 +793,86 @@ ### What's Changed -- Improved how we handle equal aspect ratio to not depend on +- Improved how we handle equal aspect ratio to not depend on -- Matplotlib. https://github.com/glue-viz/glue/pull/1894 +- Matplotlib. https://github.com/glue-viz/glue/pull/1894 - -- Avoid showing a warning when closing an empty tab. https://github.com/glue-viz/glue/pull/1890 +- Avoid showing a warning when closing an empty tab. https://github.com/glue-viz/glue/pull/1890 - -- Fix bug that caused component arithmetic to not work if +- Fix bug that caused component arithmetic to not work if -- Numpy was imported in user's config.py file. https://github.com/glue-viz/glue/pull/1887 +- Numpy was imported in user's config.py file. https://github.com/glue-viz/glue/pull/1887 - -- Added the ability to define custom layer artist makers to +- Added the ability to define custom layer artist makers to -- override default layer artists in viewers. https://github.com/glue-viz/glue/pull/1850 +- override default layer artists in viewers. https://github.com/glue-viz/glue/pull/1850 - -- Fix Plot.ly exporter for categorical components and histogram +- Fix Plot.ly exporter for categorical components and histogram -- viewer. https://github.com/glue-viz/glue/pull/1886 +- viewer. https://github.com/glue-viz/glue/pull/1886 - -- Fix issues with reading very large FITS files on some systems. https://github.com/glue-viz/glue/pull/1884 +- Fix issues with reading very large FITS files on some systems. https://github.com/glue-viz/glue/pull/1884 - -- Added documentation about plugins. https://github.com/glue-viz/glue/pull/1837 +- Added documentation about plugins. https://github.com/glue-viz/glue/pull/1837 - -- Better isolate code related to pixel selection tool in image +- Better isolate code related to pixel selection tool in image -- viewer that depended on Qt. https://github.com/glue-viz/glue/pull/1763 +- viewer that depended on Qt. https://github.com/glue-viz/glue/pull/1763 - -- Improve handling of units in FITS files. https://github.com/glue-viz/glue/pull/1723 +- Improve handling of units in FITS files. https://github.com/glue-viz/glue/pull/1723 - -- Added documentation about creating viewers for glue using the +- Added documentation about creating viewers for glue using the -- new state-based infrastructure. https://github.com/glue-viz/glue/pull/1740 +- new state-based infrastructure. https://github.com/glue-viz/glue/pull/1740 - -- Make it possible to pass the initial state of a viewer to an +- Make it possible to pass the initial state of a viewer to an -- application's `new_data_viewer` method. https://github.com/glue-viz/glue/pull/1877 +- application's `new_data_viewer` method. https://github.com/glue-viz/glue/pull/1877 - -- Ensure that glue can be imported if QtPy is installed but PyQt +- Ensure that glue can be imported if QtPy is installed but PyQt -- and PySide aren't. [#1865, #1836] +- and PySide aren't. [#1865, #1836] - -- Fix unit display for coordinates from WCS headers that don't have +- Fix unit display for coordinates from WCS headers that don't have -- CTYPE but have CUNIT. https://github.com/glue-viz/glue/pull/1856 +- CTYPE but have CUNIT. https://github.com/glue-viz/glue/pull/1856 - -- Enable tab completion on Data objects. https://github.com/glue-viz/glue/pull/1874 +- Enable tab completion on Data objects. https://github.com/glue-viz/glue/pull/1874 - -- Automatically select datasets in link editor if there are only two. https://github.com/glue-viz/glue/pull/1837 +- Automatically select datasets in link editor if there are only two. https://github.com/glue-viz/glue/pull/1837 - -- Change 'Export Session' dialog to offer to save with relative paths to data +- Change 'Export Session' dialog to offer to save with relative paths to data -- by default instead of absolute paths. https://github.com/glue-viz/glue/pull/1803 +- by default instead of absolute paths. https://github.com/glue-viz/glue/pull/1803 - -- Added a new method `screenshot` on `GlueApplication` to save a +- Added a new method `screenshot` on `GlueApplication` to save a -- screenshot of the current view. https://github.com/glue-viz/glue/pull/1808 +- screenshot of the current view. https://github.com/glue-viz/glue/pull/1808 - -- Show the active subset in the toolbar. https://github.com/glue-viz/glue/pull/1797 +- Show the active subset in the toolbar. https://github.com/glue-viz/glue/pull/1797 - -- Refactored the viewer class base classes https://github.com/glue-viz/glue/pull/1746: +- Refactored the viewer class base classes https://github.com/glue-viz/glue/pull/1746: - -- - `glue.core.application_base.ViewerBase` has been removed in favor of +- - `glue.core.application_base.ViewerBase` has been removed in favor of - - @@ -774,7 +884,7 @@ - - - -- - `glue.viewers.common.viewer.BaseViewer` and +- - `glue.viewers.common.viewer.BaseViewer` and - - @@ -786,7 +896,7 @@ - - - -- - `glue.viewers.common.viewer.Viewer`. +- - `glue.viewers.common.viewer.Viewer`. - - @@ -798,7 +908,7 @@ - - - -- - +- - - - @@ -810,7 +920,7 @@ - - - -- - `glue.viewers.common.viewer.Viewer` is now where the base logic is defined +- - `glue.viewers.common.viewer.Viewer` is now where the base logic is defined - - @@ -822,7 +932,7 @@ - - - -- - for using state classes in viewers (instead of +- - for using state classes in viewers (instead of - - @@ -834,7 +944,7 @@ - - - -- - `glue.viewers.common.qt.DataViewerWithState`). +- - `glue.viewers.common.qt.DataViewerWithState`). - - @@ -846,7 +956,7 @@ - - - -- - +- - - - @@ -858,7 +968,7 @@ - - - -- - `glue.viewers.common.qt.DataViewerWithState` is now deprecated. +- - `glue.viewers.common.qt.DataViewerWithState` is now deprecated. - - @@ -870,7 +980,7 @@ - - - -- - +- - - - @@ -883,106 +993,106 @@ - - - -- Make it so that the modest image only resamples the data when the +- Make it so that the modest image only resamples the data when the -- mouse is no longer pressed - this avoids too many refreshes when +- mouse is no longer pressed - this avoids too many refreshes when -- panning/zooming. https://github.com/glue-viz/glue/pull/1866 +- panning/zooming. https://github.com/glue-viz/glue/pull/1866 - -- Make it possible to unglue multiple links in one go. https://github.com/glue-viz/glue/pull/1809 +- Make it possible to unglue multiple links in one go. https://github.com/glue-viz/glue/pull/1809 - -- Make it so that adding a subset to a viewer no longer adds the +- Make it so that adding a subset to a viewer no longer adds the -- associated data, since in some cases the viewer can handle the +- associated data, since in some cases the viewer can handle the -- subset size, but not the full data. https://github.com/glue-viz/glue/pull/1807 +- subset size, but not the full data. https://github.com/glue-viz/glue/pull/1807 - -- Defined a new abstract base class for all datasets, `BaseData`, +- Defined a new abstract base class for all datasets, `BaseData`, -- and a base class `BaseCartesianData`, +- and a base class `BaseCartesianData`, -- which can be used to implement interfaces to datasets that may be +- which can be used to implement interfaces to datasets that may be -- remote or may not be stored as regular cartesian data. https://github.com/glue-viz/glue/pull/1768 +- remote or may not be stored as regular cartesian data. https://github.com/glue-viz/glue/pull/1768 - -- Add a new method `Data.compute_statistic` which can be used +- Add a new method `Data.compute_statistic` which can be used -- to find scalar and array statistics on the data, and use for +- to find scalar and array statistics on the data, and use for -- the profile viewer and the state limits helpers. https://github.com/glue-viz/glue/pull/1737 +- the profile viewer and the state limits helpers. https://github.com/glue-viz/glue/pull/1737 - -- Add a new method `Data.compute_histogram` which can be used +- Add a new method `Data.compute_histogram` which can be used -- to find histograms of specific components, with or without +- to find histograms of specific components, with or without -- subsets applied. https://github.com/glue-viz/glue/pull/1739 +- subsets applied. https://github.com/glue-viz/glue/pull/1739 - -- Removed `Data.get_pixel_component_ids` and `Data.get_world_component_ids` +- Removed `Data.get_pixel_component_ids` and `Data.get_world_component_ids` -- in favor of `Data.pixel_component_ids` and `Data.world_component_ids`. +- in favor of `Data.pixel_component_ids` and `Data.world_component_ids`. -- https://github.com/glue-viz/glue/pull/1784 +- https://github.com/glue-viz/glue/pull/1784 - -- Deprecated `Data.visible_components` and `Data.primary_components`. https://github.com/glue-viz/glue/pull/1788 +- Deprecated `Data.visible_components` and `Data.primary_components`. https://github.com/glue-viz/glue/pull/1788 - -- Speed up histogram calculations by using the fast-histogram package instead of +- Speed up histogram calculations by using the fast-histogram package instead of -- np.histogram. https://github.com/glue-viz/glue/pull/1806 +- np.histogram. https://github.com/glue-viz/glue/pull/1806 - -- In the case of categorical attributes, `Data[name]` now returns a +- In the case of categorical attributes, `Data[name]` now returns a -- `categorical_ndarray` object rather than the indices of the categories. You +- `categorical_ndarray` object rather than the indices of the categories. You -- can access the indices with `Data[name].codes` and the unique categories +- can access the indices with `Data[name].codes` and the unique categories -- with `Data[name].categories`. https://github.com/glue-viz/glue/pull/1784 +- with `Data[name].categories`. https://github.com/glue-viz/glue/pull/1784 - -- Compute profiles and histograms asynchronously when dataset is large +- Compute profiles and histograms asynchronously when dataset is large -- to avoid holding up the UI, and compute profiles in chunks to avoid +- to avoid holding up the UI, and compute profiles in chunks to avoid -- excessive memory usage. [#1736, #1764] +- excessive memory usage. [#1736, #1764] - -- Improved naming of components when merging datasets. https://github.com/glue-viz/glue/pull/1249 +- Improved naming of components when merging datasets. https://github.com/glue-viz/glue/pull/1249 - -- Fixed an issue that caused residual references to viewers +- Fixed an issue that caused residual references to viewers -- after they were closed if they were accessed through the +- after they were closed if they were accessed through the -- IPython console. https://github.com/glue-viz/glue/pull/1770 +- IPython console. https://github.com/glue-viz/glue/pull/1770 - -- Don't show layer edit options if layer is not visible. https://github.com/glue-viz/glue/pull/1805 +- Don't show layer edit options if layer is not visible. https://github.com/glue-viz/glue/pull/1805 - -- Make the Matplotlib viewer code that doesn't depend on Qt accessible +- Make the Matplotlib viewer code that doesn't depend on Qt accessible -- to non-Qt frontends. https://github.com/glue-viz/glue/pull/1841 +- to non-Qt frontends. https://github.com/glue-viz/glue/pull/1841 - -- Avoid repeated coordinate components in merged datasets. https://github.com/glue-viz/glue/pull/1792 +- Avoid repeated coordinate components in merged datasets. https://github.com/glue-viz/glue/pull/1792 - -- Fix bug that caused new subset to be created when dragging an existing +- Fix bug that caused new subset to be created when dragging an existing -- subset in an image viewer. https://github.com/glue-viz/glue/pull/1793 +- subset in an image viewer. https://github.com/glue-viz/glue/pull/1793 - -- Better preserve data types when exporting data/subsets to FITS +- Better preserve data types when exporting data/subsets to FITS -- and HDF5 formats. https://github.com/glue-viz/glue/pull/1800 +- and HDF5 formats. https://github.com/glue-viz/glue/pull/1800 - diff --git a/README.rst b/README.rst index a0a9d020d..421b92126 100644 --- a/README.rst +++ b/README.rst @@ -12,8 +12,8 @@ This repository contains the **glue-core** package which includes much of the co functionality of glue that is used for the different front-ends, including the Qt-based application and the Jupyter-based application. Other key repositories include: -* `glue-qt `_: the original Qt/desktop application for glue -* `glue-jupyter `_: a Jupyter front-end for glue +* `glue-qt `_: the original Qt/desktop application for glue +* `glue-jupyter `_: a Jupyter front-end for glue In addition to these, there are a number of plugin packages available. For a full list of repositories, see https://github.com/glue-viz/. diff --git a/doc/conf.py b/doc/conf.py index bfac95830..e75410542 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -33,6 +33,7 @@ ("py:class", "glue.viewers.image.layer_artist.ImageLayerBase"), ("py:class", "glue.viewers.image.layer_artist.RGBImageLayerBase"), ("py:class", "glue.viewers.image.state.BaseImageLayerState"), + ("py:class", "glue.viewers.common.stretch_state_mixin.StretchStateMixin") ] viewcode_follow_imported_members = False @@ -53,6 +54,7 @@ "astropy": ("https://docs.astropy.org/en/stable/", None), "echo": ("https://echo.readthedocs.io/en/latest/", None), "pandas": ("https://pandas.pydata.org/pandas-docs/stable/", None), + "shapely": ("https://shapely.readthedocs.io/en/stable/", None), } # -- Options for HTML output ------------------------------------------------- @@ -61,3 +63,4 @@ html_theme = "sphinx_book_theme" html_static_path = ["_static"] html_logo = "_static/logo.png" +html_theme_options = {'navigation_with_keys': False} diff --git a/glue/__init__.py b/glue/__init__.py index 8e0a8839e..91883381d 100644 --- a/glue/__init__.py +++ b/glue/__init__.py @@ -1,11 +1,10 @@ # Set up configuration variables -__all__ = ['custom_viewer', 'qglue', 'test'] +__all__ = ['custom_viewer', 'test'] import os import sys -import warnings import importlib.metadata @@ -15,19 +14,11 @@ sys.meta_path.append(MatplotlibBackendSetter()) from glue.viewers.custom.helper import custom_viewer -from glue.utils.error import GlueDeprecationWarning # Load user's configuration file from .config import load_configuration env = load_configuration() - -def qglue(*args, **kwargs): - warnings.warn('glue.qglue is deprecated, import qglue from the glue_qt module instead', GlueDeprecationWarning) - from glue_qt import qglue - return qglue(*args, **kwargs) - - from .main import load_plugins # noqa diff --git a/glue/_mpl_backend.py b/glue/_mpl_backend.py index 0069c3fff..1b464a732 100644 --- a/glue/_mpl_backend.py +++ b/glue/_mpl_backend.py @@ -4,7 +4,7 @@ class MatplotlibBackendSetter(object): """ - Import hook to make sure the proper Qt backend is set when importing + Import hook to make sure the proper backend is set when importing Matplotlib. """ diff --git a/glue/app/qt/__init__.py b/glue/app/qt/__init__.py deleted file mode 100644 index c8a89500b..000000000 --- a/glue/app/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.app.qt is deprecated, use glue_qt.app instead', GlueDeprecationWarning) -from glue_qt.app import * # noqa diff --git a/glue/app/qt/actions.py b/glue/app/qt/actions.py deleted file mode 100644 index 070bb1d7b..000000000 --- a/glue/app/qt/actions.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.app.qt.actions is deprecated, use glue_qt.app.actions instead', GlueDeprecationWarning) -from glue_qt.app.actions import * # noqa diff --git a/glue/app/qt/application.py b/glue/app/qt/application.py deleted file mode 100644 index 4f59db281..000000000 --- a/glue/app/qt/application.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.app.qt.application is deprecated, use glue_qt.app.application instead', GlueDeprecationWarning) -from glue_qt.app.application import * # noqa diff --git a/glue/app/qt/edit_subset_mode_toolbar.py b/glue/app/qt/edit_subset_mode_toolbar.py deleted file mode 100644 index 417a14ea3..000000000 --- a/glue/app/qt/edit_subset_mode_toolbar.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.app.qt.edit_subset_mode_toolbar is deprecated, use glue_qt.app.edit_subset_mode_toolbar instead', GlueDeprecationWarning) -from glue_qt.app.edit_subset_mode_toolbar import * # noqa diff --git a/glue/app/qt/feedback.py b/glue/app/qt/feedback.py deleted file mode 100644 index 0750eb2bb..000000000 --- a/glue/app/qt/feedback.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.app.qt.feedback is deprecated, use glue_qt.app.feedback instead', GlueDeprecationWarning) -from glue_qt.app.feedback import * # noqa diff --git a/glue/app/qt/keyboard_shortcuts.py b/glue/app/qt/keyboard_shortcuts.py deleted file mode 100644 index 5c3917eee..000000000 --- a/glue/app/qt/keyboard_shortcuts.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.app.qt.keyboard_shortcuts is deprecated, use glue_qt.app.keyboard_shortcuts instead', GlueDeprecationWarning) -from glue_qt.app.keyboard_shortcuts import * # noqa diff --git a/glue/app/qt/layer_tree_widget.py b/glue/app/qt/layer_tree_widget.py deleted file mode 100644 index c1d0b0910..000000000 --- a/glue/app/qt/layer_tree_widget.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.app.qt.layer_tree_widget is deprecated, use glue_qt.app.layer_tree_widget instead', GlueDeprecationWarning) -from glue_qt.app.layer_tree_widget import * # noqa diff --git a/glue/app/qt/mdi_area.py b/glue/app/qt/mdi_area.py deleted file mode 100644 index 54092e4ab..000000000 --- a/glue/app/qt/mdi_area.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.app.qt.mdi_area is deprecated, use glue_qt.app.mdi_area instead', GlueDeprecationWarning) -from glue_qt.app.mdi_area import * # noqa diff --git a/glue/app/qt/metadata.py b/glue/app/qt/metadata.py deleted file mode 100644 index 44ace5293..000000000 --- a/glue/app/qt/metadata.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.app.qt.metadata is deprecated, use glue_qt.app.metadata instead', GlueDeprecationWarning) -from glue_qt.app.metadata import * # noqa diff --git a/glue/app/qt/plugin_manager.py b/glue/app/qt/plugin_manager.py deleted file mode 100644 index 7ba92f677..000000000 --- a/glue/app/qt/plugin_manager.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.app.qt.plugin_manager is deprecated, use glue_qt.app.plugin_manager instead', GlueDeprecationWarning) -from glue_qt.app.plugin_manager import * # noqa diff --git a/glue/app/qt/preferences.py b/glue/app/qt/preferences.py deleted file mode 100644 index 30383ddba..000000000 --- a/glue/app/qt/preferences.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.app.qt.preferences is deprecated, use glue_qt.app.preferences instead', GlueDeprecationWarning) -from glue_qt.app.preferences import * # noqa diff --git a/glue/app/qt/save_data.py b/glue/app/qt/save_data.py deleted file mode 100644 index b755a9430..000000000 --- a/glue/app/qt/save_data.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.app.qt.save_data is deprecated, use glue_qt.app.save_data instead', GlueDeprecationWarning) -from glue_qt.app.save_data import * # noqa diff --git a/glue/app/qt/splash_screen.py b/glue/app/qt/splash_screen.py deleted file mode 100644 index 6cf0f7315..000000000 --- a/glue/app/qt/splash_screen.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.app.qt.splash_screen is deprecated, use glue_qt.app.splash_screen instead', GlueDeprecationWarning) -from glue_qt.app.splash_screen import * # noqa diff --git a/glue/app/qt/terminal.py b/glue/app/qt/terminal.py deleted file mode 100644 index eae551147..000000000 --- a/glue/app/qt/terminal.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.app.qt.terminal is deprecated, use glue_qt.app.terminal instead', GlueDeprecationWarning) -from glue_qt.app.terminal import * # noqa diff --git a/glue/app/qt/versions.py b/glue/app/qt/versions.py deleted file mode 100644 index cabf25c3d..000000000 --- a/glue/app/qt/versions.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.app.qt.versions is deprecated, use glue_qt.app.versions instead', GlueDeprecationWarning) -from glue_qt.app.versions import * # noqa diff --git a/glue/config.py b/glue/config.py index 24a1129b8..0c970e642 100644 --- a/glue/config.py +++ b/glue/config.py @@ -836,10 +836,10 @@ def __iter__(self): from astropy.visualization import (LinearStretch, SqrtStretch, AsinhStretch, LogStretch) stretches = StretchRegistry() -stretches.add('linear', LinearStretch(), display='Linear') -stretches.add('sqrt', SqrtStretch(), display='Square Root') -stretches.add('arcsinh', AsinhStretch(), display='Arcsinh') -stretches.add('log', LogStretch(), display='Logarithmic') +stretches.add('linear', LinearStretch, display='Linear') +stretches.add('sqrt', SqrtStretch, display='Square Root') +stretches.add('arcsinh', AsinhStretch, display='Arcsinh') +stretches.add('log', LogStretch, display='Logarithmic') # Backward-compatibility qglue_parser = cli_parser diff --git a/glue/conftest.py b/glue/conftest.py index 73a745cf3..e0e0251b1 100644 --- a/glue/conftest.py +++ b/glue/conftest.py @@ -1,24 +1,8 @@ import os import sys -import warnings - -import pytest - -try: - from qtpy import PYSIDE2 -except Exception: - PYSIDE2 = False from glue.config import CFG_DIR as CFG_DIR_ORIG -try: - import objgraph -except ImportError: - OBJGRAPH_INSTALLED = False -else: - OBJGRAPH_INSTALLED = True - - STDERR_ORIGINAL = sys.stderr ON_APPVEYOR = os.environ.get('APPVEYOR', 'False') == 'True' @@ -73,61 +57,3 @@ def pytest_unconfigure(config): # Reset configuration directory to original one from glue import config config.CFG_DIR = CFG_DIR_ORIG - - # Remove reference to QApplication to prevent segmentation fault on PySide - try: - from glue.utils.qt import app - app.qapp = None - except Exception: # for when we run the tests without the qt directories - # Note that we catch any exception, not just ImportError, because - # QtPy can raise a PythonQtError. - pass - - if OBJGRAPH_INSTALLED and not ON_APPVEYOR: - - # Make sure there are no lingering references to GlueApplication - obj = objgraph.by_type('GlueApplication') - if len(obj) > 0: - objgraph.show_backrefs(objgraph.by_type('GlueApplication')) - warnings.warn('There are {0} remaining references to GlueApplication'.format(len(obj))) - - # Uncomment when checking for memory leaks - # objgraph.show_most_common_types(limit=100) - - -# With PySide2, tests can fail in a non-deterministic way on a teardown error -# or with the following error: -# -# AttributeError: 'PySide2.QtGui.QStandardItem' object has no attribute '...' -# -# Until this can be properly debugged and fixed, we xfail any test that fails -# with one of these exceptions. - -if PYSIDE2: - QTSTANDARD_EXC = "QtGui.QStandardItem' object has no attribute " - QTSTANDARD_ATTRS = ["'connect'", "'item'", "'triggered'"] - - @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_setup(): - try: - outcome = yield - return outcome.get_result() - except AttributeError: - exc = str(outcome.excinfo[1]) - for attr in QTSTANDARD_ATTRS: - if QTSTANDARD_EXC + attr in exc: - pytest.xfail(f'Known issue {exc}') - - @pytest.hookimpl(hookwrapper=True) - def pytest_runtest_call(): - try: - outcome = yield - return outcome.get_result() - # excinfo seems only to be preserved through a single hook - except (AttributeError, ValueError): - exc = str(outcome.excinfo[1]) - if "No net viewers should be created in tests" in exc: - pytest.xfail(f'Known issue {exc}') - for attr in QTSTANDARD_ATTRS: - if QTSTANDARD_EXC + attr in exc: - pytest.xfail(f'Known issue {exc}') diff --git a/glue/core/application_base.py b/glue/core/application_base.py index 8c755814c..1f17683de 100644 --- a/glue/core/application_base.py +++ b/glue/core/application_base.py @@ -93,6 +93,9 @@ def new_data_viewer(self, viewer_class, data=None, state=None): self.add_widget(c) return c + def add_widget(self, viewer): + pass + @catch_error("Failed to save session") def save_session(self, path, include_data=False, absolute_paths=True): """ diff --git a/glue/core/component.py b/glue/core/component.py index 9857b5e37..fe7202b46 100644 --- a/glue/core/component.py +++ b/glue/core/component.py @@ -2,6 +2,7 @@ import numpy as np import pandas as pd +import shapely from glue.core.coordinate_helpers import dependent_axes, pixel2world_single_axis from glue.utils import shape_to_string, coerce_numeric, categorical_ndarray @@ -13,7 +14,7 @@ DASK_INSTALLED = False __all__ = ['Component', 'DerivedComponent', 'CategoricalComponent', - 'CoordinateComponent', 'DateTimeComponent'] + 'CoordinateComponent', 'DateTimeComponent', 'ExtendedComponent'] class Component(object): @@ -107,6 +108,13 @@ def datetime(self): """ return False + @property + def extended(self): + """ + Whether or not or not the datatype represents an extended region + """ + return False + def __str__(self): return "%s with shape %s" % (self.__class__.__name__, shape_to_string(self.shape)) @@ -549,3 +557,98 @@ def categorical(self): @property def datetime(self): return False + + +class ExtendedComponent(Component): + """ + A data component representing an extent or a region. + + This component can be used when a dataset describes regions or ranges + and is typically used with a `RegionData` object, since that object + provides helper functions to display regions on viewers. For example, + a `RegionData` object might provide properties of geographic + regions, and the boundaries of these regions would be an ExtendedComponent. + + Data loaders are required to know how to convert regions to a list + of Shapely objects which can be used to initialize an ExtendedComponent. + + A circular region can be represented as: + + >>> circle = shapely.Point(x, y).buffer(rad) + + A range in one dimension can be represented as: + + >>> range = shapely.LineString([[x0,0],[x1,0]]) + + (This is a bit of an odd representation, since we are forced to specify a y + coordinate for this line. We adopt a convention of y == 0.) + + ExtendedComponents are NOT used directly in linking. Instead, ExtendedComponents + always have corresponding ComponentIDs that represent the x (and y) coordinates + over which the regions are defined. If not specified otherwise, a + `RegionData` object will create 'representative points' + for each region, representing a point near the center of the reigon that is + guaranteed to be inside the region. + + NOTE: that this implementation does not support regions in more than + two dimensions. (Shapely has limited support for 3D shapes, but not more). + + Parameters + ---------- + data : list of `shapely.Geometry`` objects + The data to store. + center_comp_ids : list of :class:`glue.core.component_id.ComponentID` objects + The ComponentIDs of the `center` of the extended region. These do not + have to be the literal center of the region, but they must be in the x (and y) + coordinates of the regions. These ComponentIDs are used in the linking + framework to allow an ExtendedComponent to be linked to other components. + units : `str`, optional + Unit description. + + Attributes + ---------- + x : ComponentID + The ComponentID of the x coordinate at the center of the extended region. + y : ComponentID + The ComponentID of the y coordinate at the center of the extended region. + + Raises + ------ + TypeError + If data is not a list of ``shapely.Geometry`` objects + ValueError + If center_comp_ids is not a list of length 1 or 2 + """ + def __init__(self, data, center_comp_ids, units=None): + if not all(isinstance(s, shapely.Geometry) for s in data): + raise TypeError( + "Input data for a ExtendedComponent should be a list of shapely.Geometry objects" + ) + if len(center_comp_ids) == 2: + self.x = center_comp_ids[0] + self.y = center_comp_ids[1] + elif len(center_comp_ids) == 1: + self.x = center_comp_ids[0] + self.y = None + else: + raise ValueError( + "ExtendedComponent must be initialized with one or two ComponentIDs" + ) + self.units = units + self._data = data + + @property + def extended(self): + return True + + @property + def numeric(self): + return False + + @property + def datetime(self): + return False + + @property + def categorical(self): + return False diff --git a/glue/core/data.py b/glue/core/data.py index 5293a488b..6313eace5 100644 --- a/glue/core/data.py +++ b/glue/core/data.py @@ -1427,6 +1427,8 @@ def get_kind(self, cid): return 'numerical' elif comp.categorical: return 'categorical' + elif comp.extended: + return 'extended' else: raise TypeError("Unknown data kind") @@ -1543,7 +1545,7 @@ def update_components(self, mapping): # alert hub of the change if self.hub is not None: - msg = NumericalDataChangedMessage(self) + msg = NumericalDataChangedMessage(self, components_changed=list(mapping.keys())) self.hub.broadcast(msg) for subset in self.subsets: diff --git a/glue/core/data_exporters/qt/__init__.py b/glue/core/data_exporters/qt/__init__.py deleted file mode 100644 index 87cca1e92..000000000 --- a/glue/core/data_exporters/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.core.data_exporters.qt is deprecated, use glue_qt.core.data_exporters instead', GlueDeprecationWarning) -from glue_qt.core.data_exporters import * # noqa diff --git a/glue/core/data_exporters/qt/dialog.py b/glue/core/data_exporters/qt/dialog.py deleted file mode 100644 index 88e4fb38f..000000000 --- a/glue/core/data_exporters/qt/dialog.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.core.data_exporters.qt.dialog is deprecated, use glue_qt.core.data_exporters.dialog instead', GlueDeprecationWarning) -from glue_qt.core.data_exporters.dialog import * # noqa diff --git a/glue/core/data_region.py b/glue/core/data_region.py new file mode 100644 index 000000000..64d6abd8a --- /dev/null +++ b/glue/core/data_region.py @@ -0,0 +1,326 @@ +import numpy as np +import shapely + +from glue.core.data import Data +from glue.core.contracts import contract + +from glue.core.component import Component, ExtendedComponent + + +__all__ = ['RegionData'] + + +class RegionData(Data): + """ + A glue Data object for storing data that is associated with a region. + + This object can be used when a dataset describes 2D regions or 1D ranges. It + contains exactly one :class:`~glue.core.component.ExtendedComponent` object + which contains the boundaries of the regions, and must also contain + one or two components that give the center of the regions in whatever data + coordinates the regions are described in. Links in glue are not made + directly on the :class:`~glue.core.component.ExtendedComponent`, but instead + on the center components. + + Thus, a subset that includes the center of a region will include that region, + but a subset that includes just a little part of the region will not include + that region. These center components are not the same pixel components. For + example, a dataset that is a table of 2D regions will have a single + :class:`~glue.core.component.CoordinateComponent`, but must have two of these center + components. + + A typical use case for this object is to store the properties of geographic + regions, where the boundaries of the regions are stored in an + :class:`~glue.core.component.ExtendedComponent` and the centers of the + regions are stored in two components, one for the longitude and one for the + latitude. Additional components may describe arbitrary properties of these + geographic regions (e.g. population, area, etc). + + This class is mostly a convenience class. By using this class, Data + Loaders can create RegionData directly from an iterable of geometries, + since this class deals with creating representative points. Viewers + can assume that when adding a RegionData object they are probably + being asked to visualize the ExtendedComponent, and this class provides + convenience methods for ascertaining whether the components currently + visualized in a Viewer are the correct ones to enable display of the + ExtendedComponent and to transform the regions through the glue + linking architecture into the correct coordinates for display. + + Parameters + ---------- + label : `str`, optional + The label of the data. + coords : :class:`~glue.core.coordinates.Coordinates`, optional + The coordinates associated with the data. + **kwargs + All other keyword arguments are passed to the :class:`~glue.core.data.Data` + constructor. + + Attributes + ---------- + extended_component_id : :class:`~glue.core.component_id.ComponentID` + The ID of the :class:`~glue.core.component.ExtendedComponent` object + that contains the boundaries of the regions. + center_x_id : :class:`~glue.core.component_id.ComponentID` + The ID of the Component object that contains the x-coordinate of the + center of the regions. This is actually stored in the component + with the extended_component_id, but it is convenient to have it here. + center_y_id : :class:`~glue.core.component_id.ComponentID` + The ID of the Component object that contains the y-coordinate of the + center of the regions. This is actually stored in the component + with the extended_component_id, but it is convenient to have it here. + + Examples + -------- + + There are two main options for initializing a :class:`~glue.core.data_region.RegionData` + object. The first is to simply pass in a list of ``Shapely.Geometry`` objects + with dimesionality N, from which we will create N+1 components: one + :class:`~glue.core.component.ExtendedComponent` with the boundaries, and N + regular Component(s) with the center coordinates computed from the Shapley + method :meth:`~shapely.GeometryCollection.representative_point`: + + >>> geometries = [shapely.geometry.Point(0, 0).buffer(1), shapely.geometry.Point(1, 1).buffer(1)] + >>> my_region_data = RegionData(label='My Regions', boundary=geometries) + + This will create a :class:`~glue.core.data_region.RegionData` object with three + components: one :class:`~glue.core.component.ExtendedComponent` with label + "geo" and two regular Components with labels "Center [x] for boundary" + and "Center [y] for boundary". + + The second is to explicitly create an :class:`~glue.core.component.ExtendedComponent` + (which requires passing in the ComponentIDs for the center coordinates) and + then use `add_component` to add this component to a :class:`~glue.core.data_region.RegionData` + object. You might use this approach if your dataset already contains points that + represent the centers of your regions and you want to avoid re-calculating them. For example: + + >>> center_x = [0, 1] + >>> center_y = [0, 1] + >>> geometries = [shapely.geometry.Point(0, 0).buffer(1), shapely.geometry.Point(1, 1).buffer(1)] + + >>> my_region_data = RegionData(label='My Regions') + >>> # Region IDs are created and returned when we add a Component to a Data object + >>> cen_x_id = my_region_data.add_component(center_x, label='Center [x]') + >>> cen_y_id = my_region_data.add_component(center_y, label='Center [y]') + >>> extended_comp = ExtendedComponent(geometries, center_comp_ids=[cen_x_id, cen_y_id]) + >>> my_region_data.add_component(extended_comp, label='boundaries') + + """ + + def __init__(self, label="", coords=None, **kwargs): + self._extended_component_id = None + # __init__ calls add_component which deals with ExtendedComponent logic + super().__init__(label=label, coords=coords, **kwargs) + + def __repr__(self): + return f'RegionData (label: {self.label} | extended_component: {self.extended_component_id})' + + @property + def center_x_id(self): + return self.get_component(self.extended_component_id).x + + @property + def center_y_id(self): + return self.get_component(self.extended_component_id).y + + @property + def extended_component_id(self): + return self._extended_component_id + + @contract(component='component_like', label='cid_like') + def add_component(self, component, label): + """ Add a new component to this data set, allowing only one :class:`~glue.core.component.ExtendedComponent` + + If component is an array of Shapely objects then we use + :meth:`~shapely.GeometryCollection.representative_point`: to + create two new components for the center coordinates of the regions and + add them to the :class:`~glue.core.data_region.RegionData` object as well. + + If component is an :class:`~glue.core.component.ExtendedComponent`, + then we simply add it to the :class:`~glue.core.data_region.RegionData` object. + + We do this here instead of extending ``Component.autotyped`` because + we only want to use :class:`~glue.core.component.ExtendedComponent` objects + in the context of a :class:`~glue.core.data_region.RegionData` object. + + Parameters + ---------- + component : :class:`~glue.core.component.Component` or array-like + Object to add. If this is an array of Shapely objects, then we + create two new components for the center coordinates of the regions + as well. + label : `str` or :class:`~glue.core.component_id.ComponentID` + The label. If this is a string, a new + :class:`glue.core.component_id.ComponentID` + with this label will be created and associated with the Component. + + Raises + ------ + `ValueError`, if the :class:`~glue.core.data_region.RegionData` already has an extended component + """ + + if not isinstance(component, Component): + if all(isinstance(s, shapely.Geometry) for s in component): + center_x = [] + center_y = [] + for s in component: + rep = s.representative_point() + center_x.append(rep.x) + center_y.append(rep.y) + cen_x_id = super().add_component(np.asarray(center_x), f"Center [x] for {label}") + cen_y_id = super().add_component(np.asarray(center_y), f"Center [y] for {label}") + ext_component = ExtendedComponent(np.asarray(component), center_comp_ids=[cen_x_id, cen_y_id]) + self._extended_component_id = super().add_component(ext_component, label) + return self._extended_component_id + + if isinstance(component, ExtendedComponent): + if self.extended_component_id is not None: + raise ValueError(f"Cannot add another ExtendedComponent; existing extended component: {self.extended_component_id}") + else: + self._extended_component_id = super().add_component(component, label) + return self._extended_component_id + else: + return super().add_component(component, label) + + def _get_trans_to_cids(self, cen_cids, other_cids): + """ + Use recursion to traverse links and build up a list of functions + to convert cen_ids to other_cids. + + Parameters + ---------- + cen_cids : list of :class:`~glue.core.component_id.ComponentID` + The ComponentIDs that are the inputs to the transformation + function. + other_cids : list of :class:`~glue.core.component_id.ComponentID` + The ComponentIDs that are the outputs of the transformation + function. + + Raises + ------ + ValueError + If the links imply a transformation that cannot be done by Shapely. + """ + if len(other_cids) != 2: + raise ValueError("Can only deal with 2D transformations") + linkx = self._get_external_link(other_cids[0]) + linky = self._get_external_link(other_cids[1]) + + funcx = linkx.get_using() + funcy = linky.get_using() + + if len(linkx.get_from_ids()) > 2 or len(linky.get_from_ids()) > 2: + raise ValueError("Can only display regions if links depend on 2 or fewer other components.") + + def conv_function(x, y=None): + if len(linkx.get_from_ids()) == 1 and len(linky.get_from_ids()) == 1: + return [funcx(x), funcy(y)] + else: + return [funcx(x, y), funcy(x, y)] + + self.list_of_functions.append(conv_function) + if len(linkx.get_from_ids()) == 2: + other_cids = linkx.get_from_ids() + else: + other_cids = linkx.get_from_ids() + linky.get_from_ids() + if cen_cids[0] in other_cids or cen_cids[1] in other_cids: + if set([cen_cids[0]]+[cen_cids[1]]) == set(other_cids): + return + else: + raise ValueError("Cannot display regions if links depend on other components.") + else: + self._get_trans_to_cids(cen_cids, other_cids) + + def get_transform_to_cids(self, other_cids): + """ + Return the function that converts the center components to other_cids. + + We can use this to get the transformation from the x,y coordinates + that the ExtendedComponent are in to x and y attributes that are + visualized in a Viewer so that we can translate the geometries + in the ExtendedComponent to the new coordinates before displaying them. + + Can be called in viewers as: + + >>> tfunc = region_data.get_transform_to_cids([viewer_x_att, viewer_y_att]) + + And the function can be used to transform the geometries as: + + >>> from shapely.ops import transform + >>> new_geoms = [transform(tfunc, g) for g in old_geoms] + + TODO: This is currently hard-coded to work with 2D transformations, + but could be extended to work with 1D viewers as well. Our region + geometries are limited to be 2D (or 1D ranges), so links + that require more than 2 input components do not admit to + a valid transformation. + + Parameters + ---------- + other_cid : list of :class:`~glue.core.component.ComponentID` + The other ComponentIDs (typically the ones that are + visualized in a Viewer). + + Returns + ------- + func : `callable` + The function that converts center_x_id and center_y_id to + other_cids which can then be used to transform the + geometries before display. Returns None if there is no + such valid transformation. + """ + + self.list_of_functions = [] + self._get_trans_to_cids([self.center_x_id, self.center_y_id], other_cids) + if not self.list_of_functions: + return None + elif len(self.list_of_functions) == 1: + return self.list_of_functions[0] + else: + def conv_function(*args): + # Our list of functions is built up in reverse order + for f in self.list_of_functions[::-1]: + args = f(*args) + return args + return conv_function + + def linked_to_center_comp(self, target_cid): + """ + Check if target_cid can be mapped to one of the center components. + + This is used to see if we can display the ExtendedComponent in a Viewer. + + It is not sufficient to simply see if we can retrieve data from + target_cid like is commonly done in Viewers: + + >>> _ = self[target_cid] + + Because if target_cid is mapped to a Component that is not one of the + center components, then we cannot display the regions. + + Parameters + ---------- + target_cid : :class:`~glue.core.component.ComponentID` + The ComponentID (typically displayed in a Viewer) which we + want to check if it is one of the special center components. + + Returns + ------- + bool + True if target_cid can be mapped to one of the center components, False otherwise. + """ + from glue.core.link_manager import is_equivalent_cid # avoid circular import + + center_cids = [self.center_x_id, self.center_y_id] + for center_cid in center_cids: + if is_equivalent_cid(self, center_cid, target_cid): + return True + + link = self._get_external_link(target_cid) + if not link: + return False + for center_cid in center_cids: + if center_cid in link: + return True + else: + return any([self.linked_to_center_comp(x) for x in link.get_from_ids()]) diff --git a/glue/core/layer_artist.py b/glue/core/layer_artist.py index b7b5b8756..aa24e956b 100644 --- a/glue/core/layer_artist.py +++ b/glue/core/layer_artist.py @@ -228,6 +228,12 @@ def _check_subset_state_changed(self): self._changed = True self._state = state + def _on_components_changed(self, components_changed): + """ + React to a change to one or more of the components in this layer. + """ + pass + def __str__(self): return "%s for %s" % (self.__class__.__name__, self.layer.label) diff --git a/glue/core/message.py b/glue/core/message.py index 94302dd7c..68615ddd1 100644 --- a/glue/core/message.py +++ b/glue/core/message.py @@ -182,7 +182,9 @@ def __init__(self, sender, attribute, tag=None): class NumericalDataChangedMessage(DataMessage): - pass + def __init__(self, sender, components_changed=None, tag=None): + super(NumericalDataChangedMessage, self).__init__(sender, tag=tag) + self.components_changed = components_changed class DataCollectionMessage(Message): diff --git a/glue/core/qt/__init__.py b/glue/core/qt/__init__.py deleted file mode 100644 index 52b7c076a..000000000 --- a/glue/core/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.core.qt is deprecated, use glue_qt.core instead', GlueDeprecationWarning) -from glue_qt.core import * # noqa diff --git a/glue/core/qt/data_collection_model.py b/glue/core/qt/data_collection_model.py deleted file mode 100644 index 78300433e..000000000 --- a/glue/core/qt/data_collection_model.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.core.qt.data_collection_model is deprecated, use glue_qt.core.data_collection_model instead', GlueDeprecationWarning) -from glue_qt.core.data_collection_model import * # noqa diff --git a/glue/core/qt/dialogs.py b/glue/core/qt/dialogs.py deleted file mode 100644 index 117ecd691..000000000 --- a/glue/core/qt/dialogs.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.core.qt.dialogs is deprecated, use glue_qt.core.dialogs instead', GlueDeprecationWarning) -from glue_qt.core.dialogs import * # noqa diff --git a/glue/core/qt/fitters.py b/glue/core/qt/fitters.py deleted file mode 100644 index 54bb772cc..000000000 --- a/glue/core/qt/fitters.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.core.qt.fitters is deprecated, use glue_qt.core.fitters instead', GlueDeprecationWarning) -from glue_qt.core.fitters import * # noqa diff --git a/glue/core/qt/layer_artist_model.py b/glue/core/qt/layer_artist_model.py deleted file mode 100644 index af0a56455..000000000 --- a/glue/core/qt/layer_artist_model.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.core.qt.layer_artist_model is deprecated, use glue_qt.core.layer_artist_model instead', GlueDeprecationWarning) -from glue_qt.core.layer_artist_model import * # noqa diff --git a/glue/core/qt/message_widget.py b/glue/core/qt/message_widget.py deleted file mode 100644 index 2fe5fc3dd..000000000 --- a/glue/core/qt/message_widget.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.core.qt.message_widget is deprecated, use glue_qt.core.message_widget instead', GlueDeprecationWarning) -from glue_qt.core.message_widget import * # noqa diff --git a/glue/core/qt/mime.py b/glue/core/qt/mime.py deleted file mode 100644 index 4909c48f4..000000000 --- a/glue/core/qt/mime.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.core.qt.mime is deprecated, use glue_qt.core.mime instead', GlueDeprecationWarning) -from glue_qt.core.mime import * # noqa diff --git a/glue/core/qt/simpleforms.py b/glue/core/qt/simpleforms.py deleted file mode 100644 index e4be6effa..000000000 --- a/glue/core/qt/simpleforms.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.core.qt.simpleforms is deprecated, use glue_qt.core.simpleforms instead', GlueDeprecationWarning) -from glue_qt.core.simpleforms import * # noqa diff --git a/glue/core/qt/style_dialog.py b/glue/core/qt/style_dialog.py deleted file mode 100644 index 71e6c438a..000000000 --- a/glue/core/qt/style_dialog.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.core.qt.style_dialog is deprecated, use glue_qt.core.style_dialog instead', GlueDeprecationWarning) -from glue_qt.core.style_dialog import * # noqa diff --git a/glue/core/regions.py b/glue/core/regions.py new file mode 100644 index 000000000..9c28b0dfa --- /dev/null +++ b/glue/core/regions.py @@ -0,0 +1,41 @@ +""" +Functions to support data that defines regions +""" +import numpy as np + +from glue.core.roi import PolygonalROI +from glue.core.data_region import RegionData + +from glue.config import layer_action +from glue.core.subset import RoiSubsetState, MultiOrState + + +def reg_to_roi(reg): + if reg.geom_type == "Polygon": + ext_coords = np.array(reg.exterior.coords.xy) + roi = PolygonalROI(vx=ext_coords[0], vy=ext_coords[1]) # Need to account for interior rings + return roi + + +@layer_action(label='Subset of regions -> Subset over region extent', single=True, subset=True) +def layer_to_subset(layer, data_collection): + """ + This should be limited to the case where subset.Data is RegionData + and/or return a warning when applied to some other kind of data. + """ + if isinstance(layer.data, RegionData): + + extended_comp = layer.data._extended_component_ids[0] + regions = layer[extended_comp] + list_of_rois = [reg_to_roi(region) for region in regions] + + roisubstates = [RoiSubsetState(layer.data.ext_x, + layer.data.ext_y, + roi=roi + ) + for roi in list_of_rois] + if len(list_of_rois) > 1: + composite_substate = MultiOrState(roisubstates) + else: + composite_substate = roisubstates[0] + _ = data_collection.new_subset_group(subset_state=composite_substate) diff --git a/glue/core/state.py b/glue/core/state.py index 05558b641..449005c6a 100644 --- a/glue/core/state.py +++ b/glue/core/state.py @@ -66,12 +66,15 @@ def load(rec, context) from matplotlib.colors import Colormap from matplotlib import cm from astropy.wcs import WCS +import shapely from glue import core from glue.core.data import Data +from glue.core.data_region import RegionData from glue.core.component_id import ComponentID, PixelComponentID from glue.core.component import (Component, CategoricalComponent, - DerivedComponent, CoordinateComponent) + DerivedComponent, CoordinateComponent, + ExtendedComponent) from glue.core.subset import (OPSYM, SYMOP, CompositeSubsetState, SubsetState, Subset, RoiSubsetState, InequalitySubsetState, RangeSubsetState) @@ -1028,12 +1031,18 @@ def load_cid_tuple(cids): @saver(ComponentID) def _save_component_id(cid, context): - return dict(label=cid.label) + return dict(label=cid.label, + uuid=cid.uuid) @loader(ComponentID) def _load_component_id(rec, context): - return ComponentID(rec['label']) + if 'uuid' in rec and rec['uuid'] is not None: + result = ComponentID(rec['label']) + result._uuid = rec['uuid'] + return result + else: + return ComponentID(rec['label']) @saver(PixelComponentID) @@ -1057,7 +1066,6 @@ def _save_component(component, context): log = component._load_log return dict(log=context.id(log), log_item=log.id(component)) - return dict(data=context.do(component.data), units=component.units) @@ -1228,6 +1236,16 @@ def _load_datetime64(rec, context): return np.datetime64(rec['datetime64']) +@saver(shapely.Geometry) +def _save_shapely_geometry(shape, context): + return {'shapely_geometry': str(shapely.to_wkt(shape))} + + +@loader(shapely.Geometry) +def _load_shapely_geometry(rec, context): + return shapely.from_wkt(rec['shapely_geometry']) + + def apply_inplace_patches(rec): """ Apply in-place patches to a loaded session file. Ideally this should be @@ -1263,3 +1281,169 @@ def apply_inplace_patches(rec): contents = state['contents'] if 'st__coords' not in contents: contents['st__coords'] = ['x'] + + +@saver(RegionData, version=1) +def _save_regiondata(data, context): + result = dict( + components=[ + (context.id(c), context.id(data.get_component(c))) for c in data._components + ], + subsets=[context.id(s) for s in data.subsets], + label=data.label, + ) + + if data.coords is not None: + result["coords"] = context.id(data.coords) + + if data._extended_component_id is not None: + result["_extended_component_id"] = context.id(data._extended_component_id) + + result["style"] = context.do(data.style) + + def save_cid_tuple(cids): + return tuple(context.id(cid) for cid in cids) + + result["_key_joins"] = [ + [context.id(k), save_cid_tuple(v0), save_cid_tuple(v1)] + for k, (v0, v1) in data._key_joins.items() + ] + result["uuid"] = data.uuid + result["xuuid"] = data.center_x_id.uuid + result["yuuid"] = data.center_y_id.uuid + result["primary_owner"] = [ + context.id(cid) for cid in data.components if cid.parent is data + ] + # Filter out keys/values that can't be serialized + meta_filtered = OrderedDict() + for key, value in data.meta.items(): + try: + context.do(key) + context.do(value) + except GlueSerializeError: + continue + else: + meta_filtered[key] = value + result["meta"] = context.do(meta_filtered) + + return result + + +@loader(RegionData, version=1) +def _load_regiondata(rec, context): + """ + Custom load function for RegionData. + This is the same as the chain of logic in + _save_data_5 for Data, but result is an RegionData object + instead. + """ + + label = rec["label"] + result = RegionData(label=label) + + # we manually rebuild pixel/world components, so + # we override this function. This is pretty ugly + result._create_pixel_and_world_components = lambda ndim: None + + comps = [list(map(context.object, [cid, comp])) for cid, comp in rec["components"]] + + for icomp, (cid, comp) in enumerate(comps): + if isinstance(comp, CoordinateComponent): + comp._data = result + + # For backward compatibility, we need to check for cases where + # the component ID for the pixel components was not a PixelComponentID + # and upgrade it to one. This can be removed once we no longer + # support pre-v0.8 session files. + if not comp.world and not isinstance(cid, PixelComponentID): + cid = PixelComponentID(comp.axis, cid.label, parent=cid.parent) + comps[icomp] = (cid, comp) + + result.add_component(comp, cid) + + assert result._world_component_ids == [] + + coord = [c for c in comps if isinstance(c[1], CoordinateComponent)] + coord = [x[0] for x in sorted(coord, key=lambda x: x[1])] + + if getattr(result, "coords") is not None: + assert len(coord) == result.ndim * 2 + # Might black formatting break this? + result._world_component_ids = coord[: len(coord) // 2] + result._pixel_component_ids = coord[len(coord) // 2 :] # noqa E203 + else: + assert len(coord) == result.ndim + result._pixel_component_ids = coord + + # We can now re-generate the coordinate links + result._set_up_coordinate_component_links(result.ndim) + + for s in rec["subsets"]: + result.add_subset(context.object(s)) + + result.style = context.object(rec["style"]) + + if "primary_owner" in rec: + for cid in rec["primary_owner"]: + cid = context.object(cid) + cid.parent = result + yield result + + def load_cid_tuple(cids): + return tuple(context.object(cid) for cid in cids) + + result._key_joins = dict( + (context.object(k), (load_cid_tuple(v0), load_cid_tuple(v1))) + for k, v0, v1 in rec["_key_joins"] + ) + if "uuid" in rec and rec["uuid"] is not None: + result.uuid = rec["uuid"] + else: + result.uuid = str(uuid.uuid4()) + if "meta" in rec: + result.meta.update(context.object(rec["meta"])) + + result._extended_component_id = context.object(rec["_extended_component_id"]) + + def fix_special_component_ids(ext_data): + """ + We need to update the .x and .y attributes on the extended component + to be the actual component IDs in the data object. This is a fragile + way to do it because it assumes that the component labels are + unique. + """ + ext_comp = ext_data.get_component(ext_data.extended_component_id) + + for comp_id in ext_data.component_ids(): + if comp_id.uuid == rec['xuuid']: + new_x = comp_id + elif comp_id.uuid == rec['yuuid']: + new_y = comp_id + ext_comp.x = new_x + ext_comp.y = new_y + yield fix_special_component_ids(result) + + +@saver(ExtendedComponent) +def _save_extended_component(component, context): + if not context.include_data and hasattr(component, "_load_log"): + log = component._load_log + return dict(log=context.id(log), log_item=log.id(component)) + + data_to_save = [x for x in component.data] + + return dict(data=context.do(data_to_save), + x=context.do(component.x), + y=context.do(component.y), + units=component.units) + + +@loader(ExtendedComponent) +def _load_extended_component(rec, context): + if "log" in rec: + return context.object(rec["log"]).component(rec["log_item"]) + + data_to_load = np.asarray([x for x in context.object(rec["data"])]) + return ExtendedComponent(data=data_to_load, + center_comp_ids=[context.object(rec['x']), context.object(rec['y'])], + units=rec["units"]) diff --git a/glue/core/state_objects.py b/glue/core/state_objects.py index e5e31a36b..149ff43f9 100644 --- a/glue/core/state_objects.py +++ b/glue/core/state_objects.py @@ -9,9 +9,10 @@ HasCallbackProperties, CallbackList) from glue.core.state import saver, loader from glue.core.component_id import PixelComponentID +from glue.core.exceptions import IncompatibleAttribute __all__ = ['State', 'StateAttributeCacheHelper', - 'StateAttributeLimitsHelper', 'StateAttributeSingleValueHelper'] + 'StateAttributeLimitsHelper', 'StateAttributeSingleValueHelper', 'StateAttributeHistogramHelper'] @saver(CallbackList) @@ -428,9 +429,16 @@ def __init__(self, state, attribute, random_subset=10000, max_n_bin=30, self._common_n_bin = None def _apply_common_n_bin(self): - for att in self._cache: - if not self.data.get_kind(att) == 'categorical': - self._cache[att]['n_bin'] = self._common_n_bin + for att in list(self._cache): + try: + if not self.data.get_kind(att) == 'categorical': + self._cache[att]['n_bin'] = self._common_n_bin + except IncompatibleAttribute: + # This can indicate that a dataset has been removed from the + # data collection or that the attribute has changed to a + # new dataset that is not compatible with the previous one. + # In this case we should remove the entry + self._cache.pop(att) def _update_common_n_bin(self, common_n_bin): if common_n_bin: diff --git a/glue/core/tests/test_component.py b/glue/core/tests/test_component.py index 7448e6597..a1924eece 100644 --- a/glue/core/tests/test_component.py +++ b/glue/core/tests/test_component.py @@ -7,13 +7,14 @@ from unittest.mock import MagicMock from astropy.wcs import WCS +from shapely.geometry import MultiPolygon, Polygon, Point, LineString from glue import core from glue.tests.helpers import requires_astropy from ..coordinates import Coordinates from ..component import (Component, DerivedComponent, CoordinateComponent, - CategoricalComponent) + CategoricalComponent, ExtendedComponent) from ..component_id import ComponentID from ..data import Data from ..parse import ParsedCommand, ParsedComponentLink @@ -403,3 +404,52 @@ def test_coordinate_component_1d_coord(): data = Data(flux=np.random.random(5), coords=wcs, label='data') np.testing.assert_equal(data['Frequency'], [1, 2, 3, 4, 5]) + + +class TestExtendedComponent(object): + + def setup_method(self): + + self.cen_x_id = ComponentID('x') + self.cen_y_id = ComponentID('y') + + poly_1 = Polygon([(20, 20), (60, 20), (60, 40), (20, 40)]) + poly_2 = Polygon([(60, 50), (60, 70), (80, 70), (80, 50)]) + poly_3 = Polygon([(10, 10), (15, 10), (15, 15), (10, 15)]) + poly_4 = Polygon([(10, 20), (15, 20), (15, 30), (10, 30), (12, 25)]) + + polygons = MultiPolygon([poly_3, poly_4]) + self.polys = np.array([poly_1, poly_2, polygons]) + self.poly2d = ExtendedComponent(self.polys, center_comp_ids=[self.cen_x_id, self.cen_y_id]) + + circle_1 = Point(1.0, 0.0).buffer(1) + circle_2 = Point(2.0, 3.0).buffer(2) + circles = np.array([circle_1, circle_2]) + self.circles = ExtendedComponent(circles, center_comp_ids=[self.cen_x_id, self.cen_y_id]) + + ranges = np.array([LineString([(0, 0), (1, 0)]), LineString([(0, 0), (4, 0)])]) + self.ranges = ExtendedComponent(ranges, center_comp_ids=[self.cen_x_id]) + + def test_basic_proprties(self): + assert self.poly2d.ndim == 1 + assert isinstance(self.poly2d, ExtendedComponent) + assert self.poly2d.shape == (3,) + assert self.poly2d.x == self.cen_x_id + assert self.poly2d.y == self.cen_y_id + + assert self.ranges.ndim == 1 + assert isinstance(self.ranges, ExtendedComponent) + assert self.ranges.shape == (2,) + assert self.ranges.x == self.cen_x_id + assert self.ranges.y is None + + def test_incorrect_inputs(self): + with pytest.raises(TypeError, match='Input data for a ExtendedComponent should be a list of shapely.Geometry objects'): + bad_data = np.array([1, 2, 3]) + bad_data_comp = ExtendedComponent(bad_data, center_comp_ids=[self.cen_x_id, self.cen_y_id]) + + with pytest.raises(ValueError, match='ExtendedComponent must be initialized with one or two ComponentIDs'): + no_center_ids_comp = ExtendedComponent(self.polys, center_comp_ids=[]) + + with pytest.raises(ValueError, match='ExtendedComponent must be initialized with one or two ComponentIDs'): + no_center_ids_comp = ExtendedComponent(self.polys, center_comp_ids=[self.cen_x_id, self.cen_y_id, self.cen_x_id]) diff --git a/glue/core/tests/test_components_changed.py b/glue/core/tests/test_components_changed.py new file mode 100644 index 000000000..d729c97d7 --- /dev/null +++ b/glue/core/tests/test_components_changed.py @@ -0,0 +1,45 @@ +""" +Test that data.update_components() sends a NumericalDataChangedMessage +that conveys which components have been changed. +""" +from glue.core.data import Data +from glue.core.hub import HubListener +from glue.core.data_collection import DataCollection +from glue.core.message import NumericalDataChangedMessage + +import numpy as np +from numpy.testing import assert_array_equal + + +def test_message_carries_components(): + + test_data = Data(x=np.array([1, 2, 3, 4, 5]), y=np.array([1, 2, 3, 4, 5]), label='test_data') + data_collection = DataCollection([test_data]) + + class CustomListener(HubListener): + + def __init__(self, hub): + self.received = 0 + self.components_changed = None + hub.subscribe(self, NumericalDataChangedMessage, + handler=self.receive_message) + + def receive_message(self, message): + self.received += 1 + try: + self.components_changed = message.components_changed + except AttributeError: + self.components_changed = None + + listener = CustomListener(data_collection.hub) + assert listener.received == 0 + assert listener.components_changed is None + + cid_to_change = test_data.id['x'] + new_data = [5, 2, 6, 7, 10] + test_data.update_components({cid_to_change: new_data}) + + assert listener.received == 1 + assert cid_to_change in listener.components_changed + + assert_array_equal(test_data['x'], new_data) diff --git a/glue/core/tests/test_data_region.py b/glue/core/tests/test_data_region.py new file mode 100644 index 000000000..9d527cf14 --- /dev/null +++ b/glue/core/tests/test_data_region.py @@ -0,0 +1,397 @@ +import pytest +from numpy.testing import assert_array_equal + +import numpy as np +import shapely +from shapely.geometry import MultiPolygon, Polygon, Point +from shapely.affinity import affine_transform +from shapely.ops import transform + +from glue.core.data import Data +from glue.core.data_collection import DataCollection +from glue.core.data_region import RegionData +from glue.core.component import ExtendedComponent +from glue.core.state import GlueUnSerializer +from glue.core.tests.test_application_base import MockApplication +from glue.core.link_helpers import LinkSame, LinkTwoWay +from glue.core.exceptions import IncompatibleAttribute + +from glue.plugins.wcs_autolinking.wcs_autolinking import AffineLink + +poly_1 = Polygon([(20, 20), (60, 20), (60, 40), (20, 40)]) +poly_2 = Polygon([(60, 50), (60, 70), (80, 70), (80, 50)]) +poly_3 = Polygon([(10, 10), (15, 10), (15, 15), (10, 15)]) +poly_4 = Polygon([(10, 20), (15, 20), (15, 30), (10, 30), (12, 25)]) + +polygons = MultiPolygon([poly_3, poly_4]) +SHAPELY_POLYGON_ARRAY = np.array([poly_1, poly_2, polygons]) + +SHAPELY_CIRCLE_ARRAY = np.array([Point(0, 0).buffer(1), Point(1, 1).buffer(1)]) +CENTER_X = [0, 1] +CENTER_Y = [0, 1] + + +def shift(x): + return x + 1 + + +def unshift(x): + return x - 1 + + +def forwards(x): + return x * 2 + + +def backwards(x): + return x / 2 + + +class TestRegionDataLinks(object): + def setup_method(self): + self.region_data = RegionData(label='My Regions', + color=np.array(['red', 'blue', 'green']), + area=shapely.area(SHAPELY_POLYGON_ARRAY), + boundary=SHAPELY_POLYGON_ARRAY) + self.other_data = Data(label='Other Data', + x=np.array([1, 2, 3]), + y=np.array([5, 6, 7]), + color=np.array(['yellow', 'pink', 'orange']), + area=np.array([10, 20, 30])) + + def test_linked_properly(self): + dc = DataCollection([self.region_data, self.other_data]) + + viewer_x_att = self.other_data.id['x'] + viewer_y_att = self.other_data.id['y'] + + assert not self.region_data.linked_to_center_comp(viewer_x_att) + assert not self.region_data.linked_to_center_comp(viewer_y_att) + + dc.add_link(LinkSame(self.region_data.center_x_id, self.other_data.id['x'])) + dc.add_link(LinkSame(self.region_data.center_y_id, self.other_data.id['y'])) + + assert self.region_data.linked_to_center_comp(viewer_x_att) + assert self.region_data.linked_to_center_comp(viewer_y_att) + + def test_linked_incorrectly(self): + dc = DataCollection([self.region_data, self.other_data]) + + viewer_x_att = self.other_data.id['color'] + viewer_y_att = self.other_data.id['area'] + + assert not self.region_data.linked_to_center_comp(viewer_x_att) + assert not self.region_data.linked_to_center_comp(viewer_y_att) + + dc.add_link(LinkSame(self.region_data.id['color'], self.other_data.id['color'])) + dc.add_link(LinkSame(self.region_data.id['area'], self.other_data.id['area'])) + + # This is how a Viewer typically checks to see if is being asked + # to plot an incompatible dataset. RegionData is a special case + # because we need to know if the viewer is showing the center components + # (in which case we can show the regions) or some other components + # (in which case we can't). In this case we need to know that we + # CANNOT show the center component. + try: + x = self.region_data[viewer_x_att] + except IncompatibleAttribute: + assert False + + assert not self.region_data.linked_to_center_comp(viewer_x_att) + assert not self.region_data.linked_to_center_comp(viewer_y_att) + + +class TestRegionData(object): + + def setup_method(self): + self.region_data = RegionData(label='My Regions', boundary=SHAPELY_POLYGON_ARRAY) + + self.manual_region_data = RegionData(label='My Manual Regions') + + self.cen_x_id = self.manual_region_data.add_component(CENTER_X, label='Center [x]') + self.cen_y_id = self.manual_region_data.add_component(CENTER_Y, label='Center [y]') + self.extended_comp = ExtendedComponent(SHAPELY_CIRCLE_ARRAY, center_comp_ids=[self.cen_x_id, self.cen_y_id]) + self.manual_region_data.add_component(self.extended_comp, label='circles') + + self.other_data = Data(x=np.array([1, 2, 3]), y=np.array([5, 6, 7]), label='Other Data') + self.mid_data = Data(x=np.array([4, 5, 6]), y=np.array([2, 3, 4]), label='Middle Data') + + def test_basic_properties_simple(self): + assert self.region_data.label == 'My Regions' + assert self.region_data.shape == SHAPELY_POLYGON_ARRAY.shape + assert self.region_data.ndim == 1 + assert self.region_data.size == 3 + assert len(self.region_data.components) == 4 + assert_array_equal(self.region_data['boundary'], SHAPELY_POLYGON_ARRAY) + assert len(self.region_data.main_components) == 3 + component_labels = [cid.label for cid in self.region_data.main_components] + assert 'boundary' in component_labels + assert 'Center [x] for boundary' in component_labels + assert 'Center [y] for boundary' in component_labels + + def test_basic_properties_manual(self): + assert self.manual_region_data.label == 'My Manual Regions' + assert self.manual_region_data.shape == np.asarray(SHAPELY_CIRCLE_ARRAY).shape + assert self.manual_region_data.ndim == 1 + assert self.manual_region_data.size == 2 + assert len(self.manual_region_data.components) == 4 + assert_array_equal(self.manual_region_data['circles'], SHAPELY_CIRCLE_ARRAY) + assert len(self.region_data.main_components) == 3 + component_labels = [cid.label for cid in self.manual_region_data.main_components] + assert 'circles' in component_labels + assert 'Center [x]' in component_labels + assert 'Center [y]' in component_labels + + def test_get_kind(self): + assert self.region_data.get_kind('Center [x] for boundary') == 'numerical' + assert self.region_data.get_kind('Center [y] for boundary') == 'numerical' + assert self.region_data.get_kind('boundary') == 'extended' + + assert self.manual_region_data.get_kind('Center [x]') == 'numerical' + assert self.manual_region_data.get_kind('Center [y]') == 'numerical' + assert self.manual_region_data.get_kind('circles') == 'extended' + + def test_check_if_can_display(self): + dc = DataCollection([self.region_data, self.other_data]) + viewer_x_att = self.other_data.id['x'] + viewer_y_att = self.other_data.id['y'] + assert not self.region_data.linked_to_center_comp(viewer_x_att) + assert not self.region_data.linked_to_center_comp(viewer_y_att) + + dc.add_link(LinkSame(self.region_data.center_x_id, self.other_data.id['x'])) + dc.add_link(LinkSame(self.region_data.center_y_id, self.other_data.id['y'])) + + assert self.region_data.linked_to_center_comp(viewer_x_att) + assert self.region_data.linked_to_center_comp(viewer_y_att) + + def test_check_if_can_display_through_intermediate(self): + dc = DataCollection([self.region_data, self.other_data, self.mid_data]) + viewer_x_att = self.other_data.id['x'] + viewer_y_att = self.other_data.id['y'] + + assert not self.region_data.linked_to_center_comp(viewer_x_att) + assert not self.region_data.linked_to_center_comp(viewer_y_att) + + dc.add_link(LinkSame(self.region_data.center_x_id, self.mid_data.id['x'])) + dc.add_link(LinkSame(self.region_data.center_y_id, self.mid_data.id['y'])) + + assert not self.region_data.linked_to_center_comp(viewer_x_att) + assert not self.region_data.linked_to_center_comp(viewer_y_att) + + dc.add_link(LinkSame(self.other_data.id['x'], self.mid_data.id['x'])) + + assert self.region_data.linked_to_center_comp(viewer_x_att) + assert not self.region_data.linked_to_center_comp(viewer_y_att) + + dc.add_link(LinkSame(self.other_data.id['y'], self.mid_data.id['y'])) + + assert self.region_data.linked_to_center_comp(viewer_y_att) + + def test_check_if_can_display_through_complicated_intermediate(self): + dc = DataCollection([self.region_data, self.other_data, self.mid_data]) + viewer_x_att = self.other_data.id['x'] + viewer_y_att = self.other_data.id['y'] + + assert not self.region_data.linked_to_center_comp(viewer_x_att) + assert not self.region_data.linked_to_center_comp(viewer_y_att) + + dc.add_link(LinkSame(self.region_data.center_x_id, self.mid_data.id['x'])) + dc.add_link(LinkSame(self.region_data.center_y_id, self.mid_data.id['y'])) + + assert not self.region_data.linked_to_center_comp(viewer_x_att) + assert not self.region_data.linked_to_center_comp(viewer_y_att) + + dc.add_link(LinkTwoWay(self.other_data.id['x'], self.mid_data.id['x'], forwards, backwards)) + + assert self.region_data.linked_to_center_comp(viewer_x_att) + assert not self.region_data.linked_to_center_comp(viewer_y_att) + + dc.add_link(LinkTwoWay(self.other_data.id['y'], self.mid_data.id['y'], forwards, backwards)) + + assert self.region_data.linked_to_center_comp(viewer_y_att) + + def test_get_transformation_to_cid(self): + dc = DataCollection([self.region_data, self.other_data]) + viewer_x_att = self.other_data.id['x'] + viewer_y_att = self.other_data.id['y'] + + dc.add_link(LinkTwoWay(self.region_data.center_x_id, viewer_x_att, forwards, backwards)) + dc.add_link(LinkTwoWay(self.region_data.center_y_id, viewer_y_att, backwards, forwards)) + + translation_func = self.region_data.get_transform_to_cids([viewer_x_att, viewer_y_att]) + x_data = self.region_data[self.region_data.center_x_id] + y_data = self.region_data[self.region_data.center_y_id] + + assert_array_equal(translation_func(x_data, y_data), + [forwards(self.region_data[self.region_data.center_x_id]), + backwards(self.region_data[self.region_data.center_y_id])]) + + def test_get_multilink_transformation(self): + dc = DataCollection([self.region_data, self.other_data]) + viewer_x_att = self.other_data.id['x'] + viewer_y_att = self.other_data.id['y'] + + matrix = np.array([[2, 0, 0], [0, 2, 0], [0, 0, 1]]) + shap_matrix = [2, 0, 0, 2, 0, 0] # This is how shapely defines this affine matrix + dc.add_link(AffineLink(self.region_data, self.other_data, + [self.region_data.center_x_id, self.region_data.center_y_id], + [viewer_x_att, viewer_y_att], + matrix=matrix)) + assert self.region_data.linked_to_center_comp(viewer_x_att) + assert self.region_data.linked_to_center_comp(viewer_y_att) + + translation_func = self.region_data.get_transform_to_cids([viewer_x_att, viewer_y_att]) + x_data = self.region_data[self.region_data.center_x_id] + y_data = self.region_data[self.region_data.center_y_id] + assert_array_equal(translation_func(x_data, y_data), + [self.region_data[viewer_x_att], self.region_data[viewer_y_att]]) + + new_regions = [transform(translation_func, g) for g in self.region_data['boundary']] + shapley_trans = [affine_transform(g, shap_matrix) for g in SHAPELY_POLYGON_ARRAY] + assert_array_equal(new_regions, shapley_trans) + + def test_get_multilink_transformation_through_int(self): + dc = DataCollection([self.region_data, self.other_data, self.mid_data]) + viewer_x_att = self.other_data.id['x'] + viewer_y_att = self.other_data.id['y'] + + dc.add_link(LinkTwoWay(self.region_data.center_x_id, self.mid_data.id['x'], shift, unshift)) + dc.add_link(LinkTwoWay(self.region_data.center_y_id, self.mid_data.id['y'], unshift, shift)) + + matrix = np.array([[2, 0, 0], [0, 3, 0], [0, 0, 1]]) + + dc.add_link(AffineLink(self.mid_data, self.other_data, + [self.mid_data.id['x'], self.mid_data.id['y']], + [viewer_x_att, viewer_y_att], + matrix=matrix)) + assert self.region_data.linked_to_center_comp(viewer_x_att) + assert self.region_data.linked_to_center_comp(viewer_y_att) + + translation_func = self.region_data.get_transform_to_cids([viewer_x_att, viewer_y_att]) + x_data = self.region_data[self.region_data.center_x_id] + y_data = self.region_data[self.region_data.center_y_id] + assert_array_equal(translation_func(x_data, y_data), + [self.region_data[viewer_x_att], self.region_data[viewer_y_att]]) + + def test_get_multilink_transformation_through_int_mixed(self): + dc = DataCollection([self.region_data, self.other_data, self.mid_data]) + viewer_x_att = self.other_data.id['x'] + viewer_y_att = self.other_data.id['y'] + + dc.add_link(LinkTwoWay(self.region_data.center_x_id, self.mid_data.id['x'], shift, unshift)) + dc.add_link(LinkTwoWay(self.region_data.center_y_id, self.mid_data.id['y'], unshift, shift)) + + matrix = np.array([[2, 2, 0], [1, 3, 3], [0, 0, 1]]) + + dc.add_link(AffineLink(self.mid_data, self.other_data, + [self.mid_data.id['x'], self.mid_data.id['y']], + [viewer_x_att, viewer_y_att], + matrix=matrix)) + assert self.region_data.linked_to_center_comp(viewer_x_att) + assert self.region_data.linked_to_center_comp(viewer_y_att) + + translation_func = self.region_data.get_transform_to_cids([viewer_x_att, viewer_y_att]) + x_data = self.region_data[self.region_data.center_x_id] + y_data = self.region_data[self.region_data.center_y_id] + assert_array_equal(translation_func(x_data, y_data), + [self.region_data[viewer_x_att], self.region_data[viewer_y_att]]) + + def test_errors_too_many_viewer_comps(self): + dc = DataCollection([self.region_data, self.other_data, self.mid_data]) + self.other_data.add_component(np.array([4, 5, 6]), label='z') + viewer_x_att = self.other_data.id['x'] + viewer_y_att = self.other_data.id['y'] + viewer_z_att = self.other_data.id['z'] + + dc.add_link(LinkTwoWay(self.region_data.center_x_id, self.mid_data.id['x'], shift, unshift)) + dc.add_link(LinkTwoWay(self.region_data.center_y_id, self.mid_data.id['y'], unshift, shift)) + + with pytest.raises(ValueError, match="Can only deal with 2D transformations"): + _ = self.region_data.get_transform_to_cids([viewer_x_att, viewer_y_att, viewer_z_att]) + + def test_errors_too_many_comps_in_transform(self): + dc = DataCollection([self.region_data, self.other_data, self.mid_data]) + self.other_data.add_component(np.array([4, 5, 6]), label='z') + viewer_x_att = self.other_data.id['x'] + viewer_y_att = self.other_data.id['y'] + viewer_z_att = self.other_data.id['z'] + self.region_data.add_component(np.array([90, 80, 70]), label='zz') + + matrix = np.array([[2, 0, 0, 0], [0, 2, 0, 0], [0, 0, 2, 0], [0, 0, 0, 1]]) + dc.add_link(AffineLink(self.region_data, self.other_data, + [self.region_data.center_x_id, + self.region_data.center_y_id, + self.region_data.id['zz']], + [viewer_x_att, viewer_y_att, viewer_z_att], + matrix=matrix)) + + with pytest.raises(ValueError, match="Can only display regions if links depend on 2 or fewer other components."): + _ = self.region_data.get_transform_to_cids([viewer_x_att, viewer_y_att]) + + def test_errors_bad_link(self): + dc = DataCollection([self.region_data, self.other_data, self.mid_data]) + self.other_data.add_component(np.array([4, 5, 6]), label='z') + viewer_x_att = self.other_data.id['x'] + viewer_y_att = self.other_data.id['y'] + viewer_z_att = self.other_data.id['z'] + self.region_data.add_component(np.array([90, 80, 70]), label='zz') + self.region_data.add_component(np.array([90, 80, 70]), label='fakey') + + matrix = np.array([[2, 0, 0], [0, 2, 0], [0, 0, 1]]) + dc.add_link(AffineLink(self.region_data, self.other_data, + [self.region_data.center_x_id, + self.region_data.id['fakey']], + [viewer_x_att, viewer_y_att], + matrix=matrix)) + + with pytest.raises(ValueError, match="Cannot display regions if links depend on other components."): + _ = self.region_data.get_transform_to_cids([viewer_x_att, viewer_y_att]) + + +class TestRegionDataSaveRestore(object): + + @pytest.fixture(autouse=True) + def setup_method(self, tmpdir): + app = MockApplication() + geodata = RegionData(label='My Regions', regions=SHAPELY_POLYGON_ARRAY) + catdata = Data(label='catdata', x=np.array([1, 2, 3, 4]), y=np.array([10, 20, 30, 40])) + app.data_collection.append(geodata) + app.data_collection.append(catdata) + + app.data_collection.add_link(LinkSame(geodata.id['Center [x] for regions'], catdata.id['x'])) + app.data_collection.add_link(LinkSame(geodata.id['Center [y] for regions'], catdata.id['y'])) + + session_file = tmpdir.mkdir("session").join('test.glu') + app.save_session(session_file) + + with open(session_file, "r") as f: + session = f.read() + + state = GlueUnSerializer.loads(session) + ga = state.object("__main__") + dc = ga.session.data_collection + + self.reg_before = app.data_collection[0] + self.cat_before = app.data_collection[1] + + self.reg_after = dc[0] + self.cat_after = dc[1] + + def test_data_roundtrip(self): + assert_array_equal(self.reg_before['regions'], self.reg_after['regions']) + assert_array_equal(self.cat_before['x'], self.cat_after['x']) + assert_array_equal(self.cat_before['y'], self.cat_after['y']) + + def test_component_ids_are_restored_correctly(self): + for data in [self.reg_before, self.reg_after]: + assert data.extended_component_id == data.id['regions'] + assert data.extended_component_id == data.id['regions'] + + assert data.components[1] == data.get_component(data.components[3]).x + assert data.components[2] == data.get_component(data.components[3]).y + + def test_links_still_work(self): + for data in [(self.reg_before, self.cat_before), (self.reg_after, self.cat_after)]: + reg_data, cat_data = data + assert_array_equal(reg_data[reg_data.get_component(reg_data.extended_component_id).x], cat_data.id['x']) + assert_array_equal(reg_data[reg_data.get_component(reg_data.extended_component_id).y], cat_data.id['y']) diff --git a/glue/dialogs/autolinker/qt/__init__.py b/glue/dialogs/autolinker/qt/__init__.py deleted file mode 100644 index 7c5364e0d..000000000 --- a/glue/dialogs/autolinker/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.dialogs.autolinker.qt is deprecated, use glue_qt.dialogs.autolinker instead', GlueDeprecationWarning) -from glue_qt.dialogs.autolinker import * # noqa diff --git a/glue/dialogs/autolinker/qt/autolinker.py b/glue/dialogs/autolinker/qt/autolinker.py deleted file mode 100644 index bbe79aeb2..000000000 --- a/glue/dialogs/autolinker/qt/autolinker.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.dialogs.autolinker.qt.autolinker is deprecated, use glue_qt.dialogs.autolinker.autolinker instead', GlueDeprecationWarning) -from glue_qt.dialogs.autolinker.autolinker import * # noqa diff --git a/glue/dialogs/common/qt/__init__.py b/glue/dialogs/common/qt/__init__.py deleted file mode 100644 index 1326b992f..000000000 --- a/glue/dialogs/common/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.dialogs.common.qt is deprecated, use glue_qt.dialogs.common instead', GlueDeprecationWarning) -from glue_qt.dialogs.common import * # noqa diff --git a/glue/dialogs/common/qt/component_tree_widget.py b/glue/dialogs/common/qt/component_tree_widget.py deleted file mode 100644 index 29e288f19..000000000 --- a/glue/dialogs/common/qt/component_tree_widget.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.dialogs.common.qt.component_tree_widget is deprecated, use glue_qt.dialogs.common.component_tree_widget instead', GlueDeprecationWarning) -from glue_qt.dialogs.common.component_tree_widget import * # noqa diff --git a/glue/dialogs/component_arithmetic/qt/__init__.py b/glue/dialogs/component_arithmetic/qt/__init__.py deleted file mode 100644 index 8a23b3bc1..000000000 --- a/glue/dialogs/component_arithmetic/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.dialogs.component_arithmetic.qt is deprecated, use glue_qt.dialogs.component_arithmetic instead', GlueDeprecationWarning) -from glue_qt.dialogs.component_arithmetic import * # noqa diff --git a/glue/dialogs/component_arithmetic/qt/component_arithmetic.py b/glue/dialogs/component_arithmetic/qt/component_arithmetic.py deleted file mode 100644 index 7b9b34340..000000000 --- a/glue/dialogs/component_arithmetic/qt/component_arithmetic.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.dialogs.component_arithmetic.qt.component_arithmetic is deprecated, use glue_qt.dialogs.component_arithmetic.component_arithmetic instead', GlueDeprecationWarning) -from glue_qt.dialogs.component_arithmetic.component_arithmetic import * # noqa diff --git a/glue/dialogs/component_arithmetic/qt/equation_editor.py b/glue/dialogs/component_arithmetic/qt/equation_editor.py deleted file mode 100644 index 7138cd6ef..000000000 --- a/glue/dialogs/component_arithmetic/qt/equation_editor.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.dialogs.component_arithmetic.qt.equation_editor is deprecated, use glue_qt.dialogs.component_arithmetic.equation_editor instead', GlueDeprecationWarning) -from glue_qt.dialogs.component_arithmetic.equation_editor import * # noqa diff --git a/glue/dialogs/component_manager/qt/__init__.py b/glue/dialogs/component_manager/qt/__init__.py deleted file mode 100644 index 50c6717e4..000000000 --- a/glue/dialogs/component_manager/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.dialogs.component_manager.qt is deprecated, use glue_qt.dialogs.component_manager instead', GlueDeprecationWarning) -from glue_qt.dialogs.component_manager import * # noqa diff --git a/glue/dialogs/component_manager/qt/component_manager.py b/glue/dialogs/component_manager/qt/component_manager.py deleted file mode 100644 index 68e398a90..000000000 --- a/glue/dialogs/component_manager/qt/component_manager.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.dialogs.component_manager.qt.component_manager is deprecated, use glue_qt.dialogs.component_manager.component_manager instead', GlueDeprecationWarning) -from glue_qt.dialogs.component_manager.component_manager import * # noqa diff --git a/glue/dialogs/data_wizard/qt/__init__.py b/glue/dialogs/data_wizard/qt/__init__.py deleted file mode 100644 index 108d86cab..000000000 --- a/glue/dialogs/data_wizard/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.dialogs.data_wizard.qt is deprecated, use glue_qt.dialogs.data_wizard instead', GlueDeprecationWarning) -from glue_qt.dialogs.data_wizard import * # noqa diff --git a/glue/dialogs/data_wizard/qt/data_wizard_dialog.py b/glue/dialogs/data_wizard/qt/data_wizard_dialog.py deleted file mode 100644 index 9b1789158..000000000 --- a/glue/dialogs/data_wizard/qt/data_wizard_dialog.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.dialogs.data_wizard.qt.data_wizard_dialog is deprecated, use glue_qt.dialogs.data_wizard.data_wizard_dialog instead', GlueDeprecationWarning) -from glue_qt.dialogs.data_wizard.data_wizard_dialog import * # noqa diff --git a/glue/dialogs/link_editor/qt/__init__.py b/glue/dialogs/link_editor/qt/__init__.py deleted file mode 100644 index 558162ed5..000000000 --- a/glue/dialogs/link_editor/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.dialogs.link_editor.qt is deprecated, use glue_qt.dialogs.link_editor instead', GlueDeprecationWarning) -from glue_qt.dialogs.link_editor import * # noqa diff --git a/glue/dialogs/link_editor/qt/data_graph.py b/glue/dialogs/link_editor/qt/data_graph.py deleted file mode 100644 index e4d3e0378..000000000 --- a/glue/dialogs/link_editor/qt/data_graph.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.dialogs.link_editor.qt.data_graph is deprecated, use glue_qt.dialogs.link_editor.data_graph instead', GlueDeprecationWarning) -from glue_qt.dialogs.link_editor.data_graph import * # noqa diff --git a/glue/dialogs/link_editor/qt/link_editor.py b/glue/dialogs/link_editor/qt/link_editor.py deleted file mode 100644 index 39f5690e2..000000000 --- a/glue/dialogs/link_editor/qt/link_editor.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.dialogs.link_editor.qt.link_editor is deprecated, use glue_qt.dialogs.link_editor.link_editor instead', GlueDeprecationWarning) -from glue_qt.dialogs.link_editor.link_editor import * # noqa diff --git a/glue/dialogs/subset_facet/qt/__init__.py b/glue/dialogs/subset_facet/qt/__init__.py deleted file mode 100644 index 92878f2e1..000000000 --- a/glue/dialogs/subset_facet/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.dialogs.subset_facet.qt is deprecated, use glue_qt.dialogs.subset_facet instead', GlueDeprecationWarning) -from glue_qt.dialogs.subset_facet import * # noqa diff --git a/glue/dialogs/subset_facet/qt/subset_facet.py b/glue/dialogs/subset_facet/qt/subset_facet.py deleted file mode 100644 index b5dd57673..000000000 --- a/glue/dialogs/subset_facet/qt/subset_facet.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.dialogs.subset_facet.qt.subset_facet is deprecated, use glue_qt.dialogs.subset_facet.subset_facet instead', GlueDeprecationWarning) -from glue_qt.dialogs.subset_facet.subset_facet import * # noqa diff --git a/glue/icons/qt/__init__.py b/glue/icons/qt/__init__.py deleted file mode 100644 index 95a08bec4..000000000 --- a/glue/icons/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.icons.qt is deprecated, use glue_qt.icons instead', GlueDeprecationWarning) -from glue_qt.icons import * # noqa diff --git a/glue/icons/qt/helpers.py b/glue/icons/qt/helpers.py deleted file mode 100644 index 46996b1d5..000000000 --- a/glue/icons/qt/helpers.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.icons.qt.helpers is deprecated, use glue_qt.icons.helpers instead', GlueDeprecationWarning) -from glue_qt.icons.helpers import * # noqa diff --git a/glue/io/qt/__init__.py b/glue/io/qt/__init__.py deleted file mode 100644 index a40e02085..000000000 --- a/glue/io/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.io.qt is deprecated, use glue_qt.io instead', GlueDeprecationWarning) -from glue_qt.io import * # noqa diff --git a/glue/io/qt/directory_importer/__init__.py b/glue/io/qt/directory_importer/__init__.py deleted file mode 100644 index ce7679906..000000000 --- a/glue/io/qt/directory_importer/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.io.qt.directory_importer is deprecated, use glue_qt.io.directory_importer instead', GlueDeprecationWarning) -from glue_qt.io.directory_importer import * # noqa diff --git a/glue/io/qt/directory_importer/directory_importer.py b/glue/io/qt/directory_importer/directory_importer.py deleted file mode 100644 index 440576b4f..000000000 --- a/glue/io/qt/directory_importer/directory_importer.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.io.qt.directory_importer.directory_importer is deprecated, use glue_qt.io.directory_importer.directory_importer instead', GlueDeprecationWarning) -from glue_qt.io.directory_importer.directory_importer import * # noqa diff --git a/glue/io/qt/subset_mask.py b/glue/io/qt/subset_mask.py deleted file mode 100644 index 98327dfda..000000000 --- a/glue/io/qt/subset_mask.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.io.qt.subset_mask is deprecated, use glue_qt.io.subset_mask instead', GlueDeprecationWarning) -from glue_qt.io.subset_mask import * # noqa diff --git a/glue/main.py b/glue/main.py index 3a03a240b..b47d15a2c 100755 --- a/glue/main.py +++ b/glue/main.py @@ -57,7 +57,7 @@ def load_plugins(splash=None, require_qt_plugins=False, plugins_to_use=None): else: n_plugins = len(plugins_to_use) - for i_plugins, item in enumerate(list(iter_plugin_entry_points())): + for i_plugin, item in enumerate(list(iter_plugin_entry_points())): if item.value.replace(':setup', '') in plugins_to_use: if item.module not in _installed_plugins: _installed_plugins.add(item.name) @@ -103,7 +103,7 @@ def load_plugins(splash=None, require_qt_plugins=False, plugins_to_use=None): _loaded_plugins.add(item.module) if splash is not None: - splash.set_progress(100. * iplugin / float(n_plugins)) + splash.set_progress(100. * i_plugin / float(n_plugins)) try: config.save() diff --git a/glue/plugins/coordinate_helpers/link_helpers.py b/glue/plugins/coordinate_helpers/link_helpers.py index 1ac9bea9d..217a798f6 100644 --- a/glue/plugins/coordinate_helpers/link_helpers.py +++ b/glue/plugins/coordinate_helpers/link_helpers.py @@ -21,12 +21,12 @@ class BaseCelestialMultiLink(BaseMultiLink): def forwards(self, in_lon, in_lat): cin = self.frame_in(in_lon * u.deg, in_lat * u.deg) - cout = cin.transform_to(self.frame_out) + cout = cin.transform_to(self.frame_out()) return cout.spherical.lon.degree, cout.spherical.lat.degree def backwards(self, out_lon, out_lat): cout = self.frame_out(out_lon * u.deg, out_lat * u.deg) - cin = cout.transform_to(self.frame_in) + cin = cout.transform_to(self.frame_in()) return cin.spherical.lon.degree, cin.spherical.lat.degree # Backward-compatibility with glue-core <0.15 diff --git a/glue/plugins/dendro_viewer/qt/__init__.py b/glue/plugins/dendro_viewer/qt/__init__.py deleted file mode 100644 index ba953ec70..000000000 --- a/glue/plugins/dendro_viewer/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.plugins.dendro_viewer.qt is deprecated, use glue_qt.plugins.dendro_viewer instead', GlueDeprecationWarning) -from glue_qt.plugins.dendro_viewer import * # noqa diff --git a/glue/plugins/dendro_viewer/qt/data_viewer.py b/glue/plugins/dendro_viewer/qt/data_viewer.py deleted file mode 100644 index 52478e644..000000000 --- a/glue/plugins/dendro_viewer/qt/data_viewer.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.plugins.dendro_viewer.qt.data_viewer is deprecated, use glue_qt.plugins.dendro_viewer.data_viewer instead', GlueDeprecationWarning) -from glue_qt.plugins.dendro_viewer.data_viewer import * # noqa diff --git a/glue/plugins/dendro_viewer/qt/layer_style_editor.py b/glue/plugins/dendro_viewer/qt/layer_style_editor.py deleted file mode 100644 index c72401393..000000000 --- a/glue/plugins/dendro_viewer/qt/layer_style_editor.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.plugins.dendro_viewer.qt.layer_style_editor is deprecated, use glue_qt.plugins.dendro_viewer.layer_style_editor instead', GlueDeprecationWarning) -from glue_qt.plugins.dendro_viewer.layer_style_editor import * # noqa diff --git a/glue/plugins/dendro_viewer/qt/options_widget.py b/glue/plugins/dendro_viewer/qt/options_widget.py deleted file mode 100644 index 1d76ee37a..000000000 --- a/glue/plugins/dendro_viewer/qt/options_widget.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.plugins.dendro_viewer.qt.options_widget is deprecated, use glue_qt.plugins.dendro_viewer.options_widget instead', GlueDeprecationWarning) -from glue_qt.plugins.dendro_viewer.options_widget import * # noqa diff --git a/glue/plugins/tools/pv_slicer/qt/__init__.py b/glue/plugins/tools/pv_slicer/qt/__init__.py deleted file mode 100644 index 031c55332..000000000 --- a/glue/plugins/tools/pv_slicer/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.plugins.tools.pv_slicer.qt is deprecated, use glue_qt.plugins.tools.pv_slicer instead', GlueDeprecationWarning) -from glue_qt.plugins.tools.pv_slicer import * # noqa diff --git a/glue/plugins/tools/pv_slicer/qt/pv_slicer.py b/glue/plugins/tools/pv_slicer/qt/pv_slicer.py deleted file mode 100644 index 12c531e6c..000000000 --- a/glue/plugins/tools/pv_slicer/qt/pv_slicer.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.plugins.tools.pv_slicer.qt.pv_slicer is deprecated, use glue_qt.plugins.tools.pv_slicer.pv_slicer instead', GlueDeprecationWarning) -from glue_qt.plugins.tools.pv_slicer.pv_slicer import * # noqa diff --git a/glue/qglue.py b/glue/qglue.py deleted file mode 100644 index 66dc0e175..000000000 --- a/glue/qglue.py +++ /dev/null @@ -1,18 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning - -warnings.warn('Importing from glue.qglue is deprecated, use glue_qt.qglue instead', GlueDeprecationWarning) - -from glue_qt.qglue import * # noqa - - -def parse_data(*args, **kwargs): - warnings.warn('glue.qglue.parse_data is deprecated, use glue.core.parsers.parse_data instead', GlueDeprecationWarning) - from glue.core.parsers import parse_data - return parse_data(*args, **kwargs) - - -def parse_links(*args, **kwargs): - warnings.warn('glue.qglue.parse_links is deprecated, use glue.core.parsers.parse_links instead', GlueDeprecationWarning) - from glue.core.parsers import parse_links - return parse_links(*args, **kwargs) diff --git a/glue/tests/visual/__init__.py b/glue/tests/visual/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/glue/tests/visual/helpers.py b/glue/tests/visual/helpers.py new file mode 100644 index 000000000..e6601995d --- /dev/null +++ b/glue/tests/visual/helpers.py @@ -0,0 +1,46 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +from functools import wraps + +import pytest + +try: + import pytest_mpl # noqa +except ImportError: + HAS_PYTEST_MPL = False +else: + HAS_PYTEST_MPL = True + + +def visual_test(*args, **kwargs): + """ + A decorator that defines a visual test. + + This automatically decorates tests with mpl_image_compare with common + options used by all figure tests in glue-core. + """ + + tolerance = kwargs.pop("tolerance", 0) + style = kwargs.pop("style", {}) + savefig_kwargs = kwargs.pop("savefig_kwargs", {}) + savefig_kwargs["metadata"] = {"Software": None} + + def decorator(test_function): + @pytest.mark.mpl_image_compare( + tolerance=tolerance, style=style, savefig_kwargs=savefig_kwargs, **kwargs + ) + @pytest.mark.skipif( + not HAS_PYTEST_MPL, reason="pytest-mpl is required for the figure tests" + ) + @wraps(test_function) + def test_wrapper(*args, **kwargs): + return test_function(*args, **kwargs) + + return test_wrapper + + # If the decorator was used without any arguments, the only positional + # argument will be the test to decorate so we do the following: + if len(args) == 1: + return decorator(*args) + + return decorator diff --git a/glue/tests/visual/py311-test-visual.json b/glue/tests/visual/py311-test-visual.json new file mode 100644 index 000000000..f0cff7ef1 --- /dev/null +++ b/glue/tests/visual/py311-test-visual.json @@ -0,0 +1,9 @@ +{ + "glue.viewers.histogram.tests.test_viewer.test_simple_viewer": "cb08123fbad135ab614bb7ec13475fcc83321057d884fe80c3a32970b2d14762", + "glue.viewers.image.tests.test_viewer.test_simple_viewer": "72abd60b484d14f721254f027bb0ab9b36245d5db77eb87693f4dd9998fd28be", + "glue.viewers.image.tests.test_viewer.test_region_layer": "0114922ab0a3980f56252656c69545927841aea0e6950250cdc2b1bafcd19d50", + "glue.viewers.image.tests.test_viewer.test_region_layer_flip": "a142142f34961aba7e98188ad43abafe0e6e5b82e13e8cdab5131d297ed5832c", + "glue.viewers.profile.tests.test_viewer.test_simple_viewer": "f68a21be5080fec513388b2d2b220512e7b0df5498e2489da54e58708de435b3", + "glue.viewers.scatter.tests.test_viewer.test_simple_viewer": "1020a7bd3abe40510b9e03047c3b423b75c3c64ac18e6dcd6257173cec1ed53f", + "glue.viewers.scatter.tests.test_viewer.test_scatter_density_map": "3379d655262769a6ccbdbaf1970bffa9237adbec23a93d3ab75da51b9a3e7f8b" +} diff --git a/glue/utils/qt/__init__.py b/glue/utils/qt/__init__.py deleted file mode 100644 index 95cc710cd..000000000 --- a/glue/utils/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.utils.qt is deprecated, use glue_qt.utils instead', GlueDeprecationWarning) -from glue_qt.utils import * # noqa diff --git a/glue/utils/qt/app.py b/glue/utils/qt/app.py deleted file mode 100644 index 1ade6b52a..000000000 --- a/glue/utils/qt/app.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.utils.qt.app is deprecated, use glue_qt.utils.app instead', GlueDeprecationWarning) -from glue_qt.utils.app import * # noqa diff --git a/glue/utils/qt/autocomplete_widget.py b/glue/utils/qt/autocomplete_widget.py deleted file mode 100644 index 07c699e3a..000000000 --- a/glue/utils/qt/autocomplete_widget.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.utils.qt.autocomplete_widget is deprecated, use glue_qt.utils.autocomplete_widget instead', GlueDeprecationWarning) -from glue_qt.utils.autocomplete_widget import * # noqa diff --git a/glue/utils/qt/colors.py b/glue/utils/qt/colors.py deleted file mode 100644 index acd5d2aad..000000000 --- a/glue/utils/qt/colors.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.utils.qt.colors is deprecated, use glue_qt.utils.colors instead', GlueDeprecationWarning) -from glue_qt.utils.colors import * # noqa diff --git a/glue/utils/qt/decorators.py b/glue/utils/qt/decorators.py deleted file mode 100644 index 36f64a5d1..000000000 --- a/glue/utils/qt/decorators.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.utils.qt.decorators is deprecated, use glue_qt.utils.decorators instead', GlueDeprecationWarning) -from glue_qt.utils.decorators import * # noqa diff --git a/glue/utils/qt/delegates.py b/glue/utils/qt/delegates.py deleted file mode 100644 index 8906740dc..000000000 --- a/glue/utils/qt/delegates.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.utils.qt.delegates is deprecated, use glue_qt.utils.delegates instead', GlueDeprecationWarning) -from glue_qt.utils.delegates import * # noqa diff --git a/glue/utils/qt/dialogs.py b/glue/utils/qt/dialogs.py deleted file mode 100644 index dcd1d13b9..000000000 --- a/glue/utils/qt/dialogs.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.utils.qt.dialogs is deprecated, use glue_qt.utils.dialogs instead', GlueDeprecationWarning) -from glue_qt.utils.dialogs import * # noqa diff --git a/glue/utils/qt/helpers.py b/glue/utils/qt/helpers.py deleted file mode 100644 index 07138b9d9..000000000 --- a/glue/utils/qt/helpers.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.utils.qt.helpers is deprecated, use glue_qt.utils.helpers instead', GlueDeprecationWarning) -from glue_qt.utils.helpers import * # noqa diff --git a/glue/utils/qt/mime.py b/glue/utils/qt/mime.py deleted file mode 100644 index 10a67dd2b..000000000 --- a/glue/utils/qt/mime.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.utils.qt.mime is deprecated, use glue_qt.utils.mime instead', GlueDeprecationWarning) -from glue_qt.utils.mime import * # noqa diff --git a/glue/utils/qt/mixins.py b/glue/utils/qt/mixins.py deleted file mode 100644 index 0037a89ae..000000000 --- a/glue/utils/qt/mixins.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.utils.qt.mixins is deprecated, use glue_qt.utils.mixins instead', GlueDeprecationWarning) -from glue_qt.utils.mixins import * # noqa diff --git a/glue/utils/qt/python_list_model.py b/glue/utils/qt/python_list_model.py deleted file mode 100644 index a3ad8699b..000000000 --- a/glue/utils/qt/python_list_model.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.utils.qt.python_list_model is deprecated, use glue_qt.utils.python_list_model instead', GlueDeprecationWarning) -from glue_qt.utils.python_list_model import * # noqa diff --git a/glue/utils/qt/threading.py b/glue/utils/qt/threading.py deleted file mode 100644 index 99e05300c..000000000 --- a/glue/utils/qt/threading.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.utils.qt.threading is deprecated, use glue_qt.utils.threading instead', GlueDeprecationWarning) -from glue_qt.utils.threading import * # noqa diff --git a/glue/utils/qt/widget_properties.py b/glue/utils/qt/widget_properties.py deleted file mode 100644 index 6bb8f6b32..000000000 --- a/glue/utils/qt/widget_properties.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.utils.qt.widget_properties is deprecated, use glue_qt.utils.widget_properties instead', GlueDeprecationWarning) -from glue_qt.utils.widget_properties import * # noqa diff --git a/glue/viewers/common/layer_artist.py b/glue/viewers/common/layer_artist.py index 3a281c02c..96215424e 100644 --- a/glue/viewers/common/layer_artist.py +++ b/glue/viewers/common/layer_artist.py @@ -1,4 +1,4 @@ -from echo import keep_in_sync, CallbackProperty +from echo import keep_in_sync, CallbackProperty, CallbackDict from glue.core.layer_artist import LayerArtistBase from glue.viewers.common.state import LayerState from glue.core.message import LayerArtistVisibilityMessage @@ -78,4 +78,15 @@ def pop_changed_properties(self): self._last_viewer_state.update(self._viewer_state.as_dict()) self._last_layer_state.update(self.state.as_dict()) + # If any of the items are CallbackDict, we make a copy otherwise both + # the 'last' and new values will remain the same. + + for key, value in self._last_viewer_state.items(): + if isinstance(value, CallbackDict): + self._last_viewer_state[key] = dict(value) + + for key, value in self._last_layer_state.items(): + if isinstance(value, CallbackDict): + self._last_layer_state[key] = dict(value) + return changed diff --git a/glue/viewers/common/qt/__init__.py b/glue/viewers/common/qt/__init__.py deleted file mode 100644 index 63c3cdd0c..000000000 --- a/glue/viewers/common/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.common.qt is deprecated, use glue_qt.viewers.common instead', GlueDeprecationWarning) -from glue_qt.viewers.common import * # noqa diff --git a/glue/viewers/common/qt/base_widget.py b/glue/viewers/common/qt/base_widget.py deleted file mode 100644 index a863189d7..000000000 --- a/glue/viewers/common/qt/base_widget.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.common.qt.base_widget is deprecated, use glue_qt.viewers.common.base_widget instead', GlueDeprecationWarning) -from glue_qt.viewers.common.base_widget import * # noqa diff --git a/glue/viewers/common/qt/data_slice_widget.py b/glue/viewers/common/qt/data_slice_widget.py deleted file mode 100644 index edad718b0..000000000 --- a/glue/viewers/common/qt/data_slice_widget.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.common.qt.data_slice_widget is deprecated, use glue_qt.viewers.common.data_slice_widget instead', GlueDeprecationWarning) -from glue_qt.viewers.common.data_slice_widget import * # noqa diff --git a/glue/viewers/common/qt/data_viewer.py b/glue/viewers/common/qt/data_viewer.py deleted file mode 100644 index 8e9076d65..000000000 --- a/glue/viewers/common/qt/data_viewer.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.common.qt.data_viewer is deprecated, use glue_qt.viewers.common.data_viewer instead', GlueDeprecationWarning) -from glue_qt.viewers.common.data_viewer import * # noqa diff --git a/glue/viewers/common/qt/data_viewer_with_state.py b/glue/viewers/common/qt/data_viewer_with_state.py deleted file mode 100644 index 1e56224d8..000000000 --- a/glue/viewers/common/qt/data_viewer_with_state.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.common.qt.data_viewer_with_state is deprecated, use glue_qt.viewers.common.data_viewer_with_state instead', GlueDeprecationWarning) -from glue_qt.viewers.common.data_viewer_with_state import * # noqa diff --git a/glue/viewers/common/qt/tool.py b/glue/viewers/common/qt/tool.py deleted file mode 100644 index 00e222683..000000000 --- a/glue/viewers/common/qt/tool.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.common.qt.tool is deprecated, use glue_qt.viewers.common.tool instead', GlueDeprecationWarning) -from glue_qt.viewers.common.tool import * # noqa diff --git a/glue/viewers/common/qt/toolbar.py b/glue/viewers/common/qt/toolbar.py deleted file mode 100644 index a8024f693..000000000 --- a/glue/viewers/common/qt/toolbar.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.common.qt.toolbar is deprecated, use glue_qt.viewers.common.toolbar instead', GlueDeprecationWarning) -from glue_qt.viewers.common.toolbar import * # noqa diff --git a/glue/viewers/common/qt/tools.py b/glue/viewers/common/qt/tools.py deleted file mode 100644 index fcfdba44e..000000000 --- a/glue/viewers/common/qt/tools.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.common.qt.tools is deprecated, use glue_qt.viewers.common.tools instead', GlueDeprecationWarning) -from glue_qt.viewers.common.tools import * # noqa diff --git a/glue/viewers/common/stretch_state_mixin.py b/glue/viewers/common/stretch_state_mixin.py new file mode 100644 index 000000000..9fe45b3ff --- /dev/null +++ b/glue/viewers/common/stretch_state_mixin.py @@ -0,0 +1,47 @@ +from glue.config import stretches +from glue.viewers.matplotlib.state import ( + DeferredDrawDictCallbackProperty as DDDCProperty, + DeferredDrawSelectionCallbackProperty as DDSCProperty, +) + +__all__ = ["StretchStateMixin"] + + +class StretchStateMixin: + stretch = DDSCProperty( + docstring="The stretch used to render the layer, " + "which should be one of ``linear``, " + "``sqrt``, ``log``, or ``arcsinh``" + ) + stretch_parameters = DDDCProperty( + docstring="Keyword arguments to pass to the stretch" + ) + + _stretch_set_up = False + + def setup_stretch_callback(self): + type(self).stretch.set_choices(self, list(stretches.members)) + type(self).stretch.set_display_func(self, stretches.display_func) + self._reset_stretch() + self.add_callback("stretch", self._reset_stretch) + self.add_callback("stretch_parameters", self._sync_stretch_parameters) + self._stretch_set_up = True + + @property + def stretch_object(self): + if not self._stretch_set_up: + raise Exception("setup_stretch_callback has not been called") + return self._stretch_object + + def _sync_stretch_parameters(self, *args): + for key, value in self.stretch_parameters.items(): + if hasattr(self._stretch_object, key): + setattr(self._stretch_object, key, value) + else: + raise ValueError( + f"Stretch object {self._stretch_object.__class__.__name__} has no attribute {key}" + ) + + def _reset_stretch(self, *args): + self._stretch_object = stretches.members[self.stretch]() + self.stretch_parameters.clear() diff --git a/glue/viewers/common/tests/test_stretch_state_mixin.py b/glue/viewers/common/tests/test_stretch_state_mixin.py new file mode 100644 index 000000000..4325342ba --- /dev/null +++ b/glue/viewers/common/tests/test_stretch_state_mixin.py @@ -0,0 +1,56 @@ +import pytest + +from astropy.visualization import LinearStretch, LogStretch + +from glue.core.state_objects import State +from glue.viewers.common.stretch_state_mixin import StretchStateMixin + + +class ExampleStateWithStretch(State, StretchStateMixin): + pass + + +def test_not_set_up(): + state = ExampleStateWithStretch() + with pytest.raises(Exception, match="setup_stretch_callback has not been called"): + state.stretch_object + + +class TestStretchStateMixin: + def setup_method(self, method): + self.state = ExampleStateWithStretch() + self.state.setup_stretch_callback() + + def test_defaults(self): + assert self.state.stretch == "linear" + assert len(self.state.stretch_parameters) == 0 + assert isinstance(self.state.stretch_object, LinearStretch) + + def test_change_stretch(self): + self.state.stretch = "log" + assert self.state.stretch == "log" + assert len(self.state.stretch_parameters) == 0 + assert isinstance(self.state.stretch_object, LogStretch) + + def test_invalid_parameter(self): + with pytest.raises( + ValueError, match="Stretch object LinearStretch has no attribute foo" + ): + self.state.stretch_parameters["foo"] = 1 + + def test_set_parameter(self): + self.state.stretch = "log" + + assert self.state.stretch_object.exp == 1000 + + # Setting the stretch parameter 'exp' is synced with the stretch object attribute + self.state.stretch_parameters["exp"] = 200 + assert self.state.stretch_object.exp == 200 + + # Changing stretch resets the stretch parameter dictionary + self.state.stretch = "linear" + assert len(self.state.stretch_parameters) == 0 + + # And there is no memory of previous parameters + self.state.stretch = "log" + assert self.state.stretch_object.exp == 1000 diff --git a/glue/viewers/common/tests/test_viewer.py b/glue/viewers/common/tests/test_viewer.py index a7d6ad412..61f18de69 100644 --- a/glue/viewers/common/tests/test_viewer.py +++ b/glue/viewers/common/tests/test_viewer.py @@ -3,6 +3,7 @@ from glue.core import Data from glue.viewers.common.viewer import Viewer from glue.viewers.common.layer_artist import LayerArtist +from glue.core.message import NumericalDataChangedMessage def test_custom_layer_artist_maker(): @@ -45,3 +46,60 @@ def custom_maker(viewer, data): viewer.add_data(data2) assert len(viewer.layers) == 2 assert type(viewer.layers[1]) is CustomLayerArtist + + +def test_viewer_update_data(): + """ + Test that we can have a LayerArtist that can respond + to a NumericalDataChangedMessage. + """ + + class CustomUpdateLayerArtist(LayerArtist): + + def _on_components_changed(self, components_changed): + self.called_component_limits = True + self.num_components_changed = len(components_changed) + + class CustomApplication(Application): + def add_widget(self, *args, **kwargs): + pass + + @layer_artist_maker('custom_maker_for_update') + def custom_maker_for_update(viewer, data): + if hasattr(data, 'custom_for_update'): + return CustomUpdateLayerArtist(viewer.state, layer=data) + if hasattr(data.data, 'custom_for_update'): + return CustomUpdateLayerArtist(viewer.state, layer=data) + + app = CustomApplication() + + data1 = Data(x=[1, 2, 3], label='test1') + data1.custom_for_update = True + + app.data_collection.append(data1) + + viewer = app.new_data_viewer(Viewer) + + assert len(viewer.layers) == 0 + + # NOTE: Check exact type, not using isinstance + viewer.add_data(data1) + assert len(viewer.layers) == 1 + assert type(viewer.layers[0]) is CustomUpdateLayerArtist + + msg = NumericalDataChangedMessage(data1, components_changed=[data1.id['x']]) + + viewer._update_data(msg) + assert viewer.layers[0].called_component_limits + assert viewer.layers[0].num_components_changed == 1 + + subset = data1.new_subset() + subset.subset_state = data1.id['x'] > 2 + + assert len(viewer.layers) == 2 + assert type(viewer.layers[1]) is CustomUpdateLayerArtist + + msg = NumericalDataChangedMessage(data1, components_changed=[data1.id['x']]) + viewer._update_data(msg) + assert viewer.layers[1].called_component_limits + assert viewer.layers[1].num_components_changed == 1 diff --git a/glue/viewers/common/viewer.py b/glue/viewers/common/viewer.py index cc2aa4b57..71703c8d5 100644 --- a/glue/viewers/common/viewer.py +++ b/glue/viewers/common/viewer.py @@ -288,9 +288,20 @@ def _update_data(self, message): if isinstance(layer_artist.layer, Subset): if layer_artist.layer.data is message.data: layer_artist.update() + try: + components_changed = message.components_changed + layer_artist._on_components_changed(components_changed) + except AttributeError: + pass + else: if layer_artist.layer is message.data: layer_artist.update() + try: + components_changed = message.components_changed + layer_artist._on_components_changed(components_changed) + except AttributeError: + pass def _update_subset(self, message): if message.attribute == 'style': diff --git a/glue/viewers/custom/qt/__init__.py b/glue/viewers/custom/qt/__init__.py deleted file mode 100644 index b3bf0b8ed..000000000 --- a/glue/viewers/custom/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.custom.qt is deprecated, use glue_qt.viewers.custom instead', GlueDeprecationWarning) -from glue_qt.viewers.custom import * # noqa diff --git a/glue/viewers/custom/qt/custom_viewer.py b/glue/viewers/custom/qt/custom_viewer.py deleted file mode 100644 index 07313bde2..000000000 --- a/glue/viewers/custom/qt/custom_viewer.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.custom.qt.custom_viewer is deprecated, use glue_qt.viewers.custom.custom_viewer instead', GlueDeprecationWarning) -from glue_qt.viewers.custom.custom_viewer import * # noqa diff --git a/glue/viewers/custom/qt/elements.py b/glue/viewers/custom/qt/elements.py deleted file mode 100644 index 1c2d688c9..000000000 --- a/glue/viewers/custom/qt/elements.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.custom.qt.elements is deprecated, use glue_qt.viewers.custom.elements instead', GlueDeprecationWarning) -from glue_qt.viewers.custom.elements import * # noqa diff --git a/glue/viewers/histogram/layer_artist.py b/glue/viewers/histogram/layer_artist.py index 2496f6de6..69ef0a22c 100644 --- a/glue/viewers/histogram/layer_artist.py +++ b/glue/viewers/histogram/layer_artist.py @@ -12,6 +12,8 @@ from glue.viewers.matplotlib.layer_artist import MatplotlibLayerArtist from glue.core.exceptions import IncompatibleAttribute, IncompatibleDataException +__all__ = ["HistogramLayerArtist"] + class HistogramLayerArtist(MatplotlibLayerArtist): diff --git a/glue/viewers/histogram/qt/__init__.py b/glue/viewers/histogram/qt/__init__.py deleted file mode 100644 index 96226f2e9..000000000 --- a/glue/viewers/histogram/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.histogram.qt is deprecated, use glue_qt.viewers.histogram instead', GlueDeprecationWarning) -from glue_qt.viewers.histogram import * # noqa diff --git a/glue/viewers/histogram/qt/data_viewer.py b/glue/viewers/histogram/qt/data_viewer.py deleted file mode 100644 index c294f9bfb..000000000 --- a/glue/viewers/histogram/qt/data_viewer.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.histogram.qt.data_viewer is deprecated, use glue_qt.viewers.histogram.data_viewer instead', GlueDeprecationWarning) -from glue_qt.viewers.histogram.data_viewer import * # noqa diff --git a/glue/viewers/histogram/qt/layer_artist.py b/glue/viewers/histogram/qt/layer_artist.py deleted file mode 100644 index 693af623e..000000000 --- a/glue/viewers/histogram/qt/layer_artist.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.histogram.qt.layer_artist is deprecated, use glue_qt.viewers.histogram.layer_artist instead', GlueDeprecationWarning) -from glue_qt.viewers.histogram.layer_artist import * # noqa diff --git a/glue/viewers/histogram/qt/layer_style_editor.py b/glue/viewers/histogram/qt/layer_style_editor.py deleted file mode 100644 index 96f7f1f29..000000000 --- a/glue/viewers/histogram/qt/layer_style_editor.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.histogram.qt.layer_style_editor is deprecated, use glue_qt.viewers.histogram.layer_style_editor instead', GlueDeprecationWarning) -from glue_qt.viewers.histogram.layer_style_editor import * # noqa diff --git a/glue/viewers/histogram/qt/options_widget.py b/glue/viewers/histogram/qt/options_widget.py deleted file mode 100644 index 3a7c880eb..000000000 --- a/glue/viewers/histogram/qt/options_widget.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.histogram.qt.options_widget is deprecated, use glue_qt.viewers.histogram.options_widget instead', GlueDeprecationWarning) -from glue_qt.viewers.histogram.options_widget import * # noqa diff --git a/glue/viewers/histogram/state.py b/glue/viewers/histogram/state.py index 730a8cfbd..5e2b38754 100644 --- a/glue/viewers/histogram/state.py +++ b/glue/viewers/histogram/state.py @@ -38,6 +38,10 @@ class HistogramViewerState(MatplotlibDataViewerState): common_n_bin = DDCProperty(True, docstring='The number of bins to use for ' 'all numerical components') + x_limits_percentile = DDCProperty(100, docstring="Percentile to use when automatically determining x limits") + + update_bins_on_reset_limits = DDCProperty(True, docstring="Whether to update the bins to match the view when resetting limits") + def __init__(self, **kwargs): super(HistogramViewerState, self).__init__() @@ -67,9 +71,10 @@ def _reset_x_limits(self, *args): if self.x_att is None: return with delay_callback(self, 'hist_x_min', 'hist_x_max', 'x_min', 'x_max', 'x_log'): - self.x_lim_helper.percentile = 100 + self.x_lim_helper.percentile = self.x_limits_percentile self.x_lim_helper.update_values(force=True) - self.update_bins_to_view() + if self.update_bins_on_reset_limits: + self.update_bins_to_view() def reset_limits(self): self._reset_x_limits() diff --git a/glue/viewers/histogram/tests/test_viewer.py b/glue/viewers/histogram/tests/test_viewer.py new file mode 100644 index 000000000..b3fedb578 --- /dev/null +++ b/glue/viewers/histogram/tests/test_viewer.py @@ -0,0 +1,121 @@ +import numpy as np +from numpy.testing import assert_allclose + +from astropy.utils import NumpyRNGContext + +from glue.tests.visual.helpers import visual_test +from glue.viewers.histogram.viewer import SimpleHistogramViewer +from glue.core.application_base import Application +from glue.core.data import Data + + +@visual_test +def test_simple_viewer(): + + # Make sure the simple viewer can be instantiated + + with NumpyRNGContext(12345): + + data1 = Data(x=np.random.normal(1, 2, 1000), label='data1') + data2 = Data(y=np.random.uniform(-1, 5, 1000), label='data2') + + app = Application() + app.data_collection.append(data1) + app.data_collection.append(data2) + + viewer = app.new_data_viewer(SimpleHistogramViewer) + viewer.add_data(data1) + viewer.add_data(data2) + + app.data_collection.new_subset_group(label='subset1', subset_state=data1.id['x'] > 2) + + return viewer.figure + + +def test_remove_data_collection(): + + # Regression test for a bug that caused an IncompatibleAttribute + # error when updating the number of bins in a histogram after + # removing a dataset from the DataCollection (this was due to + # a caching issue) + + data1 = Data(x=[1, 2, 3], label='data1') + data2 = Data(y=[1, 2, 3], label='data2') + + app = Application() + app.data_collection.append(data1) + app.data_collection.append(data2) + + viewer = app.new_data_viewer(SimpleHistogramViewer) + viewer.add_data(data1) + viewer.add_data(data2) + + viewer.state.hist_n_bin = 30 + + app.data_collection.remove(data1) + + viewer.state.hist_n_bin = 20 + + +def test_incompatible_datasets(): + + # Regression test for a bug that caused an IncompatibleAttribute + # error when changing the dataset used in the histogram viewer to one that + # is not linked to the first dataset. + + data1 = Data(x=[1, 2, 3], label='data1') + data2 = Data(y=[1, 2, 3], label='data2') + + app = Application() + app.data_collection.append(data1) + app.data_collection.append(data2) + + viewer = app.new_data_viewer(SimpleHistogramViewer) + viewer.add_data(data1) + viewer.add_data(data2) + + viewer.state.x_att = data1.id['x'] + + viewer.state.hist_n_bin = 30 + + viewer.state.x_att = data2.id['y'] + + viewer.state.hist_n_bin = 20 + + +def test_reset_limits(): + + data1 = Data(x=np.arange(1000), label='data') + + app = Application() + app.data_collection.append(data1) + + viewer = app.new_data_viewer(SimpleHistogramViewer) + viewer.add_data(data1) + + viewer.state.reset_limits() + + assert_allclose(viewer.state.x_min, 0) + assert_allclose(viewer.state.x_max, 999) + + viewer.state.x_limits_percentile = 90 + + viewer.state.reset_limits() + + assert_allclose(viewer.state.x_min, 49.95) + assert_allclose(viewer.state.x_max, 949.05) + + assert_allclose(viewer.state.hist_x_min, 49.95) + assert_allclose(viewer.state.hist_x_max, 949.05) + + viewer.state.update_bins_on_reset_limits = False + + viewer.state.x_limits_percentile = 80 + + viewer.state.reset_limits() + + assert_allclose(viewer.state.x_min, 99.9) + assert_allclose(viewer.state.x_max, 899.1) + + assert_allclose(viewer.state.hist_x_min, 49.95) + assert_allclose(viewer.state.hist_x_max, 949.05) diff --git a/glue/viewers/histogram/viewer.py b/glue/viewers/histogram/viewer.py index 2b9b10478..3f15fb884 100644 --- a/glue/viewers/histogram/viewer.py +++ b/glue/viewers/histogram/viewer.py @@ -3,9 +3,12 @@ from glue.core.subset import roi_to_subset_state from glue.utils import mpl_to_datetime64 +from glue.viewers.matplotlib.viewer import SimpleMatplotlibViewer from glue.viewers.histogram.compat import update_histogram_viewer_state +from glue.viewers.histogram.layer_artist import HistogramLayerArtist +from glue.viewers.histogram.state import HistogramViewerState -__all__ = ['MatplotlibHistogramMixin'] +__all__ = ['MatplotlibHistogramMixin', 'SimpleHistogramViewer'] class MatplotlibHistogramMixin(object): @@ -69,3 +72,14 @@ def apply_roi(self, roi, override_mode=None): @staticmethod def update_viewer_state(rec, context): return update_histogram_viewer_state(rec, context) + + +class SimpleHistogramViewer(MatplotlibHistogramMixin, SimpleMatplotlibViewer): + + _state_cls = HistogramViewerState + _data_artist_cls = HistogramLayerArtist + _subset_artist_cls = HistogramLayerArtist + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + MatplotlibHistogramMixin.setup_callbacks(self) diff --git a/glue/viewers/image/composite_array.py b/glue/viewers/image/composite_array.py index 323bc3348..4ef60c7e0 100644 --- a/glue/viewers/image/composite_array.py +++ b/glue/viewers/image/composite_array.py @@ -29,6 +29,7 @@ def __init__(self, **kwargs): self._first = True self._mode = 'color' + self._allow_bad_alpha = False @property def mode(self): @@ -118,7 +119,10 @@ def __call__(self, bounds=None): interval = ManualInterval(*layer['clim']) contrast_bias = ContrastBiasStretch(layer['contrast'], layer['bias']) - stretch = stretches.members[layer['stretch']] + if isinstance(layer['stretch'], str): + stretch = stretches.members[layer['stretch']]() + else: + stretch = layer['stretch'] if callable(layer['array']): array = layer['array'](bounds=bounds) @@ -143,10 +147,16 @@ def __call__(self, bounds=None): # ensure "bad" values have the same alpha as the # rest of the layer: if hasattr(layer['cmap'], 'get_bad'): - bad_color = layer['cmap'].get_bad().tolist()[:3] - layer_cmap = layer['cmap'].with_extremes( - bad=bad_color + [layer['alpha']] - ) + bad_rgba = layer['cmap'].get_bad().tolist() + bad_color = bad_rgba[:3] + bad_alpha = bad_rgba[3:] + + if self._allow_bad_alpha: + bad_rgba = bad_color + bad_alpha + else: + bad_rgba = bad_color + [layer['alpha']] + + layer_cmap = layer['cmap'].with_extremes(bad=bad_rgba) else: layer_cmap = layer['cmap'] @@ -159,8 +169,7 @@ def __call__(self, bounds=None): # Check what the smallest colormap alpha value for this layer is # - if it is 1 then this colormap does not change transparency, # and this allows us to speed things up a little. - if layer_cmap(CMAP_SAMPLING)[:, 3].min() == 1: - + if layer_cmap(CMAP_SAMPLING)[:, 3].min() == 1 and (not self._allow_bad_alpha or bad_alpha == 1): if layer['alpha'] == 1: img[...] = 0 else: @@ -172,6 +181,10 @@ def __call__(self, bounds=None): # Use traditional alpha compositing alpha_plane = layer['alpha'] * plane[:, :, 3] + if self._allow_bad_alpha: + # ensure "bad" alpha is preserved: + alpha_plane[~np.isfinite(data)] *= bad_alpha + plane[:, :, 0] = plane[:, :, 0] * alpha_plane plane[:, :, 1] = plane[:, :, 1] * alpha_plane plane[:, :, 2] = plane[:, :, 2] * alpha_plane diff --git a/glue/viewers/image/layer_artist.py b/glue/viewers/image/layer_artist.py index ea2c7c452..abee0cad2 100644 --- a/glue/viewers/image/layer_artist.py +++ b/glue/viewers/image/layer_artist.py @@ -169,7 +169,7 @@ def _update_visual_attributes(self): contrast=self.state.contrast, bias=self.state.bias, alpha=self.state.alpha, - stretch=self.state.stretch) + stretch=self.state.stretch_object) self.composite_image.invalidate_cache() @@ -193,7 +193,7 @@ def _update_image(self, force=False, **kwargs): if force or any(prop in changed for prop in ('v_min', 'v_max', 'contrast', 'bias', 'alpha', 'color_mode', 'cmap', 'color', 'zorder', - 'visible', 'stretch')): + 'visible', 'stretch', 'stretch_parameters')): self._update_visual_attributes() @defer_draw @@ -238,17 +238,19 @@ def __call__(self, bounds): if (self.layer_artist is None or self.layer_state is None or self.viewer_state is None): - return np.broadcast_to(np.nan, self.shape) + return None # We should compute the mask even if the layer is not visible as we need # the layer to show up properly when it is made visible (which doesn't - # trigger __getitem__) + # trigger __getitem__). However, if the layer is disabled, then we will + # call this method when it is enabled, so in this case we just return + # an empty mask. try: mask = self.layer_state.get_sliced_data(bounds=bounds) except IncompatibleAttribute: self.layer_artist.disable_incompatible_subset() - return np.broadcast_to(np.nan, self.shape) + return None else: self.layer_artist.enable(redraw=False) diff --git a/glue/viewers/image/qt/__init__.py b/glue/viewers/image/qt/__init__.py deleted file mode 100644 index 5444f7485..000000000 --- a/glue/viewers/image/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.image.qt is deprecated, use glue_qt.viewers.image instead', GlueDeprecationWarning) -from glue_qt.viewers.image import * # noqa diff --git a/glue/viewers/image/qt/contrast_mouse_mode.py b/glue/viewers/image/qt/contrast_mouse_mode.py deleted file mode 100644 index 37d452705..000000000 --- a/glue/viewers/image/qt/contrast_mouse_mode.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.image.qt.contrast_mouse_mode is deprecated, use glue_qt.viewers.image.contrast_mouse_mode instead', GlueDeprecationWarning) -from glue_qt.viewers.image.contrast_mouse_mode import * # noqa diff --git a/glue/viewers/image/qt/data_viewer.py b/glue/viewers/image/qt/data_viewer.py deleted file mode 100644 index 7499d72a3..000000000 --- a/glue/viewers/image/qt/data_viewer.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.image.qt.data_viewer is deprecated, use glue_qt.viewers.image.data_viewer instead', GlueDeprecationWarning) -from glue_qt.viewers.image.data_viewer import * # noqa diff --git a/glue/viewers/image/qt/layer_style_editor.py b/glue/viewers/image/qt/layer_style_editor.py deleted file mode 100644 index 253591c36..000000000 --- a/glue/viewers/image/qt/layer_style_editor.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.image.qt.layer_style_editor is deprecated, use glue_qt.viewers.image.layer_style_editor instead', GlueDeprecationWarning) -from glue_qt.viewers.image.layer_style_editor import * # noqa diff --git a/glue/viewers/image/qt/layer_style_editor_subset.py b/glue/viewers/image/qt/layer_style_editor_subset.py deleted file mode 100644 index 8713eb546..000000000 --- a/glue/viewers/image/qt/layer_style_editor_subset.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.image.qt.layer_style_editor_subset is deprecated, use glue_qt.viewers.image.layer_style_editor_subset instead', GlueDeprecationWarning) -from glue_qt.viewers.image.layer_style_editor_subset import * # noqa diff --git a/glue/viewers/image/qt/mouse_mode.py b/glue/viewers/image/qt/mouse_mode.py deleted file mode 100644 index bf4789933..000000000 --- a/glue/viewers/image/qt/mouse_mode.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.image.qt.mouse_mode is deprecated, use glue_qt.viewers.image.mouse_mode instead', GlueDeprecationWarning) -from glue_qt.viewers.image.mouse_mode import * # noqa diff --git a/glue/viewers/image/qt/options_widget.py b/glue/viewers/image/qt/options_widget.py deleted file mode 100644 index b157c97de..000000000 --- a/glue/viewers/image/qt/options_widget.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.image.qt.options_widget is deprecated, use glue_qt.viewers.image.options_widget instead', GlueDeprecationWarning) -from glue_qt.viewers.image.options_widget import * # noqa diff --git a/glue/viewers/image/qt/pixel_selection_mode.py b/glue/viewers/image/qt/pixel_selection_mode.py deleted file mode 100644 index 4bad22fc4..000000000 --- a/glue/viewers/image/qt/pixel_selection_mode.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.image.qt.pixel_selection_mode is deprecated, use glue_qt.viewers.image.pixel_selection_mode instead', GlueDeprecationWarning) -from glue_qt.viewers.image.pixel_selection_mode import * # noqa diff --git a/glue/viewers/image/qt/profile_viewer_tool.py b/glue/viewers/image/qt/profile_viewer_tool.py deleted file mode 100644 index c89021d39..000000000 --- a/glue/viewers/image/qt/profile_viewer_tool.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.image.qt.profile_viewer_tool is deprecated, use glue_qt.viewers.image.profile_viewer_tool instead', GlueDeprecationWarning) -from glue_qt.viewers.image.profile_viewer_tool import * # noqa diff --git a/glue/viewers/image/qt/slice_widget.py b/glue/viewers/image/qt/slice_widget.py deleted file mode 100644 index 692f4ea6c..000000000 --- a/glue/viewers/image/qt/slice_widget.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.image.qt.slice_widget is deprecated, use glue_qt.viewers.image.slice_widget instead', GlueDeprecationWarning) -from glue_qt.viewers.image.slice_widget import * # noqa diff --git a/glue/viewers/image/qt/standalone_image_viewer.py b/glue/viewers/image/qt/standalone_image_viewer.py deleted file mode 100644 index 437466bba..000000000 --- a/glue/viewers/image/qt/standalone_image_viewer.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.image.qt.standalone_image_viewer is deprecated, use glue_qt.viewers.image.standalone_image_viewer instead', GlueDeprecationWarning) -from glue_qt.viewers.image.standalone_image_viewer import * # noqa diff --git a/glue/viewers/image/state.py b/glue/viewers/image/state.py index ecb1db944..45f29dba0 100644 --- a/glue/viewers/image/state.py +++ b/glue/viewers/image/state.py @@ -2,7 +2,7 @@ from collections import defaultdict from glue.core import BaseData -from glue.config import colormaps, stretches +from glue.config import colormaps from glue.viewers.matplotlib.state import (MatplotlibDataViewerState, MatplotlibLayerState, DeferredDrawCallbackProperty as DDCProperty, @@ -12,6 +12,7 @@ from echo import delay_callback from glue.core.data_combo_helper import ManualDataComboHelper, ComponentIDComboHelper from glue.core.exceptions import IncompatibleDataException +from glue.viewers.common.stretch_state_mixin import StretchStateMixin __all__ = ['ImageViewerState', 'ImageLayerState', 'ImageSubsetLayerState', 'AggregateSlice'] @@ -481,7 +482,7 @@ def slice_to_bound(slc, size): return image -class ImageLayerState(BaseImageLayerState): +class ImageLayerState(BaseImageLayerState, StretchStateMixin): """ A state class that includes all the attributes for data layers in an image plot. """ @@ -495,9 +496,6 @@ class ImageLayerState(BaseImageLayerState): bias = DDCProperty(0.5, docstring='A constant value that is added to the ' 'layer before rendering') cmap = DDCProperty(docstring='The colormap used to render the layer') - stretch = DDSCProperty(docstring='The stretch used to render the layer, ' - 'which should be one of ``linear``, ' - '``sqrt``, ``log``, or ``arcsinh``') global_sync = DDCProperty(False, docstring='Whether the color and transparency ' 'should be synced with the global ' 'color and transparency for the data') @@ -525,8 +523,7 @@ def __init__(self, layer=None, viewer_state=None, **kwargs): ImageLayerState.percentile.set_choices(self, [100, 99.5, 99, 95, 90, 'Custom']) ImageLayerState.percentile.set_display_func(self, percentile_display.get) - ImageLayerState.stretch.set_choices(self, list(stretches.members)) - ImageLayerState.stretch.set_display_func(self, stretches.display_func) + self.setup_stretch_callback() self.add_callback('global_sync', self._update_syncing) self.add_callback('layer', self._update_attribute) diff --git a/glue/viewers/image/tests/test_viewer.py b/glue/viewers/image/tests/test_viewer.py new file mode 100644 index 000000000..ec8b38133 --- /dev/null +++ b/glue/viewers/image/tests/test_viewer.py @@ -0,0 +1,99 @@ +import numpy as np +from echo import delay_callback +from glue.tests.visual.helpers import visual_test +from glue.viewers.image.viewer import SimpleImageViewer +from glue.core.application_base import Application +from glue.core.data import Data +from glue.core.link_helpers import LinkSame +from glue.core.data_region import RegionData + +from shapely.geometry import Polygon, MultiPolygon + + +@visual_test +def test_simple_viewer(): + + # Make sure the simple viewer can be instantiated + + data1 = Data(x=np.arange(6).reshape((2, 3)), label='data1') + data2 = Data(y=2 * np.arange(6).reshape((2, 3)), label='data2') + + app = Application() + app.data_collection.append(data1) + app.data_collection.append(data2) + + viewer = app.new_data_viewer(SimpleImageViewer) + viewer.add_data(data1) + viewer.add_data(data2) + + app.data_collection.new_subset_group(label='subset1', subset_state=data1.pixel_component_ids[1] > 1.2) + + return viewer.figure + + +@visual_test +def test_region_layer(): + poly_1 = Polygon([(20, 20), (60, 20), (60, 40), (20, 40)]) + poly_2 = Polygon([(60, 50), (60, 70), (80, 70), (80, 50)]) + poly_3 = Polygon([(10, 10), (15, 10), (15, 15), (10, 15)]) + poly_4 = Polygon([(10, 20), (15, 20), (15, 30), (10, 30), (12, 25)]) + + polygons = MultiPolygon([poly_3, poly_4]) + + geoms = np.array([poly_1, poly_2, polygons]) + values = np.array([1, 2, 3]) + region_data = RegionData(regions=geoms, values=values) + + image_data = Data(x=np.arange(10000).reshape((100, 100)), label='data1') + app = Application() + app.data_collection.append(image_data) + app.data_collection.append(region_data) + + link1 = LinkSame(region_data.center_x_id, image_data.pixel_component_ids[0]) + link2 = LinkSame(region_data.center_y_id, image_data.pixel_component_ids[1]) + app.data_collection.add_link(link1) + app.data_collection.add_link(link2) + + viewer = app.new_data_viewer(SimpleImageViewer) + viewer.add_data(image_data) + viewer.add_data(region_data) + + return viewer.figure + + +@visual_test +def test_region_layer_flip(): + poly_1 = Polygon([(20, 20), (60, 20), (60, 40), (20, 40)]) + poly_2 = Polygon([(60, 50), (60, 70), (80, 70), (80, 50)]) + poly_3 = Polygon([(10, 10), (15, 10), (15, 15), (10, 15)]) + poly_4 = Polygon([(10, 20), (15, 20), (15, 30), (10, 30), (12, 25)]) + + polygons = MultiPolygon([poly_3, poly_4]) + + geoms = np.array([poly_1, poly_2, polygons]) + values = np.array([1, 2, 3]) + region_data = RegionData(regions=geoms, values=values) + + image_data = Data(x=np.arange(10000).reshape((100, 100)), label='data1') + app = Application() + app.data_collection.append(image_data) + app.data_collection.append(region_data) + + link1 = LinkSame(region_data.center_x_id, image_data.pixel_component_ids[0]) + link2 = LinkSame(region_data.center_y_id, image_data.pixel_component_ids[1]) + app.data_collection.add_link(link1) + app.data_collection.add_link(link2) + + viewer = app.new_data_viewer(SimpleImageViewer) + viewer.add_data(image_data) + viewer.add_data(region_data) + + # We need this delay callback here because, while this works in the QT GUI, + # we need to make sure not to try and redraw the regions while we are flipping + # the coordinates displayed. + + with delay_callback(viewer.state, 'x_att', 'y_att'): + viewer.state.x_att = image_data.pixel_component_ids[0] + viewer.state.y_att = image_data.pixel_component_ids[1] + + return viewer.figure diff --git a/glue/viewers/image/viewer.py b/glue/viewers/image/viewer.py index 02f23e243..aec96cc9d 100644 --- a/glue/viewers/image/viewer.py +++ b/glue/viewers/image/viewer.py @@ -5,15 +5,18 @@ from glue.core.subset import roi_to_subset_state from glue.core.coordinates import Coordinates, LegacyCoordinates from glue.core.coordinate_helpers import dependent_axes +from glue.core.data_region import RegionData -from glue.viewers.scatter.layer_artist import ScatterLayerArtist +from glue.viewers.matplotlib.viewer import SimpleMatplotlibViewer +from glue.viewers.scatter.layer_artist import ScatterLayerArtist, ScatterRegionLayerArtist from glue.viewers.image.layer_artist import ImageLayerArtist, ImageSubsetLayerArtist from glue.viewers.image.compat import update_image_viewer_state +from glue.viewers.image.state import ImageViewerState from glue.viewers.image.frb_artist import imshow from glue.viewers.image.composite_array import CompositeArray -__all__ = ['MatplotlibImageMixin'] +__all__ = ['MatplotlibImageMixin', 'SimpleImageViewer'] def get_identity_wcs(naxis): @@ -172,15 +175,24 @@ def _scatter_artist(self, axes, state, layer=None, layer_state=None): raise Exception("Can only add a scatter plot overlay once an image is present") return ScatterLayerArtist(axes, state, layer=layer, layer_state=None) + def _region_artist(self, axes, state, layer=None, layer_state=None): + if len(self._layer_artist_container) == 0: + raise Exception("Can only add a region plot overlay once an image is present") + return ScatterRegionLayerArtist(axes, state, layer=layer, layer_state=None) + def get_data_layer_artist(self, layer=None, layer_state=None): - if layer.ndim == 1: + if isinstance(layer, RegionData): + cls = self._region_artist + elif layer.ndim == 1: cls = self._scatter_artist else: cls = ImageLayerArtist return self.get_layer_artist(cls, layer=layer, layer_state=layer_state) def get_subset_layer_artist(self, layer=None, layer_state=None): - if layer.ndim == 1: + if isinstance(layer.data, RegionData): + cls = self._region_artist + elif layer.ndim == 1: cls = self._scatter_artist else: cls = ImageSubsetLayerArtist @@ -248,3 +260,13 @@ def _script_footer(self): x_ticklabel_size=self.state.x_ticklabel_size, y_ticklabel_size=self.state.y_ticklabel_size) return [], EXTRA_FOOTER.format(**options) + os.linesep * 2 + script + + +class SimpleImageViewer(MatplotlibImageMixin, SimpleMatplotlibViewer): + + _state_cls = ImageViewerState + + def __init__(self, *args, **kwargs): + kwargs['wcs'] = True + super().__init__(*args, **kwargs) + MatplotlibImageMixin.setup_callbacks(self) diff --git a/glue/viewers/matplotlib/qt/__init__.py b/glue/viewers/matplotlib/qt/__init__.py deleted file mode 100644 index 2d725e620..000000000 --- a/glue/viewers/matplotlib/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.matplotlib.qt is deprecated, use glue_qt.viewers.matplotlib instead', GlueDeprecationWarning) -from glue_qt.viewers.matplotlib import * # noqa diff --git a/glue/viewers/matplotlib/qt/axes_editor.py b/glue/viewers/matplotlib/qt/axes_editor.py deleted file mode 100644 index dbcf092e9..000000000 --- a/glue/viewers/matplotlib/qt/axes_editor.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.matplotlib.qt.axes_editor is deprecated, use glue_qt.viewers.matplotlib.axes_editor instead', GlueDeprecationWarning) -from glue_qt.viewers.matplotlib.axes_editor import * # noqa diff --git a/glue/viewers/matplotlib/qt/compute_worker.py b/glue/viewers/matplotlib/qt/compute_worker.py deleted file mode 100644 index 34e2f794b..000000000 --- a/glue/viewers/matplotlib/qt/compute_worker.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.matplotlib.qt.compute_worker is deprecated, use glue_qt.viewers.matplotlib.compute_worker instead', GlueDeprecationWarning) -from glue_qt.viewers.matplotlib.compute_worker import * # noqa diff --git a/glue/viewers/matplotlib/qt/data_viewer.py b/glue/viewers/matplotlib/qt/data_viewer.py deleted file mode 100644 index aa334f43c..000000000 --- a/glue/viewers/matplotlib/qt/data_viewer.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.matplotlib.qt.data_viewer is deprecated, use glue_qt.viewers.matplotlib.data_viewer instead', GlueDeprecationWarning) -from glue_qt.viewers.matplotlib.data_viewer import * # noqa diff --git a/glue/viewers/matplotlib/qt/legend_editor.py b/glue/viewers/matplotlib/qt/legend_editor.py deleted file mode 100644 index 762f23046..000000000 --- a/glue/viewers/matplotlib/qt/legend_editor.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.matplotlib.qt.legend_editor is deprecated, use glue_qt.viewers.matplotlib.legend_editor instead', GlueDeprecationWarning) -from glue_qt.viewers.matplotlib.legend_editor import * # noqa diff --git a/glue/viewers/matplotlib/qt/toolbar.py b/glue/viewers/matplotlib/qt/toolbar.py deleted file mode 100644 index c2ec8c2be..000000000 --- a/glue/viewers/matplotlib/qt/toolbar.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.matplotlib.qt.toolbar is deprecated, use glue_qt.viewers.matplotlib.toolbar instead', GlueDeprecationWarning) -from glue_qt.viewers.matplotlib.toolbar import * # noqa diff --git a/glue/viewers/matplotlib/qt/toolbar_mode.py b/glue/viewers/matplotlib/qt/toolbar_mode.py deleted file mode 100644 index 69af6a89e..000000000 --- a/glue/viewers/matplotlib/qt/toolbar_mode.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.matplotlib.qt.toolbar_mode is deprecated, use glue_qt.viewers.matplotlib.toolbar_mode instead', GlueDeprecationWarning) -from glue_qt.viewers.matplotlib.toolbar_mode import * # noqa diff --git a/glue/viewers/matplotlib/qt/widget.py b/glue/viewers/matplotlib/qt/widget.py deleted file mode 100644 index d9b1a89ac..000000000 --- a/glue/viewers/matplotlib/qt/widget.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.matplotlib.qt.widget is deprecated, use glue_qt.viewers.matplotlib.widget instead', GlueDeprecationWarning) -from glue_qt.viewers.matplotlib.widget import * # noqa diff --git a/glue/viewers/matplotlib/state.py b/glue/viewers/matplotlib/state.py index 01a392c28..038cf3d73 100644 --- a/glue/viewers/matplotlib/state.py +++ b/glue/viewers/matplotlib/state.py @@ -1,4 +1,7 @@ -from echo import CallbackProperty, SelectionCallbackProperty, keep_in_sync, delay_callback +from echo import (CallbackProperty, + SelectionCallbackProperty, + DictCallbackProperty, + keep_in_sync, delay_callback) from matplotlib.colors import to_rgba @@ -35,6 +38,17 @@ def notify(self, *args, **kwargs): super(DeferredDrawSelectionCallbackProperty, self).notify(*args, **kwargs) +class DeferredDrawDictCallbackProperty(DictCallbackProperty): + """ + A callback property where drawing is deferred until + after notify has called all callback functions. + """ + + @defer_draw + def notify(self, *args, **kwargs): + super(DeferredDrawDictCallbackProperty, self).notify(*args, **kwargs) + + VALID_WEIGHTS = ['light', 'normal', 'medium', 'semibold', 'bold', 'heavy', 'black'] diff --git a/glue/viewers/matplotlib/viewer.py b/glue/viewers/matplotlib/viewer.py index 7b174d8eb..bab8e5442 100644 --- a/glue/viewers/matplotlib/viewer.py +++ b/glue/viewers/matplotlib/viewer.py @@ -6,11 +6,12 @@ from matplotlib.artist import setp as msetp from glue.config import settings -from glue.viewers.matplotlib.mpl_axes import update_appearance_from_settings +from glue.viewers.common.viewer import Viewer +from glue.viewers.matplotlib.mpl_axes import update_appearance_from_settings, init_mpl from echo import delay_callback from glue.utils import mpl_to_datetime64 -__all__ = ['MatplotlibViewerMixin'] +__all__ = ['MatplotlibViewerMixin', 'SimpleMatplotlibViewer'] SCRIPT_HEADER = """ # Initialize figure @@ -352,3 +353,11 @@ def _script_legend(self): if not self.state.legend.visible: legend_str = indent(legend_str, "# ") return [], legend_str + + +class SimpleMatplotlibViewer(MatplotlibViewerMixin, Viewer): + + def __init__(self, session, parent=None, wcs=None, state=None, projection=None): + super().__init__(session, state=state) + self.figure, self.axes = init_mpl(wcs=wcs, projection=projection) + MatplotlibViewerMixin.setup_callbacks(self) diff --git a/glue/viewers/profile/qt/__init__.py b/glue/viewers/profile/qt/__init__.py deleted file mode 100644 index 2b9e5fa54..000000000 --- a/glue/viewers/profile/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.profile.qt is deprecated, use glue_qt.viewers.profile instead', GlueDeprecationWarning) -from glue_qt.viewers.profile import * # noqa diff --git a/glue/viewers/profile/qt/data_viewer.py b/glue/viewers/profile/qt/data_viewer.py deleted file mode 100644 index 77ed871f7..000000000 --- a/glue/viewers/profile/qt/data_viewer.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.profile.qt.data_viewer is deprecated, use glue_qt.viewers.profile.data_viewer instead', GlueDeprecationWarning) -from glue_qt.viewers.profile.data_viewer import * # noqa diff --git a/glue/viewers/profile/qt/layer_artist.py b/glue/viewers/profile/qt/layer_artist.py deleted file mode 100644 index f584c22fd..000000000 --- a/glue/viewers/profile/qt/layer_artist.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.profile.qt.layer_artist is deprecated, use glue_qt.viewers.profile.layer_artist instead', GlueDeprecationWarning) -from glue_qt.viewers.profile.layer_artist import * # noqa diff --git a/glue/viewers/profile/qt/layer_style_editor.py b/glue/viewers/profile/qt/layer_style_editor.py deleted file mode 100644 index 8ff0884bb..000000000 --- a/glue/viewers/profile/qt/layer_style_editor.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.profile.qt.layer_style_editor is deprecated, use glue_qt.viewers.profile.layer_style_editor instead', GlueDeprecationWarning) -from glue_qt.viewers.profile.layer_style_editor import * # noqa diff --git a/glue/viewers/profile/qt/mouse_mode.py b/glue/viewers/profile/qt/mouse_mode.py deleted file mode 100644 index 7c14a0143..000000000 --- a/glue/viewers/profile/qt/mouse_mode.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.profile.qt.mouse_mode is deprecated, use glue_qt.viewers.profile.mouse_mode instead', GlueDeprecationWarning) -from glue_qt.viewers.profile.mouse_mode import * # noqa diff --git a/glue/viewers/profile/qt/options_widget.py b/glue/viewers/profile/qt/options_widget.py deleted file mode 100644 index 93ff9ed25..000000000 --- a/glue/viewers/profile/qt/options_widget.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.profile.qt.options_widget is deprecated, use glue_qt.viewers.profile.options_widget instead', GlueDeprecationWarning) -from glue_qt.viewers.profile.options_widget import * # noqa diff --git a/glue/viewers/profile/qt/profile_tools.py b/glue/viewers/profile/qt/profile_tools.py deleted file mode 100644 index 41eb0cf45..000000000 --- a/glue/viewers/profile/qt/profile_tools.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.profile.qt.profile_tools is deprecated, use glue_qt.viewers.profile.profile_tools instead', GlueDeprecationWarning) -from glue_qt.viewers.profile.profile_tools import * # noqa diff --git a/glue/viewers/profile/tests/test_viewer.py b/glue/viewers/profile/tests/test_viewer.py new file mode 100644 index 000000000..9c384ba1b --- /dev/null +++ b/glue/viewers/profile/tests/test_viewer.py @@ -0,0 +1,27 @@ +from glue.tests.visual.helpers import visual_test +from glue.viewers.profile.viewer import SimpleProfileViewer +from glue.core.application_base import Application +from glue.core.data import Data + + +@visual_test +def test_simple_viewer(): + + # Make sure the simple viewer can be instantiated + + data1 = Data(x=[1, 2, 3], label='data1') + data2 = Data(y=[1, 2, 3], label='data2') + + app = Application() + app.data_collection.append(data1) + app.data_collection.append(data2) + + viewer = app.new_data_viewer(SimpleProfileViewer) + viewer.add_data(data1) + viewer.add_data(data2) + + app.data_collection.new_subset_group(label='subset1', subset_state=data1.pixel_component_ids[0] > 0.8) + + viewer.state.layers[2].linewidth = 5 + + return viewer.figure diff --git a/glue/viewers/profile/viewer.py b/glue/viewers/profile/viewer.py index fad4a05e3..03edadf2e 100644 --- a/glue/viewers/profile/viewer.py +++ b/glue/viewers/profile/viewer.py @@ -3,7 +3,11 @@ from glue.core.units import UnitConverter from glue.core.subset import roi_to_subset_state -__all__ = ['MatplotlibProfileMixin'] +from glue.viewers.matplotlib.viewer import SimpleMatplotlibViewer +from glue.viewers.profile.state import ProfileViewerState +from glue.viewers.profile.layer_artist import ProfileLayerArtist + +__all__ = ['MatplotlibProfileMixin', 'SimpleProfileViewer'] class MatplotlibProfileMixin(object): @@ -55,3 +59,14 @@ def apply_roi(self, roi, override_mode=None): subset_state = roi_to_subset_state(roi, x_att=self.state.x_att) self.apply_subset_state(subset_state, override_mode=override_mode) + + +class SimpleProfileViewer(MatplotlibProfileMixin, SimpleMatplotlibViewer): + + _state_cls = ProfileViewerState + _data_artist_cls = ProfileLayerArtist + _subset_artist_cls = ProfileLayerArtist + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + MatplotlibProfileMixin.setup_callbacks(self) diff --git a/glue/viewers/scatter/layer_artist.py b/glue/viewers/scatter/layer_artist.py index bb8ac5c6e..c6277ad46 100644 --- a/glue/viewers/scatter/layer_artist.py +++ b/glue/viewers/scatter/layer_artist.py @@ -10,21 +10,28 @@ from glue.config import stretches from glue.utils import defer_draw, ensure_numerical, datetime64_to_mpl -from glue.viewers.scatter.state import ScatterLayerState +from glue.viewers.scatter.state import ScatterLayerState, ScatterRegionLayerState from glue.viewers.scatter.python_export import python_export_scatter_layer +from glue.viewers.scatter.plot_polygons import (UpdateableRegionCollection, + get_geometry_type, + _sanitize_geoms, + _PolygonPatch, + transform_shapely) from glue.viewers.matplotlib.layer_artist import MatplotlibLayerArtist from glue.core.exceptions import IncompatibleAttribute +from glue.core.data import BaseData from matplotlib.lines import Line2D +from shapely.ops import transform # We keep the following so that scripts exported with previous versions of glue # continue to work, as they imported STRETCHES from here. -STRETCHES = stretches.members +STRETCHES = {key: value() for key, value in stretches.members.items()} CMAP_PROPERTIES = set(['cmap_mode', 'cmap_att', 'cmap_vmin', 'cmap_vmax', 'cmap']) MARKER_PROPERTIES = set(['size_mode', 'size_att', 'size_vmin', 'size_vmax', 'size_scaling', 'size', 'fill']) LINE_PROPERTIES = set(['linewidth', 'linestyle']) -DENSITY_PROPERTIES = set(['dpi', 'stretch', 'density_contrast']) +DENSITY_PROPERTIES = set(['dpi', 'stretch', 'stretch_parameters', 'density_contrast']) VISUAL_PROPERTIES = (CMAP_PROPERTIES | MARKER_PROPERTIES | DENSITY_PROPERTIES | LINE_PROPERTIES | set(['color', 'alpha', 'zorder', 'visible'])) @@ -364,8 +371,8 @@ def _update_visual_attributes(self, changed, force=False): c = ensure_numerical(self.layer[self.state.cmap_att].ravel()) set_mpl_artist_cmap(self.density_artist, c, self.state) - if force or 'stretch' in changed: - self.density_artist.set_norm(ImageNormalize(stretch=stretches.members[self.state.stretch])) + if force or 'stretch' in changed or 'stretch_parameters' in changed: + self.density_artist.set_norm(ImageNormalize(stretch=self.state.stretch_object)) if force or 'dpi' in changed: self.density_artist.set_dpi(self._viewer_state.dpi) @@ -594,3 +601,214 @@ def _use_plot_artist(self): res = self.state.cmap_mode == 'Fixed' and self.state.size_mode == 'Fixed' return res and (not hasattr(self._viewer_state, 'plot_mode') or not self._viewer_state.plot_mode == 'polar') + + +class ScatterRegionLayerArtist(MatplotlibLayerArtist): + + _layer_state_cls = ScatterRegionLayerState + # _python_exporter = python_export_scatter_layer # TODO: Update this to work with regions + + def __init__(self, axes, viewer_state, layer_state=None, layer=None): + + super().__init__(axes, viewer_state, + layer_state=layer_state, layer=layer) + self._viewer_state.add_global_callback(self._update_scatter_region) + self.state.add_global_callback(self._update_scatter_region) + self._set_axes(axes) + + def _set_axes(self, axes): + self.axes = axes + self.region_collection = UpdateableRegionCollection([]) + self.axes.add_collection(self.region_collection) + # This is a little unnecessary, but keeps code more parallel + self.mpl_artists = [self.region_collection] + + @defer_draw + def _update_data(self): + # Layer artist has been cleared already + if len(self.mpl_artists) == 0: + return + + if self.layer is not None: + if isinstance(self.layer, BaseData): + data = self.layer + else: + data = self.layer.data + region_att = data.extended_component_id + + try: + # These must be special attributes that are linked to a region_att + if ((not data.linked_to_center_comp(self._viewer_state.x_att)) and + (not data.linked_to_center_comp(self._viewer_state.x_att_world))): + raise IncompatibleAttribute + x = ensure_numerical(self.layer[self._viewer_state.x_att].ravel()) + xx = ensure_numerical(data[data.center_x_id].ravel()) + except (IncompatibleAttribute, IndexError): + # The following includes a call to self.clear() + self.disable_invalid_attributes(self._viewer_state.x_att) + return + else: + self.enable() + + try: + # These must be special attributes that are linked to a region_att + if ((not data.linked_to_center_comp(self._viewer_state.y_att)) and + (not data.linked_to_center_comp(self._viewer_state.y_att_world))): + raise IncompatibleAttribute + y = ensure_numerical(self.layer[self._viewer_state.y_att].ravel()) + yy = ensure_numerical(data[data.center_y_id].ravel()) + except (IncompatibleAttribute, IndexError): + # The following includes a call to self.clear() + self.disable_invalid_attributes(self._viewer_state.y_att) + return + else: + self.enable() + + # We need to make sure that x and y viewer attributes are + # really the center_x and center_y attributes of the underlying + # data, so we compare the values on the centroids using the + # glue data access machinery. + + regions = self.layer[region_att] + + def flip_xy(g): + return transform(lambda x, y: (y, x), g) + + x_no_match = False + if np.array_equal(y, yy): + if np.array_equal(x, xx): + self.enable() + else: + x_no_match = True + else: + if np.array_equal(y, xx) and np.array_equal(x, yy): # This means x and y have been swapped + regions = [flip_xy(g) for g in regions] + self.enable() + else: + self.disable_invalid_attributes(self._viewer_state.y_att) + if x_no_match: + self.disable_invalid_attributes(self._viewer_state.x_att) + return + + # If we are using world coordinates (i.e. the regions are specified in world coordinates) + # we need to transform the geometries of the regions into pixel coordinates for display + # Note that this calls a custom version of the transform function from shapely + # to accomodate glue WCS objects + if self._viewer_state._display_world: + # First, convert to world coordinates + try: + tfunc = data.get_transform_to_cids([self._viewer_state.x_att_world, self._viewer_state.y_att_world]) + regions = np.array([transform(tfunc, g) for g in regions]) + + # Then convert to pixels for display + world2pix = self._viewer_state.reference_data.coords.world_to_pixel_values + regions = np.array([transform_shapely(world2pix, g) for g in regions]) + except ValueError: + self.disable_invalid_attributes([self._viewer_state.x_att_world, self._viewer_state.y_att_world]) + return + else: + try: + tfunc = data.get_transform_to_cids([self._viewer_state.x_att, self._viewer_state.y_att]) + regions = np.array([transform(tfunc, g) for g in regions]) + except ValueError: + self.disable_invalid_attributes([self._viewer_state.x_att, self._viewer_state.y_att]) + return + + # decompose GeometryCollections + geoms, multiindex = _sanitize_geoms(regions, prefix="Geom") + self.multiindex_geometry = multiindex + + geom_types = get_geometry_type(geoms) + poly_idx = np.asarray((geom_types == "Polygon") | (geom_types == "MultiPolygon")) + polys = geoms[poly_idx] + + # decompose MultiPolygons + geoms, multiindex = _sanitize_geoms(polys, prefix="Multi") + self.region_collection.patches = [_PolygonPatch(poly) for poly in geoms] + + self.geoms = geoms + self.multiindex = multiindex + + @defer_draw + def _update_visual_attributes(self, changed, force=False): + + if not self.enabled: + return + + if self.state.cmap_mode == 'Fixed': + if force or 'color' in changed or 'cmap_mode' in changed or 'fill' in changed: + self.region_collection.set_array(None) + if self.state.fill: + self.region_collection.set_facecolors(self.state.color) + self.region_collection.set_edgecolors('none') + else: + self.region_collection.set_facecolors('none') + self.region_collection.set_edgecolors(self.state.color) + elif force or any(prop in changed for prop in CMAP_PROPERTIES) or 'fill' in changed: + self.region_collection.set_edgecolors(None) + self.region_collection.set_facecolors(None) + c = ensure_numerical(self.layer[self.state.cmap_att].ravel()) + c_values = np.take(c, self.multiindex_geometry, axis=0) # Decompose Geoms + c_values = np.take(c_values, self.multiindex, axis=0) # Decompose MultiPolys + set_mpl_artist_cmap(self.region_collection, c_values, self.state) + if self.state.fill: + self.region_collection.set_edgecolors('none') + else: + self.region_collection.set_facecolors('none') + + for artist in [self.region_collection]: + + if artist is None: + continue + + if force or 'alpha' in changed: + artist.set_alpha(self.state.alpha) + + if force or 'zorder' in changed: + artist.set_zorder(self.state.zorder) + + if force or 'visible' in changed: + artist.set_visible(self.state.visible) + + self.redraw() + + @defer_draw + def _update_scatter_region(self, force=False, **kwargs): + + if (self._viewer_state.x_att is None or + self._viewer_state.y_att is None or + self.state.layer is None): + return + + # NOTE: we need to evaluate this even if force=True so that the cache + # of updated properties is up to date after this method has been called. + changed = self.pop_changed_properties() + + full_sphere = getattr(self._viewer_state, 'using_full_sphere', False) + change_from_limits = full_sphere and len(changed & LIMIT_PROPERTIES) > 0 + if force or change_from_limits or len(changed & DATA_PROPERTIES) > 0: + # This is the signature of flipping the x and y axis of an image in the UI + # We must *not* run update_data right away, or it will return an error + # The delay_callback wrapper on state._on_xatt_world_change() and + # state._on_yatt_world_change() is not properly deferring the callback + # until after x and y have been fully swapped, so we need to do it manually. + if changed == {'x_att_world', 'x_att', 'y_att_world'} or changed == {'y_att_world', 'y_att', 'x_att_world'}: + pass + else: + self._update_data() + force = True + + if force or len(changed & VISUAL_PROPERTIES) > 0: + self._update_visual_attributes(changed, force=force) + + @defer_draw + def update(self): + self._update_scatter_region(force=True) + self.redraw() + + @defer_draw + def _on_components_changed(self, components_changed): + for limit_helper in [self.state.cmap_lim_helper]: + if limit_helper.attribute in components_changed: + limit_helper.update_values('attribute') + self.redraw() diff --git a/glue/viewers/scatter/plot_polygons.py b/glue/viewers/scatter/plot_polygons.py new file mode 100644 index 000000000..bc197fb99 --- /dev/null +++ b/glue/viewers/scatter/plot_polygons.py @@ -0,0 +1,157 @@ +""" +Some lightly edited code from geopandas.plotting.py for efficiently +plotting multiple polygons in matplotlib. +""" +# Copyright (c) 2013-2022, GeoPandas developers. +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# * Neither the name of GeoPandas nor the names of its contributors may +# be used to endorse or promote products derived from this software without +# specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import numpy as np +import shapely +from matplotlib.collections import PatchCollection +from shapely.errors import GeometryTypeError + + +class UpdateableRegionCollection(PatchCollection): + """ + Allow paths in PatchCollection to be modified after creation. + """ + + def __init__(self, patches, *args, **kwargs): + self.patches = patches + PatchCollection.__init__(self, patches, *args, **kwargs) + + def get_paths(self): + self.set_paths(self.patches) + return self._paths + + +def _sanitize_geoms(geoms, prefix="Multi"): + """ + Returns Series like geoms and index, except that any Multi geometries + are split into their components and indices are repeated for all component + in the same Multi geometry. At the same time, empty or missing geometries are + filtered out. Maintains 1:1 matching of geometry to value. + Prefix specifies type of geometry to be flatten. 'Multi' for MultiPoint and similar, + "Geom" for GeometryCollection. + Returns + ------- + components : list of geometry + component_index : index array + indices are repeated for all components in the same Multi geometry + """ + # TODO(shapely) look into simplifying this with + # shapely.get_parts(geoms, return_index=True) from shapely 2.0 + components, component_index = [], [] + + geom_types = get_geometry_type(geoms).astype("str") + + if ( + not np.char.startswith(geom_types, prefix).any() + # and not geoms.is_empty.any() + # and not geoms.isna().any() + ): + return geoms, np.arange(len(geoms)) + + for ix, (geom, geom_type) in enumerate(zip(geoms, geom_types)): + if geom is not None and geom_type.startswith(prefix): + for poly in geom.geoms: + components.append(poly) + component_index.append(ix) + elif geom is None: + continue + else: + components.append(geom) + component_index.append(ix) + + return components, np.array(component_index) + + +def get_geometry_type(data): + _names = { + "MISSING": None, + "NAG": None, + "POINT": "Point", + "LINESTRING": "LineString", + "LINEARRING": "LinearRing", + "POLYGON": "Polygon", + "MULTIPOINT": "MultiPoint", + "MULTILINESTRING": "MultiLineString", + "MULTIPOLYGON": "MultiPolygon", + "GEOMETRYCOLLECTION": "GeometryCollection", + } + + type_mapping = {p.value: _names[p.name] for p in shapely.GeometryType} + geometry_type_ids = list(type_mapping.keys()) + geometry_type_values = np.array(list(type_mapping.values()), dtype=object) + res = shapely.get_type_id(data) + return geometry_type_values[np.searchsorted(geometry_type_ids, res)] + + +def transform_shapely(func, geom): + """ + A simplified/modified version of shapely.ops.transform where the func + call signature is tuned for the coordinate transform functions + coming from glue. + """ + if geom.is_empty: + return geom + if geom.geom_type in ("Point", "LineString", "LinearRing", "Polygon"): + if geom.geom_type in ("Point", "LineString", "LinearRing"): + return type(geom)(func(geom.coords)) + elif geom.geom_type == "Polygon": + shell = type(geom.exterior)(func(geom.exterior.coords)) + holes = list( + type(ring)(func(ring.coords)) + for ring in geom.interiors + ) + return type(geom)(shell, holes) + + elif geom.geom_type.startswith("Multi") or geom.geom_type == "GeometryCollection": + return type(geom)([transform_shapely(func, part) for part in geom.geoms]) + else: + raise GeometryTypeError(f"Type {geom.geom_type!r} not recognized") + + +def _PolygonPatch(polygon, **kwargs): + """Constructs a matplotlib patch from a Polygon geometry + The `kwargs` are those supported by the matplotlib.patches.PathPatch class + constructor. Returns an instance of matplotlib.patches.PathPatch. + Example (using Shapely Point and a matplotlib axes):: + b = shapely.geometry.Point(0, 0).buffer(1.0) + patch = _PolygonPatch(b, fc='blue', ec='blue', alpha=0.5) + ax.add_patch(patch) + GeoPandas originally relied on the descartes package by Sean Gillies + (BSD license, https://pypi.org/project/descartes) for PolygonPatch, but + this dependency was removed in favor of the below matplotlib code. + """ + from matplotlib.patches import PathPatch + from matplotlib.path import Path + + path = Path.make_compound_path( + Path(np.asarray(polygon.exterior.coords)[:, :2]), + *[Path(np.asarray(ring.coords)[:, :2]) for ring in polygon.interiors], + ) + return PathPatch(path, **kwargs) diff --git a/glue/viewers/scatter/python_export.py b/glue/viewers/scatter/python_export.py index 717805078..d31d53866 100644 --- a/glue/viewers/scatter/python_export.py +++ b/glue/viewers/scatter/python_export.py @@ -77,8 +77,7 @@ def python_export_scatter_layer(layer, *args): if layer.state.density_map: imports += ["from mpl_scatter_density import ScatterDensityArtist"] - imports += ["from glue.config import stretches"] - imports += ["from glue.viewers.scatter.layer_artist import DensityMapLimits"] + imports += ["from glue.viewers.scatter.layer_artist import DensityMapLimits, STRETCHES"] imports += ["from astropy.visualization import ImageNormalize"] script += "density_limits = DensityMapLimits()\n" @@ -92,7 +91,7 @@ def python_export_scatter_layer(layer, *args): options['color'] = layer.state.color options['vmin'] = code('density_limits.min') options['vmax'] = code('density_limits.max') - options['norm'] = code("ImageNormalize(stretch=stretches.members['{0}'])".format(layer.state.stretch)) + options['norm'] = code("ImageNormalize(stretch=STRETCHES['{0}'])".format(layer.state.stretch)) else: options['c'] = code("layer_data['{0}']".format(layer.state.cmap_att.label)) options['cmap'] = code("plt.cm.{0}".format(layer.state.cmap.name)) diff --git a/glue/viewers/scatter/qt/__init__.py b/glue/viewers/scatter/qt/__init__.py deleted file mode 100644 index 5c9d59659..000000000 --- a/glue/viewers/scatter/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.scatter.qt is deprecated, use glue_qt.viewers.scatter instead', GlueDeprecationWarning) -from glue_qt.viewers.scatter import * # noqa diff --git a/glue/viewers/scatter/qt/data_viewer.py b/glue/viewers/scatter/qt/data_viewer.py deleted file mode 100644 index b9f1f1865..000000000 --- a/glue/viewers/scatter/qt/data_viewer.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.scatter.qt.data_viewer is deprecated, use glue_qt.viewers.scatter.data_viewer instead', GlueDeprecationWarning) -from glue_qt.viewers.scatter.data_viewer import * # noqa diff --git a/glue/viewers/scatter/qt/layer_style_editor.py b/glue/viewers/scatter/qt/layer_style_editor.py deleted file mode 100644 index 39c7a490c..000000000 --- a/glue/viewers/scatter/qt/layer_style_editor.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.scatter.qt.layer_style_editor is deprecated, use glue_qt.viewers.scatter.layer_style_editor instead', GlueDeprecationWarning) -from glue_qt.viewers.scatter.layer_style_editor import * # noqa diff --git a/glue/viewers/scatter/qt/options_widget.py b/glue/viewers/scatter/qt/options_widget.py deleted file mode 100644 index fe1e5fc51..000000000 --- a/glue/viewers/scatter/qt/options_widget.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.scatter.qt.options_widget is deprecated, use glue_qt.viewers.scatter.options_widget instead', GlueDeprecationWarning) -from glue_qt.viewers.scatter.options_widget import * # noqa diff --git a/glue/viewers/scatter/state.py b/glue/viewers/scatter/state.py index 0ad007bb9..9fb0095f4 100644 --- a/glue/viewers/scatter/state.py +++ b/glue/viewers/scatter/state.py @@ -4,7 +4,7 @@ from glue.core import BaseData, Subset -from glue.config import colormaps, stretches +from glue.config import colormaps from glue.viewers.matplotlib.state import (MatplotlibDataViewerState, MatplotlibLayerState, DeferredDrawCallbackProperty as DDCProperty, @@ -13,10 +13,11 @@ from echo import keep_in_sync, delay_callback from glue.core.data_combo_helper import ComponentIDComboHelper, ComboHelper from glue.core.exceptions import IncompatibleAttribute +from glue.viewers.common.stretch_state_mixin import StretchStateMixin from matplotlib.projections import get_projection_names -__all__ = ['ScatterViewerState', 'ScatterLayerState'] +__all__ = ['ScatterViewerState', 'ScatterLayerState', 'ScatterRegionLayerState'] class ScatterViewerState(MatplotlibDataViewerState): @@ -30,6 +31,9 @@ class ScatterViewerState(MatplotlibDataViewerState): plot_mode = DDSCProperty(docstring="Whether to plot the data in cartesian, polar or another projection") angle_unit = DDSCProperty(docstring="Whether to use radians or degrees for any angular coordinates") + x_limits_percentile = DDCProperty(100, docstring="Percentile to use when automatically determining x limits") + y_limits_percentile = DDCProperty(100, docstring="Percentile to use when automatically determining y limits") + def __init__(self, **kwargs): super(ScatterViewerState, self).__init__() @@ -70,13 +74,13 @@ def __init__(self, **kwargs): def _reset_x_limits(self, *args): if self.x_att is None: return - self.x_lim_helper.percentile = 100 + self.x_lim_helper.percentile = self.x_limits_percentile self.x_lim_helper.update_values(force=True) def _reset_y_limits(self, *args): if self.y_att is None: return - self.y_lim_helper.percentile = 100 + self.y_lim_helper.percentile = self.y_limits_percentile self.y_lim_helper.update_values(force=True) def reset_limits(self): @@ -201,7 +205,7 @@ def display_func_slow(x): return x -class ScatterLayerState(MatplotlibLayerState): +class ScatterLayerState(MatplotlibLayerState, StretchStateMixin): """ A state class that includes all the attributes for layers in a scatter plot. """ @@ -232,9 +236,6 @@ class ScatterLayerState(MatplotlibLayerState): # Density map density_map = DDCProperty(False, docstring="Whether to show the points as a density map") - stretch = DDSCProperty(default='log', docstring='The stretch used to render the layer, ' - 'which should be one of ``linear``, ' - '``sqrt``, ``log``, or ``arcsinh``') density_contrast = DDCProperty(1, docstring="The dynamic range of the density map") # Note that we keep the dpi in the viewer state since we want it to always @@ -327,8 +328,8 @@ def __init__(self, viewer_state=None, layer=None, **kwargs): ScatterLayerState.vector_origin.set_choices(self, ['tail', 'middle', 'tip']) ScatterLayerState.vector_origin.set_display_func(self, vector_origin_display.get) - ScatterLayerState.stretch.set_choices(self, ['linear', 'sqrt', 'arcsinh', 'log']) - ScatterLayerState.stretch.set_display_func(self, stretches.display_func) + self.setup_stretch_callback() + self.stretch = 'log' if self.viewer_state is not None: self.viewer_state.add_callback('x_att', self._on_xy_change, priority=10000) @@ -343,6 +344,7 @@ def __init__(self, viewer_state=None, layer=None, **kwargs): self._on_layer_change() self.cmap = colormaps.members[0][1] + self.add_callback('cmap_att', self._check_for_preferred_cmap) self.size = self.layer.style.markersize @@ -350,6 +352,15 @@ def __init__(self, viewer_state=None, layer=None, **kwargs): self.update_from_dict(kwargs) + def _check_for_preferred_cmap(self, *args): + if isinstance(self.layer, BaseData): + layer = self.layer + else: + layer = self.layer.data + actual_component = layer.get_component(self.cmap_att) + if getattr(actual_component, 'preferred_cmap', False): + self.cmap = actual_component.preferred_cmap + def _update_points_mode(self, *args): if getattr(self.viewer_state, 'using_polar', False) or getattr(self.viewer_state, 'using_full_sphere', False): self.points_mode_helper.choices = ['markers'] @@ -493,3 +504,97 @@ def __setgluestate__(cls, rec, context): rec['values']['markers_visible'] = False rec['values']['line_visible'] = True return super(ScatterLayerState, cls).__setgluestate__(rec, context) + + +class ScatterRegionLayerState(MatplotlibLayerState): + """ + A state class that includes all the attributes for layers in a scatter region layer. + """ + # Color + + cmap_mode = DDSCProperty(docstring="Whether to use color to encode an attribute") + cmap_att = DDSCProperty(docstring="The attribute to use for the color") + cmap_vmin = DDCProperty(docstring="The lower level for the colormap") + cmap_vmax = DDCProperty(docstring="The upper level for the colormap") + cmap = DDCProperty(docstring="The colormap to use (when in colormap mode)") + percentile = DDSCProperty(docstring='The percentile value used to ' + 'automatically calculate levels') + + fill = DDCProperty(True, docstring="Whether to fill the regions") + + def __init__(self, viewer_state=None, layer=None, **kwargs): + + super().__init__(viewer_state=viewer_state, layer=layer) + self.limits_cache = {} + + self.cmap_lim_helper = StateAttributeLimitsHelper(self, attribute='cmap_att', + lower='cmap_vmin', upper='cmap_vmax', + percentile='percentile', + limits_cache=self.limits_cache) + + self.cmap_att_helper = ComponentIDComboHelper(self, 'cmap_att', + numeric=True, datetime=False, + categorical=True) + + percentile_display = {100: 'Min/Max', + 99.5: '99.5%', + 99: '99%', + 95: '95%', + 90: '90%', + 'Custom': 'Custom'} + + ScatterRegionLayerState.percentile.set_choices(self, [100, 99.5, 99, 95, 90, 'Custom']) + ScatterRegionLayerState.percentile.set_display_func(self, percentile_display.get) + + ScatterRegionLayerState.cmap_mode.set_choices(self, ['Fixed', 'Linear']) + + if self.viewer_state is not None: + self.viewer_state.add_callback('x_att', self._on_xy_change, priority=10000) + self.viewer_state.add_callback('y_att', self._on_xy_change, priority=10000) + self._on_xy_change() + + self.add_callback('layer', self._on_layer_change) + if layer is not None: + self._on_layer_change() + + self.cmap = colormaps.members[0][1] + + self.add_callback('cmap_att', self._check_for_preferred_cmap) + self.update_from_dict(kwargs) + + def _check_for_preferred_cmap(self, *args): + if isinstance(self.layer, BaseData): + layer = self.layer + else: + layer = self.layer.data + actual_component = layer.get_component(self.cmap_att) + if getattr(actual_component, 'preferred_cmap', False): + self.cmap = actual_component.preferred_cmap + + def _on_layer_change(self, layer=None): + with delay_callback(self, 'cmap_vmin', 'cmap_vmax'): + + if self.layer is None: + self.cmap_att_helper.set_multiple_data([]) + else: + self.cmap_att_helper.set_multiple_data([self.layer]) + + def _on_xy_change(self, *event): + + if self.viewer_state.x_att is None or self.viewer_state.y_att is None: + return + + if isinstance(self.layer, BaseData): + layer = self.layer + else: + layer = self.layer.data + + def flip_cmap(self): + """ + Flip the cmap_vmin/cmap_vmax limits. + """ + self.cmap_lim_helper.flip_limits() + + @property + def cmap_name(self): + return colormaps.name_from_cmap(self.cmap) diff --git a/glue/viewers/scatter/tests/__init__.py b/glue/viewers/scatter/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/glue/viewers/scatter/tests/test_viewer.py b/glue/viewers/scatter/tests/test_viewer.py new file mode 100644 index 000000000..e622b213d --- /dev/null +++ b/glue/viewers/scatter/tests/test_viewer.py @@ -0,0 +1,105 @@ +import numpy as np +from numpy.testing import assert_allclose + +import matplotlib.pyplot as plt + +from glue.tests.visual.helpers import visual_test + +from glue.viewers.scatter.viewer import SimpleScatterViewer +from glue.core.application_base import Application +from glue.core.data import Data +from glue.core.link_helpers import LinkSame + + +@visual_test +def test_simple_viewer(): + + # Make sure the simple viewer can be instantiated + + data1 = Data(x=[1, 2, 3], label='data1') + data2 = Data(y=[1, 2, 3], label='data2') + + app = Application() + app.data_collection.append(data1) + app.data_collection.append(data2) + + viewer = app.new_data_viewer(SimpleScatterViewer) + viewer.add_data(data1) + viewer.add_data(data2) + + app.data_collection.new_subset_group(label='subset1', subset_state=data1.id['x'] > 2) + + return viewer.figure + + +@visual_test +def test_scatter_density_map(): + + # Test the scatter density map + + np.random.seed(12345) + + app = Application() + + x = np.random.normal(3, 1, 100) + y = np.random.normal(2, 1.5, 100) + c = np.hypot(x - 3, y - 2) + s = (x - 3) + + data1 = app.add_data(a={"x": x, "y": y, "c": c, "s": s})[0] + + xx = np.random.normal(3, 1, 1000000) + yy = np.random.normal(2, 1.5, 1000000) + + data2 = app.add_data(a={"x": xx, "y": yy})[0] + + app.data_collection.add_link(LinkSame(data1.id['x'], data2.id['x'])) + app.data_collection.add_link(LinkSame(data1.id['y'], data2.id['y'])) + + viewer = app.new_data_viewer(SimpleScatterViewer) + viewer.add_data(data1) + viewer.add_data(data2) + + viewer.state.layers[0].cmap_mode = 'Linear' + viewer.state.layers[0].cmap_att = data1.id['c'] + viewer.state.layers[0].cmap = plt.cm.viridis + viewer.state.layers[0].size_mode = 'Linear' + viewer.state.layers[0].size_att = data1.id['s'] + + viewer.state.layers[1].zorder = 0.5 + + app.data_collection.new_subset_group(label='subset1', subset_state=data1.id['x'] > 2) + + return viewer.figure + + +def test_reset_limits(): + + data1 = Data(x=np.arange(1000), y=np.arange(1000) + 1000, label='data') + + app = Application() + app.data_collection.append(data1) + + viewer = app.new_data_viewer(SimpleScatterViewer) + viewer.add_data(data1) + + viewer.state.reset_limits() + + # Note that there is a margin included which is why the limits are not 0 to 999 + + assert_allclose(viewer.state.x_min, -39.96) + assert_allclose(viewer.state.x_max, 1038.96) + + assert_allclose(viewer.state.y_min, 1000 - 39.96) + assert_allclose(viewer.state.y_max, 1000 + 1038.96) + + viewer.state.x_limits_percentile = 90 + viewer.state.y_limits_percentile = 80 + + viewer.state.reset_limits() + + assert_allclose(viewer.state.x_min, 13.986) + assert_allclose(viewer.state.x_max, 985.014) + + assert_allclose(viewer.state.y_min, 1000 + 67.932) + assert_allclose(viewer.state.y_max, 1000 + 931.068) diff --git a/glue/viewers/scatter/viewer.py b/glue/viewers/scatter/viewer.py index 9608d0ac2..c986fd706 100644 --- a/glue/viewers/scatter/viewer.py +++ b/glue/viewers/scatter/viewer.py @@ -5,9 +5,11 @@ from glue.utils import mpl_to_datetime64 from glue.viewers.scatter.compat import update_scatter_viewer_state from glue.viewers.matplotlib.mpl_axes import init_mpl +from glue.viewers.matplotlib.viewer import SimpleMatplotlibViewer +from glue.viewers.scatter.state import ScatterViewerState +from glue.viewers.scatter.layer_artist import ScatterLayerArtist - -__all__ = ['MatplotlibScatterMixin'] +__all__ = ['MatplotlibScatterMixin', 'SimpleScatterViewer'] class MatplotlibScatterMixin(object): @@ -179,3 +181,14 @@ def apply_roi(self, roi, override_mode=None): @staticmethod def update_viewer_state(rec, context): return update_scatter_viewer_state(rec, context) + + +class SimpleScatterViewer(MatplotlibScatterMixin, SimpleMatplotlibViewer): + + _state_cls = ScatterViewerState + _data_artist_cls = ScatterLayerArtist + _subset_artist_cls = ScatterLayerArtist + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + MatplotlibScatterMixin.setup_callbacks(self) diff --git a/glue/viewers/table/qt/__init__.py b/glue/viewers/table/qt/__init__.py deleted file mode 100644 index 6220498c9..000000000 --- a/glue/viewers/table/qt/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.table.qt is deprecated, use glue_qt.viewers.table instead', GlueDeprecationWarning) -from glue_qt.viewers.table import * # noqa diff --git a/glue/viewers/table/qt/data_viewer.py b/glue/viewers/table/qt/data_viewer.py deleted file mode 100644 index e54734d0b..000000000 --- a/glue/viewers/table/qt/data_viewer.py +++ /dev/null @@ -1,4 +0,0 @@ -import warnings -from glue.utils.error import GlueDeprecationWarning -warnings.warn('Importing from glue.viewers.table.qt.data_viewer is deprecated, use glue_qt.viewers.table.data_viewer instead', GlueDeprecationWarning) -from glue_qt.viewers.table.data_viewer import * # noqa diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..03bd076da --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["setuptools", + "setuptools_scm", + "wheel"] +build-backend = 'setuptools.build_meta' + +[tool.gilesbot] +[tool.gilesbot.circleci_artifacts] +enabled = true + +[tool.gilesbot.circleci_artifacts.figure_report] +url = "results/fig_comparison.html" +message = "Click details to see the image test comparisons, for py311-test-visual" +report_on_fail = true diff --git a/setup.cfg b/setup.cfg index c94696d1b..44e4f5bed 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,11 +38,7 @@ install_requires = pvextractor>=0.2 importlib_resources>=1.3; python_version<'3.9' importlib_metadata>=3.6; python_version<'3.10' - # For now, we include a dependency on glue-qt so that imports of the - # Qt-related functionality continue to work albeit with a deprecation - # warning. Once the deprecation phase is over, we can remove this - # dependency as well as all the compatibility imports. - glue-qt>=0.1.0 + shapely>=2.0 [options.entry_points] glue.plugins = @@ -85,6 +81,8 @@ test = pytest-flake8 h5py>=2.10; platform_system=="Linux" objgraph +visualtest = + pytest-mpl [options.package_data] * = *.png, *.ui, *.glu, *.hdf5, *.fits, *.xlsx, *.txt, *.csv, *.svg, *.vot @@ -107,13 +105,15 @@ filterwarnings = [coverage:run] omit = - glue/*tests/* - glue/core/odict.py, - glue/core/glue_pickle.py + glue/tests/* + glue/*/tests/* + glue/*/*/tests/* + glue/*/*/*/tests/* glue/external/* - */glue/*tests/* - */glue/core/odict.py, - */glue/core/glue_pickle.py + */glue/tests/* + */glue/*/tests/* + */glue/*/*/tests/* + */glue/*/*/*/tests/* */glue/external/* [coverage:paths] diff --git a/tox.ini b/tox.ini index 2fe7aa9c8..469416cda 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{38,39,310,311}-{codestyle,test,docs}-all-{dev,legacy} + py{38,39,310,311}-{codestyle,test,docs}-all-{dev,legacy}{,-visual} requires = pip >= 18.0 setuptools >= 30.3.0 @@ -11,6 +11,7 @@ passenv = HOME setenv = dev: PIP_EXTRA_INDEX_URL = https://pypi.anaconda.org/astropy/simple https://pypi.anaconda.org/scientific-python-nightly-wheels/simple + visual: MPLFLAGS = -m "mpl_image_compare" --mpl --mpl-generate-summary=html --mpl-results-path={toxinidir}/results --mpl-hash-library={toxinidir}/glue/tests/visual/{envname}.json --mpl-baseline-path=https://raw.githubusercontent.com/glue-viz/glue-core-visual-tests/images/{envname}/ whitelist_externals = find rm @@ -44,9 +45,10 @@ extras = test all: all docs: docs + visual: visualtest commands = test: pip freeze - test: pytest --pyargs glue --cov glue --cov-config={toxinidir}/setup.cfg {posargs} + test: pytest --pyargs glue --cov glue --cov-config={toxinidir}/setup.cfg {env:MPLFLAGS} {posargs} docs: sphinx-build -W -n -b html -d _build/doctrees . _build/html [testenv:codestyle]