LSSTApplications  18.1.0
LSSTDataManagementBasePackage
anetAstrometry.py
Go to the documentation of this file.
1 #
2 # LSST Data Management System
3 # Copyright 2008-2016 AURA/LSST.
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 from __future__ import absolute_import, division, print_function
23 
24 __all__ = ["ANetAstrometryConfig", "ANetAstrometryTask", "showAstrometry"]
25 
26 from builtins import input
27 from builtins import zip
28 from builtins import range
29 
30 import numpy as np
31 
32 import lsstDebug
34 import lsst.afw.geom as afwGeom
35 from lsst.afw.cameraGeom import PIXELS, TAN_PIXELS
36 from lsst.afw.table import Point2DKey, CovarianceMatrix2fKey, updateSourceCoords
37 import lsst.pex.config as pexConfig
38 import lsst.pipe.base as pipeBase
39 from lsst.meas.astrom import displayAstrometry
40 from lsst.meas.astrom.sip import makeCreateWcsWithSip
41 from .anetBasicAstrometry import ANetBasicAstrometryTask
42 
43 
44 class ANetAstrometryConfig(pexConfig.Config):
45  solver = pexConfig.ConfigurableField(
46  target=ANetBasicAstrometryTask,
47  doc="Basic astrometry solver",
48  )
49  forceKnownWcs = pexConfig.Field(dtype=bool, doc=(
50  "Assume that the input image's WCS is correct, without comparing it to any external reality."
51  " (In contrast to using Astrometry.net). NOTE, if you set this, you probably also want to"
52  " un-set 'solver.calculateSip'; otherwise we'll still try to find a TAN-SIP WCS starting "
53  " from the existing WCS"), default=False)
54  rejectThresh = pexConfig.RangeField(dtype=float, default=3.0, doc="Rejection threshold for Wcs fitting",
55  min=0.0, inclusiveMin=False)
56  rejectIter = pexConfig.RangeField(dtype=int, default=3, doc="Rejection iterations for Wcs fitting",
57  min=0)
58 
59  @property
60  def refObjLoader(self):
61  """An alias, for a uniform interface with the standard AstrometryTask"""
62  return self.solver
63 
64  # \addtogroup LSST_task_documentation
65  # \{
66  # \page measAstrom_anetAstrometryTask
67  # \ref ANetAstrometryTask_ "ANetAstrometryTask"
68  # Use astrometry.net to match input sources with a reference catalog and solve for the Wcs
69  # \}
70 
71 
72 class ANetAstrometryTask(pipeBase.Task):
73  r"""!Use astrometry.net to match input sources with a reference catalog and solve for the Wcs
74 
75  @anchor ANetAstrometryTask_
76 
77  The actual matching and solving is done by the 'solver'; this Task
78  serves as a wrapper for taking into account the known optical distortion.
79 
80  \section pipe_tasks_astrometry_Contents Contents
81 
82  - \ref pipe_tasks_astrometry_Purpose
83  - \ref pipe_tasks_astrometry_Initialize
84  - \ref pipe_tasks_astrometry_IO
85  - \ref pipe_tasks_astrometry_Config
86  - \ref pipe_tasks_astrometry_Debug
87  - \ref pipe_tasks_astrometry_Example
88 
89  \section pipe_tasks_astrometry_Purpose Description
90 
91  \copybrief ANetAstrometryTask
92 
93  \section pipe_tasks_astrometry_Initialize Task initialisation
94 
95  \copydoc \_\_init\_\_
96 
97  \section pipe_tasks_astrometry_IO Invoking the Task
98 
99  \copydoc run
100 
101  \section pipe_tasks_astrometry_Config Configuration parameters
102 
103  See \ref ANetAstrometryConfig
104 
105  \section pipe_tasks_astrometry_Debug Debug variables
106 
107  The \link lsst.pipe.base.cmdLineTask.CmdLineTask command line task\endlink interface supports a
108  flag \c -d to import \b debug.py from your \c PYTHONPATH;
109  see \ref baseDebug for more about \b debug.py files.
110 
111  The available variables in ANetAstrometryTask are:
112  <DL>
113  <DT> \c display
114  <DD> If True call showAstrometry while iterating ANetAstrometryConfig.rejectIter times,
115  and also after converging; and call displayAstrometry after applying the distortion correction.
116  <DT> \c frame
117  <DD> display frame to use in showAstrometry and displayAstrometry
118  <DT> \c pause
119  <DD> Pause after showAstrometry and displayAstrometry?
120  </DL>
121 
122  \section pipe_tasks_astrometry_Example A complete example of using ANetAstrometryTask
123 
124  See \ref pipe_tasks_photocal_Example.
125 
126  To investigate the \ref pipe_tasks_astrometry_Debug, put something like
127  \code{.py}
128  import lsstDebug
129  def DebugInfo(name):
130  di = lsstDebug.getInfo(name) # N.b. lsstDebug.Info(name) would call us recursively
131  if name in ("lsst.pipe.tasks.anetAstrometry", "lsst.pipe.tasks.anetBasicAstrometry"):
132  di.display = 1
133  di.frame = 1
134  di.pause = True
135 
136  return di
137 
138  lsstDebug.Info = DebugInfo
139  \endcode
140  into your debug.py file and run photoCalTask.py with the \c --debug flag.
141  """
142  ConfigClass = ANetAstrometryConfig
143  _DefaultName = "astrometricSolver"
144 
145  def __init__(self, schema, refObjLoader=None, **kwds):
146  r"""!Create the astrometric calibration task. Most arguments are simply passed onto pipe.base.Task.
147 
148  \param schema An lsst::afw::table::Schema used to create the output lsst.afw.table.SourceCatalog
149  \param refObjLoader The AstrometryTask constructor requires a refObjLoader. In order to make this
150  task retargettable for AstrometryTask it needs to take the same arguments. This argument will be
151  ignored since it uses its own internal loader.
152  \param **kwds keyword arguments to be passed to the lsst.pipe.base.task.Task constructor
153 
154  A centroid field "centroid.distorted" (used internally during the Task's operation)
155  will be added to the schema.
156  """
157  pipeBase.Task.__init__(self, **kwds)
158  self.distortedName = "astrom_distorted"
159  self.centroidXKey = schema.addField(self.distortedName + "_x", type="D",
160  doc="centroid distorted for astrometry solver")
161  self.centroidYKey = schema.addField(self.distortedName + "_y", type="D",
162  doc="centroid distorted for astrometry solver")
163  self.centroidXErrKey = schema.addField(self.distortedName + "_xErr", type="F",
164  doc="centroid distorted err for astrometry solver")
165  self.centroidYErrKey = schema.addField(self.distortedName + "_yErr", type="F",
166  doc="centroid distorted err for astrometry solver")
167  self.centroidFlagKey = schema.addField(self.distortedName + "_flag", type="Flag",
168  doc="centroid distorted flag astrometry solver")
170  self.centroidErrKey = CovarianceMatrix2fKey((self.centroidXErrKey, self.centroidYErrKey))
171  # postpone making the solver subtask because it may not be needed and is expensive to create
172  self.solver = None
173 
174  @pipeBase.timeMethod
175  def run(self, exposure, sourceCat):
176  """!Load reference objects, match sources and optionally fit a WCS
177 
178  This is a thin layer around solve or loadAndMatch, depending on config.forceKnownWcs
179 
180  @param[in,out] exposure exposure whose WCS is to be fit
181  The following are read only:
182  - bbox
183  - photoCalib (may be absent)
184  - filter (may be unset)
185  - detector (if wcs is pure tangent; may be absent)
186  The following are updated:
187  - wcs (the initial value is used as an initial guess, and is required)
188  @param[in] sourceCat catalog of sourceCat detected on the exposure (an lsst.afw.table.SourceCatalog)
189  @return an lsst.pipe.base.Struct with these fields:
190  - refCat reference object catalog of objects that overlap the exposure (with some margin)
191  (an lsst::afw::table::SimpleCatalog)
192  - matches astrometric matches, a list of lsst.afw.table.ReferenceMatch
193  - matchMeta metadata about the field (an lsst.daf.base.PropertyList)
194  """
195  if self.config.forceKnownWcs:
196  return self.loadAndMatch(exposure=exposure, sourceCat=sourceCat)
197  else:
198  return self.solve(exposure=exposure, sourceCat=sourceCat)
199 
200  @pipeBase.timeMethod
201  def solve(self, exposure, sourceCat):
202  r"""!Match with reference sources and calculate an astrometric solution
203 
204  \param[in,out] exposure Exposure to calibrate; wcs is updated
205  \param[in] sourceCat catalog of measured sources (an lsst.afw.table.SourceCatalog)
206  \return a pipeBase.Struct with fields:
207  - refCat reference object catalog of objects that overlap the exposure (with some margin)
208  (an lsst::afw::table::SimpleCatalog)
209  - matches astrometric matches, a list of lsst.afw.table.ReferenceMatch
210  - matchMeta metadata about the field (an lsst.daf.base.PropertyList)
211 
212  The reference catalog actually used is up to the implementation
213  of the solver; it will be manifested in the returned matches as
214  a list of lsst.afw.table.ReferenceMatch objects (\em i.e. of lsst.afw.table.Match with
215  \c first being of type lsst.afw.table.SimpleRecord and \c second type lsst.afw.table.SourceRecord ---
216  the reference object and matched object respectively).
217 
218  \note
219  The input sources have the centroid slot moved to a new column "centroid.distorted"
220  which has the positions corrected for any known optical distortion;
221  the 'solver' (which is instantiated in the 'astrometry' member)
222  should therefore simply use the centroids provided by calling
223  afw.table.Source.getCentroid() on the individual source records. This column \em must
224  be present in the sources table.
225 
226  \note ignores config.forceKnownWcs
227  """
228  results = self._astrometry(sourceCat=sourceCat, exposure=exposure)
229 
230  if results.matches:
231  self.refitWcs(sourceCat=sourceCat, exposure=exposure, matches=results.matches)
232 
233  return results
234 
235  @pipeBase.timeMethod
236  def distort(self, sourceCat, exposure):
237  r"""!Calculate distorted source positions
238 
239  CCD images are often affected by optical distortion that makes
240  the astrometric solution higher order than linear. Unfortunately,
241  most (all?) matching algorithms require that the distortion be
242  small or zero, and so it must be removed. We do this by calculating
243  (un-)distorted positions, based on a known optical distortion model
244  in the Ccd.
245 
246  The distortion correction moves sources, so we return the distorted bounding box.
247 
248  \param[in] exposure Exposure to process
249  \param[in,out] sourceCat SourceCatalog; getX() and getY() will be used as inputs,
250  with distorted points in "centroid.distorted" field.
251  \return bounding box of distorted exposure
252  """
253  detector = exposure.getDetector()
254  pixToTanXYTransform = None
255  if detector is None:
256  self.log.warn("No detector associated with exposure; assuming null distortion")
257  else:
258  pixToTanXYTransform = detector.getTransform(PIXELS, TAN_PIXELS)
259 
260  if pixToTanXYTransform is None:
261  self.log.info("Null distortion correction")
262  for s in sourceCat:
263  s.set(self.centroidKey, s.getCentroid())
264  s.set(self.centroidErrKey, s.getCentroidErr())
265  s.set(self.centroidFlagKey, s.getCentroidFlag())
266  return exposure.getBBox()
267 
268  # Distort source positions
269  self.log.info("Applying distortion correction")
270  for s in sourceCat:
271  centroid = pixToTanXYTransform.forwardTransform(s.getCentroid())
272  s.set(self.centroidKey, centroid)
273  s.set(self.centroidErrKey, s.getCentroidErr())
274  s.set(self.centroidFlagKey, s.getCentroidFlag())
275 
276  # Get distorted image size so that astrometry_net does not clip.
277  bboxD = afwGeom.Box2D()
278  for corner in detector.getCorners(TAN_PIXELS):
279  bboxD.include(corner)
280 
281  if lsstDebug.Info(__name__).display:
282  frame = lsstDebug.Info(__name__).frame
283  pause = lsstDebug.Info(__name__).pause
284  displayAstrometry(sourceCat=sourceCat, distortedCentroidKey=self.centroidKey,
285  exposure=exposure, frame=frame, pause=pause)
286 
287  return afwGeom.Box2I(bboxD)
288 
289  @pipeBase.timeMethod
290  def loadAndMatch(self, exposure, sourceCat, bbox=None):
291  """!Load reference objects overlapping an exposure and match to sources detected on that exposure
292 
293  @param[in] exposure exposure whose WCS is to be fit
294  @param[in] sourceCat catalog of sourceCat detected on the exposure (an lsst.afw.table.SourceCatalog)
295  @param[in] bbox bounding box go use for finding reference objects; if None, use exposure's bbox
296 
297  @return an lsst.pipe.base.Struct with these fields:
298  - refCat reference object catalog of objects that overlap the exposure (with some margin)
299  (an lsst::afw::table::SimpleCatalog)
300  - matches astrometric matches, a list of lsst.afw.table.ReferenceMatch
301  - matchMeta metadata about the field (an lsst.daf.base.PropertyList)
302 
303  @note ignores config.forceKnownWcs
304  """
305  bbox = exposure.getBBox()
306  if not self.solver:
307  self.makeSubtask("solver")
308 
309  astrom = self.solver.useKnownWcs(
310  sourceCat=sourceCat,
311  exposure=exposure,
312  bbox=bbox,
313  calculateSip=False,
314  )
315 
316  if astrom is None or astrom.getWcs() is None:
317  raise RuntimeError("Unable to solve astrometry")
318 
319  matches = astrom.getMatches()
320  matchMeta = astrom.getMatchMetadata()
321  if matches is None or len(matches) == 0:
322  raise RuntimeError("No astrometric matches")
323  self.log.info("%d astrometric matches", len(matches))
324 
325  if self._display:
326  frame = lsstDebug.Info(__name__).frame
327  displayAstrometry(exposure=exposure, sourceCat=sourceCat, matches=matches,
328  frame=frame, pause=False)
329 
330  return pipeBase.Struct(
331  refCat=astrom.refCat,
332  matches=matches,
333  matchMeta=matchMeta,
334  )
335 
336  @pipeBase.timeMethod
337  def _astrometry(self, sourceCat, exposure, bbox=None):
338  r"""!Solve astrometry to produce WCS
339 
340  \param[in] sourceCat Sources on exposure, an lsst.afw.table.SourceCatalog
341  \param[in,out] exposure Exposure to process, an lsst.afw.image.ExposureF or D; wcs is updated
342  \param[in] bbox Bounding box, or None to use exposure
343  \return a pipe.base.Struct with fields:
344  - refCat reference object catalog of objects that overlap the exposure (with some margin)
345  (an lsst::afw::table::SimpleCatalog)
346  - matches astrometric matches, a list of lsst.afw.table.ReferenceMatch
347  - matchMeta metadata about the field (an lsst.daf.base.PropertyList)
348  """
349  self.log.info("Solving astrometry")
350  if bbox is None:
351  bbox = exposure.getBBox()
352 
353  if not self.solver:
354  self.makeSubtask("solver")
355 
356  astrom = self.solver.determineWcs(sourceCat=sourceCat, exposure=exposure, bbox=bbox)
357 
358  if astrom is None or astrom.getWcs() is None:
359  raise RuntimeError("Unable to solve astrometry")
360 
361  matches = astrom.getMatches()
362  matchMeta = astrom.getMatchMetadata()
363  if matches is None or len(matches) == 0:
364  raise RuntimeError("No astrometric matches")
365  self.log.info("%d astrometric matches", len(matches))
366 
367  # Note that this is the Wcs for the provided positions, which may be distorted
368  exposure.setWcs(astrom.getWcs())
369 
370  if self._display:
371  frame = lsstDebug.Info(__name__).frame
372  displayAstrometry(exposure=exposure, sourceCat=sourceCat, matches=matches,
373  frame=frame, pause=False)
374 
375  return pipeBase.Struct(
376  refCat=astrom.refCat,
377  matches=matches,
378  matchMeta=matchMeta,
379  )
380 
381  @pipeBase.timeMethod
382  def refitWcs(self, sourceCat, exposure, matches):
383  """!A final Wcs solution after matching and removing distortion
384 
385  Specifically, fitting the non-linear part, since the linear
386  part has been provided by the matching engine.
387 
388  @param sourceCat Sources on exposure, an lsst.afw.table.SourceCatalog
389  @param exposure Exposure of interest, an lsst.afw.image.ExposureF or D
390  @param matches Astrometric matches, as a list of lsst.afw.table.ReferenceMatch
391 
392  @return the resolved-Wcs object, or None if config.solver.calculateSip is False.
393  """
394  sip = None
395  if self.config.solver.calculateSip:
396  self.log.info("Refitting WCS")
397  origMatches = matches
398  wcs = exposure.getWcs()
399 
400  import lsstDebug
401  display = lsstDebug.Info(__name__).display
402  frame = lsstDebug.Info(__name__).frame
403  pause = lsstDebug.Info(__name__).pause
404 
405  def fitWcs(initialWcs, title=None):
406  """!Do the WCS fitting and display of the results"""
407  sip = makeCreateWcsWithSip(matches, initialWcs, self.config.solver.sipOrder)
408  resultWcs = sip.getNewWcs()
409  if display:
410  showAstrometry(exposure, resultWcs, origMatches, matches, frame=frame,
411  title=title, pause=pause)
412  return resultWcs, sip.getScatterOnSky()
413 
414  numRejected = 0
415  try:
416  for i in range(self.config.rejectIter):
417  wcs, scatter = fitWcs(wcs, title="Iteration %d" % i)
418 
419  ref = np.array([wcs.skyToPixel(m.first.getCoord()) for m in matches])
420  src = np.array([m.second.getCentroid() for m in matches])
421  diff = ref - src
422  rms = diff.std()
423  trimmed = []
424  for d, m in zip(diff, matches):
425  if np.all(np.abs(d) < self.config.rejectThresh*rms):
426  trimmed.append(m)
427  else:
428  numRejected += 1
429  if len(matches) == len(trimmed):
430  break
431  matches = trimmed
432 
433  # Final fit after rejection iterations
434  wcs, scatter = fitWcs(wcs, title="Final astrometry")
435 
437  self.log.warn("Unable to fit SIP: %s", e)
438 
439  self.log.info("Astrometric scatter: %f arcsec (%d matches, %d rejected)",
440  scatter.asArcseconds(), len(matches), numRejected)
441  exposure.setWcs(wcs)
442 
443  # Apply WCS to sources
444  updateSourceCoords(wcs, sourceCat)
445  else:
446  self.log.warn("Not calculating a SIP solution; matches may be suspect")
447 
448  if self._display:
449  frame = lsstDebug.Info(__name__).frame
450  displayAstrometry(exposure=exposure, sourceCat=sourceCat, matches=matches,
451  frame=frame, pause=False)
452 
453  return sip
454 
455 
456 def showAstrometry(exposure, wcs, allMatches, useMatches, frame=0, title=None, pause=False):
457  r"""!Show results of astrometry fitting
458 
459  \param exposure Image to display
460  \param wcs Astrometric solution
461  \param allMatches List of all astrometric matches (including rejects)
462  \param useMatches List of used astrometric matches
463  \param frame Frame number for display
464  \param title Title for display
465  \param pause Pause to allow viewing of the display and optional debugging?
466 
467  - Matches are shown in yellow if used in the Wcs solution, otherwise red
468  - +: Detected objects
469  - x: Catalogue objects
470  """
471  import lsst.afw.display as afwDisplay
472  disp = afwDisplay.Display(frame=frame)
473  disp.mtv(exposure, title=title)
474 
475  useIndices = set(m.second.getId() for m in useMatches)
476 
477  radii = []
478  with disp.Buffering():
479  for i, m in enumerate(allMatches):
480  x, y = m.second.getX(), m.second.getY()
481  pix = wcs.skyToPixel(m.first.getCoord())
482 
483  isUsed = m.second.getId() in useIndices
484  if isUsed:
485  radii.append(np.hypot(pix[0] - x, pix[1] - y))
486 
487  color = afwDisplay.YELLOW if isUsed else afwDisplay.RED
488 
489  disp.dot("+", x, y, size=10, ctype=color)
490  disp.dot("x", pix[0], pix[1], size=10, ctype=color)
491 
492  radii = np.array(radii)
493  print("<dr> = %.4g +- %.4g pixels [%d/%d matches]" % (radii.mean(), radii.std(),
494  len(useMatches), len(allMatches)))
495 
496  if pause:
497  import sys
498  while True:
499  try:
500  reply = input("Debugging? [p]db [q]uit; any other key to continue... ").strip()
501  except EOFError:
502  reply = ""
503 
504  if len(reply) > 1:
505  reply = reply[0]
506  if reply == "p":
507  import pdb
508  pdb.set_trace()
509  elif reply == "q":
510  sys.exit(1)
511  else:
512  break
A floating-point coordinate rectangle geometry.
Definition: Box.h:305
Reports attempts to exceed implementation-defined length limits for some classes. ...
Definition: Runtime.h:76
def loadAndMatch(self, exposure, sourceCat, bbox=None)
Load reference objects overlapping an exposure and match to sources detected on that exposure...
def solve(self, exposure, sourceCat)
Match with reference sources and calculate an astrometric solution.
PointKey< double > Point2DKey
Definition: aggregates.h:118
def distort(self, sourceCat, exposure)
Calculate distorted source positions.
def showAstrometry(exposure, wcs, allMatches, useMatches, frame=0, title=None, pause=False)
Show results of astrometry fitting.
daf::base::PropertySet * set
Definition: fits.cc:884
void updateSourceCoords(geom::SkyWcs const &wcs, SourceCollection &sourceList)
Update sky coordinates in a collection of source objects.
Definition: wcsUtils.cc:96
def refitWcs(self, sourceCat, exposure, matches)
A final Wcs solution after matching and removing distortion.
def _astrometry(self, sourceCat, exposure, bbox=None)
Solve astrometry to produce WCS.
def run(self, exposure, sourceCat)
Load reference objects, match sources and optionally fit a WCS.
def displayAstrometry(refCat=None, sourceCat=None, distortedCentroidKey=None, bbox=None, exposure=None, matches=None, frame=1, title="", pause=True)
Definition: display.py:34
CreateWcsWithSip< MatchT > makeCreateWcsWithSip(std::vector< MatchT > const &matches, afw::geom::SkyWcs const &linearWcs, int const order, geom::Box2I const &bbox=geom::Box2I(), int const ngrid=0)
Factory function for CreateWcsWithSip.
An integer coordinate rectangle.
Definition: Box.h:54
def __init__(self, schema, refObjLoader=None, kwds)
Create the astrometric calibration task.
Use astrometry.net to match input sources with a reference catalog and solve for the Wcs...
bool strip
Definition: fits.cc:883