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