Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Reproject plugin for Imviz #1949

Closed
wants to merge 13 commits into from
4 changes: 3 additions & 1 deletion CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ Imviz

- Table exposing past results in the aperture photometry plugin. [#1985]

- New Reproject plugin. [#1949]

Mosviz
^^^^^^

Expand Down Expand Up @@ -66,7 +68,7 @@ Other Changes and Additions
Bug Fixes
---------

* Auto-label component no longer disables the automatic labeling behavior on any keypress, but only when changing the
- Auto-label component no longer disables the automatic labeling behavior on any keypress, but only when changing the
label [#2007].

Cubeviz
Expand Down
30 changes: 30 additions & 0 deletions docs/imviz/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,36 @@ are not stored. To save the current result before submitting a new query, you ca
The table returned from the API above may cover more sources than shown in the currently zoomed-in
portion of the image. Additional steps will be needed to filter out these points, if necessary.

.. _imviz-reproject:

Reproject
=========

.. note:: This plugin requires ``reproject`` to be installed.

.. warning::

This operation is not recommended if the input image has not been
corrected for distortion.

Reprojecting a large image may be resource intensive.

Compass zoom box might be inaccurate on data linked to the reprojected
image.

Use the `reproject <https://reproject.readthedocs.io/>`_ package to create a new image
that is the input image reprojected to its optimal celestial WCS.

Choose the desired image from the data selection menu, if applicable.
Then enter the desired data label for the result.
Next, click on the :guilabel:`REPROJECT` button
(this might take a while to complete, please be patient).

If successful, a new reprojected image (N-up, E-left) with the given label will be
displayed in Imviz's default viewer and is now the viewer's reference data.
The original image before reprojection is also removed from the viewer
(but still available in the application's underlying data collection).

.. _imviz-export-plot:

Export Plot
Expand Down
1 change: 1 addition & 0 deletions jdaviz/configs/imviz/imviz.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ tray:
- imviz-line-profile-xy
- imviz-aper-phot-simple
- imviz-catalogs
- imviz-reproject
- g-export-plot
viewer_area:
- container: col
Expand Down
3 changes: 2 additions & 1 deletion jdaviz/configs/imviz/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
from .compass import * # noqa
from .aper_phot_simple import * # noqa
from .line_profile_xy import * # noqa
from .catalogs import * # noqa
from .catalogs import * # noqa
from .reproject import * # noqa
1 change: 1 addition & 0 deletions jdaviz/configs/imviz/plugins/reproject/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .reproject import * # noqa
124 changes: 124 additions & 0 deletions jdaviz/configs/imviz/plugins/reproject/reproject.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import numpy as np
import time
from glue.core.data import Data
from traitlets import Bool, observe

from jdaviz.core.events import SnackbarMessage
from jdaviz.core.registries import tray_registry
from jdaviz.core.template_mixin import (PluginTemplateMixin, DatasetSelectMixin,
AutoTextFieldMixin)
from jdaviz.core.user_api import PluginUserApi

try:
from reproject import reproject_interp
from reproject.mosaicking import find_optimal_celestial_wcs
except ImportError:
HAS_REPROJECT = False
else:
HAS_REPROJECT = True

__all__ = ['Reproject']


@tray_registry('imviz-reproject', label="Reproject")
class Reproject(PluginTemplateMixin, DatasetSelectMixin, AutoTextFieldMixin):
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am guessing just throwing AutoTextFieldMixin in there isn't enough? I am getting error about not enough arguments for the constructor. And I can't quite figure it out from the API doc. Maybe I am using this wrong, @kecnry ?

Copy link
Member

@kecnry kecnry Feb 10, 2023

Choose a reason for hiding this comment

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

Looking into it. EDIT: pushed directly to the branch with the fix and plugging in the rest of the functionality. Turns out we weren't making use of the mixin anywhere, so the typo slipped by 😬

One downside to using this is that the API for the user is now plugin.label instead of something like plugin.add_results.label... but I guess since it acts differently, that is ok?

template_file = __file__, "reproject.vue"

reproject_in_progress = Bool(False).tag(sync=True)

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

if not HAS_REPROJECT:
self.disabled_msg = 'Please install reproject and restart Jdaviz to use this plugin'
return

@property
def user_api(self):
return PluginUserApi(self, expose=('dataset', 'label', 'reproject'))

@observe("dataset_selected")
def _set_default_results_label(self, event={}):
'''Generate a label and set the results field to that value'''
self.label_default = f"{self.dataset_selected} (reprojected)"

@observe('label')
def _label_changed(self, event={}):
if not len(self.label.strip()):
# strip will raise the same error for a label of all spaces
self.label_invalid_msg = 'label must be provided'
return
if self.label.strip() in self.data_collection:
self.label_invalid_msg = 'label already in use'
return
self.label_invalid_msg = ''

def reproject(self):
"""
Reproject ``dataset`` so that North is up in a new entry labeled ``label`` and set as the
reference image.
"""
if (not HAS_REPROJECT or self.dataset_selected not in self.data_collection
or self.reproject_in_progress):
return

from reproject import __version__ as reproject_version

data = self.data_collection[self.dataset_selected]
wcs_in = data.coords
if wcs_in is None:
self.hub.broadcast(SnackbarMessage(
f"Failed to reproject {data.label}: WCS not found",
color='error', sender=self))
return
pllim marked this conversation as resolved.
Show resolved Hide resolved

viewer_reference = f"{self.app.config}-0"
self.reproject_in_progress = True
t_start = time.time()
try:
if self.label_invalid_msg:
raise ValueError(f'{self.label}: {self.label_invalid_msg}')

# Find WCS where North is pointing up.
wcs_out, shape_out = find_optimal_celestial_wcs([(data.shape, wcs_in)], frame='icrs')

# Reproject image to new WCS.
comp = data.get_component(data.main_components[0])
new_arr = reproject_interp((comp.data, wcs_in), wcs_out, shape_out=shape_out,
return_footprint=False)
Comment on lines +82 to +88
Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry if this has already been discussed and I'm repeating a question. Should we support reprojection onto non-optimal WCS? For example, one might want to rotate one image so that it matches the orientation of another (see reproject Quick Start), which might be helpful for blinking in Imviz.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ori wanted N-up, so that is what he is getting. Arbitrary rotation would be a different ticket.


# Stuff it back into Imviz and show in default viewer.
# We don't want to inherit input metadata because it might have wrong (unrotated)
# WCS info in the header metadata.
new_data = Data(label=self.label,
coords=wcs_out,
data=np.nan_to_num(new_arr, copy=False))
new_data.meta.update({'orig_label': data.label,
'reproject_version': reproject_version})
self.app.add_data(new_data, self.label)
self.app.add_data_to_viewer(viewer_reference, self.label)

# We unload the unrotated image from default viewer.
# Only do this after we add the reprojected data to avoid JSON warning.
self.app.remove_data_from_viewer(viewer_reference, data.label)

# Make the reprojected image the reference data for the default viewer.
viewer = self.app.get_viewer(viewer_reference)
viewer.state.reference_data = new_data

except Exception as e:
Copy link
Member

Choose a reason for hiding this comment

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

Exception seems quite broad to me, but I guess if there are any bugs in the block above outside the actual reprojection, CI should catch that? Although the CI itself would probably not expose the snackbar error message, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I cannot know all the possible ways this can crash, including invalid calculations over at reproject. CI can still check to see if the new data is generated as expected or not, even if we don't throw exception here.

Copy link
Member

Choose a reason for hiding this comment

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

ok, we've done similar elsewhere, so I think its ok. If those CI tests ever fail, we'll just have to modify this to throw the exception in a local run of the test to see the full traceback.

self.hub.broadcast(SnackbarMessage(
f"Failed to reproject {data.label}: {repr(e)}",
color='error', sender=self))

else:
t_end = time.time()
self.hub.broadcast(SnackbarMessage(
f"Reprojection of {data.label} took {t_end - t_start:.1f} seconds.",
color='info', sender=self))

finally:
self.reproject_in_progress = False

def vue_do_reproject(self, *args, **kwargs):
self.reproject()
55 changes: 55 additions & 0 deletions jdaviz/configs/imviz/plugins/reproject/reproject.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<template>
<j-tray-plugin
description='Perform reprojection for a single image to align X/Y to N-up/E-left.'
:link="'https://jdaviz.readthedocs.io/en/'+vdocs+'/'+config+'/plugins.html#reproject'"
:disabled_msg="disabled_msg"
:popout_button="popout_button">


<plugin-dataset-select
:items="dataset_items"
:selected.sync="dataset_selected"
:show_if_single_entry="false"
label="Data"
hint="Select the data for reprojection."
/>

<div style="display: grid"> <!-- overlay container -->
<div style="grid-area: 1/1">
<div v-if='dataset_selected'>
<plugin-auto-label
:value.sync="label"
:default="label_default"
:auto.sync="label_auto"
:invalid_msg="label_invalid_msg"
hint="Data label for reprojected data."
></plugin-auto-label>
<v-row justify="end">
<v-btn color="primary"
text
:disabled="label_invalid_msg.length > 0"
@click="do_reproject">
Reproject
</v-btn>
</v-row>
</div>
</div>

<div v-if="reproject_in_progress"
class="text-center"
style="grid-area: 1/1;
z-index:2;
margin-left: -24px;
margin-right: -24px;
padding-top: 60px;
background-color: rgb(0 0 0 / 20%)">
<v-progress-circular
indeterminate
color="spinner"
size="50"
width="6"
></v-progress-circular>
</div>
</div>
</j-tray-plugin>
</template>
55 changes: 55 additions & 0 deletions jdaviz/configs/imviz/tests/test_reproject.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import pytest

from jdaviz.configs.imviz.plugins.reproject.reproject import HAS_REPROJECT
from jdaviz.configs.imviz.tests.utils import BaseImviz_WCS_GWCS


@pytest.mark.skipif(not HAS_REPROJECT, reason='reproject not installed')
class TestReproject_WCS_GWCS(BaseImviz_WCS_GWCS):
def test_reproject_fits_wcs(self):
self.imviz.link_data(link_type='wcs', error_on_fail=True)

plg = self.imviz.plugins["Reproject"]
assert not plg._obj.disabled_msg

# Attempt to reproject without WCS should be no-op.
plg.dataset = "no_wcs"
plg.reproject()
assert self.imviz.app.data_collection.labels == ['fits_wcs[DATA]', 'gwcs[DATA]', 'no_wcs']

# Reproject FITS WCS. We do not test the actual reprojection algorithm.
plg.dataset = 'fits_wcs[DATA]'
assert plg.label == 'fits_wcs[DATA] (reprojected)'
plg.label = 'Reprojected' # Overwrite default label
plg.reproject()
assert self.imviz.app.data_collection.labels == ['fits_wcs[DATA]', 'gwcs[DATA]', 'no_wcs',
'Reprojected']
assert self.imviz.app.data_collection['Reprojected'].meta['orig_label'] == 'fits_wcs[DATA]'
# Original data should not be loaded in the viewer anymore.
assert [data.label for data in self.viewer.data()] == ['gwcs[DATA]', 'no_wcs',
'Reprojected']
# Reprojected data now is the viewer reference.
assert self.viewer.state.reference_data.label == 'Reprojected'

# Reproject again using existing label is not allowed.
# Only snackbar message is shown, so that is not tested here. Result should be unchanged.
plg.dataset = 'gwcs[DATA]'
plg.label = 'Reprojected' # Reuse existing label
plg.reproject()
assert self.imviz.app.data_collection.labels == ['fits_wcs[DATA]', 'gwcs[DATA]', 'no_wcs',
'Reprojected']
assert self.imviz.app.data_collection['Reprojected'].meta['orig_label'] == 'fits_wcs[DATA]'


@pytest.mark.skipif(not HAS_REPROJECT, reason='reproject not installed')
def test_reproject_no_data(imviz_helper):
"""This should be silent no-op."""
plg = imviz_helper.plugins["Reproject"]
plg.reproject()
assert len(imviz_helper.app.data_collection) == 0


@pytest.mark.skipif(HAS_REPROJECT, reason='reproject is installed')
def test_reproject_no_reproject(imviz_helper):
plg = imviz_helper.plugins["Reproject"]
assert "Please install reproject" in plg._obj.disabled_msg
8 changes: 4 additions & 4 deletions jdaviz/core/template_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -1599,7 +1599,7 @@ class AutoTextFieldMixin(VuetifyTemplate, HubListener):
:value.sync="label"
:default="label_default"
:auto.sync="label_auto"
:invalid_msg="invalid_msg"
:invalid_msg="label_invalid_msg"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, so I wasn't going crazy! Thanks for fixing it, Kyle. 😸

Copy link
Member

Choose a reason for hiding this comment

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

It was mostly my fault for having incorrect dev docs for the mixin (now fixed) 😉

hint="Label hint."
></plugin-auto-label>
"""
Expand All @@ -1610,9 +1610,9 @@ class AutoTextFieldMixin(VuetifyTemplate, HubListener):

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.auto_label = AddResults(self, 'label',
'label_default', 'label_auto',
'label_invalid_msg')
self.auto_label = AutoTextField(self, 'label',
'label_default', 'label_auto',
'label_invalid_msg')


class AddResults(BasePluginComponent):
Expand Down
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ test =
docs =
sphinx-rtd-theme
sphinx-astropy
all =
reproject>=0.10

[options.package_data]
jdaviz =
Expand Down
3 changes: 1 addition & 2 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ deps =
# The following indicates which extras_require from setup.cfg will be installed
extras =
test
# Uncomment when we have all again in setup.cfg
#alldeps: all
alldeps: all

commands =
devdeps: pip install -U -i https://pypi.anaconda.org/astropy/simple astropy --pre
Expand Down