LSSTApplications  11.0-13-gbb96280,12.1+18,12.1+7,12.1-1-g14f38d3+72,12.1-1-g16c0db7+5,12.1-1-g5961e7a+84,12.1-1-ge22e12b+23,12.1-11-g06625e2+4,12.1-11-g0d7f63b+4,12.1-19-gd507bfc,12.1-2-g7dda0ab+38,12.1-2-gc0bc6ab+81,12.1-21-g6ffe579+2,12.1-21-gbdb6c2a+4,12.1-24-g941c398+5,12.1-3-g57f6835+7,12.1-3-gf0736f3,12.1-37-g3ddd237,12.1-4-gf46015e+5,12.1-5-g06c326c+20,12.1-5-g648ee80+3,12.1-5-gc2189d7+4,12.1-6-ga608fc0+1,12.1-7-g3349e2a+5,12.1-7-gfd75620+9,12.1-9-g577b946+5,12.1-9-gc4df26a+10
LSSTDataManagementBasePackage
anetBasicAstrometry.py
Go to the documentation of this file.
1 from __future__ import absolute_import, division
2 from __future__ import print_function
3 from builtins import zip
4 from builtins import next
5 from builtins import input
6 from builtins import range
7 from builtins import object
8 #
9 # LSST Data Management System
10 #
11 # Copyright 2008-2015 AURA/LSST.
12 #
13 # This product includes software developed by the
14 # LSST Project (http://www.lsst.org/).
15 #
16 # This program is free software: you can redistribute it and/or modify
17 # it under the terms of the GNU General Public License as published by
18 # the Free Software Foundation, either version 3 of the License, or
19 # (at your option) any later version.
20 #
21 # This program is distributed in the hope that it will be useful,
22 # but WITHOUT ANY WARRANTY; without even the implied warranty of
23 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
24 # GNU General Public License for more details.
25 #
26 # You should have received a copy of the LSST License Statement and
27 # the GNU General Public License along with this program. If not,
28 # see <https://www.lsstcorp.org/LegalNotices/>.
29 #
30 import math
31 import sys
32 
33 import numpy as np
34 
35 import lsst.daf.base as dafBase
36 from lsst.pex.config import Field, RangeField, ListField
37 import lsst.pex.exceptions as pexExceptions
38 import lsst.pipe.base as pipeBase
39 import lsst.afw.geom as afwGeom
40 import lsst.afw.image as afwImage
41 import lsst.afw.math as afwMath
42 import lsst.afw.table as afwTable
43 import lsst.meas.algorithms.utils as maUtils
44 from .loadAstrometryNetObjects import LoadAstrometryNetObjectsTask, LoadMultiIndexes
45 from .display import displayAstrometry
46 from .astromLib import makeMatchStatisticsInRadians
47 
48 from . import sip as astromSip
49 
50 __all__ = ["InitialAstrometry", "ANetBasicAstrometryConfig", "ANetBasicAstrometryTask"]
51 
52 
53 class InitialAstrometry(object):
54  """
55  Object returned by Astrometry.determineWcs
56 
57  getWcs(): sipWcs or tanWcs
58  getMatches(): sipMatches or tanMatches
59 
60  Other fields are:
61  solveQa (PropertyList)
62  tanWcs (Wcs)
63  tanMatches (MatchList)
64  sipWcs (Wcs)
65  sipMatches (MatchList)
66  refCat (lsst::afw::table::SimpleCatalog)
67  matchMeta (PropertyList)
68  """
69 
70  def __init__(self):
71  self.tanWcs = None
72  self.tanMatches = None
73  self.sipWcs = None
74  self.sipMatches = None
75  self.refCat = None
77  self.solveQa = None
78 
79  def getMatches(self):
80  """
81  Get "sipMatches" -- MatchList using the SIP WCS solution, if it
82  exists, or "tanMatches" -- MatchList using the TAN WCS solution
83  otherwise.
84  """
85  return self.sipMatches or self.tanMatches
86 
87  def getWcs(self):
88  """
89  Returns the SIP WCS, if one was found, or a TAN WCS
90  """
91  return self.sipWcs or self.tanWcs
92 
93  matches = property(getMatches)
94  wcs = property(getWcs)
95 
96  # "Not very pythonic!" complains Paul.
97  # Consider these methods deprecated; if you want these elements, just
98  # .grab them.
99  def getSipWcs(self):
100  return self.sipWcs
101 
102  def getTanWcs(self):
103  return self.tanWcs
104 
105  def getSipMatches(self):
106  return self.sipMatches
107 
108  def getTanMatches(self):
109  return self.tanMatches
110 
111  def getMatchMetadata(self):
112  return self.matchMeta
113 
115  return self.solveQa
116 
117 
118 class ANetBasicAstrometryConfig(LoadAstrometryNetObjectsTask.ConfigClass):
119 
120  maxCpuTime = RangeField(
121  doc="Maximum CPU time to spend solving, in seconds",
122  dtype=float,
123  default=0.,
124  min=0.,
125  )
126  matchThreshold = RangeField(
127  doc="Matching threshold for Astrometry.net solver (log-odds)",
128  dtype=float,
129  default=math.log(1e12),
130  min=math.log(1e6),
131  )
132  maxStars = RangeField(
133  doc="Maximum number of stars to use in Astrometry.net solving",
134  dtype=int,
135  default=50,
136  min=10,
137  )
138  useWcsPixelScale = Field(
139  doc="Use the pixel scale from the input exposure\'s WCS headers?",
140  dtype=bool,
141  default=True,
142  )
143  useWcsRaDecCenter = Field(
144  doc="Use the RA,Dec center information from the input exposure\'s WCS headers?",
145  dtype=bool,
146  default=True,
147  )
148  useWcsParity = Field(
149  doc="Use the parity (flip / handedness) of the image from the input exposure\'s WCS headers?",
150  dtype=bool,
151  default=True,
152  )
153  raDecSearchRadius = RangeField(
154  doc="When useWcsRaDecCenter=True, this is the radius, in degrees, around the RA,Dec center " +
155  "specified in the input exposure\'s WCS to search for a solution.",
156  dtype=float,
157  default=1.0,
158  min=0.0,
159  )
160  pixelScaleUncertainty = RangeField(
161  doc="Range of pixel scales, around the value in the WCS header, to search. " +
162  "If the value of this field is X and the nominal scale is S, " +
163  "the range searched will be S/X to S*X",
164  dtype=float,
165  default=1.1,
166  min=1.001,
167  )
168  catalogMatchDist = RangeField(
169  doc="Matching radius (arcsec) for matching sources to reference objects",
170  dtype=float,
171  default=1.0,
172  min=0.0,
173  )
174  cleaningParameter = RangeField(
175  doc="Sigma-clipping parameter in sip/cleanBadPoints.py",
176  dtype=float,
177  default=3.0,
178  min=0.0,
179  )
180  calculateSip = Field(
181  doc="Compute polynomial SIP distortion terms?",
182  dtype=bool,
183  default=True,
184  )
185  sipOrder = RangeField(
186  doc="Polynomial order of SIP distortion terms",
187  dtype=int,
188  default=4,
189  min=2,
190  )
191  badFlags = ListField(
192  doc="List of flags which cause a source to be rejected as bad",
193  dtype=str,
194  default=[
195  "slot_Centroid_flag", # bad centroids
196  "base_PixelFlags_flag_edge",
197  "base_PixelFlags_flag_saturated",
198  "base_PixelFlags_flag_crCenter", # cosmic rays
199  ],
200  )
201  allFluxes = Field(
202  doc="Retrieve all available fluxes (and errors) from catalog?",
203  dtype=bool,
204  default=True,
205  )
206  maxIter = RangeField(
207  doc="maximum number of iterations of match sources and fit WCS" +
208  "ignored if not fitting a WCS",
209  dtype=int,
210  default=5,
211  min=1,
212  )
213  matchDistanceSigma = RangeField(
214  doc="The match and fit loop stops when maxMatchDist minimized: "
215  " maxMatchDist = meanMatchDist + matchDistanceSigma*stdDevMatchDistance " +
216  " (where the mean and std dev are computed using outlier rejection);" +
217  " ignored if not fitting a WCS",
218  dtype=float,
219  default=2,
220  min=0,
221  )
222 
223 
224 class ANetBasicAstrometryTask(pipeBase.Task):
225  """!Basic implemeentation of the astrometry.net astrometrical fitter
226 
227  A higher-level class ANetAstrometryTask takes care of dealing with the fact
228  that the initial WCS is probably only a pure TAN SIP, yet we may have
229  significant distortion and a good estimate for that distortion.
230 
231 
232  About Astrometry.net index files (astrometry_net_data):
233 
234  There are three components of an index file: a list of stars
235  (stored as a star kd-tree), a list of quadrangles of stars ("quad
236  file") and a list of the shapes ("codes") of those quadrangles,
237  stored as a code kd-tree.
238 
239  Each index covers a region of the sky, defined by healpix nside
240  and number, and a range of angular scales. In LSST, we share the
241  list of stars in a part of the sky between multiple indexes. That
242  is, the star kd-tree is shared between multiple indices (quads and
243  code kd-trees). In the astrometry.net code, this is called a
244  "multiindex".
245 
246  It is possible to "unload" and "reload" multiindex (and index)
247  objects. When "unloaded", they consume no FILE or mmap resources.
248 
249  The multiindex object holds the star kd-tree and gives each index
250  object it holds a pointer to it, so it is necessary to
251  multiindex_reload_starkd() before reloading the indices it holds.
252  The multiindex_unload() method, on the other hand, unloads its
253  starkd and unloads each index it holds.
254  """
255  ConfigClass = ANetBasicAstrometryConfig
256  _DefaultName = "aNetBasicAstrometry"
257 
258  def __init__(self,
259  config,
260  andConfig=None,
261  **kwargs):
262  """!Construct an ANetBasicAstrometryTask
263 
264  @param[in] config configuration (an instance of self.ConfigClass)
265  @param[in] andConfig astrometry.net data config (an instance of AstromNetDataConfig, or None);
266  if None then use andConfig.py in the astrometry_net_data product (which must be setup)
267  @param[in] kwargs additional keyword arguments for pipe_base Task.\_\_init\_\_
268 
269  @throw RuntimeError if andConfig is None and the configuration cannot be found,
270  either because astrometry_net_data is not setup in eups
271  or because the setup version does not include the file "andConfig.py"
272  """
273  pipeBase.Task.__init__(self, config=config, **kwargs)
274  self.config = config
275  # this is not a subtask because it cannot safely be retargeted
276  self.refObjLoader = LoadAstrometryNetObjectsTask(
277  config=self.config,
278  andConfig=andConfig,
279  log=self.log,
280  name="loadAN",
281  )
282  self.refObjLoader._readIndexFiles()
283 
284  def memusage(self, prefix=''):
285  # Not logging at DEBUG: do nothing
286  if self.log.getLevel() > self.log.DEBUG:
287  return
288  from astrometry.util.ttime import get_memusage
289  mu = get_memusage()
290  ss = []
291  for key in ['VmPeak', 'VmSize', 'VmRSS', 'VmData']:
292  if key in mu:
293  ss.append(key + ': ' + ' '.join(mu[key]))
294  if 'mmaps' in mu:
295  ss.append('Mmaps: %i' % len(mu['mmaps']))
296  if 'mmaps_total' in mu:
297  ss.append('Mmaps: %i kB' % (mu['mmaps_total'] / 1024))
298  self.log.debug(prefix + 'Memory: ' + ', '.join(ss))
299 
300  def _getImageParams(self, exposure=None, bbox=None, wcs=None, filterName=None, wcsRequired=True):
301  """Get image parameters
302 
303  @param[in] exposure exposure (an afwImage.Exposure) or None
304  @param[in] bbox bounding box (an afwGeom.Box2I) or None; if None then bbox must be specified
305  @param[in] wcs WCS (an afwImage.Wcs) or None; if None then exposure must be specified
306  @param[in] filterName filter name, a string, or None; if None exposure must be specified
307  @param[in] wcsRequired if True then either wcs must be specified or exposure must contain a wcs;
308  if False then the returned wcs may be None
309  @return these items:
310  - bbox bounding box; guaranteed to be set
311  - wcs WCS if known, else None
312  - filterName filter name if known, else None
313  @throw RuntimeError if bbox cannot be determined, or wcs cannot be determined and wcsRequired True
314  """
315  if exposure is not None:
316  if bbox is None:
317  bbox = exposure.getBBox()
318  self.log.debug("Setting bbox = %s from exposure metadata", bbox)
319  if wcs is None:
320  self.log.debug("Setting wcs from exposure metadata")
321  wcs = exposure.getWcs()
322  if filterName is None:
323  filterName = exposure.getFilter().getName()
324  self.log.debug("Setting filterName = %r from exposure metadata", filterName)
325  if bbox is None:
326  raise RuntimeError("bbox or exposure must be specified")
327  if wcs is None and wcsRequired:
328  raise RuntimeError("wcs or exposure (with a WCS) must be specified")
329  return bbox, wcs, filterName
330 
331  def useKnownWcs(self, sourceCat, wcs=None, exposure=None, filterName=None, bbox=None, calculateSip=None):
332  """!Return an InitialAstrometry object, just like determineWcs,
333  but assuming the given input WCS is correct.
334 
335  This involves searching for reference sources within the WCS
336  area, and matching them to the given 'sourceCat'. If
337  'calculateSip' is set, we will try to compute a TAN-SIP
338  distortion correction.
339 
340  @param[in] sourceCat list of detected sources in this image.
341  @param[in] wcs your known WCS, or None to get from exposure
342  @param[in] exposure the exposure holding metadata for this image;
343  if None then you must specify wcs, filterName and bbox
344  @param[in] filterName string, filter name, eg "i", or None to get from exposure`
345  @param[in] bbox bounding box of image, or None to get from exposure
346  @param[in] calculateSip calculate SIP distortion terms for the WCS? If None
347  then use self.config.calculateSip. To disable WCS fitting set calculateSip=False
348 
349  @note this function is also called by 'determineWcs' (via 'determineWcs2'), since the steps are all
350  the same.
351  """
352  # return value:
353  astrom = InitialAstrometry()
354 
355  if calculateSip is None:
356  calculateSip = self.config.calculateSip
357 
358  bbox, wcs, filterName = self._getImageParams(
359  exposure=exposure,
360  bbox=bbox,
361  wcs=wcs,
362  filterName=filterName,
363  wcsRequired=True,
364  )
365  refCat = self.refObjLoader.loadPixelBox(
366  bbox=bbox,
367  wcs=wcs,
368  filterName=filterName,
369  calib=None,
370  ).refCat
371  astrom.refCat = refCat
372  catids = [src.getId() for src in refCat]
373  uids = set(catids)
374  self.log.debug('%i reference sources; %i unique IDs', len(catids), len(uids))
375  matches = self._getMatchList(sourceCat, refCat, wcs)
376  uniq = set([sm.second.getId() for sm in matches])
377  if len(matches) != len(uniq):
378  self.log.warn('The list of matched stars contains duplicate reference source IDs ' +
379  '(%i sources, %i unique ids)', len(matches), len(uniq))
380  if len(matches) == 0:
381  self.log.warn('No matches found between input sources and reference catalogue.')
382  return astrom
383 
384  self.log.debug('%i reference objects match input sources using input WCS', len(matches))
385  astrom.tanMatches = matches
386  astrom.tanWcs = wcs
387 
388  srcids = [s.getId() for s in sourceCat]
389  for m in matches:
390  assert(m.second.getId() in srcids)
391  assert(m.second in sourceCat)
392 
393  if calculateSip:
394  sipwcs, matches = self._calculateSipTerms(wcs, refCat, sourceCat, matches, bbox=bbox)
395  if sipwcs == wcs:
396  self.log.debug('Failed to find a SIP WCS better than the initial one.')
397  else:
398  self.log.debug('%i reference objects match input sources using SIP WCS',
399  len(matches))
400  astrom.sipWcs = sipwcs
401  astrom.sipMatches = matches
402 
403  wcs = astrom.getWcs()
404  # _getMatchList() modifies the source list RA,Dec coordinates.
405  # Here, we make them consistent with the WCS we are returning.
406  for src in sourceCat:
407  src.updateCoord(wcs)
408  astrom.matchMeta = _createMetadata(bbox, wcs, filterName)
409  return astrom
410 
411  def determineWcs(self, sourceCat, exposure, **kwargs):
412  """Find a WCS solution for the given 'sourceCat' in the given
413  'exposure', getting other parameters from config.
414 
415  Valid kwargs include:
416 
417  'radecCenter', an afw.coord.Coord giving the RA,Dec position
418  of the center of the field. This is used to limit the
419  search done by Astrometry.net (to make it faster and load
420  fewer index files, thereby using less memory). Defaults to
421  the RA,Dec center from the exposure's WCS; turn that off
422  with the boolean kwarg 'useRaDecCenter' or config option
423  'useWcsRaDecCenter'
424 
425  'useRaDecCenter', a boolean. Don't use the RA,Dec center from
426  the exposure's initial WCS.
427 
428  'searchRadius', in degrees, to search for a solution around
429  the given 'radecCenter'; default from config option
430  'raDecSearchRadius'.
431 
432  'useParity': parity is the 'flip' of the image. Knowing it
433  reduces the search space (hence time) for Astrometry.net.
434  The parity can be computed from the exposure's WCS (the
435  sign of the determinant of the CD matrix); this option
436  controls whether we do that or force Astrometry.net to
437  search both parities. Default from config.useWcsParity.
438 
439  'pixelScale': afwGeom.Angle, estimate of the angle-per-pixel
440  (ie, arcseconds per pixel). Defaults to a value derived
441  from the exposure's WCS. If enabled, this value, plus or
442  minus config.pixelScaleUncertainty, will be used to limit
443  Astrometry.net's search.
444 
445  'usePixelScale': boolean. Use the pixel scale to limit
446  Astrometry.net's search? Defaults to config.useWcsPixelScale.
447 
448  'filterName', a string, the filter name of this image. Will
449  be mapped through the 'filterMap' config dictionary to a
450  column name in the astrometry_net_data index FITS files.
451  Defaults to the exposure.getFilter() value.
452 
453  'bbox', bounding box of exposure; defaults to exposure.getBBox()
454 
455  """
456  assert(exposure is not None)
457 
458  margs = kwargs.copy()
459  if 'searchRadius' not in margs:
460  margs.update(searchRadius=self.config.raDecSearchRadius * afwGeom.degrees)
461  if 'usePixelScale' not in margs:
462  margs.update(usePixelScale=self.config.useWcsPixelScale)
463  if 'useRaDecCenter' not in margs:
464  margs.update(useRaDecCenter=self.config.useWcsRaDecCenter)
465  if 'useParity' not in margs:
466  margs.update(useParity=self.config.useWcsParity)
467  margs.update(exposure=exposure)
468  return self.determineWcs2(sourceCat=sourceCat, **margs)
469 
470  def determineWcs2(self, sourceCat, **kwargs):
471  """Get a blind astrometric solution for the given catalog of sources.
472 
473  We need:
474  -the image size;
475  -the filter
476 
477  And if available, we can use:
478  -an initial Wcs estimate;
479  --> RA,Dec center
480  --> pixel scale
481  --> "parity"
482 
483  (all of which are metadata of Exposure).
484 
485  filterName: string
486  imageSize: (W,H) integer tuple/iterable
487  pixelScale: afwGeom::Angle per pixel.
488  radecCenter: afwCoord::Coord
489  """
490  wcs, qa = self.getBlindWcsSolution(sourceCat, **kwargs)
491  kw = {}
492  # Keys passed to useKnownWcs
493  for key in ['exposure', 'bbox', 'filterName']:
494  if key in kwargs:
495  kw[key] = kwargs[key]
496  astrom = self.useKnownWcs(sourceCat, wcs=wcs, **kw)
497  astrom.solveQa = qa
498  astrom.tanWcs = wcs
499  return astrom
500 
501  def getBlindWcsSolution(self, sourceCat,
502  exposure=None,
503  wcs=None,
504  bbox=None,
505  radecCenter=None,
506  searchRadius=None,
507  pixelScale=None,
508  filterName=None,
509  doTrim=False,
510  usePixelScale=True,
511  useRaDecCenter=True,
512  useParity=True,
513  searchRadiusScale=2.):
514  if not useRaDecCenter and radecCenter is not None:
515  raise RuntimeError('radecCenter is set, but useRaDecCenter is False. Make up your mind!')
516  if not usePixelScale and pixelScale is not None:
517  raise RuntimeError('pixelScale is set, but usePixelScale is False. Make up your mind!')
518 
519  bbox, wcs, filterName = self._getImageParams(
520  exposure=exposure,
521  bbox=bbox,
522  wcs=wcs,
523  filterName=filterName,
524  wcsRequired=False,
525  )
526 
527  bboxD = afwGeom.Box2D(bbox)
528  xc, yc = bboxD.getCenter()
529  parity = None
530 
531  if wcs is not None:
532  if pixelScale is None:
533  if usePixelScale:
534  pixelScale = wcs.pixelScale()
535  self.log.debug('Setting pixel scale estimate = %.3f from given WCS estimate',
536  pixelScale.asArcseconds())
537 
538  if radecCenter is None:
539  if useRaDecCenter:
540  radecCenter = wcs.pixelToSky(xc, yc)
541  self.log.debug('Setting RA,Dec center estimate = (%.3f, %.3f) from given WCS ' +
542  'estimate, using pixel center = (%.1f, %.1f)',
543  radecCenter.getLongitude().asDegrees(),
544  radecCenter.getLatitude().asDegrees(), xc, yc)
545 
546  if searchRadius is None:
547  if useRaDecCenter:
548  assert(pixelScale is not None)
549  pixRadius = math.hypot(*bboxD.getDimensions()) / 2
550  searchRadius = (pixelScale * pixRadius * searchRadiusScale)
551  self.log.debug('Using RA,Dec search radius = %.3f deg, from pixel scale, ' +
552  'image size, and searchRadiusScale = %g',
553  searchRadius, searchRadiusScale)
554  if useParity:
555  parity = wcs.isFlipped()
556  self.log.debug('Using parity = %s' % (parity and 'True' or 'False'))
557 
558  if doTrim:
559  n = len(sourceCat)
560  if exposure is not None:
561  exposureBBoxD = afwGeom.Box2D(exposure.getMaskedImage().getBBox())
562  else:
563  exposureBBoxD = bboxD
564  sourceCat = self._trimBadPoints(sourceCat, exposureBBoxD)
565  self.log.debug("Trimming: kept %i of %i sources", n, len(sourceCat))
566 
567  wcs, qa = self._solve(
568  sourceCat=sourceCat,
569  wcs=wcs,
570  bbox=bbox,
571  pixelScale=pixelScale,
572  radecCenter=radecCenter,
573  searchRadius=searchRadius,
574  parity=parity,
575  filterName=filterName,
576  )
577  if wcs is None:
578  raise RuntimeError("Unable to match sources with catalog.")
579  self.log.info('Got astrometric solution from Astrometry.net')
580 
581  rdc = wcs.pixelToSky(xc, yc)
582  self.log.debug('New WCS says image center pixel (%.1f, %.1f) -> RA,Dec (%.3f, %.3f)',
583  xc, yc, rdc.getLongitude().asDegrees(), rdc.getLatitude().asDegrees())
584  return wcs, qa
585 
586  def getSipWcsFromWcs(self, wcs, bbox, ngrid=20, linearizeAtCenter=True):
587  """!Get a TAN-SIP WCS, starting from an existing WCS.
588 
589  It uses your WCS to compute a fake grid of corresponding "stars" in pixel and sky coords,
590  and feeds that to the regular SIP code.
591 
592  @param[in] wcs initial WCS
593  @param[in] bbox bounding box of image
594  @param[in] ngrid number of grid points along x and y for fitting (fit at ngrid^2 points)
595  @param[in] linearizeAtCenter if True, get a linear approximation of the input
596  WCS at the image center and use that as the TAN initialization for
597  the TAN-SIP solution. You probably want this if your WCS has its
598  CRPIX outside the image bounding box.
599  """
600  # Ugh, build src and ref tables
601  srcSchema = afwTable.SourceTable.makeMinimalSchema()
602  key = srcSchema.addField("centroid", type="PointD")
603  srcTable = afwTable.SourceTable.make(srcSchema)
604  srcTable.defineCentroid("centroid")
605  srcs = srcTable
606  refs = afwTable.SimpleTable.make(afwTable.SimpleTable.makeMinimalSchema())
607  cref = []
608  csrc = []
609  (W, H) = bbox.getDimensions()
610  x0, y0 = bbox.getMin()
611  for xx in np.linspace(0., W, ngrid):
612  for yy in np.linspace(0, H, ngrid):
613  src = srcs.makeRecord()
614  src.set(key.getX(), x0 + xx)
615  src.set(key.getY(), y0 + yy)
616  csrc.append(src)
617  rd = wcs.pixelToSky(afwGeom.Point2D(xx + x0, yy + y0))
618  ref = refs.makeRecord()
619  ref.setCoord(rd)
620  cref.append(ref)
621 
622  if linearizeAtCenter:
623  # Linearize the original WCS around the image center to create a
624  # TAN WCS.
625  # Reference pixel in LSST coords
626  crpix = afwGeom.Box2D(bbox).getCenter()
627  crval = wcs.pixelToSky(crpix)
628  crval = crval.getPosition(afwGeom.degrees)
629  # Linearize *AT* crval to get effective CD at crval.
630  # (we use the default skyUnit of degrees as per WCS standard)
631  aff = wcs.linearizePixelToSky(crval)
632  cd = aff.getLinear().getMatrix()
633  wcs = afwImage.Wcs(crval, crpix, cd)
634 
635  return self.getSipWcsFromCorrespondences(wcs, cref, csrc, (W, H), x0=x0, y0=y0)
636 
637  def getSipWcsFromCorrespondences(self, origWcs, refCat, sourceCat, bbox):
638  """Produce a SIP solution given a list of known correspondences.
639 
640  Unlike _calculateSipTerms, this does not iterate the solution;
641  it assumes you have given it a good sets of corresponding stars.
642 
643  NOTE that "refCat" and "sourceCat" are assumed to be the same length;
644  entries "refCat[i]" and "sourceCat[i]" are assumed to be correspondences.
645 
646  @param[in] origWcs the WCS to linearize in order to get the TAN part of the TAN-SIP WCS.
647  @param[in] refCat reference source catalog
648  @param[in] sourceCat source catalog
649  @param[in] bbox bounding box of image
650  """
651  sipOrder = self.config.sipOrder
652  matches = []
653  for ci, si in zip(refCat, sourceCat):
654  matches.append(afwTable.ReferenceMatch(ci, si, 0.))
655 
656  sipObject = astromSip.makeCreateWcsWithSip(matches, origWcs, sipOrder, bbox)
657  return sipObject.getNewWcs()
658 
659  def _calculateSipTerms(self, origWcs, refCat, sourceCat, matches, bbox):
660  """!Iteratively calculate SIP distortions and regenerate matches based on improved WCS.
661 
662  @param[in] origWcs original WCS object, probably (but not necessarily) a TAN WCS;
663  this is used to set the baseline when determining whether a SIP
664  solution is any better; it will be returned if no better SIP solution
665  can be found.
666  @param[in] refCat reference source catalog
667  @param[in] sourceCat sources in the image to be solved
668  @param[in] matches list of supposedly matched sources, using the "origWcs".
669  @param[in] bbox bounding box of image, which is used when finding reverse SIP coefficients.
670  """
671  sipOrder = self.config.sipOrder
672  wcs = origWcs
673 
674  lastMatchSize = len(matches)
675  lastMatchStats = self._computeMatchStatsOnSky(wcs=wcs, matchList=matches)
676  for i in range(self.config.maxIter):
677  # fit SIP terms
678  try:
679  sipObject = astromSip.makeCreateWcsWithSip(matches, wcs, sipOrder, bbox)
680  proposedWcs = sipObject.getNewWcs()
681  self.plotSolution(matches, proposedWcs, bbox.getDimensions())
682  except pexExceptions.Exception as e:
683  self.log.warn('Failed to calculate distortion terms. Error: ', str(e))
684  break
685 
686  # use new WCS to get new matchlist.
687  proposedMatchlist = self._getMatchList(sourceCat, refCat, proposedWcs)
688  proposedMatchSize = len(proposedMatchlist)
689  proposedMatchStats = self._computeMatchStatsOnSky(wcs=proposedWcs, matchList=proposedMatchlist)
690 
691  self.log.debug(
692  "SIP iteration %i: %i objects match, previous = %i;" %
693  (i, proposedMatchSize, lastMatchSize) +
694  " clipped mean scatter = %s arcsec, previous = %s; " %
695  (proposedMatchStats.distMean.asArcseconds(), lastMatchStats.distMean.asArcseconds()) +
696  " max match dist = %s arcsec, previous = %s" %
697  (proposedMatchStats.maxMatchDist.asArcseconds(),
698  lastMatchStats.maxMatchDist.asArcseconds())
699  )
700 
701  if lastMatchStats.maxMatchDist <= proposedMatchStats.maxMatchDist:
702  self.log.debug(
703  "Fit WCS: use iter %s because max match distance no better in next iter: " % (i-1,) +
704  " %g < %g arcsec" % (lastMatchStats.maxMatchDist.asArcseconds(),
705  proposedMatchStats.maxMatchDist.asArcseconds()))
706  break
707 
708  wcs = proposedWcs
709  matches = proposedMatchlist
710  lastMatchSize = proposedMatchSize
711  lastMatchStats = proposedMatchStats
712 
713  return wcs, matches
714 
715  def plotSolution(self, matches, wcs, imageSize):
716  """Plot the solution, when debugging is turned on.
717 
718  @param matches The list of matches
719  @param wcs The Wcs
720  @param imageSize 2-tuple with the image size (W,H)
721  """
722  import lsstDebug
723  display = lsstDebug.Info(__name__).display
724  if not display:
725  return
726 
727  try:
728  import matplotlib.pyplot as plt
729  except ImportError as e:
730  self.log.warn("Unable to import matplotlib: %s", e)
731  return
732 
733  fig = plt.figure(1)
734  fig.clf()
735  try:
736  fig.canvas._tkcanvas._root().lift() # == Tk's raise, but raise is a python reserved word
737  except: # protect against API changes
738  pass
739 
740  num = len(matches)
741  x = np.zeros(num)
742  y = np.zeros(num)
743  dx = np.zeros(num)
744  dy = np.zeros(num)
745  for i, m in enumerate(matches):
746  x[i] = m.second.getX()
747  y[i] = m.second.getY()
748  pixel = wcs.skyToPixel(m.first.getCoord())
749  dx[i] = x[i] - pixel.getX()
750  dy[i] = y[i] - pixel.getY()
751 
752  subplots = maUtils.makeSubplots(fig, 2, 2, xgutter=0.1, ygutter=0.1, pygutter=0.04)
753 
754  def plotNext(x, y, xLabel, yLabel, xMax):
755  ax = next(subplots)
756  ax.set_autoscalex_on(False)
757  ax.set_xbound(lower=0, upper=xMax)
758  ax.scatter(x, y)
759  ax.set_xlabel(xLabel)
760  ax.set_ylabel(yLabel)
761  ax.axhline(0.0)
762 
763  plotNext(x, dx, "x", "dx", imageSize[0])
764  plotNext(x, dy, "x", "dy", imageSize[0])
765  plotNext(y, dx, "y", "dx", imageSize[1])
766  plotNext(y, dy, "y", "dy", imageSize[1])
767 
768  fig.show()
769 
770  while True:
771  try:
772  reply = input("Pausing for inspection, enter to continue... [hpQ] ").strip()
773  except EOFError:
774  reply = "n"
775 
776  reply = reply.split()
777  if reply:
778  reply = reply[0]
779  else:
780  reply = ""
781 
782  if reply in ("", "h", "p", "Q"):
783  if reply == "h":
784  print("h[elp] p[db] Q[uit]")
785  continue
786  elif reply == "p":
787  import pdb
788  pdb.set_trace()
789  elif reply == "Q":
790  sys.exit(1)
791  break
792 
793  def _computeMatchStatsOnSky(self, wcs, matchList):
794  """Compute on-sky radial distance statistics for a match list
795 
796  @param[in] wcs WCS for match list; an lsst.afw.image.Wcs
797  @param[in] matchList list of matches between reference object and sources;
798  a list of lsst.afw.table.ReferenceMatch;
799  the source centroid and reference object coord are read
800 
801  @return a pipe_base Struct containing these fields:
802  - distMean clipped mean of on-sky radial separation
803  - distStdDev clipped standard deviation of on-sky radial separation
804  - maxMatchDist distMean + self.config.matchDistanceSigma*distStdDev
805  """
806  distStatsInRadians = makeMatchStatisticsInRadians(wcs, matchList,
807  afwMath.MEANCLIP | afwMath.STDEVCLIP)
808  distMean = distStatsInRadians.getValue(afwMath.MEANCLIP)*afwGeom.radians
809  distStdDev = distStatsInRadians.getValue(afwMath.STDEVCLIP)*afwGeom.radians
810  return pipeBase.Struct(
811  distMean=distMean,
812  distStdDev=distStdDev,
813  maxMatchDist=distMean + self.config.matchDistanceSigma*distStdDev,
814  )
815 
816  def _getMatchList(self, sourceCat, refCat, wcs):
817  dist = self.config.catalogMatchDist * afwGeom.arcseconds
818  clean = self.config.cleaningParameter
819  matcher = astromSip.MatchSrcToCatalogue(refCat, sourceCat, wcs, dist)
820  matches = matcher.getMatches()
821  if matches is None:
822  # Produce debugging stats...
823  X = [src.getX() for src in sourceCat]
824  Y = [src.getY() for src in sourceCat]
825  R1 = [src.getRa().asDegrees() for src in sourceCat]
826  D1 = [src.getDec().asDegrees() for src in sourceCat]
827  R2 = [src.getRa().asDegrees() for src in refCat]
828  D2 = [src.getDec().asDegrees() for src in refCat]
829  # for src in sourceCat:
830  # self.log.debug("source: x,y (%.1f, %.1f), RA,Dec (%.3f, %.3f)" %
831  #(src.getX(), src.getY(), src.getRa().asDegrees(), src.getDec().asDegrees()))
832  # for src in refCat:
833  # self.log.debug("ref: RA,Dec (%.3f, %.3f)" %
834  #(src.getRa().asDegrees(), src.getDec().asDegrees()))
835  self.loginfo('_getMatchList: %i sources, %i reference sources' % (len(sourceCat), len(refCat)))
836  if len(sourceCat):
837  self.loginfo(
838  'Source range: x [%.1f, %.1f], y [%.1f, %.1f], RA [%.3f, %.3f], Dec [%.3f, %.3f]' %
839  (min(X), max(X), min(Y), max(Y), min(R1), max(R1), min(D1), max(D1)))
840  if len(refCat):
841  self.loginfo('Reference range: RA [%.3f, %.3f], Dec [%.3f, %.3f]' %
842  (min(R2), max(R2), min(D2), max(D2)))
843  raise RuntimeError('No matches found between image and catalogue')
844  matches = astromSip.cleanBadPoints.clean(matches, wcs, nsigma=clean)
845  return matches
846 
847  def getColumnName(self, filterName, columnMap, default=None):
848  """
849  Returns the column name in the astrometry_net_data index file that will be used
850  for the given filter name.
851 
852  @param filterName Name of filter used in exposure
853  @param columnMap Dict that maps filter names to column names
854  @param default Default column name
855  """
856  filterName = self.config.filterMap.get(filterName, filterName) # Exposure filter --> desired filter
857  try:
858  return columnMap[filterName] # Desired filter --> a_n_d column name
859  except KeyError:
860  self.log.warn("No column in configuration for filter '%s'; using default '%s'" %
861  (filterName, default))
862  return default
863 
864  def _solve(self, sourceCat, wcs, bbox, pixelScale, radecCenter, searchRadius, parity, filterName=None):
865  solver = self.refObjLoader._getSolver()
866 
867  imageSize = bbox.getDimensions()
868  x0, y0 = bbox.getMin()
869 
870  # select sources with valid x, y, flux
871  xybb = afwGeom.Box2D()
872  goodsources = afwTable.SourceCatalog(sourceCat.table)
873  badkeys = [goodsources.getSchema().find(name).key for name in self.config.badFlags]
874 
875  for s in sourceCat:
876  if np.isfinite(s.getX()) and np.isfinite(s.getY()) and np.isfinite(s.getPsfFlux()) \
877  and self._isGoodSource(s, badkeys):
878  goodsources.append(s)
879  xybb.include(afwGeom.Point2D(s.getX() - x0, s.getY() - y0))
880  self.log.info("Number of selected sources for astrometry : %d" % (len(goodsources)))
881  if len(goodsources) < len(sourceCat):
882  self.log.debug('Keeping %i of %i sources with finite X,Y positions and PSF flux',
883  len(goodsources), len(sourceCat))
884  self.log.debug('Feeding sources in range x=[%.1f, %.1f], y=[%.1f, %.1f] ' +
885  '(after subtracting x0,y0 = %.1f,%.1f) to Astrometry.net',
886  xybb.getMinX(), xybb.getMaxX(), xybb.getMinY(), xybb.getMaxY(), x0, y0)
887  # setStars sorts them by PSF flux.
888  solver.setStars(goodsources, x0, y0)
889  solver.setMaxStars(self.config.maxStars)
890  solver.setImageSize(*imageSize)
891  solver.setMatchThreshold(self.config.matchThreshold)
892  raDecRadius = None
893  if radecCenter is not None:
894  raDecRadius = (radecCenter.getLongitude().asDegrees(), radecCenter.getLatitude().asDegrees(),
895  searchRadius.asDegrees())
896  solver.setRaDecRadius(*raDecRadius)
897  self.log.debug('Searching for match around RA,Dec = (%g, %g) with radius %g deg' %
898  raDecRadius)
899 
900  if pixelScale is not None:
901  dscale = self.config.pixelScaleUncertainty
902  scale = pixelScale.asArcseconds()
903  lo = scale / dscale
904  hi = scale * dscale
905  solver.setPixelScaleRange(lo, hi)
906  self.log.debug(
907  'Searching for matches with pixel scale = %g +- %g %% -> range [%g, %g] arcsec/pix',
908  scale, 100.*(dscale-1.), lo, hi)
909 
910  if parity is not None:
911  solver.setParity(parity)
912  self.log.debug('Searching for match with parity = %s', str(parity))
913 
914  # Find and load index files within RA,Dec range and scale range.
915  if radecCenter is not None:
916  multiInds = self.refObjLoader._getMIndexesWithinRange(radecCenter, searchRadius)
917  else:
918  multiInds = self.refObjLoader.multiInds
919  qlo, qhi = solver.getQuadSizeLow(), solver.getQuadSizeHigh()
920 
921  toload_multiInds = set()
922  toload_inds = []
923  for mi in multiInds:
924  for i in range(len(mi)):
925  ind = mi[i]
926  if not ind.overlapsScaleRange(qlo, qhi):
927  continue
928  toload_multiInds.add(mi)
929  toload_inds.append(ind)
930 
931  import lsstDebug
932  if lsstDebug.Info(__name__).display:
933  # Use separate context for display, since astrometry.net can segfault if we don't...
934  with LoadMultiIndexes(toload_multiInds):
935  displayAstrometry(refCat=self.refObjLoader.loadPixelBox(bbox, wcs, filterName).refCat,
936  frame=lsstDebug.Info(__name__).frame, pause=lsstDebug.Info(__name__).pause)
937 
938  with LoadMultiIndexes(toload_multiInds):
939  solver.addIndices(toload_inds)
940  self.memusage('Index files loaded: ')
941 
942  cpulimit = self.config.maxCpuTime
943  solver.run(cpulimit)
944 
945  self.memusage('Solving finished: ')
946 
947  self.memusage('Index files unloaded: ')
948 
949  if solver.didSolve():
950  self.log.debug('Solved!')
951  wcs = solver.getWcs()
952  self.log.debug('WCS: %s', wcs.getFitsMetadata().toString())
953 
954  if x0 != 0 or y0 != 0:
955  wcs.shiftReferencePixel(x0, y0)
956  self.log.debug('After shifting reference pixel by x0,y0 = (%i,%i), WCS is: %s',
957  x0, y0, wcs.getFitsMetadata().toString())
958 
959  else:
960  self.log.warn('Did not get an astrometric solution from Astrometry.net')
961  wcs = None
962  # Gather debugging info...
963 
964  # -are there any reference stars in the proposed search area?
965  # log the number found and discard the results
966  if radecCenter is not None:
967  self.refObjLoader.loadSkyCircle(radecCenter, searchRadius, filterName)
968 
969  qa = solver.getSolveStats()
970  self.log.debug('qa: %s', qa.toString())
971  return wcs, qa
972 
973  def _isGoodSource(self, candsource, keys):
974  for k in keys:
975  if candsource.get(k):
976  return False
977  return True
978 
979  @staticmethod
980  def _trimBadPoints(sourceCat, bbox, wcs=None):
981  """Remove elements from catalog whose xy positions are not within the given bbox.
982 
983  sourceCat: a Catalog of SimpleRecord or SourceRecord objects
984  bbox: an afwImage.Box2D
985  wcs: if not None, will be used to compute the xy positions on-the-fly;
986  this is required when sources actually contains SimpleRecords.
987 
988  Returns:
989  a list of Source objects with xAstrom, yAstrom within the bbox.
990  """
991  keep = type(sourceCat)(sourceCat.table)
992  for s in sourceCat:
993  point = s.getCentroid() if wcs is None else wcs.skyToPixel(s.getCoord())
994  if bbox.contains(point):
995  keep.append(s)
996  return keep
997 
998 
999 def _createMetadata(bbox, wcs, filterName):
1000  """
1001  Create match metadata entries required for regenerating the catalog
1002 
1003  @param bbox bounding box of image (pixels)
1004  @param filterName Name of filter, used for magnitudes
1005  @return Metadata
1006  """
1007  meta = dafBase.PropertyList()
1008 
1009  bboxD = afwGeom.Box2D(bbox)
1010  cx, cy = bboxD.getCenter()
1011  radec = wcs.pixelToSky(cx, cy).toIcrs()
1012  meta.add('RA', radec.getRa().asDegrees(), 'field center in degrees')
1013  meta.add('DEC', radec.getDec().asDegrees(), 'field center in degrees')
1014  pixelRadius = math.hypot(*bboxD.getDimensions())/2.0
1015  skyRadius = wcs.pixelScale() * pixelRadius
1016  meta.add('RADIUS', skyRadius.asDegrees(),
1017  'field radius in degrees, approximate')
1018  meta.add('SMATCHV', 1, 'SourceMatchVector version number')
1019  if filterName is not None:
1020  meta.add('FILTER', filterName, 'LSST filter name for tagalong data')
1021  return meta
Basic implemeentation of the astrometry.net astrometrical fitter.
Class for storing ordered metadata with comments.
Definition: PropertyList.h:82
Implementation of the WCS standard for a any projection.
Definition: Wcs.h:107
def useKnownWcs
Return an InitialAstrometry object, just like determineWcs, but assuming the given input WCS is corre...
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
def _calculateSipTerms
Iteratively calculate SIP distortions and regenerate matches based on improved WCS.
def getSipWcsFromWcs
Get a TAN-SIP WCS, starting from an existing WCS.
afw::math::Statistics makeMatchStatisticsInRadians(afw::image::Wcs const &wcs, std::vector< MatchT > const &matchList, int const flags, afw::math::StatisticsControl const &sctrl=afw::math::StatisticsControl())
Compute statistics of on-sky radial separation for a match list, in radians.
metadata input
A floating-point coordinate rectangle geometry.
Definition: Box.h:271