LSSTApplications  10.0-2-g4f67435,11.0.rc2+1,11.0.rc2+12,11.0.rc2+3,11.0.rc2+4,11.0.rc2+5,11.0.rc2+6,11.0.rc2+7,11.0.rc2+8
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 
316  @return an lsst.pipe.base.Struct with these fields:
317  - refCat reference object catalog of objects that overlap the exposure (with some margin)
318  (an lsst::afw::table::SimpleCatalog)
319  - matches list of reference object/source matches (an lsst.afw.table.ReferenceMatchVector)
320  - matchMeta metadata about the field (an lsst.daf.base.PropertyList)
321 
322  @note ignores config.forceKnownWcs
323  """
324  with self.distortionContext(sourceCat=sourceCat, exposure=exposure) as bbox:
325  if not self.solver:
326  self.makeSubtask("solver")
327 
328  astrom = self.solver.useKnownWcs(
329  sourceCat = sourceCat,
330  exposure = exposure,
331  bbox = bbox,
332  calculateSip = False,
333  )
334 
335  if astrom is None or astrom.getWcs() is None:
336  raise RuntimeError("Unable to solve astrometry")
337 
338  matches = astrom.getMatches()
339  matchMeta = astrom.getMatchMetadata()
340  if matches is None or len(matches) == 0:
341  raise RuntimeError("No astrometric matches")
342  self.log.info("%d astrometric matches" % (len(matches)))
343 
344  self.display('astrometry', exposure=exposure, sources=sourceCat, matches=matches)
345 
346  return pipeBase.Struct(
347  refCat = astrom.refCat,
348  matches = matches,
349  matchMeta = matchMeta,
350  )
351 
352  @pipeBase.timeMethod
353  def _astrometry(self, sourceCat, exposure, bbox=None):
354  """!Solve astrometry to produce WCS
355 
356  \param[in] sourceCat Sources on exposure, an lsst.afw.table.SourceCatalog
357  \param[in,out] exposure Exposure to process, an lsst.afw.image.ExposureF or D; wcs is updated
358  \param[in] bbox Bounding box, or None to use exposure
359  \return a pipe.base.Struct with fields:
360  - refCat reference object catalog of objects that overlap the exposure (with some margin)
361  (an lsst::afw::table::SimpleCatalog)
362  - matches list of reference object/source matches (an lsst.afw.table.ReferenceMatchVector)
363  - matchMeta metadata about the field (an lsst.daf.base.PropertyList)
364  """
365  self.log.info("Solving astrometry")
366  if bbox is None:
367  bbox = exposure.getBBox()
368 
369  if not self.solver:
370  self.makeSubtask("solver")
371 
372  astrom = self.solver.determineWcs(sourceCat=sourceCat, exposure=exposure, bbox=bbox)
373 
374  if astrom is None or astrom.getWcs() is None:
375  raise RuntimeError("Unable to solve astrometry")
376 
377  matches = astrom.getMatches()
378  matchMeta = astrom.getMatchMetadata()
379  if matches is None or len(matches) == 0:
380  raise RuntimeError("No astrometric matches")
381  self.log.info("%d astrometric matches" % (len(matches)))
382 
383  # Note that this is the Wcs for the provided positions, which may be distorted
384  exposure.setWcs(astrom.getWcs())
385 
386  self.display('astrometry', exposure=exposure, sources=sourceCat, matches=matches)
387 
388  return pipeBase.Struct(
389  refCat = astrom.refCat,
390  matches = matches,
391  matchMeta = matchMeta,
392  )
393 
394  @pipeBase.timeMethod
395  def refitWcs(self, sourceCat, exposure, matches):
396  """!A final Wcs solution after matching and removing distortion
397 
398  Specifically, fitting the non-linear part, since the linear
399  part has been provided by the matching engine.
400 
401  \param sourceCat Sources on exposure, an lsst.afw.table.SourceCatalog
402  \param exposure Exposure of interest, an lsst.afw.image.ExposureF or D
403  \param matches Astrometric matches, as an lsst.afw.table.ReferenceMatchVector
404 
405  \return the resolved-Wcs object, or None if config.solver.calculateSip is False.
406  """
407  sip = None
408  if self.config.solver.calculateSip:
409  self.log.info("Refitting WCS")
410  origMatches = matches
411  wcs = exposure.getWcs()
412 
413  import lsstDebug
414  display = lsstDebug.Info(__name__).display
415  frame = lsstDebug.Info(__name__).frame
416  pause = lsstDebug.Info(__name__).pause
417 
418  def fitWcs(initialWcs, title=None):
419  """!Do the WCS fitting and display of the results"""
420  sip = makeCreateWcsWithSip(matches, initialWcs, self.config.solver.sipOrder)
421  resultWcs = sip.getNewWcs()
422  if display:
423  showAstrometry(exposure, resultWcs, origMatches, matches, frame=frame,
424  title=title, pause=pause)
425  return resultWcs, sip.getScatterOnSky()
426 
427  numRejected = 0
428  try:
429  for i in range(self.config.rejectIter):
430  wcs, scatter = fitWcs(wcs, title="Iteration %d" % i)
431 
432  ref = numpy.array([wcs.skyToPixel(m.first.getCoord()) for m in matches])
433  src = numpy.array([m.second.getCentroid() for m in matches])
434  diff = ref - src
435  rms = diff.std()
436  trimmed = []
437  for d, m in zip(diff, matches):
438  if numpy.all(numpy.abs(d) < self.config.rejectThresh*rms):
439  trimmed.append(m)
440  else:
441  numRejected += 1
442  if len(matches) == len(trimmed):
443  break
444  matches = trimmed
445 
446  # Final fit after rejection iterations
447  wcs, scatter = fitWcs(wcs, title="Final astrometry")
448 
449  except lsst.pex.exceptions.LengthError as e:
450  self.log.warn("Unable to fit SIP: %s" % e)
451 
452  self.log.info("Astrometric scatter: %f arcsec (%s non-linear terms, %d matches, %d rejected)" %
453  (scatter.asArcseconds(), "with" if wcs.hasDistortion() else "without",
454  len(matches), numRejected))
455  exposure.setWcs(wcs)
456 
457  # Apply WCS to sources
458  for index, source in enumerate(sourceCat):
459  sky = wcs.pixelToSky(source.getX(), source.getY())
460  source.setCoord(sky)
461  else:
462  self.log.warn("Not calculating a SIP solution; matches may be suspect")
463 
464  self.display('astrometry', exposure=exposure, sources=sourceCat, matches=matches)
465 
466  return sip
467 
468 
469 def showAstrometry(exposure, wcs, allMatches, useMatches, frame=0, title=None, pause=False):
470  """!Show results of astrometry fitting
471 
472  \param exposure Image to display
473  \param wcs Astrometric solution
474  \param allMatches List of all astrometric matches (including rejects)
475  \param useMatches List of used astrometric matches
476  \param frame Frame number for display
477  \param title Title for display
478  \param pause Pause to allow viewing of the display and optional debugging?
479 
480  - Matches are shown in yellow if used in the Wcs solution, otherwise red
481  - +: Detected objects
482  - x: Catalogue objects
483  """
484  import lsst.afw.display.ds9 as ds9
485  ds9.mtv(exposure, frame=frame, title=title)
486 
487  useIndices = set(m.second.getId() for m in useMatches)
488 
489  radii = []
490  with ds9.Buffering():
491  for i, m in enumerate(allMatches):
492  x, y = m.second.getX(), m.second.getY()
493  pix = wcs.skyToPixel(m.first.getCoord())
494 
495  isUsed = m.second.getId() in useIndices
496  if isUsed:
497  radii.append(numpy.hypot(pix[0] - x, pix[1] - y))
498 
499  color = ds9.YELLOW if isUsed else ds9.RED
500 
501  ds9.dot("+", x, y, size=10, frame=frame, ctype=color)
502  ds9.dot("x", pix[0], pix[1], size=10, frame=frame, ctype=color)
503 
504  radii = numpy.array(radii)
505  print "<dr> = %.4g +- %.4g pixels [%d/%d matches]" % (radii.mean(), radii.std(),
506  len(useMatches), len(allMatches))
507 
508  if pause:
509  import sys
510  while True:
511  try:
512  reply = raw_input("Debugging? [p]db [q]uit; any other key to continue... ").strip()
513  except EOFError:
514  reply = ""
515 
516  if len(reply) > 1:
517  reply = reply[0]
518  if reply == "p":
519  import pdb;pdb.set_trace()
520  elif reply == "q":
521  sys.exit(1)
522  else:
523  break
def refitWcs
A final Wcs solution after matching and removing distortion.
def __init__
Create the astrometric calibration task.
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...
PointKey< double > Point2DKey
Definition: aggregates.h:111
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.