-
Notifications
You must be signed in to change notification settings - Fork 75
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
Changes from all commits
03a678a
ace12e2
2ca2031
00e6b95
2464f06
a466a1c
ed4a0d2
d137e4e
6063620
da2d447
31bbba8
2bd76c4
575227e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from .reproject import * # noqa |
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): | ||
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() |
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> |
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, so I wasn't going crazy! Thanks for fixing it, Kyle. 😸 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
""" | ||
|
@@ -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): | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -60,6 +60,8 @@ test = | |
docs = | ||
sphinx-rtd-theme | ||
sphinx-astropy | ||
all = | ||
reproject>=0.10 | ||
|
||
[options.package_data] | ||
jdaviz = | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I 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 ?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 likeplugin.add_results.label
... but I guess since it acts differently, that is ok?