lsst.ip.diffim  13.0-17-gc2cefa3
 All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Macros Groups Pages
imageMapReduce.py
Go to the documentation of this file.
1 from __future__ import absolute_import, division, print_function
2 from future import standard_library
3 standard_library.install_aliases()
4 #
5 # LSST Data Management System
6 # Copyright 2016 AURA/LSST.
7 #
8 # This product includes software developed by the
9 # LSST Project (http://www.lsst.org/).
10 #
11 # This program is free software: you can redistribute it and/or modify
12 # it under the terms of the GNU General Public License as published by
13 # the Free Software Foundation, either version 3 of the License, or
14 # (at your option) any later version.
15 #
16 # This program is distributed in the hope that it will be useful,
17 # but WITHOUT ANY WARRANTY; without even the implied warranty of
18 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 # GNU General Public License for more details.
20 #
21 # You should have received a copy of the LSST License Statement and
22 # the GNU General Public License along with this program. If not,
23 # see <https://www.lsstcorp.org/LegalNotices/>.
24 #
25 
26 import numpy as np
27 import abc
28 from future.utils import with_metaclass
29 
30 import lsst.afw.image as afwImage
31 import lsst.afw.geom as afwGeom
32 import lsst.afw.table as afwTable
33 import lsst.meas.algorithms as measAlg
34 import lsst.pex.config as pexConfig
35 import lsst.pipe.base as pipeBase
36 
37 __all__ = ("ImageMapReduceTask", "ImageMapReduceConfig",
38  "ImageMapperSubtask", "ImageMapperSubtaskConfig",
39  "ImageReducerSubtask", "ImageReducerSubtaskConfig")
40 
41 
42 """Tasks for processing an exposure via processing on
43 multiple sub-exposures and then collecting the results
44 to either re-stitch the sub-exposures back into a new
45 exposure, or return summary results for each sub-exposure.
46 
47 This essentially provides a framework for arbitrary mapper-reducer
48 operations on an exposure by implementing simple operations in
49 subTasks. It currently is not parallelized, although it could be in
50 the future. It does enable operations such as spatially-mapped
51 processing on a grid across an image, processing regions surrounding
52 centroids (such as for PSF processing), etc.
53 
54 It is implemented as primary Task, `ImageMapReduceTask` which contains
55 two subtasks, `ImageMapperSubtask` and `ImageReducerSubtask`.
56 `ImageMapReduceTask` configures the centroids and sub-exposure
57 dimensions to be processed, and then calls the `run` methods of the
58 `ImageMapperSubtask` and `ImageReducerSubtask` on those sub-exposures.
59 `ImageMapReduceTask` may be configured with a list of sub-exposure
60 centroids (`config.cellCentroidsX` and `config.cellCentroidsY`) and a
61 single pair of bounding boxes defining their dimensions, or a set of
62 parameters defining a regular grid of centroids (`config.gridStepX`
63 and `config.gridStepY`).
64 
65 `ImageMapperSubtask` is an abstract class and must be subclassed with
66 an implemented `run` method to provide the desired operation for
67 processing individual sub-exposures. It is called from
68 `ImageMapReduceTask.run`, and may return a new, processed sub-exposure
69 which is to be "stitched" back into a new resulting larger exposure
70 (depending on the configured `ImageMapReduceTask.mapperSubtask`);
71 otherwise if it does not return an lsst.afw.image.Exposure, then the results are
72 passed back directly to the caller.
73 
74 `ImageReducerSubtask` will either stitch the `mapperResults` list of
75 results generated by the `ImageMapperSubtask` together into a new
76 Exposure (by default) or pass it through to the
77 caller. `ImageReducerSubtask` has an implemented `run` method for
78 basic reducing operations (`reduceOperation`) such as `average` (which
79 will average all overlapping pixels from sub-exposures produced by the
80 `ImageMapperSubtask` into the new exposure). Another notable
81 implemented `reduceOperation` is 'none', in which case the
82 `mapperResults` list is simply returned directly.
83 """
84 
85 class ImageMapperSubtaskConfig(pexConfig.Config):
86  """Configuration parameters for ImageMapperSubtask
87  """
88  pass
89 
90 
91 class ImageMapperSubtask(with_metaclass(abc.ABCMeta, pipeBase.Task)):
92  """Abstract base class for any task that is to be
93  used as `ImageMapReduceConfig.mapperSubtask`.
94 
95  An `ImageMapperSubtask` is responsible for processing individual
96  sub-exposures in its `run` method, which is called from
97  `ImageMapReduceTask.run`. `run` may return a processed new
98  sub-exposure which can be be "stitched" back into a new resulting
99  larger exposure (depending on the configured
100  `ImageReducerSubtask`); otherwise if it does not return an
101  lsst.afw.image.Exposure, then the
102  `ImageReducerSubtask.config.reducerSubtask.reduceOperation`
103  should be set to 'none' and the result will be propagated
104  as-is.
105  """
106  ConfigClass = ImageMapperSubtaskConfig
107  _DefaultName = "ip_diffim_ImageMapperSubtask"
108 
109  @abc.abstractmethod
110  def run(self, subExposure, expandedSubExposure, fullBBox, **kwargs):
111  """Perform operation on `subExposure`.
112 
113  To be implemented by subclasses. See class docstring for more
114  details. This method is given the `subExposure` which
115  is to be operated upon, and an `expandedSubExposure` which
116  will contain `subExposure` with additional surrounding
117  pixels. This allows for, for example, convolutions (which
118  should be performed on `expandedSubExposure`), to prevent the
119  returned sub-exposure from containing invalid pixels.
120 
121  This method may return a new, processed sub-exposure which can
122  be be "stitched" back into a new resulting larger exposure
123  (depending on the paired, configured `ImageReducerSubtask`);
124  otherwise if it does not return an lsst.afw.image.Exposure, then the
125  `ImageReducerSubtask.config.mapperSubtask.reduceOperation`
126  should be set to 'none' and the result will be propagated
127  as-is.
128 
129  Parameters
130  ----------
131  subExposure : lsst.afw.image.Exposure
132  the sub-exposure upon which to operate
133  expandedSubExposure : lsst.afw.image.Exposure
134  the expanded sub-exposure upon which to operate
135  fullBBox : afwGeom.BoundingBox
136  the bounding box of the original exposure
137  kwargs :
138  additional keyword arguments propagated from
139  `ImageMapReduceTask.run`.
140 
141  Returns
142  -------
143  A `pipeBase.Struct containing the result of the `subExposure` processing,
144  which may itself be of any type. See above for details. If it is an
145  lsst.afw.image.Exposure (processed sub-exposure), then the name in the Struct
146  should be 'subExposure'. This is implemented here as a pass-through
147  example only.
148  """
149  return pipeBase.Struct(subExposure=subExposure)
150 
151 
152 class ImageReducerSubtaskConfig(pexConfig.Config):
153  """Configuration parameters for the ImageReducerSubtask
154  """
155  reduceOperation = pexConfig.ChoiceField(
156  dtype=str,
157  doc="""Operation to use for reducing subimages into new image.""",
158  default="average",
159  allowed={
160  "none": """simply return a list of values and don't re-map results into
161  a new image (noop operation)""",
162  "copy": """copy pixels directly from subimage into correct location in
163  new exposure (potentially non-deterministic for overlaps)""",
164  "sum": """add pixels from overlaps (probably never wanted; used for testing)
165  into correct location in new exposure""",
166  "average": """same as copy, but also average pixels from overlapped regions
167  (NaNs ignored)""",
168  "coaddPsf": """Instead of constructing an Exposure, take a list of returned
169  PSFs and use CoaddPsf to construct a single PSF that covers the
170  entire input exposure""",
171  }
172  )
173 
174 
175 class ImageReducerSubtask(pipeBase.Task):
176  """Base class for any 'reduce' task that is to be
177  used as `ImageMapReduceConfig.reducerSubtask`.
178 
179  Basic reduce operations are provided by the `run` method
180  of this class, to be selected by its config.
181  """
182  ConfigClass = ImageReducerSubtaskConfig
183  _DefaultName = "ip_diffim_ImageReducerSubtask"
184 
185  def run(self, mapperResults, exposure, **kwargs):
186  """Reduce a list of items produced by `ImageMapperSubtask`.
187 
188  Either stitch the passed `mapperResults` list
189  together into a new Exposure (default) or pass it through
190  (if `self.config.reduceOperation` is 'none').
191 
192  If `self.config.reduceOperation` is not 'none', then expect
193  that the `pipeBase.Struct`s in the `mapperResults` list
194  contain sub-exposures named 'subExposure', to be stitched back
195  into a single Exposure with the same dimensions, PSF, and mask
196  as the input `exposure`. Otherwise, the `mapperResults` list
197  is simply returned directly.
198 
199  Parameters
200  ----------
201  mapperResults : list
202  list of `pipeBase.Struct` returned by `ImageMapperSubtask.run`.
203  exposure : lsst.afw.image.Exposure
204  the original exposure which is cloned to use as the
205  basis for the resulting exposure (if
206  self.config.mapperSubtask.reduceOperation is not 'none')
207  kwargs :
208  additional keyword arguments propagated from
209  `ImageMapReduceTask.run`.
210 
211  Returns
212  -------
213  A `pipeBase.Struct` containing either an `lsst.afw.image.Exposure` (named 'exposure')
214  or a list (named 'result'), depending on `config.reduceOperation`.
215 
216  Notes
217  -----
218  1. This currently correctly handles overlapping sub-exposures.
219  For overlapping sub-exposures, use `config.reduceOperation='average'`.
220  2. This correctly handles varying PSFs, constructing the resulting
221  exposure's PSF via CoaddPsf (DM-9629).
222 
223  Known issues
224  ------------
225  1. To be done: correct handling of masks (nearly there)
226  2. This logic currently makes *two* copies of the original exposure
227  (one here and one in `mapperSubtask.run()`). Possibly of concern
228  for large images on memory-constrained systems.
229  """
230  # No-op; simply pass mapperResults directly to ImageMapReduceTask.run
231  if self.config.reduceOperation == 'none':
232  return pipeBase.Struct(result=mapperResults)
233 
234  if self.config.reduceOperation == 'coaddPsf':
235  # Each element of `mapperResults` should contain 'psf' and 'bbox'
236  coaddPsf = self._constructPsf(mapperResults, exposure)
237  return pipeBase.Struct(result=coaddPsf)
238 
239  newExp = exposure.clone()
240  newMI = newExp.getMaskedImage()
241 
242  reduceOp = self.config.reduceOperation
243  if reduceOp == 'copy':
244  weights = None
245  newMI.getImage()[:, :] = np.nan
246  newMI.getVariance()[:, :] = np.nan
247  else:
248  newMI.getImage()[:, :] = 0.
249  newMI.getVariance()[:, :] = 0.
250  if reduceOp == 'average': # make an array to keep track of weights
251  weights = afwImage.ImageI(newMI.getBBox())
252 
253  for item in mapperResults:
254  item = item.subExposure # Expected named value in the pipeBase.Struct
255  if not (isinstance(item, afwImage.ExposureF) or isinstance(item, afwImage.ExposureI) or
256  isinstance(item, afwImage.ExposureU) or isinstance(item, afwImage.ExposureD)):
257  raise TypeError("""Expecting an Exposure type, got %s.
258  Consider using `reduceOperation="none".""" % str(type(item)))
259  subExp = newExp.Factory(newExp, item.getBBox())
260  subMI = subExp.getMaskedImage()
261  patchMI = item.getMaskedImage()
262  isNotNan = ~(np.isnan(patchMI.getImage().getArray()) |
263  np.isnan(patchMI.getVariance().getArray()))
264  if reduceOp == 'copy':
265  subMI.getImage().getArray()[isNotNan] = patchMI.getImage().getArray()[isNotNan]
266  subMI.getVariance().getArray()[isNotNan] = patchMI.getVariance().getArray()[isNotNan]
267  subMI.getMask().getArray()[:, :] |= patchMI.getMask().getArray()
268 
269  if reduceOp == 'sum' or reduceOp == 'average': # much of these two options is the same
270  subMI.getImage().getArray()[isNotNan] += patchMI.getImage().getArray()[isNotNan]
271  subMI.getVariance().getArray()[isNotNan] += patchMI.getVariance().getArray()[isNotNan]
272  subMI.getMask().getArray()[:, :] |= patchMI.getMask().getArray()
273  if reduceOp == 'average':
274  # wtsView is a view into the `weights` Image
275  wtsView = afwImage.ImageI(weights, item.getBBox())
276  wtsView.getArray()[isNotNan] += 1
277 
278  if reduceOp == 'average':
279  wts = weights.getArray().astype(np.float)
280  self.log.info('AVERAGE: Maximum overlap: %f', wts.max())
281  self.log.info('AVERAGE: Average overlap: %f', np.nanmean(wts))
282  newMI.getImage().getArray()[:, :] /= wts
283  newMI.getVariance().getArray()[:, :] /= wts
284  wtsZero = wts == 0.
285  newMI.getImage().getArray()[wtsZero] = newMI.getVariance().getArray()[wtsZero] = np.nan
286  # TBD: set mask to something for pixels where wts == 0. Shouldn't happen. (DM-10009)
287 
288  # Not sure how to construct a PSF when reduceOp=='copy'...
289  if reduceOp == 'sum' or reduceOp == 'average':
290  psf = self._constructPsf(mapperResults, exposure)
291  newExp.setPsf(psf)
292 
293  return pipeBase.Struct(exposure=newExp)
294 
295  def _constructPsf(self, mapperResults, exposure):
296  """Construct a CoaddPsf based on PSFs from individual subExposures
297 
298  Currently uses (and returns) a CoaddPsf. TBD if we want to
299  create a custom subclass of CoaddPsf to differentiate it.
300 
301  Parameters
302  ----------
303  mapperResults : list
304  list of `pipeBase.Struct` returned by `ImageMapperSubtask.run`.
305  For this to work, each element of `mapperResults` must contain
306  a `subExposure` element, from which the component Psfs are
307  extracted (thus the reducerTask cannot have
308  `reduceOperation = 'none'`.
309  exposure : lsst.afw.image.Exposure
310  the original exposure which is used here solely for its
311  bounding-box and WCS.
312 
313  Returns
314  -------
315  A `measAlg.CoaddPsf` constructed from the PSFs of the individual
316  subExposures.
317  """
318  schema = afwTable.ExposureTable.makeMinimalSchema()
319  schema.addField("weight", type="D", doc="Coadd weight")
320  mycatalog = afwTable.ExposureCatalog(schema)
321 
322  # We're just using the exposure's WCS (assuming that the subExposures'
323  # WCSs are the same, which they better be!).
324  wcsref = exposure.getWcs()
325  for i, res in enumerate(mapperResults):
326  record = mycatalog.getTable().makeRecord()
327  if 'subExposure' in res.getDict():
328  subExp = res.subExposure
329  if subExp.getWcs() != wcsref:
330  raise ValueError('Wcs of subExposure is different from exposure')
331  record.setPsf(subExp.getPsf())
332  record.setWcs(subExp.getWcs())
333  record.setBBox(subExp.getBBox())
334  elif 'psf' in res.getDict():
335  record.setPsf(res.psf)
336  record.setWcs(wcsref)
337  record.setBBox(res.bbox)
338  record['weight'] = 1.0
339  record['id'] = i
340  mycatalog.append(record)
341 
342  # create the coaddpsf
343  psf = measAlg.CoaddPsf(mycatalog, wcsref, 'weight')
344  return psf
345 
346 
347 class ImageMapReduceConfig(pexConfig.Config):
348  """Configuration parameters for the ImageMapReduceTask
349  """
350  mapperSubtask = pexConfig.ConfigurableField(
351  doc="Subtask to run on each subimage",
352  target=ImageMapperSubtask,
353  )
354 
355  reducerSubtask = pexConfig.ConfigurableField(
356  doc="Subtask to combine results of mapperSubTask",
357  target=ImageReducerSubtask,
358  )
359 
360  # Separate cellCentroidsX and cellCentroidsY since pexConfig.ListField accepts limited dtypes
361  # (i.e., no Point2D). The resulting set of centroids is the "vertical stack" of
362  # `cellCentroidsX` and `cellCentroidsY`, i.e. for (1,2), (3,4) respectively, the
363  # resulting centroids are ((1,3), (2,4)).
364  cellCentroidsX = pexConfig.ListField(
365  dtype=float,
366  doc="""Input X centroids around which to place subimages.
367  If None, use grid config options below.""",
368  optional=True,
369  default=None
370  )
371 
372  cellCentroidsY = pexConfig.ListField(
373  dtype=float,
374  doc="""Input Y centroids around which to place subimages.
375  If None, use grid config options below.""",
376  optional=True,
377  default=None
378  )
379 
380  cellSizeX = pexConfig.Field(
381  dtype=float,
382  doc="""Dimensions of each grid cell in x direction""",
383  default=10.,
384  check=lambda x: x > 0.
385  )
386 
387  cellSizeY = pexConfig.Field(
388  dtype=float,
389  doc="""Dimensions of each grid cell in y direction""",
390  default=10.,
391  check=lambda x: x > 0.
392  )
393 
394  gridStepX = pexConfig.Field(
395  dtype=float,
396  doc="""Spacing between subsequent grid cells in x direction. If equal to
397  cellSizeX, then there is no overlap in the x direction.""",
398  default=10.,
399  check=lambda x: x > 0.
400  )
401 
402  gridStepY = pexConfig.Field(
403  dtype=float,
404  doc="""Spacing between subsequent grid cells in y direction. If equal to
405  cellSizeY, then there is no overlap in the y direction.""",
406  default=10.,
407  check=lambda x: x > 0.
408  )
409 
410  borderSizeX = pexConfig.Field(
411  dtype=float,
412  doc="""Dimensions of grid cell border in +/- x direction, to be used
413  for generating `expandedSubExposure`.""",
414  default=5.,
415  check=lambda x: x > 0.
416  )
417 
418  borderSizeY = pexConfig.Field(
419  dtype=float,
420  doc="""Dimensions of grid cell border in +/- y direction, to be used
421  for generating `expandedSubExposure`.""",
422  default=5.,
423  check=lambda x: x > 0.
424  )
425 
426  adjustGridOption = pexConfig.ChoiceField(
427  dtype=str,
428  doc="""Whether and how to adjust grid to fit evenly within, and cover entire
429  image""",
430  default="spacing",
431  allowed={
432  "spacing": "adjust spacing between centers of grid cells (allowing overlaps)",
433  "size": "adjust the sizes of the grid cells (disallowing overlaps)",
434  "none": "do not adjust the grid sizes or spacing"
435  }
436  )
437 
438  scaleByFwhm = pexConfig.Field(
439  dtype=bool,
440  doc="""Scale cellSize/gridStep/borderSize/overlapSize by PSF FWHM rather
441  than pixels?""",
442  default=True
443  )
444 
445  returnSubImages = pexConfig.Field(
446  dtype=bool,
447  doc="""Return the input subExposures alongside the processed ones (for debugging)""",
448  default=False
449  )
450 
451  ignoreMaskPlanes = pexConfig.ListField(
452  dtype=str,
453  doc="""Mask planes to ignore for sigma-clipped statistics""",
454  default=("INTRP", "EDGE", "DETECTED", "SAT", "CR", "BAD", "NO_DATA", "DETECTED_NEGATIVE")
455  )
456 
457 
458 ## \addtogroup LSST_task_documentation
459 ## \{
460 ## \page ImageMapReduceTask
461 ## \ref ImageMapReduceTask_ "ImageMapReduceTask"
462 ## Task for performing operations on an image over a regular-spaced grid
463 ## \}
464 
465 
466 class ImageMapReduceTask(pipeBase.Task):
467  """Split an Exposure into subExposures (optionally on a grid) and
468  perform the same operation on each.
469 
470  Perform 'simple' operations on a gridded set of subExposures of a
471  larger Exposure, and then (by default) have those subExposures
472  stitched back together into a new, full-sized image.
473 
474  Contrary to the expectation given by its name, this task does not
475  perform these operations in parallel, although it could be updatd
476  to provide such functionality.
477 
478  The actual operations are performed by two subTasks passed to the
479  config. The exposure passed to this task's `run` method will be
480  divided, and those subExposures will be passed to the subTasks,
481  along with the original exposure. The reducing operation is
482  performed by the second subtask.
483  """
484  ConfigClass = ImageMapReduceConfig
485  _DefaultName = "ip_diffim_imageMapReduce"
486 
487  def __init__(self, *args, **kwargs):
488  """Create the image map-reduce task
489 
490  Parameters
491  ----------
492  args :
493  arguments to be passed to
494  `lsst.pipe.base.task.Task.__init__`
495  kwargs :
496  additional keyword arguments to be passed to
497  `lsst.pipe.base.task.Task.__init__`
498  """
499  pipeBase.Task.__init__(self, *args, **kwargs)
500 
501  self.boxes0 = self.boxes1 = None
502  self.makeSubtask("mapperSubtask")
503  self.makeSubtask("reducerSubtask")
504 
505  @pipeBase.timeMethod
506  def run(self, exposure, **kwargs):
507  """Perform a map-reduce operation on the given exposure.
508 
509  Split the exposure into sub-expposures on a grid (parameters
510  given by `ImageMapReduceConfig`) and perform
511  `config.mapperSubtask.run()` on each. Reduce the resulting
512  sub-exposures by running `config.reducerSubtask.run()`.
513 
514  Parameters
515  ----------
516  exposure : lsst.afw.image.Exposure
517  the full exposure to process
518  kwargs :
519  additional keyword arguments to be passed to
520  subtask `run` methods
521 
522  Returns
523  -------
524  output of `reducerSubtask.run()`
525 
526  """
527  self.log.info("Mapper sub-task: %s", self.mapperSubtask._DefaultName)
528  mapperResults = self._runMapper(exposure, **kwargs)
529  self.log.info("Reducer sub-task: %s", self.reducerSubtask._DefaultName)
530  result = self._reduceImage(mapperResults, exposure, **kwargs)
531  return result
532 
533  def _runMapper(self, exposure, doClone=False, **kwargs):
534  """Perform `mapperSubtask.run` on each sub-exposure
535 
536  Perform `mapperSubtask.run` on each sub-exposure across a
537  grid on `exposure` generated by `_generateGrid`. Also pass to
538  `mapperSubtask.run` an 'expanded sub-exposure' containing the
539  same region as the sub-exposure but with an expanded bounding box.
540 
541  Parameters
542  ----------
543  exposure : lsst.afw.image.Exposure
544  the original exposure which is used as the template
545  doClone : boolean
546  if True, clone the subimages before passing to subtask;
547  in that case, the sub-exps do not have to be considered as read-only
548  kwargs :
549  additional keyword arguments to be passed to
550  `mapperSubtask.run` and `self._generateGrid`, including `forceEvenSized`.
551 
552  Returns
553  -------
554  a list of `pipeBase.Struct`s as returned by `mapperSubtask.run`.
555  """
556  if self.boxes0 is None:
557  self._generateGrid(exposure, **kwargs) # possibly pass `forceEvenSized`
558  if len(self.boxes0) != len(self.boxes1):
559  raise ValueError('Bounding boxes list and expanded bounding boxes list are of different lengths')
560 
561  self.log.info("Processing %d sub-exposures", len(self.boxes0))
562  mapperResults = []
563  for box0, box1 in zip(self.boxes0, self.boxes1):
564  subExp = exposure.Factory(exposure, box0)
565  expandedSubExp = exposure.Factory(exposure, box1)
566  if doClone:
567  subExp = subExp.clone()
568  expandedSubExp = expandedSubExp.clone()
569  result = self.mapperSubtask.run(subExp, expandedSubExp, exposure.getBBox(), **kwargs)
570  if self.config.returnSubImages:
571  toAdd = pipeBase.Struct(inputSubExposure=subExp,
572  inputExpandedSubExposure=expandedSubExp)
573  result.mergeItems(toAdd, 'inputSubExposure', 'inputExpandedSubExposure')
574  mapperResults.append(result)
575 
576  return mapperResults
577 
578  def _reduceImage(self, mapperResults, exposure, **kwargs):
579  """Reduce/merge a set of sub-exposures into a final result
580 
581  Return an exposure of the same dimensions as `exposure`.
582  `mapperResults` is expected to have been produced by `runMapper`.
583 
584  Parameters
585  ----------
586  mapperResults : list
587  list of `pipeBase.Struct`, each of which was produced by
588  `config.mapperSubtask`
589  exposure : lsst.afw.image.Exposure
590  the original exposure
591  **kwargs :
592  additional keyword arguments
593 
594  Returns
595  -------
596  Output of `reducerSubtask.run` which is a `pipeBase.Struct`.
597  """
598  result = self.reducerSubtask.run(mapperResults, exposure, **kwargs)
599  return result
600 
601  def _generateGrid(self, exposure, forceEvenSized=False, **kwargs):
602  """Generate two lists of bounding boxes that evenly grid `exposure`
603 
604  Unless the config was provided with `cellCentroidsX` and
605  `cellCentroidsY`, grid (subimage) centers are spaced evenly
606  by gridStepX/Y. Then the grid is adjusted as little as
607  possible to evenly cover the input exposure (if
608  adjustGridOption is not 'none'). Then the second set of
609  bounding boxes is expanded by borderSizeX/Y. The expanded
610  bounding boxes are adjusted to ensure that they intersect the
611  exposure's bounding box. The resulting lists of bounding boxes
612  and corresponding expanded bounding boxes are set to
613  `self.boxes0`, `self.boxes1`.
614 
615  Parameters
616  ----------
617  exposure : lsst.afw.image.Exposure
618  input exposure whose full bounding box is to be evenly gridded.
619  forceEvenSized : boolean
620  force grid elements to have even-valued x- and y- dimensions?
621  (Potentially useful if doing Fourier transform of subExposures.)
622  """
623  # kwargs are ignored, but necessary to enable optional passing of
624  # `forceEvenSized` from `_runMapper`.
625  bbox = exposure.getBBox()
626 
627  # Extract the config parameters for conciseness.
628  cellCentroidsX = self.config.cellCentroidsX
629  cellCentroidsY = self.config.cellCentroidsY
630  cellSizeX = self.config.cellSizeX
631  cellSizeY = self.config.cellSizeY
632  gridStepX = self.config.gridStepX
633  gridStepY = self.config.gridStepY
634  borderSizeX = self.config.borderSizeX
635  borderSizeY = self.config.borderSizeY
636  adjustGridOption = self.config.adjustGridOption
637  scaleByFwhm = self.config.scaleByFwhm
638 
639  if cellCentroidsX is None or len(cellCentroidsX) <= 0:
640  # Not given centroids; construct them from cellSize/gridStep
641 
642  psfFwhm = (exposure.getPsf().computeShape().getDeterminantRadius() *
643  2.*np.sqrt(2.*np.log(2.)))
644  if scaleByFwhm:
645  self.log.info("Scaling grid parameters by %f" % psfFwhm)
646 
647  def rescaleValue(val):
648  if scaleByFwhm:
649  return np.rint(val*psfFwhm).astype(int)
650  else:
651  return np.rint(val).astype(int)
652 
653  cellSizeX = rescaleValue(cellSizeX)
654  cellSizeY = rescaleValue(cellSizeY)
655  gridStepX = rescaleValue(gridStepX)
656  gridStepY = rescaleValue(gridStepY)
657  borderSizeX = rescaleValue(borderSizeX)
658  borderSizeY = rescaleValue(borderSizeY)
659 
660  nGridX = bbox.getWidth()//gridStepX
661  nGridY = bbox.getHeight()//gridStepY
662 
663  if adjustGridOption == 'spacing':
664  # Readjust spacings so that they fit perfectly in the image.
665  nGridX = bbox.getWidth()//cellSizeX + 1
666  nGridY = bbox.getHeight()//cellSizeY + 1
667  xLinSpace = np.linspace(cellSizeX//2, bbox.getWidth() - cellSizeX//2, nGridX)
668  yLinSpace = np.linspace(cellSizeY//2, bbox.getHeight() - cellSizeY//2, nGridY)
669 
670  elif adjustGridOption == 'size':
671  cellSizeX = gridStepX
672  cellSizeY = gridStepY
673  xLinSpace = np.arange(cellSizeX//2, bbox.getWidth() + cellSizeX//2, cellSizeX)
674  yLinSpace = np.arange(cellSizeY//2, bbox.getHeight() + cellSizeY//2, cellSizeY)
675  cellSizeX += 1 # add 1 to make sure there are no gaps
676  cellSizeY += 1
677 
678  else:
679  xLinSpace = np.arange(cellSizeX//2, bbox.getWidth() + cellSizeX//2, gridStepX)
680  yLinSpace = np.arange(cellSizeY//2, bbox.getHeight() + cellSizeY//2, gridStepY)
681 
682  cellCentroids = [(x, y) for x in xLinSpace for y in yLinSpace]
683 
684  else:
685  # in py3 zip returns an iterator, but want to test length below, so use this instead:
686  cellCentroids = [(cellCentroidsX[i], cellCentroidsY[i]) for i in range(len(cellCentroidsX))]
687 
688  # first "main" box at 0,0
689  bbox0 = afwGeom.Box2I(afwGeom.Point2I(bbox.getBegin()), afwGeom.Extent2I(cellSizeX, cellSizeY))
690  # first expanded box
691  bbox1 = afwGeom.Box2I(bbox0)
692  bbox1.grow(afwGeom.Extent2I(borderSizeX, borderSizeY))
693 
694  self.boxes0 = [] # "main" boxes; store in task so can be extracted if needed
695  self.boxes1 = [] # "expanded" boxes
696 
697  def _makeBoxEvenSized(bb):
698  """Force a bounding-box to have dimensions that are modulo 2."""
699 
700  if bb.getWidth() % 2 == 1: # grow to the right
701  bb.include(afwGeom.Point2I(bb.getMaxX()+1, bb.getMaxY())) # Expand by 1 pixel!
702  bb.clip(bbox)
703  if bb.getWidth() % 2 == 1: # clipped at right -- so grow to the left
704  bb.include(afwGeom.Point2I(bb.getMinX()-1, bb.getMaxY()))
705  bb.clip(bbox)
706  if bb.getHeight() % 2 == 1: # grow upwards
707  bb.include(afwGeom.Point2I(bb.getMaxX(), bb.getMaxY()+1)) # Expand by 1 pixel!
708  bb.clip(bbox)
709  if bb.getHeight() % 2 == 1: # clipped upwards -- so grow down
710  bb.include(afwGeom.Point2I(bb.getMaxX(), bb.getMinY()-1))
711  bb.clip(bbox)
712  if bb.getWidth() % 2 == 1 or bb.getHeight() % 2 == 1: # Box is probably too big
713  raise RuntimeError('Cannot make bounding box even-sized. Probably too big.')
714 
715  return bb
716 
717  # Use given or grid-parameterized centroids as centers for bounding boxes
718  if cellCentroids is not None and len(cellCentroids) > 0:
719  for x, y in cellCentroids:
720  centroid = afwGeom.Point2D(x, y)
721  bb0 = afwGeom.Box2I(bbox0)
722  xoff = int(np.floor(centroid.getX())) - bb0.getWidth()//2
723  yoff = int(np.floor(centroid.getY())) - bb0.getHeight()//2
724  bb0.shift(afwGeom.Extent2I(xoff, yoff))
725  bb0.clip(bbox)
726  if forceEvenSized:
727  bb0 = _makeBoxEvenSized(bb0)
728  bb1 = afwGeom.Box2I(bbox1)
729  bb1.shift(afwGeom.Extent2I(xoff, yoff))
730  bb1.clip(bbox)
731  if forceEvenSized:
732  bb1 = _makeBoxEvenSized(bb1)
733 
734  if bb0.getArea() > 1 and bb1.getArea() > 1:
735  self.boxes0.append(bb0)
736  self.boxes1.append(bb1)
737 
738  return self.boxes0, self.boxes1
739 
740  def plotBoxes(self, fullBBox, skip=3):
741  """Plot both grids of boxes using matplotlib.
742 
743  Will compute the grid via `_generateGrid` if
744  `self.boxes0` and `self.boxes1` have not already been set.
745 
746  Parameters
747  ----------
748  exposure : lsst.afw.image.Exposure
749  Exposure whose bounding box is gridded by this task.
750  skip : int
751  Plot every skip-ped box (help make plots less confusing)
752  """
753  import matplotlib.pyplot as plt
754 
755  if self.boxes0 is None:
756  raise RuntimeError('Cannot plot boxes. Run _generateGrid first.')
757  self._plotBoxGrid(self.boxes0[::skip], fullBBox, ls='--')
758  # reset the color cycle -- see
759  # http://stackoverflow.com/questions/24193174/reset-color-cycle-in-matplotlib
760  plt.gca().set_prop_cycle(None)
761  self._plotBoxGrid(self.boxes1[::skip], fullBBox, ls=':')
762 
763  def _plotBoxGrid(self, boxes, bbox, **kwargs):
764  """Plot a grid of boxes using matplotlib.
765 
766  Parameters
767  ----------
768  boxes : list
769  a list of `afwGeom.BoundingBox`es
770  bbox : afwGeom.BoundingBox
771  an overall bounding box
772  **kwargs :
773  additional keyword arguments for matplotlib
774  """
775  import matplotlib.pyplot as plt
776 
777  def plotBox(box):
778  corners = np.array([np.array([pt.getX(), pt.getY()]) for pt in box.getCorners()])
779  corners = np.vstack([corners, corners[0, :]])
780  plt.plot(corners[:, 0], corners[:, 1], **kwargs)
781 
782  for b in boxes:
783  plotBox(b)
784  plt.xlim(bbox.getBeginX(), bbox.getEndX())
785  plt.ylim(bbox.getBeginY(), bbox.getEndY())