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