Coverage for python/lsst/display/matplotlib/wcsAxes.py: 88%

119 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-09 02:07 -0700

1# 

2# LSST Data Management System 

3# Copyright 2026 LSST Corporation. 

4# 

5# This product includes software developed by the 

6# LSST Project (http://www.lsst.org/). 

7# 

8# This program is free software: you can redistribute it and/or modify 

9# it under the terms of the GNU General Public License as published by 

10# the Free Software Foundation, either version 3 of the License, or 

11# (at your option) any later version. 

12# 

13# This program is distributed in the hope that it will be useful, 

14# but WITHOUT ANY WARRANTY; without even the implied warranty of 

15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

16# GNU General Public License for more details. 

17# 

18# You should have received a copy of the LSST License Statement and 

19# the GNU General Public License along with this program. If not, 

20# see <http://www.lsstcorp.org/LegalNotices/>. 

21# 

22 

23"""Draw sky coordinate axes on a matplotlib Axes using AST. 

24 

25The axes are drawn by `starlink.Ast.Plot` using the matplotlib grf 

26plugin, so arbitrary AST WCS transforms are supported with no FITS 

27approximation. 

28""" 

29 

30__all__ = ["astFrameSetFromWcs", "WcsAxesManager"] 

31 

32import astshim 

33import matplotlib.text 

34import starlink.Ast 

35from matplotlib.backend_bases import TimerBase 

36from starlink.Grf import grf_matplotlib 

37 

38 

39class _AstStringSource: 

40 """Present AST native serialization text to `starlink.Ast.Channel`. 

41 

42 `starlink.Ast.Channel` reads via an object with an ``astsource`` 

43 method returning one line per call, and `None` at end of input. 

44 

45 Parameters 

46 ---------- 

47 text : `str` 

48 AST native serialization of an AST object. 

49 """ 

50 

51 def __init__(self, text): 

52 self._lines = text.splitlines() 

53 self._pos = 0 

54 

55 def astsource(self): 

56 if self._pos >= len(self._lines): 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true

57 return None 

58 line = self._lines[self._pos] 

59 self._pos += 1 

60 return line 

61 

62 

63def astFrameSetFromWcs(wcs): 

64 """Convert an afw SkyWcs to a starlink-pyast FrameSet. 

65 

66 Parameters 

67 ---------- 

68 wcs : `lsst.afw.geom.SkyWcs` 

69 The WCS to convert. 

70 

71 Returns 

72 ------- 

73 frameSet : `starlink.Ast.FrameSet` 

74 FrameSet whose base frame is LSST 0-based pixel coordinates 

75 (domain ``PIXELS``) and whose current frame is sky coordinates 

76 in radians (domain ``SKY``). 

77 

78 Notes 

79 ----- 

80 The conversion serializes the WCS's underlying `astshim.FrameDict` 

81 to AST native text and reads it back with pyast, so the result is 

82 exact; no FITS approximation is involved. 

83 """ 

84 stream = astshim.StringStream() 

85 astshim.Channel(stream).write(wcs.getFrameDict()) 

86 channel = starlink.Ast.Channel(_AstStringSource(stream.getSinkData())) 

87 return channel.read() 

88 

89 

90class WcsAxesManager: 

91 """Manage AST-drawn sky coordinate axes on a matplotlib Axes. 

92 

93 All visible axis furniture (border, ticks, labels, optional grid) 

94 is drawn by AST; matplotlib's own axis furniture is hidden while 

95 this manager is active and restored by `remove`. 

96 

97 The axes' data coordinates must be pixel coordinates in the LSST 

98 convention, matching the base frame of ``frameSet`` as produced by 

99 `astFrameSetFromWcs`: the center of the first pixel of the parent 

100 image is at (0, 0), and a subimage keeps its parent's coordinates 

101 (its pixel coordinates start at the subimage's XY0, not at zero). 

102 This differs from the FITS convention, in which the first pixel of 

103 the image is centered at (1, 1). 

104 

105 Parameters 

106 ---------- 

107 axes : `matplotlib.axes.Axes` 

108 The axes to draw on. 

109 frameSet : `starlink.Ast.FrameSet` 

110 Pixel-to-sky transform, base frame in axes data coordinates. 

111 grid : `bool`, optional 

112 Draw the full curvilinear coordinate grid across the image? 

113 useSexagesimal : `bool`, optional 

114 Format labels as e.g. HH:MM:SS.s rather than decimal degrees. 

115 extraOptions : `str`, optional 

116 Comma-separated AST Plot attribute settings appended after the 

117 defaults, so they take precedence (e.g. 

118 ``"Colour(grid)=2, Width(border)=2"``). 

119 

120 Notes 

121 ----- 

122 AST colour values are 1-based indices into the grf colour table 

123 (see `starlink.Grf.grf_matplotlib`), not colour names: 

124 1=default, 2=red, 3=green, 4=blue, 5=cyan, 6=magenta, 7=yellow, 

125 8=black, 9=dark grey, 10=grey, 11=light grey, 12=white. 

126 """ 

127 

128 def __init__(self, axes, frameSet, *, 

129 grid=False, useSexagesimal=False, extraOptions=""): 

130 self._axes = axes 

131 self._frameSet = frameSet 

132 self._grid = grid 

133 self._useSexagesimal = useSexagesimal 

134 self._extraOptions = extraOptions 

135 self.artists = [] 

136 

137 self._savedVisibility = ( 

138 axes.xaxis.get_visible(), 

139 axes.yaxis.get_visible(), 

140 {name: spine.get_visible() for name, spine in axes.spines.items()}, 

141 ) 

142 axes.xaxis.set_visible(False) 

143 axes.yaxis.set_visible(False) 

144 for spine in axes.spines.values(): 

