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
astrom.py
Go to the documentation of this file.
1 import os
2 import math
3 import sys
4 
5 import numpy as np # for isfinite()
6 
7 import lsst.daf.base as dafBase
8 import lsst.pex.logging as pexLog
9 import lsst.pex.exceptions as pexExceptions
10 import lsst.afw.geom as afwGeom
11 import lsst.afw.table as afwTable
12 import lsst.afw.image as afwImage
13 import lsst.meas.algorithms.utils as maUtils
14 
15 from .config import MeasAstromConfig, AstrometryNetDataConfig
16 import sip as astromSip
17 
18 # Object returned by determineWcs.
19 class InitialAstrometry(object):
20  '''
21  getWcs(): sipWcs or tanWcs
22  getMatches(): sipMatches or tanMatches
23 
24  Other fields are:
25  solveQa (PropertyList)
26  tanWcs (Wcs)
27  tanMatches (MatchList)
28  sipWcs (Wcs)
29  sipMatches (MatchList)
30  matchMetadata (PropertyList)
31  '''
32  def __init__(self):
33  self.tanWcs = None
34  self.tanMatches = None
35  self.sipWcs = None
36  self.sipMatches = None
38  self.solveQa = None
39 
40  def getMatches(self):
41  '''
42  Get "sipMatches" -- MatchList using the SIP WCS solution, if it
43  exists, or "tanMatches" -- MatchList using the TAN WCS solution
44  otherwise.
45  '''
46  return self.sipMatches or self.tanMatches
47 
48  def getWcs(self):
49  '''
50  Returns the SIP WCS, if one was found, or a TAN WCS
51  '''
52  return self.sipWcs or self.tanWcs
53 
54  matches = property(getMatches)
55  wcs = property(getWcs)
56 
57  ### "Not very pythonic!" complains Paul.
58  # Consider these methods deprecated; if you want these elements, just
59  # .grab them.
60  def getSipWcs(self):
61  return self.sipWcs
62  def getTanWcs(self):
63  return self.tanWcs
64  def getSipMatches(self):
65  return self.sipMatches
66  def getTanMatches(self):
67  return self.tanMatches
68  def getMatchMetadata(self):
69  return self.matchMetadata
70  def getSolveQaMetadata(self):
71  return self.solveQa
72 
73 
74 class Astrometry(object):
75  ConfigClass = MeasAstromConfig
76 
77  '''
78  About Astrometry.net index files (astrometry_net_data):
79 
80  There are three components of an index file: a list of stars
81  (stored as a star kd-tree), a list of quadrangles of stars ("quad
82  file") and a list of the shapes ("codes") of those quadrangles,
83  stored as a code kd-tree.
84 
85  Each index covers a region of the sky, defined by healpix nside
86  and number, and a range of angular scales. In LSST, we share the
87  list of stars in a part of the sky between multiple indexes. That
88  is, the star kd-tree is shared between multiple indices (quads and
89  code kd-trees). In the astrometry.net code, this is called a
90  "multiindex".
91 
92  It is possible to "unload" and "reload" multiindex (and index)
93  objects. When "unloaded", they consume no FILE or mmap resources.
94 
95  The multiindex object holds the star kd-tree and gives each index
96  object it holds a pointer to it, so it is necessary to
97  multiindex_reload_starkd() before reloading the indices it holds.
98  The multiindex_unload() method, on the other hand, unloads its
99  starkd and unloads each index it holds.
100  '''
101 
102  class _LoadedMIndexes(object):
103  def __init__(self, multiInds):
104  self.multiInds = multiInds
105  def __enter__(self):
106  for mi in self.multiInds:
107  #print 'Loading', mi.name
108  mi.reload()
109  return self.multiInds
110  def __exit__(self, typ, val, trace):
111  for mi in self.multiInds:
112  #print 'Unloading', mi.name
113  mi.unload()
114 
115  def __init__(self,
116  config,
117  andConfig=None,
118  log=None,
119  logLevel=pexLog.Log.INFO):
120  '''
121  conf: an AstromConfig object.
122  andConfig: an AstromNetDataConfig object
123  log: a pexLogging.Log
124  logLevel: if log is None, the log level to use
125  '''
126  self.config = config
127  if log is not None:
128  self.log = log
129  else:
130  self.log = pexLog.Log(pexLog.Log.getDefaultLog(),
131  'meas.astrom',
132  logLevel)
133  if andConfig is None:
134  # ASSUME SETUP IN EUPS
135  dirnm = os.environ.get('ASTROMETRY_NET_DATA_DIR')
136  if dirnm is None:
137  raise RuntimeError("astrometry_net_data is not setup")
138 
139  andConfig = AstrometryNetDataConfig()
140  fn = os.path.join(dirnm, 'andConfig.py')
141  if not os.path.exists(fn):
142  raise RuntimeError('astrometry_net_data config file \"%s\" required but not found' % fn)
143  andConfig.load(fn)
144 
145  self.andConfig = andConfig
146  self._readIndexFiles()
147 
148  def _readIndexFiles(self):
149  import astrometry_net as an
150  # .multiInds: multi-index objects
151  self.multiInds = []
152 
153  # merge indexFiles and multiIndexFiles; we'll treat both as
154  # multiindex for simplicity.
155  mifiles = ([(True,[fn,fn]) for fn in self.andConfig.indexFiles] +
156  [(False,fns) for fns in self.andConfig.multiIndexFiles])
157 
158  nMissing = 0
159  for single,fns in mifiles:
160  # First filename in "fns" is star kdtree, the rest are index files.
161  fn = fns[0]
162  if single:
163  self.log.log(self.log.DEBUG, 'Adding index file %s' % fns[0])
164  else:
165  self.log.log(self.log.DEBUG, 'Adding multiindex files %s' % str(fns))
166  fn2 = self._getIndexPath(fn)
167  if fn2 is None:
168  if single:
169  self.log.logdebug('Unable to find index file %s' % fn)
170  else:
171  self.log.logdebug('Unable to find star part of multiindex file %s' % fn)
172  nMissing += 1
173  continue
174  fn = fn2
175  self.log.log(self.log.DEBUG, 'Path: %s' % fn)
176 
177  mi = an.multiindex_new(fn)
178  if mi is None:
179  raise RuntimeError('Failed to read stars from multiindex filename "%s"' % fn)
180  for i,fn in enumerate(fns[1:]):
181  self.log.log(self.log.DEBUG, 'Reading index from multiindex file "%s"' % fn)
182  fn2 = self._getIndexPath(fn)
183  if fn2 is None:
184  self.log.logdebug('Unable to find index part of multiindex file %s' % fn)
185  nMissing += 1
186  continue
187  fn = fn2
188  self.log.log(self.log.DEBUG, 'Path: %s' % fn)
189  if an.multiindex_add_index(mi, fn, an.INDEX_ONLY_LOAD_METADATA):
190  raise RuntimeError('Failed to read index from multiindex filename "%s"' % fn)
191  ind = mi[i]
192  self.log.log(self.log.DEBUG, ' index %i, hp %i (nside %i), nstars %i, nquads %i' %
193  (ind.indexid, ind.healpix, ind.hpnside, ind.nstars, ind.nquads))
194  an.multiindex_unload_starkd(mi)
195  self.multiInds.append(mi)
196 
197  if len(self.multiInds) == 0:
198  self.log.warn('Unable to find any index files')
199  elif nMissing > 0:
200  self.log.warn('Unable to find %d index files' % nMissing)
201 
202  def _debug(self, s):
203  self.log.log(self.log.DEBUG, s)
204  def _warn(self, s):
205  self.log.log(self.log.WARN, s)
206 
207  def memusage(self, prefix=''):
208  # Not logging at DEBUG: do nothing
209  if self.log.getThreshold() > pexLog.Log.DEBUG:
210  return
211  from astrometry.util.ttime import get_memusage
212  mu = get_memusage()
213  ss = []
214  for key in ['VmPeak', 'VmSize', 'VmRSS', 'VmData']:
215  if key in mu:
216  ss.append(key + ': ' + ' '.join(mu[key]))
217  if 'mmaps' in mu:
218  ss.append('Mmaps: %i' % len(mu['mmaps']))
219  if 'mmaps_total' in mu:
220  ss.append('Mmaps: %i kB' % (mu['mmaps_total'] / 1024))
221  self.log.logdebug(prefix + 'Memory: ' + ', '.join(ss))
222 
223  def setAndConfig(self, andconfig):
224  self.andConfig = andconfig
225 
226  def _getImageParams(self, wcs, exposure, filterName=None, imageSize=None,
227  x0=None, y0=None):
228  if exposure is not None:
229  ex0,ey0 = exposure.getX0(), exposure.getY0()
230  if x0 is None:
231  x0 = ex0
232  if y0 is None:
233  y0 = ey0
234  self._debug('Got exposure x0,y0 = %i,%i' % (ex0,ey0))
235  if filterName is None:
236  filterName = exposure.getFilter().getName()
237  self._debug('Setting filterName = "%s" from exposure metadata' % str(filterName))
238  if imageSize is None:
239  imageSize = (exposure.getWidth(), exposure.getHeight())
240  self._debug('Setting image size = (%i, %i) from exposure metadata' % (imageSize))
241  if x0 is None:
242  x0 = 0
243  if y0 is None:
244  y0 = 0
245  return filterName, imageSize, x0, y0
246 
247  def useKnownWcs(self, sources, wcs=None, exposure=None, filterName=None, imageSize=None, x0=None, y0=None):
248  """
249  Returns an InitialAstrometry object, just like determineWcs,
250  but assuming the given input WCS is correct.
251 
252  This is enabled by the pipe_tasks AstrometryConfig
253  'forceKnownWcs' option. If you are using that option, you
254  probably also want to turn OFF 'calculateSip'.
255 
256  This involves searching for reference sources within the WCS
257  area, and matching them to the given 'sources'. If
258  'calculateSip' is set, we will try to compute a TAN-SIP
259  distortion correction.
260 
261  sources: list of detected sources in this image.
262  wcs: your known WCS
263  exposure: the exposure holding metadata for this image.
264  filterName: string, filter name, eg "i"
265  x0,y0: image origin / offset; these coordinates along with the
266  "imageSize" determine the bounding-box in pixel coordinates of
267  the image in question; this is used for finding reference sources
268  in the image, among other things.
269 
270  You MUST provide a WCS, either by providing the 'wcs' kwarg
271  (an lsst.image.Wcs object), or by providing the 'exposure' on
272  which we will call 'exposure.getWcs()'.
273 
274  You MUST provide a filter name, either by providing the
275  'filterName' kwarg (a string), or by setting the 'exposure';
276  we will call 'exposure.getFilter().getName()'.
277 
278  You MUST provide the image size, either by providing the
279  'imageSize' kwargs, an (W,H) tuple of ints, or by providing
280  the 'exposure'; we will call 'exposure.getWidth()' and
281  'exposure.getHeight()'.
282 
283  Note, when modifying this function, that it is also called by
284  'determineWcs' (via 'determineWcs2'), since the steps are all
285  the same.
286  """
287  # return value:
288  astrom = InitialAstrometry()
289 
290  if wcs is None:
291  if exposure is None:
292  raise RuntimeError('useKnownWcs: need either "wcs=" or "exposure=" kwarg.')
293  wcs = exposure.getWcs()
294  if wcs is None:
295  raise RuntimeError('useKnownWcs: wcs==None and exposure.getWcs()==None.')
296 
297  filterName,imageSize,x0,y0 = self._getImageParams(exposure=exposure, wcs=wcs,
298  imageSize=imageSize,
299  filterName=filterName,
300  x0=x0, y0=y0)
301  pixelMargin = 50.
302 
303  cat = self.getReferenceSourcesForWcs(wcs, imageSize, filterName, pixelMargin, x0=x0, y0=y0)
304  catids = [src.getId() for src in cat]
305  uids = set(catids)
306  self.log.logdebug('%i reference sources; %i unique IDs' % (len(catids), len(uids)))
307  matchList = self._getMatchList(sources, cat, wcs)
308  uniq = set([sm.second.getId() for sm in matchList])
309  if len(matchList) != len(uniq):
310  self._warn(('The list of matched stars contains duplicate reference source IDs ' +
311  '(%i sources, %i unique ids)') % (len(matchList), len(uniq)))
312  if len(matchList) == 0:
313  self._warn('No matches found between input sources and reference catalogue.')
314  return astrom
315 
316  self._debug('%i reference objects match input sources using input WCS' % (len(matchList)))
317  astrom.tanMatches = matchList
318  astrom.tanWcs = wcs
319 
320  srcids = [s.getId() for s in sources]
321  for m in matchList:
322  assert(m.second.getId() in srcids)
323  assert(m.second in sources)
324 
325  if self.config.calculateSip:
326  sipwcs,matchList = self._calculateSipTerms(wcs, cat, sources, matchList, imageSize, x0=x0, y0=y0)
327  if sipwcs == wcs:
328  self._debug('Failed to find a SIP WCS better than the initial one.')
329  else:
330  self._debug('%i reference objects match input sources using SIP WCS' % (len(matchList)))
331  astrom.sipWcs = sipwcs
332  astrom.sipMatches = matchList
333 
334  W,H = imageSize
335  wcs = astrom.getWcs()
336  # _getMatchList() modifies the source list RA,Dec coordinates.
337  # Here, we make them consistent with the WCS we are returning.
338  for src in sources:
339  src.updateCoord(wcs)
340  astrom.matchMetadata = _createMetadata(W, H, x0, y0, wcs, filterName)
341  return astrom
342 
343  def determineWcs(self,
344  sources,
345  exposure,
346  **kwargs):
347  """
348  Finds a WCS solution for the given 'sources' in the given
349  'exposure', getting other parameters from config.
350 
351  Valid kwargs include:
352 
353  'radecCenter', an afw.coord.Coord giving the RA,Dec position
354  of the center of the field. This is used to limit the
355  search done by Astrometry.net (to make it faster and load
356  fewer index files, thereby using less memory). Defaults to
357  the RA,Dec center from the exposure's WCS; turn that off
358  with the boolean kwarg 'useRaDecCenter' or config option
359  'useWcsRaDecCenter'
360 
361  'useRaDecCenter', a boolean. Don't use the RA,Dec center from
362  the exposure's initial WCS.
363 
364  'searchRadius', in degrees, to search for a solution around
365  the given 'radecCenter'; default from config option
366  'raDecSearchRadius'.
367 
368  'useParity': parity is the 'flip' of the image. Knowing it
369  reduces the search space (hence time) for Astrometry.net.
370  The parity can be computed from the exposure's WCS (the
371  sign of the determinant of the CD matrix); this option
372  controls whether we do that or force Astrometry.net to
373  search both parities. Default from config.useWcsParity.
374 
375  'pixelScale': afwGeom.Angle, estimate of the angle-per-pixel
376  (ie, arcseconds per pixel). Defaults to a value derived
377  from the exposure's WCS. If enabled, this value, plus or
378  minus config.pixelScaleUncertainty, will be used to limit
379  Astrometry.net's search.
380 
381  'usePixelScale': boolean. Use the pixel scale to limit
382  Astrometry.net's search? Defaults to config.useWcsPixelScale.
383 
384  'filterName', a string, the filter name of this image. Will
385  be mapped through the 'filterMap' config dictionary to a
386  column name in the astrometry_net_data index FITS files.
387  Defaults to the exposure.getFilter() value.
388 
389  'imageSize', a tuple (W,H) of integers, the image size.
390  Defaults to the exposure.get{Width,Height}() values.
391 
392  """
393  assert(exposure is not None)
394 
395  margs = kwargs.copy()
396  if not 'searchRadius' in margs:
397  margs.update(searchRadius = self.config.raDecSearchRadius * afwGeom.degrees)
398  if not 'usePixelScale' in margs:
399  margs.update(usePixelScale = self.config.useWcsPixelScale)
400  if not 'useRaDecCenter' in margs:
401  margs.update(useRaDecCenter = self.config.useWcsRaDecCenter)
402  if not 'useParity' in margs:
403  margs.update(useParity = self.config.useWcsParity)
404  margs.update(exposure=exposure)
405  return self.determineWcs2(sources, **margs)
406 
407  def determineWcs2(self, sources, **kwargs):
408  '''
409  Get a blind astrometric solution for the given list of sources.
410 
411  We need:
412  -the image size;
413  -the filter
414 
415  And if available, we can use:
416  -an initial Wcs estimate;
417  --> RA,Dec center
418  --> pixel scale
419  --> "parity"
420 
421  (all of which are metadata of Exposure).
422 
423  filterName: string
424  imageSize: (W,H) integer tuple/iterable
425  pixelScale: afwGeom::Angle per pixel.
426  radecCenter: afwCoord::Coord
427  '''
428  wcs,qa = self.getBlindWcsSolution(sources, **kwargs)
429  kw = {}
430  # Keys passed to useKnownWcs
431  for key in ['exposure', 'filterName', 'imageSize', 'x0', 'y0']:
432  if key in kwargs:
433  kw[key] = kwargs[key]
434  astrom = self.useKnownWcs(sources, wcs=wcs, **kw)
435  astrom.solveQa = qa
436  astrom.tanWcs = wcs
437  return astrom
438 
439  def getBlindWcsSolution(self, sources,
440  exposure=None,
441  wcs=None,
442  imageSize=None,
443  x0=None, y0=None,
444  radecCenter=None,
445  searchRadius=None,
446  pixelScale=None,
447  filterName=None,
448  doTrim=False,
449  usePixelScale=True,
450  useRaDecCenter=True,
451  useParity=True,
452  searchRadiusScale=2.):
453  if not useRaDecCenter and radecCenter is not None:
454  raise RuntimeError('radecCenter is set, but useRaDecCenter is False. Make up your mind!')
455  if not usePixelScale and pixelScale is not None:
456  raise RuntimeError('pixelScale is set, but usePixelScale is False. Make up your mind!')
457 
458  filterName,imageSize,x0,y0 = self._getImageParams(exposure=exposure, wcs=wcs,
459  imageSize=imageSize,
460  filterName=filterName,
461  x0=x0, y0=y0)
462 
463  if exposure is not None:
464  if wcs is None:
465  wcs = exposure.getWcs()
466  self._debug('Setting initial WCS estimate from exposure metadata')
467 
468  if imageSize is None:
469  # Could guess from the extent of the Sources...
470  raise RuntimeError('Image size must be specified by passing "exposure" or "imageSize"')
471 
472  W,H = imageSize
473  xc, yc = W/2. + 0.5 + x0, H/2. + 0.5 + y0
474  parity = None
475 
476  if wcs is not None:
477  if pixelScale is None:
478  if usePixelScale:
479  pixelScale = wcs.pixelScale()
480  self._debug('Setting pixel scale estimate = %.3f from given WCS estimate' %
481  (pixelScale.asArcseconds()))
482 
483  if radecCenter is None:
484  if useRaDecCenter:
485  radecCenter = wcs.pixelToSky(xc, yc)
486  self._debug(('Setting RA,Dec center estimate = (%.3f, %.3f) from given WCS '
487  + 'estimate, using pixel center = (%.1f, %.1f)') %
488  (radecCenter.getLongitude().asDegrees(),
489  radecCenter.getLatitude().asDegrees(), xc, yc))
490 
491  if searchRadius is None:
492  if useRaDecCenter:
493  assert(pixelScale is not None)
494  searchRadius = (pixelScale * math.hypot(W,H)/2. *
495  searchRadiusScale)
496  self._debug(('Using RA,Dec search radius = %.3f deg, from pixel scale, '
497  + 'image size, and searchRadiusScale = %g') %
498  (searchRadius, searchRadiusScale))
499  if useParity:
500  parity = wcs.isFlipped()
501  self._debug('Using parity = %s' % (parity and 'True' or 'False'))
502 
503  if doTrim:
504  n = len(sources)
505  if exposure is not None:
506  bbox = afwGeom.Box2D(exposure.getMaskedImage().getBBox())
507  else:
508  # CHECK -- half-pixel issues here?
509  bbox = afwGeom.Box2D(afwGeom.Point2D(0.,0.), afwGeom.Point2D(W, H))
510  sources = self._trimBadPoints(sources, bbox)
511  self._debug("Trimming: kept %i of %i sources" % (n, len(sources)))
512 
513  wcs,qa = self._solve(sources, wcs, imageSize, pixelScale, radecCenter, searchRadius, parity,
514  filterName, xy0=(x0,y0))
515  if wcs is None:
516  raise RuntimeError("Unable to match sources with catalog.")
517  self.log.info('Got astrometric solution from Astrometry.net')
518 
519  rdc = wcs.pixelToSky(xc, yc)
520  self._debug('New WCS says image center pixel (%.1f, %.1f) -> RA,Dec (%.3f, %.3f)' %
521  (xc, yc, rdc.getLongitude().asDegrees(), rdc.getLatitude().asDegrees()))
522  return wcs,qa
523 
524  def getSipWcsFromWcs(self, wcs, imageSize, x0=0, y0=0, ngrid=20,
525  linearizeAtCenter=True):
526  '''
527  This function allows one to get a TAN-SIP WCS, starting from
528  an existing WCS. It uses your WCS to compute a fake grid of
529  corresponding "stars" in pixel and sky coords, and feeds that
530  to the regular SIP code.
531 
532  linearizeCenter: if True, get a linear approximation of the input
533  WCS at the image center and use that as the TAN initialization for
534  the TAN-SIP solution. You probably want this if your WCS has its
535  CRPIX outside the image bounding box.
536 
537  '''
538  # Ugh, build src and ref tables
539  srcSchema = afwTable.SourceTable.makeMinimalSchema()
540  key = srcSchema.addField("centroid", type="PointD")
541  srcTable = afwTable.SourceTable.make(srcSchema)
542  srcTable.defineCentroid("centroid")
543  srcs = srcTable
544  refs = afwTable.SimpleTable.make(afwTable.SimpleTable.makeMinimalSchema())
545  cref = []
546  csrc = []
547  (W,H) = imageSize
548  for xx in np.linspace(0., W, ngrid):
549  for yy in np.linspace(0, H, ngrid):
550  src = srcs.makeRecord()
551  src.set(key.getX(), x0 + xx)
552  src.set(key.getY(), y0 + yy)
553  csrc.append(src)
554  rd = wcs.pixelToSky(afwGeom.Point2D(xx + x0, yy + y0))
555  ref = refs.makeRecord()
556  ref.setCoord(rd)
557  cref.append(ref)
558 
559  if linearizeAtCenter:
560  # Linearize the original WCS around the image center to create a
561  # TAN WCS.
562  # Reference pixel in LSST coords
563  crpix = afwGeom.Point2D(x0 + W/2. - 0.5, y0 + H/2. - 0.5)
564  crval = wcs.pixelToSky(crpix)
565  crval = crval.getPosition(afwGeom.degrees)
566  # Linearize *AT* crval to get effective CD at crval.
567  # (we use the default skyUnit of degrees as per WCS standard)
568  aff = wcs.linearizePixelToSky(crval)
569  cd = aff.getLinear().getMatrix()
570  wcs = afwImage.Wcs(crval, crpix, cd)
571 
572  return self.getSipWcsFromCorrespondences(wcs, cref, csrc, (W,H),
573  x0=x0, y0=y0)
574 
575 
576  def getSipWcsFromCorrespondences(self, origWcs, cat, sources, imageSize,
577  x0=0, y0=0):
578  '''
579  Produces a SIP solution given a list of known correspondences.
580  Unlike _calculateSipTerms, this does not iterate the solution;
581  it assumes you have given it a good sets of corresponding stars.
582 
583  NOTE that "cat" and "sources" are assumed to be the same length;
584  entries "cat[i]" and "sources[i]" are assumed to be correspondences.
585 
586  origWcs: the WCS to linearize in order to get the TAN part of the
587  TAN-SIP WCS.
588 
589  cat: reference source catalog
590 
591  sources: image sources
592 
593  imageSize, x0, y0: these describe the bounding-box of the image,
594  which is used when computing reverse SIP polynomials.
595 
596  '''
597  sipOrder = self.config.sipOrder
598  bbox = afwGeom.Box2I(afwGeom.Point2I(x0,y0),
599  afwGeom.Extent2I(imageSize[0], imageSize[1]))
600  matchList = []
601  for ci,si in zip(cat, sources):
602  matchList.append(afwTable.ReferenceMatch(ci, si, 0.))
603 
604  sipObject = astromSip.makeCreateWcsWithSip(matchList, origWcs, sipOrder, bbox)
605  return sipObject.getNewWcs()
606 
607  def _calculateSipTerms(self, origWcs, cat, sources, matchList, imageSize,
608  x0=0, y0=0):
609  '''
610  Iteratively calculate SIP distortions and regenerate matchList based on improved WCS.
611 
612  origWcs: original WCS object, probably (but not necessarily) a TAN WCS;
613  this is used to set the baseline when determining whether a SIP
614  solution is any better; it will be returned if no better SIP solution
615  can be found.
616 
617  matchList: list of supposedly matched sources, using the "origWcs".
618 
619  cat: reference source catalog
620 
621  sources: sources in the image to be solved
622 
623  imageSize, x0, y0: these determine the bounding-box of the image,
624  which is used when finding reverse SIP coefficients.
625  '''
626  sipOrder = self.config.sipOrder
627  wcs = origWcs
628  bbox = afwGeom.Box2I(afwGeom.Point2I(x0,y0),
629  afwGeom.Extent2I(imageSize[0], imageSize[1]))
630 
631  i=0
632  lastScatPix = None
633  while True:
634  try:
635  sipObject = astromSip.makeCreateWcsWithSip(matchList, wcs, sipOrder, bbox)
636  if lastScatPix is None:
637  lastScatPix = sipObject.getLinearScatterInPixels()
638  proposedWcs = sipObject.getNewWcs()
639  scatPix = sipObject.getScatterInPixels()
640  self.plotSolution(matchList, proposedWcs, imageSize)
641  except pexExceptions.Exception as e:
642  self._warn('Failed to calculate distortion terms. Error: ' + str(e))
643  break
644 
645  matchSize = len(matchList)
646  # use new WCS to get new matchlist.
647  proposedMatchlist = self._getMatchList(sources, cat, proposedWcs)
648 
649  self._debug('SIP iteration %i: %i objects match. Median scatter is %g arcsec = %g pixels (vs previous: %i matches, %g pixels)' %
650  (i, len(proposedMatchlist), sipObject.getScatterOnSky().asArcseconds(), scatPix, matchSize, lastScatPix))
651  #self._debug('Proposed WCS: ' + proposedWcs.getFitsMetadata().toString())
652  # Hack convergence tests
653  if len(proposedMatchlist) < matchSize:
654  break
655  if len(proposedMatchlist) == matchSize and scatPix >= lastScatPix:
656  break
657 
658  wcs = proposedWcs
659  matchList = proposedMatchlist
660  lastScatPix = scatPix
661  matchSize = len(matchList)
662  i += 1
663 
664  return wcs, matchList
665 
666  def plotSolution(self, matchList, wcs, imageSize):
667  """Plot the solution, when debugging is turned on.
668 
669  @param matchList The list of matches
670  @param wcs The Wcs
671  @param imageSize 2-tuple with the image size (W,H)
672  """
673  import lsstDebug
674  display = lsstDebug.Info(__name__).display
675  if not display:
676  return
677 
678  try:
679  import matplotlib.pyplot as plt
680  import numpy
681  except ImportError:
682  print >> sys.stderr, "Unable to import matplotlib"
683  return
684 
685  fig = plt.figure(1)
686  fig.clf()
687  try:
688  fig.canvas._tkcanvas._root().lift() # == Tk's raise, but raise is a python reserved word
689  except: # protect against API changes
690  pass
691 
692  num = len(matchList)
693  x = numpy.zeros(num)
694  y = numpy.zeros(num)
695  dx = numpy.zeros(num)
696  dy = numpy.zeros(num)
697  for i, m in enumerate(matchList):
698  x[i] = m.second.getX()
699  y[i] = m.second.getY()
700  pixel = wcs.skyToPixel(m.first.getCoord())
701  dx[i] = x[i] - pixel.getX()
702  dy[i] = y[i] - pixel.getY()
703 
704  subplots = maUtils.makeSubplots(fig, 2, 2, xgutter=0.1, ygutter=0.1, pygutter=0.04)
705 
706  def plotNext(x, y, xLabel, yLabel, xMax):
707  ax = subplots.next()
708  ax.set_autoscalex_on(False)
709  ax.set_xbound(lower=0, upper=xMax)
710  ax.scatter(x, y)
711  ax.set_xlabel(xLabel)
712  ax.set_ylabel(yLabel)
713  ax.axhline(0.0)
714 
715  plotNext(x, dx, "x", "dx", imageSize[0])
716  plotNext(x, dy, "x", "dy", imageSize[0])
717  plotNext(y, dx, "y", "dx", imageSize[1])
718  plotNext(y, dy, "y", "dy", imageSize[1])
719 
720  fig.show()
721 
722  while True:
723  try:
724  reply = raw_input("Pausing for inspection, enter to continue... [hpQ] ").strip()
725  except EOFError:
726  reply = "n"
727 
728  reply = reply.split()
729  if reply:
730  reply = reply[0]
731  else:
732  reply = ""
733 
734  if reply in ("", "h", "p", "Q"):
735  if reply == "h":
736  print "h[elp] p[db] Q[uit]"
737  continue
738  elif reply == "p":
739  import pdb; pdb.set_trace()
740  elif reply == "Q":
741  sys.exit(1)
742  break
743 
744  def _getMatchList(self, sources, cat, wcs):
745  dist = self.config.catalogMatchDist * afwGeom.arcseconds
746  clean = self.config.cleaningParameter
747  matcher = astromSip.MatchSrcToCatalogue(cat, sources, wcs, dist)
748  matchList = matcher.getMatches()
749  if matchList is None:
750  # Produce debugging stats...
751  X = [src.getX() for src in sources]
752  Y = [src.getY() for src in sources]
753  R1 = [src.getRa().asDegrees() for src in sources]
754  D1 = [src.getDec().asDegrees() for src in sources]
755  R2 = [src.getRa().asDegrees() for src in cat]
756  D2 = [src.getDec().asDegrees() for src in cat]
757  # for src in sources:
758  #self._debug("source: x,y (%.1f, %.1f), RA,Dec (%.3f, %.3f)" %
759  #(src.getX(), src.getY(), src.getRa().asDegrees(), src.getDec().asDegrees()))
760  #for src in cat:
761  #self._debug("ref: RA,Dec (%.3f, %.3f)" %
762  #(src.getRa().asDegrees(), src.getDec().asDegrees()))
763  self.loginfo('_getMatchList: %i sources, %i reference sources' % (len(sources), len(cat)))
764  if len(sources):
765  self.loginfo('Source range: x [%.1f, %.1f], y [%.1f, %.1f], RA [%.3f, %.3f], Dec [%.3f, %.3f]' %
766  (min(X), max(X), min(Y), max(Y), min(R1), max(R1), min(D1), max(D1)))
767  if len(cat):
768  self.loginfo('Reference range: RA [%.3f, %.3f], Dec [%.3f, %.3f]' %
769  (min(R2), max(R2), min(D2), max(D2)))
770  raise RuntimeError('No matches found between image and catalogue')
771  matchList = astromSip.cleanBadPoints.clean(matchList, wcs, nsigma=clean)
772  return matchList
773 
774  def getColumnName(self, filterName, columnMap, default=None):
775  '''
776  Returns the column name in the astrometry_net_data index file that will be used
777  for the given filter name.
778 
779  @param filterName Name of filter used in exposure
780  @param columnMap Dict that maps filter names to column names
781  @param default Default column name
782  '''
783  filterName = self.config.filterMap.get(filterName, filterName) # Exposure filter --> desired filter
784  try:
785  return columnMap[filterName] # Desired filter --> a_n_d column name
786  except KeyError:
787  self.log.warn("No column in configuration for filter '%s'; using default '%s'" %
788  (filterName, default))
789  return default
790 
791  def getCatalogFilterName(self, filterName):
792  """Deprecated method for retrieving the magnitude column name from the filter name"""
793  return self.getColumnName(filterName, self.andConfig.magColumnMap, self.andConfig.defaultMagColumn)
794 
795 
796  def getReferenceSourcesForWcs(self, wcs, imageSize, filterName, pixelMargin=50, x0=0, y0=0, trim=True):
797  W,H = imageSize
798  xc, yc = W/2. + 0.5, H/2. + 0.5
799  rdc = wcs.pixelToSky(x0 + xc, y0 + yc)
800  ra,dec = rdc.getLongitude(), rdc.getLatitude()
801  self._debug('Getting reference sources using center: pixel (%.1f, %.1f) -> RA,Dec (%.3f, %.3f)' %
802  (xc, yc, ra.asDegrees(), dec.asDegrees()))
803  pixelScale = wcs.pixelScale()
804  rad = pixelScale * (math.hypot(W,H)/2. + pixelMargin)
805  self._debug('Getting reference sources using radius of %.3g deg' % rad.asDegrees())
806  cat = self.getReferenceSources(ra, dec, rad, filterName)
807  # NOTE: reference objects don't have (x,y) anymore, so we can't apply WCS to set x,y positions
808  if trim:
809  # cut to image bounds + margin.
810  bbox = afwGeom.Box2D(afwGeom.Point2D(x0, y0), afwGeom.Extent2D(W, H))
811  bbox.grow(pixelMargin)
812  cat = self._trimBadPoints(cat, bbox, wcs=wcs) # passing wcs says to compute x,y on-the-fly
813  return cat
814 
815 
816  def getReferenceSources(self, ra, dec, radius, filterName):
817  '''
818  Searches for reference-catalog sources (in the
819  astrometry_net_data files) in the requested RA,Dec region
820  (afwGeom::Angle objects), with the requested radius (also an
821  Angle). The flux values will be set based on the requested
822  filter (None => default filter).
823 
824  Returns: an lsst.afw.table.SimpleCatalog of reference objects
825  '''
826  sgCol = self.andConfig.starGalaxyColumn
827  varCol = self.andConfig.variableColumn
828  idcolumn = self.andConfig.idColumn
829 
830  magCol = self.getColumnName(filterName, self.andConfig.magColumnMap, self.andConfig.defaultMagColumn)
831  magerrCol = self.getColumnName(filterName, self.andConfig.magErrorColumnMap,
832  self.andConfig.defaultMagErrorColumn)
833 
834  if self.config.allFluxes:
835  names = []
836  mcols = []
837  ecols = []
838  if magCol:
839  names.append('flux')
840  mcols.append(magCol)
841  ecols.append(magerrCol)
842 
843  for col,mcol in self.andConfig.magColumnMap.items():
844  names.append(col)
845  mcols.append(mcol)
846  ecols.append(self.andConfig.magErrorColumnMap.get(col, ''))
847  margs = (names, mcols, ecols)
848 
849  else:
850  margs = (magCol, magerrCol)
851 
852  '''
853  Note about multiple astrometry_net index files and duplicate IDs:
854 
855  -as of astrometry_net 0.30, we take a reference catalog and build
856  a set of astrometry_net index files from it, with each one covering a
857  region of sky and a range of angular scales. The index files covering
858  the same region of sky at different scales use exactly the same stars.
859  Therefore, if we search every index file, we will get multiple copies of
860  each reference star (one from each index file).
861  For now, we have the "unique_ids" option to solver.getCatalog().
862  -recall that the index files to be used are specified in the
863  AstrometryNetDataConfig.indexFiles flat list.
864 
865  -as of astrometry_net 0.40, we have the capability to share
866  the reference stars between index files (called
867  "multiindex"), so we will no longer have to repeat the
868  reference stars in each index. We will, however, have to
869  change the way the index files are configured to take
870  advantage of this functionality. Once this is in place, we
871  can eliminate the horrid ID checking and deduplication (in solver.getCatalog()).
872  '''
873 
874  solver = self._getSolver()
875 
876  # Find multi-index files within range
877  raDecRadius = (ra.asDegrees(), dec.asDegrees(), radius.asDegrees())
878  multiInds = self._getMIndexesWithinRange(*raDecRadius)
879 
880  with Astrometry._LoadedMIndexes(multiInds):
881  # We just want to pass the star kd-trees, so just pass the
882  # first element of each multi-index.
883  inds = [mi[0] for mi in multiInds]
884 
885  cat = solver.getCatalog(*((inds,) + raDecRadius + (idcolumn,)
886  + margs + (sgCol, varCol)))
887  del solver
888  return cat
889 
890  def _solve(self, sources, wcs, imageSize, pixelScale, radecCenter,
891  searchRadius, parity, filterName=None, xy0=None):
892  solver = self._getSolver()
893 
894  x0,y0 = 0,0
895  if xy0 is not None:
896  x0,y0 = xy0
897 
898  # select sources with valid x,y, flux
899  xybb = afwGeom.Box2D()
900  goodsources = afwTable.SourceCatalog(sources.table)
901  badkeys = [goodsources.getSchema().find(name).key for name in self.config.badFlags]
902 
903  for s in sources:
904  if np.isfinite(s.getX()) and np.isfinite(s.getY()) and np.isfinite(s.getPsfFlux()) and self._isGoodSource(s, badkeys) :
905  goodsources.append(s)
906  xybb.include(afwGeom.Point2D(s.getX() - x0, s.getY() - y0))
907  self.log.info("Number of selected sources for astrometry : %d" %(len(goodsources)))
908  if len(goodsources) < len(sources):
909  self.log.logdebug('Keeping %i of %i sources with finite X,Y positions and PSF flux' %
910  (len(goodsources), len(sources)))
911  self._debug(('Feeding sources in range x=[%.1f, %.1f], y=[%.1f, %.1f] ' +
912  '(after subtracting x0,y0 = %.1f,%.1f) to Astrometry.net') %
913  (xybb.getMinX(), xybb.getMaxX(), xybb.getMinY(), xybb.getMaxY(), x0, y0))
914  # setStars sorts them by PSF flux.
915  solver.setStars(goodsources, x0, y0)
916  solver.setMaxStars(self.config.maxStars)
917  solver.setImageSize(*imageSize)
918  solver.setMatchThreshold(self.config.matchThreshold)
919  raDecRadius = None
920  if radecCenter is not None:
921  raDecRadius = (radecCenter.getLongitude().asDegrees(),
922  radecCenter.getLatitude().asDegrees(),
923  searchRadius.asDegrees())
924  solver.setRaDecRadius(*raDecRadius)
925  self.log.logdebug('Searching for match around RA,Dec = (%g, %g) with radius %g deg' %
926  raDecRadius)
927 
928  if pixelScale is not None:
929  dscale = self.config.pixelScaleUncertainty
930  scale = pixelScale.asArcseconds()
931  lo = scale / dscale
932  hi = scale * dscale
933  solver.setPixelScaleRange(lo, hi)
934  self.log.logdebug('Searching for matches with pixel scale = %g +- %g %% -> range [%g, %g] arcsec/pix' %
935  (scale, 100.*(dscale-1.), lo, hi))
936 
937  if parity is not None:
938  solver.setParity(parity)
939  self.log.logdebug('Searching for match with parity = ' + str(parity))
940 
941  # Find and load index files within RA,Dec range and scale range.
942  if raDecRadius is not None:
943  multiInds = self._getMIndexesWithinRange(*raDecRadius)
944  else:
945  multiInds = self.multiInds
946  qlo,qhi = solver.getQuadSizeLow(), solver.getQuadSizeHigh()
947 
948  toload_multiInds = set()
949  toload_inds = []
950  for mi in multiInds:
951  for i in range(len(mi)):
952  ind = mi[i]
953  if not ind.overlapsScaleRange(qlo, qhi):
954  continue
955  toload_multiInds.add(mi)
956  toload_inds.append(ind)
957 
958  with Astrometry._LoadedMIndexes(toload_multiInds):
959  solver.addIndices(toload_inds)
960  self.memusage('Index files loaded: ')
961 
962  cpulimit = self.config.maxCpuTime
963  solver.run(cpulimit)
964 
965  self.memusage('Solving finished: ')
966 
967  self.memusage('Index files unloaded: ')
968 
969  if solver.didSolve():
970  self.log.logdebug('Solved!')
971  wcs = solver.getWcs()
972  self.log.logdebug('WCS: %s' % wcs.getFitsMetadata().toString())
973 
974  if x0 != 0 or y0 != 0:
975  wcs.shiftReferencePixel(x0, y0)
976  self.log.logdebug('After shifting reference pixel by x0,y0 = (%i,%i), WCS is: %s' %
977  (x0, y0, wcs.getFitsMetadata().toString()))
978 
979  else:
980  self.log.warn('Did not get an astrometric solution from Astrometry.net')
981  wcs = None
982  # Gather debugging info...
983 
984  # -are there any reference stars in the proposed search area?
985  if radecCenter is not None:
986  ra = radecCenter.getLongitude()
987  dec = radecCenter.getLatitude()
988  refs = self.getReferenceSources(ra, dec, searchRadius, filterName)
989  self.log.info('Searching around RA,Dec = (%g,%g) with radius %g deg yields %i reference-catalog sources' %
990  (ra.asDegrees(), dec.asDegrees(), searchRadius.asDegrees(), len(refs)))
991 
992  qa = solver.getSolveStats()
993  self.log.logdebug('qa: %s' % qa.toString())
994  return wcs, qa
995 
996  def _isGoodSource(self, candsource, keys):
997  for k in keys:
998  if candsource.get(k):
999  return False
1000  return True
1001 
1002  def _getIndexPath(self, fn):
1003  if os.path.isabs(fn):
1004  return fn
1005  andir = os.getenv('ASTROMETRY_NET_DATA_DIR')
1006  if andir is not None:
1007  fn2 = os.path.join(andir, fn)
1008  if os.path.exists(fn2):
1009  return fn2
1010 
1011  if os.path.exists(fn):
1012  return os.path.abspath(fn)
1013  else:
1014  return None
1015 
1016  def _getMIndexesWithinRange(self, ra, dec, radius):
1017  '''
1018  ra,dec,radius: [deg], spatial cut based on the healpix of the index
1019 
1020  Returns list of multiindex objects within range.
1021  '''
1022  good = []
1023  for mi in self.multiInds:
1024  if mi.isWithinRange(ra, dec, radius):
1025  good.append(mi)
1026  return good
1027 
1028  def _getSolver(self):
1029  import astrometry_net as an
1030  solver = an.solver_new()
1031  # HACK, set huge default pixel scale range.
1032  lo,hi = 0.01, 3600.
1033  solver.setPixelScaleRange(lo, hi)
1034  return solver
1035 
1036  @staticmethod
1037  def _trimBadPoints(sources, bbox, wcs=None):
1038  '''Remove elements from catalog whose xy positions are not within the given bbox.
1039 
1040  sources: a Catalog of SimpleRecord or SourceRecord objects
1041  bbox: an afwImage.Box2D
1042  wcs: if not None, will be used to compute the xy positions on-the-fly;
1043  this is required when sources actually contains SimpleRecords.
1044 
1045  Returns:
1046  a list of Source objects with xAstrom,yAstrom within the bbox.
1047  '''
1048  keep = type(sources)(sources.table)
1049  for s in sources:
1050  point = s.getCentroid() if wcs is None else wcs.skyToPixel(s.getCoord())
1051  if bbox.contains(point):
1052  keep.append(s)
1053  return keep
1054 
1055  def joinMatchListWithCatalog(self, packedMatches, sourceCat):
1056  '''
1057  This function is required to reconstitute a ReferenceMatchVector after being
1058  unpersisted. The persisted form of a ReferenceMatchVector is the
1059  normalized Catalog of IDs produced by afw.table.packMatches(), with the result of
1060  InitialAstrometry.getMatchMetadata() in the associated tables\' metadata.
1061 
1062  The "live" form of a matchlist has links to
1063  the real record objects that are matched; it is "denormalized".
1064  This function takes a normalized match catalog, along with the catalog of
1065  sources to which the match catalog refers. It fetches the reference
1066  sources that are within range, and then denormalizes the matches
1067  -- sets the "matchList[*].first" and "matchList[*].second" entries
1068  to point to the sources in the "sources" argument, and to the
1069  reference sources fetched from the astrometry_net_data files.
1070 
1071  @param[in] packedMatches Unpersisted match list (an lsst.afw.table.BaseCatalog).
1072  packedMatches.table.getMetadata() must contain the
1073  values from InitialAstrometry.getMatchMetadata()
1074  @param[in,out] sourceCat Source catalog used for the 'second' side of the matches
1075  (an lsst.afw.table.SourceCatalog). As a side effect,
1076  the catalog will be sorted by ID.
1077 
1078  @return An lsst.afw.table.ReferenceMatchVector of denormalized matches.
1079  '''
1080  matchmeta = packedMatches.table.getMetadata()
1081  version = matchmeta.getInt('SMATCHV')
1082  if version != 1:
1083  raise ValueError('SourceMatchVector version number is %i, not 1.' % version)
1084  filterName = matchmeta.getString('FILTER').strip()
1085  # all in deg.
1086  ra = matchmeta.getDouble('RA') * afwGeom.degrees
1087  dec = matchmeta.getDouble('DEC') * afwGeom.degrees
1088  rad = matchmeta.getDouble('RADIUS') * afwGeom.degrees
1089  self.log.logdebug('Searching RA,Dec %.3f,%.3f, radius %.1f arcsec, filter "%s"' %
1090  (ra.asDegrees(), dec.asDegrees(), rad.asArcseconds(), filterName))
1091  refCat = self.getReferenceSources(ra, dec, rad, filterName)
1092  self.log.logdebug('Found %i reference catalog sources in range' % len(refCat))
1093  refCat.sort()
1094  sourceCat.sort()
1095  return afwTable.unpackMatches(packedMatches, refCat, sourceCat)
1096 
1097 
1098 def _createMetadata(width, height, x0, y0, wcs, filterName):
1099  """
1100  Create match metadata entries required for regenerating the catalog
1101 
1102  @param width Width of the image (pixels)
1103  @param height Height of the image (pixels)
1104  @param x0 x offset of image origin from parent (pixels)
1105  @param y0 y offset of image origin from parent (pixels)
1106  @param filterName Name of filter, used for magnitudes
1107  @return Metadata
1108  """
1109  meta = dafBase.PropertyList()
1110 
1111  # cache: field center and size.
1112  cx,cy = x0 + 0.5 + width/2., y0 + 0.5 + height/2.
1113  radec = wcs.pixelToSky(cx, cy).toIcrs()
1114  meta.add('RA', radec.getRa().asDegrees(), 'field center in degrees')
1115  meta.add('DEC', radec.getDec().asDegrees(), 'field center in degrees')
1116  imgSize = wcs.pixelScale() * math.hypot(width, height)/2.
1117  meta.add('RADIUS', imgSize.asDegrees(),
1118  'field radius in degrees, approximate')
1119  meta.add('SMATCHV', 1, 'SourceMatchVector version number')
1120  if filterName is not None:
1121  meta.add('FILTER', filterName, 'LSST filter name for tagalong data')
1122  return meta
1123 
1124 def readMatches(butler, dataId, sourcesName='icSrc', matchesName='icMatch', config=MeasAstromConfig(),
1125  sourcesFlags=afwTable.SOURCE_IO_NO_FOOTPRINTS):
1126  """Read matches, sources and catalogue; combine.
1127 
1128  @param butler Data butler
1129  @param dataId Data identifier for butler
1130  @param sourcesName Name for sources from butler
1131  @param matchesName Name for matches from butler
1132  @param sourcesFlags Flags to pass for source retrieval
1133  @returns Matches
1134  """
1135  sources = butler.get(sourcesName, dataId, flags=sourcesFlags)
1136  packedMatches = butler.get(matchesName, dataId)
1137  astrom = Astrometry(config)
1138  return astrom.joinMatchListWithCatalog(packedMatches, sources)
Class for storing ordered metadata with comments.
Definition: PropertyList.h:81
Implementation of the WCS standard for a any projection.
Definition: Wcs.h:107
a place to record messages and descriptions of the state of processing.
Definition: Log.h:154
An integer coordinate rectangle.
Definition: Box.h:53
double min
Definition: attributes.cc:216
double max
Definition: attributes.cc:218
Custom catalog class for record/table subclasses that are guaranteed to have an ID, and should generally be sorted by that ID.
Definition: fwd.h:55
Lightweight representation of a geometric match between two records.
Definition: fwd.h:90
A floating-point coordinate rectangle geometry.
Definition: Box.h:271
def getSipWcs
&quot;Not very pythonic!&quot; complains Paul.
Definition: astrom.py:60
std::vector< Match< typename Cat1::Record, typename Cat2::Record > > unpackMatches(BaseCatalog const &matches, Cat1 const &cat1, Cat2 const &cat2)
Reconstruct a MatchVector from a BaseCatalog representation of the matches and a pair of catalogs...