Skip to content

Commit 105ad7c

Browse files
committed
WIP dummy viewer
1 parent 534b8d4 commit 105ad7c

File tree

2 files changed

+362
-0
lines changed

2 files changed

+362
-0
lines changed
Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
import os
2+
from dataclasses import dataclass, field
3+
from pathlib import Path
4+
from typing import Any
5+
6+
from astropy.coordinates import SkyCoord
7+
from astropy.nddata import CCDData, NDData
8+
from astropy.table import Table, vstack
9+
from astropy.units import Quantity, get_physical_type
10+
from astropy.wcs import WCS
11+
from numpy.typing import ArrayLike
12+
13+
from .interface_definition import ImageViewerInterface
14+
15+
16+
@dataclass
17+
class ImageViewer:
18+
"""
19+
This viewer does not do anything except making changes to its internal
20+
state to simulate the behavior of a real viewer.
21+
"""
22+
# These are attributes, not methods. The type annotations are there
23+
# to make sure Protocol knows they are attributes. Python does not
24+
# do any checking at all of these types.
25+
click_center: bool = False
26+
click_drag: bool = True
27+
scroll_pan: bool = False
28+
image_width: int = 0
29+
image_height: int = 0
30+
zoom_level: float = 1
31+
is_marking: bool = False
32+
stretch_options: tuple = ("linear", "log", "sqrt")
33+
autocut_options: tuple = ("minmax", "zscale", "asinh", "percentile")
34+
cursor: str = ImageViewerInterface.ALLOWED_CURSOR_LOCATIONS[0]
35+
marker: Any = "marker"
36+
cuts: Any = (0, 1)
37+
stretch: str = "linear"
38+
# viewer: Any
39+
40+
# Allowed locations for cursor display
41+
ALLOWED_CURSOR_LOCATIONS: tuple = ImageViewerInterface.ALLOWED_CURSOR_LOCATIONS
42+
43+
# List of marker names that are for internal use only
44+
RESERVED_MARKER_SET_NAMES: tuple = ImageViewerInterface.RESERVED_MARKER_SET_NAMES
45+
46+
# Default marker name for marking via API
47+
DEFAULT_MARKER_NAME: str = ImageViewerInterface.DEFAULT_MARKER_NAME
48+
49+
# Default marker name for interactive marking
50+
DEFAULT_INTERACTIVE_MARKER_NAME: str = ImageViewerInterface.DEFAULT_INTERACTIVE_MARKER_NAME
51+
52+
# some internal variable for keeping track of viewer state
53+
_interactive_marker_name: str = ""
54+
_previous_click_center: bool = False
55+
_previous_click_drag: bool = True
56+
_previous_scroll_pan: bool = False
57+
_previous_marker: Any = ""
58+
_markers: dict[str, Table] = field(default_factory=dict)
59+
_wcs: WCS | None = None
60+
_center: tuple[float, float] = (0.0, 0.0)
61+
62+
# The methods, grouped loosely by purpose
63+
64+
# Methods for loading data
65+
def load_fits(self, file: str | os.PathLike) -> None:
66+
"""
67+
Load a FITS file into the viewer.
68+
69+
Parameters
70+
----------
71+
file : str or `astropy.io.fits.HDU`
72+
The FITS file to load. If a string, it can be a URL or a
73+
file path.
74+
"""
75+
ccd = CCDData.read(file)
76+
self._wcs = ccd.wcs
77+
self.image_height, self.image_width = ccd.shape
78+
# Totally made up number...as currently defined, zoom_level means, esentially, ratio
79+
# of image size to viewer size.
80+
self.zoom_level = 1.0
81+
self.center_on((self.image_width / 2, self.image_height / 2))
82+
83+
def load_array(self, array: ArrayLike) -> None:
84+
"""
85+
Load a 2D array into the viewer.
86+
87+
Parameters
88+
----------
89+
array : array-like
90+
The array to load.
91+
"""
92+
self.image_height, self.image_width = array.shape
93+
# Totally made up number...as currently defined, zoom_level means, esentially, ratio
94+
# of image size to viewer size.
95+
self.zoom_level = 1.0
96+
self.center_on((self.image_width / 2, self.image_height / 2))
97+
98+
99+
def load_nddata(self, data: NDData) -> None:
100+
"""
101+
Load an `astropy.nddata.NDData` object into the viewer.
102+
103+
Parameters
104+
----------
105+
data : `astropy.nddata.NDData`
106+
The NDData object to load.
107+
"""
108+
self._wcs = data.wcs
109+
self.image_height, self.image_width = data.shape
110+
# Totally made up number...as currently defined, zoom_level means, esentially, ratio
111+
# of image size to viewer size.
112+
self.zoom_level = 1.0
113+
self.center_on((self.image_width / 2, self.image_height / 2))
114+
115+
# Saving contents of the view and accessing the view
116+
def save(self, filename: str | os.PathLike, overwrite: bool = False) -> None:
117+
"""
118+
Save the current view to a file.
119+
120+
Parameters
121+
----------
122+
filename : str or `os.PathLike`
123+
The file to save to. The format is determined by the
124+
extension.
125+
126+
overwrite : bool, optional
127+
If `True`, overwrite the file if it exists. Default is
128+
`False`.
129+
"""
130+
p = Path(filename)
131+
p.write_text("This is a dummy file. The viewer does not save anything.")
132+
133+
# Marker-related methods
134+
def start_marking(self, marker_name: str | None = None, marker: Any = None) -> None:
135+
"""
136+
Start interactive marking of points on the image.
137+
138+
Parameters
139+
----------
140+
marker_name : str, optional
141+
The name of the marker set to use. If not given, a unique
142+
name will be generated.
143+
"""
144+
self.is_marking = True
145+
self._previous_click_center = self.click_center
146+
self._previous_click_drag = self.click_drag
147+
self._previous_marker = self.marker
148+
self._previous_scroll_pan = self.scroll_pan
149+
self.click_center = False
150+
self.click_drag = False
151+
self.scroll_pan = True
152+
self._interactive_marker_name = marker_name if marker_name else self.DEFAULT_INTERACTIVE_MARKER_NAME
153+
self.marker = marker if marker else self.DEFAULT_INTERACTIVE_MARKER_NAME
154+
155+
def stop_marking(self, clear_markers: bool = False) -> None:
156+
"""
157+
Stop interactive marking of points on the image.
158+
159+
Parameters
160+
----------
161+
clear_markers : bool, optional
162+
If `True`, clear the markers that were created during
163+
interactive marking. Default is `False`.
164+
"""
165+
self.is_marking = False
166+
self.click_center = self._previous_click_center
167+
self.click_drag = self._previous_click_drag
168+
self.scroll_pan = self._previous_scroll_pan
169+
self.marker = self._previous_marker
170+
if clear_markers:
171+
self.remove_markers(self._interactive_marker_name)
172+
173+
def add_markers(self, table: Table, x_colname: str = 'x', y_colname: str = 'y',
174+
skycoord_colname: str = 'coord', use_skycoord: bool = False,
175+
marker_name: str | None = None) -> None:
176+
"""
177+
Add markers to the image.
178+
179+
Parameters
180+
----------
181+
table : `astropy.table.Table`
182+
The table containing the marker positions.
183+
x_colname : str, optional
184+
The name of the column containing the x positions. Default
185+
is ``'x'``.
186+
y_colname : str, optional
187+
The name of the column containing the y positions. Default
188+
is ``'y'``.
189+
skycoord_colname : str, optional
190+
The name of the column containing the sky coordinates. If
191+
given, the ``use_skycoord`` parameter is ignored. Default
192+
is ``'coord'``.
193+
use_skycoord : bool, optional
194+
If `True`, the ``skycoord_colname`` column will be used to
195+
get the marker positions. Default is `False`.
196+
marker_name : str, optional
197+
The name of the marker set to use. If not given, a unique
198+
name will be generated.
199+
"""
200+
201+
if use_skycoord:
202+
coords = table[skycoord_colname]
203+
if self._wcs is not None:
204+
x, y = self._wcs.world_to_pixel(coords)
205+
else:
206+
raise ValueError("WCS is not set. Cannot convert to pixel coordinates.")
207+
else:
208+
x = table[x_colname]
209+
y = table[y_colname]
210+
if marker_name in self.RESERVED_MARKER_SET_NAMES:
211+
raise ValueError(f"Marker name {marker_name} not allowed.")
212+
marker_name = marker_name if marker_name else self.DEFAULT_MARKER_NAME
213+
if marker_name in self._markers:
214+
marker_table = self._markers[marker_name]
215+
else:
216+
marker_table = Table(names=["x", "y", "marker name"],
217+
dtype=[float, float, str])
218+
219+
to_add = table[x_colname, y_colname]
220+
to_add[marker_name] = marker_name
221+
self._markers[marker_name] = vstack([marker_table, to_add])
222+
223+
def reset_markers(self) -> None:
224+
"""
225+
Remove all markers from the image.
226+
"""
227+
self._markers = {}
228+
229+
def remove_markers(self, marker_name: str | list[str] | None = None) -> None:
230+
"""
231+
Remove markers from the image.
232+
233+
Parameters
234+
----------
235+
marker_name : str, optional
236+
The name of the marker set to remove. If the value is ``"all"``,
237+
then all markers will be removed.
238+
"""
239+
if isinstance(marker_name, str):
240+
if marker_name in self._markers:
241+
del self._markers[marker_name]
242+
elif marker_name == "all":
243+
self._markers = {}
244+
elif isinstance(marker_name, list):
245+
for name in marker_name:
246+
if name in self._markers:
247+
del self._markers[name]
248+
249+
def get_markers(self, x_colname: str = 'x', y_colname: str = 'y',
250+
skycoord_colname: str = 'coord',
251+
marker_name: str | list[str] | None = None) -> Table:
252+
"""
253+
Get the marker positions.
254+
255+
Parameters
256+
----------
257+
x_colname : str, optional
258+
The name of the column containing the x positions. Default
259+
is ``'x'``.
260+
y_colname : str, optional
261+
The name of the column containing the y positions. Default
262+
is ``'y'``.
263+
skycoord_colname : str, optional
264+
The name of the column containing the sky coordinates. Default
265+
is ``'coord'``.
266+
marker_name : str or list of str, optional
267+
The name of the marker set to use. If that value is ``"all"``,
268+
then all markers will be returned.
269+
270+
Returns
271+
-------
272+
table : `astropy.table.Table`
273+
The table containing the marker positions. If no markers match the
274+
``marker_name`` parameter, an empty table is returned.
275+
"""
276+
if isinstance(marker_name, str):
277+
if marker_name == "all":
278+
marker_name = self._markers.keys()
279+
else:
280+
marker_name = [marker_name]
281+
282+
to_stack = [self._markers[name] for name in marker_name if name in self._markers]
283+
284+
result = vstack(to_stack) if to_stack else Table(names=["x", "y", "coord", "marker name"])
285+
286+
return result.rename_columns(["x", "y", "coord"], [x_colname, y_colname, skycoord_colname])
287+
288+
289+
# Methods that modify the view
290+
def center_on(self, point: tuple | SkyCoord):
291+
"""
292+
Center the view on the point.
293+
294+
Parameters
295+
----------
296+
tuple or `~astropy.coordinates.SkyCoord`
297+
If tuple of ``(X, Y)`` is given, it is assumed
298+
to be in data coordinates.
299+
"""
300+
# currently there is no way to get the position of the center, but we may as well make
301+
# note of it
302+
if isinstance(point, SkyCoord):
303+
if self._wcs is not None:
304+
point = self._wcs.world_to_pixel(point)
305+
else:
306+
raise ValueError("WCS is not set. Cannot convert to pixel coordinates.")
307+
308+
self._center = point
309+
310+
def offset_by(self, dx: float | Quantity, dy: float | Quantity) -> None:
311+
"""
312+
Move the center to a point that is given offset
313+
away from the current center.
314+
315+
Parameters
316+
----------
317+
dx, dy : float or `~astropy.units.Quantity`
318+
Offset value. Without a unit, assumed to be pixel offsets.
319+
If a unit is attached, offset by pixel or sky is assumed from
320+
the unit.
321+
"""
322+
# Convert to quantity to make the rest of the processing uniform
323+
dx = Quantity(dx)
324+
dy = Quantity(dy)
325+
326+
# This raises a UnitConversionError if the units are not compatible
327+
dx.to(dy.unit)
328+
329+
# Do we have an angle or pixel offset?
330+
if get_physical_type(dx) == "angle":
331+
# This is a sky offset
332+
if self._wcs is not None:
333+
old_center_coord = self._wcs.pixel_to_world(self._center[0], self._center[1])
334+
new_center = old_center_coord.spherical_offsets_byt(dx, dy)
335+
self.center_on(new_center)
336+
else:
337+
raise ValueError("WCS is not set. Cannot convert to pixel coordinates.")
338+
else:
339+
# This is a pixel offset
340+
new_center = (self._center[0] + dx.value, self._center[1] + dy.value)
341+
self.center_on(new_center)
342+
343+
def zoom(self) -> None:
344+
"""
345+
Zoom in or out by the given factor.
346+
347+
Parameters
348+
----------
349+
val : int
350+
The zoom level to zoom the image.
351+
See `zoom_level`.
352+
"""
353+
raise NotImplementedError

tests/test_astro_image_display_api.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,12 @@ def test_api_test_class_completeness():
2727
"ImageWidgetAPITest does not access these attributes/methods:\n "
2828
f"{"\n".join(attr for attr, present in zip(required_attributes, attr_present) if not present)}. "
2929
)
30+
31+
32+
def test_instance():
33+
image = ImageViewer()
34+
assert isinstance(image, ImageViewerInterface)
35+
36+
37+
class TestDummyViewer(ImageWidgetAPITest):
38+
image_widget_class = ImageViewer

0 commit comments

Comments
 (0)