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