145 spine.set_visible(False) 

146 

147 self._redrawing = False 

148 self._redrawTimer = None 

149 self._callbackIds = [ 

150 axes.callbacks.connect("xlim_changed", self._onLimitsChanged), 

151 axes.callbacks.connect("ylim_changed", self._onLimitsChanged), 

152 ] 

153 

154 try: 

155 self.draw() 

156 except Exception: 

157 # Restore the matplotlib furniture so the axes remain 

158 # usable as ordinary pixel axes. 

159 self.remove() 

160 raise 

161 

162 def _plotOptions(self): 

163 """Return the AST Plot options string for the current state.""" 

164 options = ["DrawTitle=0"] 

165 options.append("Grid=1" if self._grid else "Grid=0") 

166 if not self._useSexagesimal: 

167 # Decimal degrees; ".*" lets the Plot choose the precision 

168 # needed to keep adjacent labels distinct. 

169 options.append("Format(1)=d.*") 

170 options.append("Format(2)=d.*") 

171 if self._extraOptions: 

172 options.append(self._extraOptions) 

173 return ", ".join(options) 

174 

175 def draw(self): 

176 """Draw (or redraw) the AST axes for the current view limits.""" 

177 self._removeArtists() 

178 

179 xlo, xhi = self._axes.get_xlim() 

180 ylo, yhi = self._axes.get_ylim() 

181 box = (xlo, ylo, xhi, yhi) 

182 

183 before = set(self._axes.lines) | set(self._axes.texts) 

184 try: 

185 plot = starlink.Ast.Plot(self._frameSet, box, box, 

186 grf_matplotlib(self._axes), 

187 self._plotOptions()) 

188 plot.grid() 

189 except Exception: 

190 # grid() may have drawn some artists before failing. 

191 for artist in list(self._axes.lines) + list(self._axes.texts): 191 ↛ 192line 191 didn't jump to line 192 because the loop on line 191 never started

192 if artist not in before: 

193 artist.remove() 

194 raise 

195 self.artists = [a for a in list(self._axes.lines) + list(self._axes.texts) 

196 if a not in before] 

197 

198 # Axes.add_artist clips to the axes patch but the exterior 

199 # labels sit just outside it. 

200 for artist in self.artists: 

201 if isinstance(artist, matplotlib.text.Text): 

202 artist.set_clip_on(False) 

203 

204 def _removeArtists(self): 

205 """Remove the AST artists from the axes.""" 

206 for artist in self.artists: 

207 try: 

208 artist.remove() 

209 except (ValueError, NotImplementedError): 

210 # Already gone, e.g. the axes were cleared. 

211 pass 

212 self.artists = [] 

213 

214 # Idle time before redrawing after a view change. Interactive 

215 # panning and zooming change the limits on every mouse-motion 

216 # event, and a full AST rebuild per event is far too slow. 

217 _redrawDelayMs = 200 

218 

219 def _onLimitsChanged(self, axes): 

220 """Schedule a redraw for new view limits; matplotlib callback. 

221 

222 The redraw is debounced with a one-shot timer so that a stream 

223 of limit changes (interactive panning or zooming, or the x/y 

224 pair from a single zoom) produces a single rebuild once the 

225 view settles. Backends without a running event loop cannot 

226 fire timers, so there the redraw happens immediately. 

227 """ 

228 if self._redrawing: 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true

229 return 

230 if self._redrawTimer is None: 

231 canvas = self._axes.get_figure().canvas 

232 timer = canvas.new_timer(interval=self._redrawDelayMs) 

233 if type(timer) is TimerBase: 

234 # The base class is a no-op that never fires. 

235 self._redrawNow() 

236 return 

237 timer.single_shot = True 

238 timer.add_callback(self._redrawNow) 

239 self._redrawTimer = timer 

240 self._redrawTimer.stop() 

241 self._redrawTimer.start() 

242 

243 def _redrawNow(self): 

244 """Rebuild the AST axes for the current view and repaint.""" 

245 if not self._callbackIds: 245 ↛ 247line 245 didn't jump to line 247 because the condition on line 245 was never true

246 # The manager was removed while a redraw was pending. 

247 return 

248 if self._redrawing: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true

249 return 

250 self._redrawing = True 

251 try: 

252 self.draw() 

253 finally: 

254 self._redrawing = False 

255 self._axes.get_figure().canvas.draw_idle() 

256 

257 def setSexagesimal(self, useSexagesimal): 

258 """Switch between sexagesimal and decimal degree labels. 

259 

260 Parameters 

261 ---------- 

262 useSexagesimal : `bool` 

263 Format labels as e.g. HH:MM:SS.s iff True. 

264 """ 

265 useSexagesimal = bool(useSexagesimal) 

266 if useSexagesimal != self._useSexagesimal: 266 ↛ exitline 266 didn't return from function 'setSexagesimal' because the condition on line 266 was always true

267 self._useSexagesimal = useSexagesimal 

268 self.draw() 

269 

270 def remove(self): 

271 """Remove the AST axes and restore matplotlib's axis furniture.""" 

272 if self._redrawTimer is not None: 272 ↛ 273line 272 didn't jump to line 273 because the condition on line 272 was never true

273 self._redrawTimer.stop() 

274 self._redrawTimer = None 

275 for callbackId in self._callbackIds: 

276 self._axes.callbacks.disconnect(callbackId) 

277 self._callbackIds = [] 

278 

279 self._removeArtists() 

280 

281 xVisible, yVisible, spines = self._savedVisibility 

282 self._axes.xaxis.set_visible(xVisible) 

283 self._axes.yaxis.set_visible(yVisible) 

284 for name, visible in spines.items(): 

285 self._axes.spines[name].set_visible(visible)