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

131 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-23 07:21 +0000

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._renderedSize = None 

150 self._callbackIds = [ 

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

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

153 ] 

154 # A figure resize changes the axes size without changing the view 

155 # limits, so the labels must be re-spaced for the new geometry too. 

156 self._resizeCallbackId = axes.get_figure().canvas.mpl_connect( 

157 "resize_event", self._onResize) 

158 

159 try: 

160 self.draw() 

161 except Exception: 

162 # Restore the matplotlib furniture so the axes remain 

163 # usable as ordinary pixel axes. 

164 self.remove() 

165 raise 

166 

167 def _plotOptions(self): 

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

169 options = ["DrawTitle=0"] 

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

171 if not self._useSexagesimal: 

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

173 # needed to keep adjacent labels distinct. 

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

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

176 if self._extraOptions: 

177 options.append(self._extraOptions) 

178 return ", ".join(options) 

179 

180 def draw(self): 

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

182 self._removeArtists() 

183 

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

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

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

187 

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

189 try: 

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

191 grf_matplotlib(self._axes), 

192 self._plotOptions()) 

193 plot.grid() 

194 except Exception: 

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

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

197 if artist not in before: 

198 artist.remove() 

199 raise 

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

201 if a not in before] 

202 

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

204 # labels sit just outside it. 

205 for artist in self.artists: 

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

207 artist.set_clip_on(False) 

208 

209 # Record the size the labels were spaced for, so a later resize 

210 # event that leaves the size unchanged can be ignored. 

211 self._renderedSize = self._canvasSize() 

212 

213 def _canvasSize(self): 

214 """Return the current canvas size in device pixels.""" 

215 return tuple(self._axes.get_figure().canvas.get_width_height()) 

216 

217 def _removeArtists(self): 

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

219 for artist in self.artists: 

220 try: 

221 artist.remove() 

222 except (ValueError, NotImplementedError): 

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

224 pass 

225 self.artists = [] 

226 

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

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

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

230 _redrawDelayMs = 200 

231 

232 def _onLimitsChanged(self, axes): 

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

234 

235 The ``axes`` argument (the axes the callback fired for) is unused. 

236 """ 

237 self._scheduleRedraw() 

238 

239 def _onResize(self, event): 

240 """Schedule a redraw for a new figure size; matplotlib callback. 

241 

242 The ``event`` argument (the resize event) is unused. Interactive 

243 web backends (e.g. ipympl) emit ``resize_event`` repeatedly at the 

244 size the figure already has, and a redraw repaints the canvas, 

245 which prompts yet another such event; acting on every one produces 

246 an unbounded rebuild loop. Rebuild only when the canvas has 

247 actually changed size since it was last drawn. 

248 """ 

249 if self._canvasSize() == self._renderedSize: 

250 return 

251 self._scheduleRedraw() 

252 

253 def _scheduleRedraw(self): 

254 """Debounce a rebuild of the AST axes after a view or size change. 

255 

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

257 changes (interactive panning or zooming, the x/y pair from a single 

258 zoom, or a drag-resize) produces a single rebuild once the view 

259 settles. Backends without a running event loop cannot fire timers, 

260 so there the redraw happens immediately. 

261 """ 

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

263 return 

264 if self._redrawTimer is None: 

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

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

267 if type(timer) is TimerBase: 

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

269 self._redrawNow() 

270 return 

271 timer.single_shot = True 

272 timer.add_callback(self._redrawNow) 

273 self._redrawTimer = timer 

274 self._redrawTimer.stop() 

275 self._redrawTimer.start() 

276 

277 def _redrawNow(self): 

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

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

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

281 return 

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

283 return 

284 self._redrawing = True 

285 try: 

286 self.draw() 

287 finally: 

288 self._redrawing = False 

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

290 

291 def setSexagesimal(self, useSexagesimal): 

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

293 

294 Parameters 

295 ---------- 

296 useSexagesimal : `bool` 

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

298 """ 

299 useSexagesimal = bool(useSexagesimal) 

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

301 self._useSexagesimal = useSexagesimal 

302 self.draw() 

303 

304 def remove(self): 

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

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

307 self._redrawTimer.stop() 

308 self._redrawTimer = None 

309 for callbackId in self._callbackIds: 

310 self._axes.callbacks.disconnect(callbackId) 

311 self._callbackIds = [] 

312 self._axes.get_figure().canvas.mpl_disconnect(self._resizeCallbackId) 

313 

314 self._removeArtists() 

315 

316 xVisible, yVisible, spines = self._savedVisibility 

317 self._axes.xaxis.set_visible(xVisible) 

318 self._axes.yaxis.set_visible(yVisible) 

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

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