Skip to content

Commit 50f60cf

Browse files
authored
Merge pull request #173 from mwcraig/update-marker-access
Update the interface and ginga implementation to make some changes of the marker API
2 parents 3e1190c + a240d9f commit 50f60cf

File tree

4 files changed

+130
-58
lines changed

4 files changed

+130
-58
lines changed

astrowidgets/ginga.py

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424

2525
from astrowidgets.interface_definition import (
2626
ALLOWED_CURSOR_LOCATIONS,
27-
RESERVED_MARKER_SET_NAMES
27+
RESERVED_MARKER_SET_NAMES,
28+
DEFAULT_MARKER_NAME,
29+
DEFAULT_INTERACTIVE_MARKER_NAME
2830
)
2931

3032
__all__ = ['ImageWidget']
@@ -61,6 +63,12 @@ class ImageWidget(ipyw.VBox):
6163
# List of marker names that are for internal use only
6264
RESERVED_MARKER_SET_NAMES = RESERVED_MARKER_SET_NAMES
6365

66+
# Default marker name for marking via API
67+
DEFAULT_MARKER_NAME: str = DEFAULT_MARKER_NAME
68+
69+
# Default marker name for interactive marking
70+
DEFAULT_INTERACTIVE_MARKER_NAME: str = DEFAULT_INTERACTIVE_MARKER_NAME
71+
6472
def __init__(self, logger=None, image_width=500, image_height=500,
6573
pixel_coords_offset=0, **kwargs):
6674
super().__init__()
@@ -133,8 +141,8 @@ def __init__(self, logger=None, image_width=500, image_height=500,
133141
# duplicate names.
134142
self._marktags = set()
135143
# Let's have a default name for the tag too:
136-
self._default_mark_tag_name = 'default-marker-name'
137-
self._interactive_marker_set_name_default = 'interactive-markers'
144+
self._default_mark_tag_name = DEFAULT_MARKER_NAME
145+
self._interactive_marker_set_name_default = DEFAULT_INTERACTIVE_MARKER_NAME
138146
self._interactive_marker_set_name = self._interactive_marker_set_name_default
139147

140148
# coordinates display
@@ -542,9 +550,21 @@ def get_markers(self, x_colname='x', y_colname='y',
542550
Table of markers, if any, or ``None``.
543551
544552
"""
553+
default_column_names = [x_colname, y_colname, skycoord_colname, 'marker name']
554+
555+
empty_table = Table(names=default_column_names)
556+
545557
if marker_name is None:
546558
marker_name = self._default_mark_tag_name
547559

560+
# If it isn't a string assume it is a list of strings
561+
if not isinstance(marker_name, str):
562+
return vstack([self.get_markers(x_colname=x_colname,
563+
y_colname=y_colname,
564+
skycoord_colname=skycoord_colname,
565+
marker_name=name)
566+
for name in marker_name])
567+
548568
if marker_name == 'all':
549569
# If it wasn't for the fact that SKyCoord columns can't
550570
# be stacked this would all fit nicely into a list
@@ -558,7 +578,7 @@ def get_markers(self, x_colname='x', y_colname='y',
558578
y_colname=y_colname,
559579
skycoord_colname=skycoord_colname,
560580
marker_name=name)
561-
if table is None:
581+
if len(table) == 0:
562582
# No markers by this name, skip it
563583
continue
564584

@@ -571,7 +591,8 @@ def get_markers(self, x_colname='x', y_colname='y',
571591
tables.append(table)
572592

573593
if len(tables) == 0:
574-
return None
594+
# No markers at all, return an empty table
595+
return empty_table
575596

576597
stacked = vstack(tables, join_type='exact')
577598

@@ -584,15 +605,13 @@ def get_markers(self, x_colname='x', y_colname='y',
584605
# where that table is empty will be handled in a moment.
585606
if (marker_name not in self._marktags
586607
and marker_name != self._default_mark_tag_name):
587-
raise ValueError(f"No markers named '{marker_name}' found.")
608+
return empty_table
588609

589610
try:
590611
c_mark = self._viewer.canvas.get_object_by_tag(marker_name)
591-
except Exception:
592-
# No markers in this table. Issue a warning and continue
593-
warnings.warn(f"Marker set named '{marker_name}' is empty",
594-
category=UserWarning)
595-
return None
612+
except Exception: # Keep this broad -- unclear what exceptions can be raised
613+
# No markers in this table.
614+
return empty_table
596615

597616
image = self._viewer.get_image()
598617
xy_col = []
@@ -764,8 +783,9 @@ def remove_markers(self, marker_name=None):
764783
Parameters
765784
----------
766785
767-
marker_name : str, optional
768-
Name used when the markers were added.
786+
marker_name : str, or list of str, optional
787+
Name used when the markers were added. The name "all" will
788+
remove all markers.
769789
"""
770790
# TODO:
771791
# arr : ``SkyCoord`` or array-like
@@ -777,6 +797,18 @@ def remove_markers(self, marker_name=None):
777797
if marker_name is None:
778798
marker_name = self._default_mark_tag_name
779799

800+
if marker_name == "all":
801+
all_markers = self._marktags.copy()
802+
for marker in all_markers:
803+
self.remove_markers(marker_name=marker)
804+
return
805+
806+
# If not a string assume, marker_name is a list
807+
if not isinstance(marker_name, str):
808+
for name in marker_name:
809+
self.remove_markers(marker_name=name)
810+
return
811+
780812
if marker_name not in self._marktags:
781813
# This shouldn't have happened, raise an error
782814
raise ValueError('Marker name {} not found in current markers.'

astrowidgets/interface_definition.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@
1212
# Allowed locations for cursor display
1313
ALLOWED_CURSOR_LOCATIONS = ('top', 'bottom', None)
1414

15+
DEFAULT_MARKER_NAME = 'default-marker-name'
16+
DEFAULT_INTERACTIVE_MARKER_NAME = 'interactive-markers'
17+
1518
# List of marker names that are for internal use only
1619
RESERVED_MARKER_SET_NAMES = ('all',)
1720

1821
__all__ = [
1922
'ImageViewerInterface',
2023
'ALLOWED_CURSOR_LOCATIONS',
21-
'RESERVED_MARKER_SET_NAMES'
24+
'RESERVED_MARKER_SET_NAMES',
25+
'DEFAULT_MARKER_NAME',
26+
'DEFAULT_INTERACTIVE_MARKER_NAME'
2227
]
2328

2429

@@ -48,6 +53,12 @@ class ImageViewerInterface(Protocol):
4853
# List of marker names that are for internal use only
4954
RESERVED_MARKER_SET_NAMES: tuple = RESERVED_MARKER_SET_NAMES
5055

56+
# Default marker name for marking via API
57+
DEFAULT_MARKER_NAME: str = DEFAULT_MARKER_NAME
58+
59+
# Default marker name for interactive marking
60+
DEFAULT_INTERACTIVE_MARKER_NAME: str = DEFAULT_INTERACTIVE_MARKER_NAME
61+
5162
# The methods, grouped loosely by purpose
5263

5364
# Methods for loading data
@@ -179,15 +190,15 @@ def reset_markers(self) -> None:
179190
# raise NotImplementedError
180191

181192
@abstractmethod
182-
def remove_markers(self, marker_name: str | None = None) -> None:
193+
def remove_markers(self, marker_name: str | list[str] | None = None) -> None:
183194
"""
184195
Remove markers from the image.
185196
186197
Parameters
187198
----------
188199
marker_name : str, optional
189-
The name of the marker set to remove. If not given, all
190-
markers will be removed.
200+
The name of the marker set to remove. If the value is ``"all"``,
201+
then all markers will be removed.
191202
"""
192203
raise NotImplementedError
193204

@@ -198,7 +209,7 @@ def remove_markers(self, marker_name: str | None = None) -> None:
198209
@abstractmethod
199210
def get_markers(self, x_colname: str = 'x', y_colname: str = 'y',
200211
skycoord_colname: str = 'coord',
201-
marker_name: str | None = None) -> Table:
212+
marker_name: str | list[str] | None = None) -> Table:
202213
"""
203214
Get the marker positions.
204215
@@ -213,14 +224,15 @@ def get_markers(self, x_colname: str = 'x', y_colname: str = 'y',
213224
skycoord_colname : str, optional
214225
The name of the column containing the sky coordinates. Default
215226
is ``'coord'``.
216-
marker_name : str, optional
217-
The name of the marker set to use. If not given, all
218-
markers will be returned.
227+
marker_name : str or list of str, optional
228+
The name of the marker set to use. If that value is ``"all"``,
229+
then all markers will be returned.
219230
220231
Returns
221232
-------
222233
table : `astropy.table.Table`
223-
The table containing the marker positions.
234+
The table containing the marker positions. If no markers match the
235+
``marker_name`` parameter, an empty table is returned.
224236
"""
225237
raise NotImplementedError
226238

astrowidgets/tests/test_image_widget.py

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import re
22

3-
import pytest
43
import numpy as np
54
from astropy.table import Table, vstack
65
from astropy.wcs import WCS
@@ -119,21 +118,6 @@ def test_get_marker_with_names():
119118
assert (expected['y'] == all_marks['y']).all()
120119

121120

122-
def test_unknown_marker_name_error():
123-
"""
124-
Regression test for https://github.com/astropy/astrowidgets/issues/97
125-
126-
This particular test checks that getting a marker name that
127-
does not exist raises an error.
128-
"""
129-
iw = ImageWidget()
130-
bad_name = 'not a real marker name'
131-
with pytest.raises(ValueError) as e:
132-
iw.get_markers(marker_name=bad_name)
133-
134-
assert f"No markers named '{bad_name}'" in str(e.value)
135-
136-
137121
def test_empty_marker_name_works_with_all():
138122
"""
139123
Regression test for https://github.com/astropy/astrowidgets/issues/97

astrowidgets/tests/widget_api_test.py

Lines changed: 64 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# TODO: How to enable switching out backend and still run the same tests?
2+
import warnings
23

34
import pytest
45

@@ -46,6 +47,11 @@ class variable does the trick.
4647
"""
4748
self.image = self.image_widget_class(image_width=250, image_height=100)
4849

50+
def _check_marker_table_return_properties(self, table):
51+
assert isinstance(table, Table)
52+
assert len(table) == 0
53+
assert sorted(table.colnames) == sorted(['x', 'y', 'coord', 'marker name'])
54+
4955
def test_width_height(self):
5056
assert self.image.image_width == 250
5157
assert self.image.image_height == 100
@@ -103,7 +109,7 @@ def test_zoom(self):
103109

104110
def test_marking_operations(self):
105111
marks = self.image.get_markers(marker_name="all")
106-
assert marks is None
112+
self._check_marker_table_return_properties(marks)
107113
assert not self.image.is_marking
108114

109115
# Ensure you cannot set it like this.
@@ -138,12 +144,13 @@ def test_marking_operations(self):
138144
assert not self.image.click_drag
139145
assert not self.image.scroll_pan
140146

141-
# Regression test for GitHub Issue 97:
142-
# Marker name with no markers should give warning.
143-
with pytest.warns(UserWarning, match='is empty') as warning_lines:
147+
# Make sure no warning is issued when trying to retrieve markers
148+
# with a name that does not exist.
149+
with warnings.catch_warnings():
150+
warnings.simplefilter("error")
144151
t = self.image.get_markers(marker_name='markymark')
145-
assert t is None
146-
assert len(warning_lines) == 1
152+
153+
self._check_marker_table_return_properties(t)
147154

148155
self.image.click_drag = True
149156
self.image.start_marking()
@@ -160,7 +167,7 @@ def test_marking_operations(self):
160167
self.image.stop_marking(clear_markers=True)
161168

162169
assert self.image.is_marking is False
163-
assert self.image.get_markers(marker_name="all") is None
170+
self._check_marker_table_return_properties(self.image.get_markers(marker_name="all"))
164171

165172
# Hate this, should add to public API
166173
marknames = self.image._marktags
@@ -170,8 +177,7 @@ def test_marking_operations(self):
170177
assert self.image.click_drag
171178

172179
def test_add_markers(self):
173-
rng = np.random.default_rng(1234)
174-
data = rng.integers(0, 100, (5, 2))
180+
data = np.arange(10).reshape(5, 2)
175181
orig_tab = Table(data=data, names=['x', 'y'], dtype=('float', 'float'))
176182
tab = Table(data=data, names=['x', 'y'], dtype=('float', 'float'))
177183
self.image.add_markers(tab, x_colname='x', y_colname='y',
@@ -234,35 +240,73 @@ def test_add_markers(self):
234240
self.image.reset_markers()
235241
marknames = self.image._marktags
236242
assert len(marknames) == 0
237-
assert self.image.get_markers(marker_name="all") is None
238-
with pytest.warns(UserWarning, match='is empty'):
239-
assert self.image.get_markers(marker_name=self.image._default_mark_tag_name) is None
243+
self._check_marker_table_return_properties(self.image.get_markers(marker_name="all"))
244+
# Check that no markers remain after clearing
245+
tab = self.image.get_markers(marker_name=self.image._default_mark_tag_name)
246+
self._check_marker_table_return_properties(tab)
247+
248+
# Check that retrieving a marker set that doesn't exist returns
249+
# an empty table with the right columns
250+
tab = self.image.get_markers(marker_name='test1')
251+
self._check_marker_table_return_properties(tab)
252+
253+
def test_get_markers_accepts_list_of_names(self):
254+
# Check that the get_markers method accepts a list of marker names
255+
# and returns a table with all the markers from all the named sets.
256+
data = np.arange(10).reshape((5, 2))
257+
tab = Table(data=data, names=['x', 'y'])
258+
self.image.add_markers(tab, marker_name='test1')
259+
self.image.add_markers(tab, marker_name='test2')
240260

241-
with pytest.raises(ValueError, match="No markers named 'test1'"):
242-
self.image.get_markers(marker_name='test1')
243-
with pytest.raises(ValueError, match="No markers named 'test2'"):
244-
self.image.get_markers(marker_name='test2')
261+
# No guarantee markers will come back in the same order, so sort them.
262+
t1 = self.image.get_markers(marker_name=['test1', 'test2'])
263+
# Sort before comparing
264+
t1.sort('x')
265+
expected = vstack([tab, tab], join_type='exact')
266+
expected.sort('x')
267+
np.testing.assert_array_equal(t1['x'], expected['x'])
268+
np.testing.assert_array_equal(t1['y'], expected['y'])
245269

246270
def test_remove_markers(self):
247271
with pytest.raises(ValueError, match='arf'):
248272
self.image.remove_markers(marker_name='arf')
249273

274+
def test_remove_markers_name_all(self):
275+
data = np.arange(10).reshape(5, 2)
276+
tab = Table(data=data, names=['x', 'y'])
277+
self.image.add_markers(tab, marker_name='test1')
278+
self.image.add_markers(tab, marker_name='test2')
279+
280+
self.image.remove_markers(marker_name='all')
281+
self._check_marker_table_return_properties(self.image.get_markers(marker_name='all'))
282+
283+
def test_remove_marker_accepts_list(self):
284+
data = np.arange(10).reshape(5, 2)
285+
tab = Table(data=data, names=['x', 'y'])
286+
self.image.add_markers(tab, marker_name='test1')
287+
self.image.add_markers(tab, marker_name='test2')
288+
289+
self.image.remove_markers(marker_name=['test1', 'test2'])
290+
marks = self.image.get_markers(marker_name='all')
291+
self._check_marker_table_return_properties(marks)
292+
250293
def test_adding_markers_as_world(self, data, wcs):
251294
ndd = NDData(data=data, wcs=wcs)
252295
self.image.load_nddata(ndd)
253296

254297
# Add markers using world coordinates
255-
rng = np.random.default_rng(9435)
256-
257-
pixels = rng.integers(0, 100, (5, 2))
298+
pixels = np.linspace(0, 100, num=10).reshape(5, 2)
258299
marks_pix = Table(data=pixels, names=['x', 'y'], dtype=('float', 'float'))
259300
marks_world = wcs.pixel_to_world(marks_pix['x'], marks_pix['y'])
260301
marks_coords = SkyCoord(marks_world, unit='degree')
261302
mark_coord_table = Table(data=[marks_coords], names=['coord'])
262303
self.image.add_markers(mark_coord_table, use_skycoord=True)
263304
result = self.image.get_markers()
264305
# Check the x, y positions as long as we are testing things...
265-
np.testing.assert_allclose(result['x'], marks_pix['x'])
306+
# The first test had one entry that was zero, so any check
307+
# based on rtol will will. Added a small atol to make sure
308+
# the test passes.
309+
np.testing.assert_allclose(result['x'], marks_pix['x'], atol=1e-9)
266310
np.testing.assert_allclose(result['y'], marks_pix['y'])
267311
np.testing.assert_allclose(result['coord'].ra.deg,
268312
mark_coord_table['coord'].ra.deg)

0 commit comments

Comments
 (0)