lsst.pipe.drivers g96f01af41f+e856532418
Loading...
Searching...
No Matches
background.py
Go to the documentation of this file.
1import numpy
2import itertools
3from scipy.ndimage import gaussian_filter
4from deprecated.sphinx import deprecated
5
6import lsst.afw.math as afwMath
7import lsst.afw.image as afwImage
8import lsst.afw.geom as afwGeom
9import lsst.afw.cameraGeom as afwCameraGeom
10import lsst.geom as geom
11import lsst.meas.algorithms as measAlg
12import lsst.afw.table as afwTable
13
14from lsst.pex.config import Config, Field, ListField, ChoiceField, ConfigField, RangeField, ConfigurableField
15from lsst.pipe.base import Task
16
17
18def robustMean(array, rej=3.0):
19 """Measure a robust mean of an array
20
21 Parameters
22 ----------
23 array : `numpy.ndarray`
24 Array for which to measure the mean.
25 rej : `float`
26 k-sigma rejection threshold.
27
28 Returns
29 -------
30 mean : `array.dtype`
31 Robust mean of `array`.
32 """
33 q1, median, q3 = numpy.percentile(array, [25.0, 50.0, 100.0])
34 good = numpy.abs(array - median) < rej*0.74*(q3 - q1)
35 return array[good].mean()
36
37
38class BackgroundConfig(Config):
39 """Configuration for background measurement"""
40 statistic = ChoiceField(dtype=str, default="MEANCLIP", doc="type of statistic to use for grid points",
41 allowed={"MEANCLIP": "clipped mean",
42 "MEAN": "unclipped mean",
43 "MEDIAN": "median"})
44 xBinSize = RangeField(dtype=int, default=32, min=1, doc="Superpixel size in x")
45 yBinSize = RangeField(dtype=int, default=32, min=1, doc="Superpixel size in y")
46 algorithm = ChoiceField(dtype=str, default="NATURAL_SPLINE", optional=True,
47 doc="How to interpolate the background values. "
48 "This maps to an enum; see afw::math::Background",
49 allowed={
50 "CONSTANT": "Use a single constant value",
51 "LINEAR": "Use linear interpolation",
52 "NATURAL_SPLINE": "cubic spline with zero second derivative at endpoints",
53 "AKIMA_SPLINE": "higher-level nonlinear spline that is more robust"
54 " to outliers",
55 "NONE": "No background estimation is to be attempted",
56 })
57 mask = ListField(dtype=str, default=["SAT", "BAD", "EDGE", "DETECTED", "DETECTED_NEGATIVE", "NO_DATA"],
58 doc="Names of mask planes to ignore while estimating the background")
59
60
61class SkyStatsConfig(Config):
62 """Parameters controlling the measurement of sky statistics"""
63 statistic = ChoiceField(dtype=str, default="MEANCLIP", doc="type of statistic to use for grid points",
64 allowed={"MEANCLIP": "clipped mean",
65 "MEAN": "unclipped mean",
66 "MEDIAN": "median"})
67 clip = Field(doc="Clipping threshold for background", dtype=float, default=3.0)
68 nIter = Field(doc="Clipping iterations for background", dtype=int, default=3)
69 mask = ListField(doc="Mask planes to reject", dtype=str,
70 default=["SAT", "DETECTED", "DETECTED_NEGATIVE", "BAD", "NO_DATA"])
71
72
74 """Configuration for SkyMeasurementTask"""
75 skyIter = Field(dtype=int, default=3, doc="k-sigma rejection iterations for sky scale")
76 skyRej = Field(dtype=float, default=3.0, doc="k-sigma rejection threshold for sky scale")
77 background = ConfigField(dtype=BackgroundConfig, doc="Background measurement")
78 xNumSamples = Field(dtype=int, default=4, doc="Number of samples in x for scaling sky frame")
79 yNumSamples = Field(dtype=int, default=4, doc="Number of samples in y for scaling sky frame")
80 stats = ConfigField(dtype=SkyStatsConfig, doc="Measurement of sky statistics in the samples")
81
82
84 """Task for creating, persisting and using sky frames
85
86 A sky frame is like a fringe frame (the sum of many exposures of the night sky,
87 combined with rejection to remove astrophysical objects) except the structure
88 is on larger scales, and hence we bin the images and represent them as a
89 background model (a `lsst.afw.math.BackgroundMI`). The sky frame represents
90 the dominant response of the camera to the sky background.
91 """
92 ConfigClass = SkyMeasurementConfig
93
94 def getSkyData(self, butler, calibId):
95 """Retrieve sky frame from the butler
96
97 Parameters
98 ----------
99 butler : `lsst.daf.persistence.Butler`
100 Data butler
101 calibId : `dict`
102 Data identifier for calib
103
104 Returns
105 -------
106 sky : `lsst.afw.math.BackgroundList`
107 Sky frame
108 """
109 exp = butler.get("sky", calibId)
110 return self.exposureToBackground(exp)
111
112 @staticmethod
114 """Convert an exposure to background model
115
116 Calibs need to be persisted as an Exposure, so we need to convert
117 the persisted Exposure to a background model.
118
119 Parameters
120 ----------
122 Background model in Exposure format.
123
124 Returns
125 -------
126 bg : `lsst.afw.math.BackgroundList`
127 Background model
128 """
129 header = bgExp.getMetadata()
130 xMin = header.getScalar("BOX.MINX")
131 yMin = header.getScalar("BOX.MINY")
132 xMax = header.getScalar("BOX.MAXX")
133 yMax = header.getScalar("BOX.MAXY")
134 algorithm = header.getScalar("ALGORITHM")
135 bbox = geom.Box2I(geom.Point2I(xMin, yMin), geom.Point2I(xMax, yMax))
136 return afwMath.BackgroundList(
137 (afwMath.BackgroundMI(bbox, bgExp.getMaskedImage()),
138 afwMath.stringToInterpStyle(algorithm),
139 afwMath.stringToUndersampleStyle("REDUCE_INTERP_ORDER"),
140 afwMath.ApproximateControl.UNKNOWN,
141 0, 0, False))
142
143 def backgroundToExposure(self, statsImage, bbox):
144 """Convert a background model to an exposure
145
146 Calibs need to be persisted as an Exposure, so we need to convert
147 the background model to an Exposure.
148
149 Parameters
150 ----------
151 statsImage : `lsst.afw.image.MaskedImageF`
152 Background model's statistics image.
153 bbox : `lsst.geom.Box2I`
154 Bounding box for image.
155
156 Returns
157 -------
159 Background model in Exposure format.
160 """
161 exp = afwImage.makeExposure(statsImage)
162 header = exp.getMetadata()
163 header.set("BOX.MINX", bbox.getMinX())
164 header.set("BOX.MINY", bbox.getMinY())
165 header.set("BOX.MAXX", bbox.getMaxX())
166 header.set("BOX.MAXY", bbox.getMaxY())
167 header.set("ALGORITHM", self.config.background.algorithm)
168 return exp
169
170 def measureBackground(self, image):
171 """Measure a background model for image
172
173 This doesn't use a full-featured background model (e.g., no Chebyshev
174 approximation) because we just want the binning behaviour. This will
175 allow us to average the bins later (`averageBackgrounds`).
176
177 The `BackgroundMI` is wrapped in a `BackgroundList` so it can be
178 pickled and persisted.
179
180 Parameters
181 ----------
183 Image for which to measure background.
184
185 Returns
186 -------
187 bgModel : `lsst.afw.math.BackgroundList`
188 Background model.
189 """
190 stats = afwMath.StatisticsControl()
191 stats.setAndMask(image.getMask().getPlaneBitMask(self.config.background.mask))
192 stats.setNanSafe(True)
193 ctrl = afwMath.BackgroundControl(
194 self.config.background.algorithm,
195 max(int(image.getWidth()/self.config.background.xBinSize + 0.5), 1),
196 max(int(image.getHeight()/self.config.background.yBinSize + 0.5), 1),
197 "REDUCE_INTERP_ORDER",
198 stats,
199 self.config.background.statistic
200 )
201
202 bg = afwMath.makeBackground(image, ctrl)
203
204 return afwMath.BackgroundList((
205 bg,
206 afwMath.stringToInterpStyle(self.config.background.algorithm),
207 afwMath.stringToUndersampleStyle("REDUCE_INTERP_ORDER"),
208 afwMath.ApproximateControl.UNKNOWN,
209 0, 0, False
210 ))
211
212 def averageBackgrounds(self, bgList):
213 """Average multiple background models
214
215 The input background models should be a `BackgroundList` consisting
216 of a single `BackgroundMI`.
217
218 Parameters
219 ----------
220 bgList : `list` of `lsst.afw.math.BackgroundList`
221 Background models to average.
222
223 Returns
224 -------
226 Background model in Exposure format.
227 """
228 assert all(len(bg) == 1 for bg in bgList), "Mixed bgList: %s" % ([len(bg) for bg in bgList],)
229 images = [bg[0][0].getStatsImage() for bg in bgList]
230 boxes = [bg[0][0].getImageBBox() for bg in bgList]
231 assert len(set((box.getMinX(), box.getMinY(), box.getMaxX(), box.getMaxY()) for box in boxes)) == 1, \
232 "Bounding boxes not all equal"
233 bbox = boxes.pop(0)
234
235 # Ensure bad pixels are masked
236 maskVal = afwImage.Mask.getPlaneBitMask("BAD")
237 for img in images:
238 bad = numpy.isnan(img.getImage().getArray())
239 img.getMask().getArray()[bad] = maskVal
240
241 stats = afwMath.StatisticsControl()
242 stats.setAndMask(maskVal)
243 stats.setNanSafe(True)
244 combined = afwMath.statisticsStack(images, afwMath.MEANCLIP, stats)
245
246 # Set bad pixels to the median
247 # Specifically NOT going to attempt to interpolate the bad values because we're only working on a
248 # single CCD here and can't use the entire field-of-view to do the interpolation (which is what we
249 # would need to avoid introducing problems at the edge of CCDs).
250 array = combined.getImage().getArray()
251 bad = numpy.isnan(array)
252 median = numpy.median(array[~bad])
253 array[bad] = median
254
255 # Put it into an exposure, which is required for calibs
256 return self.backgroundToExposure(combined, bbox)
257
258 def measureScale(self, image, skyBackground):
259 """Measure scale of background model in image
260
261 We treat the sky frame much as we would a fringe frame
262 (except the length scale of the variations is different):
263 we measure samples on the input image and the sky frame,
264 which we will use to determine the scaling factor in the
265 'solveScales` method.
266
267 Parameters
268 ----------
270 Science image for which to measure scale.
271 skyBackground : `lsst.afw.math.BackgroundList`
272 Sky background model.
273
274 Returns
275 -------
276 imageSamples : `numpy.ndarray`
277 Sample measurements on image.
278 skySamples : `numpy.ndarray`
279 Sample measurements on sky frame.
280 """
281 if isinstance(image, afwImage.Exposure):
282 image = image.getMaskedImage()
283 # Ensure more samples than pixels
284 xNumSamples = min(self.config.xNumSamples, image.getWidth())
285 yNumSamples = min(self.config.yNumSamples, image.getHeight())
286 xLimits = numpy.linspace(0, image.getWidth(), xNumSamples + 1, dtype=int)
287 yLimits = numpy.linspace(0, image.getHeight(), yNumSamples + 1, dtype=int)
288 sky = skyBackground.getImage()
289 maskVal = image.getMask().getPlaneBitMask(self.config.stats.mask)
290 ctrl = afwMath.StatisticsControl(self.config.stats.clip, self.config.stats.nIter, maskVal)
291 statistic = afwMath.stringToStatisticsProperty(self.config.stats.statistic)
292 imageSamples = []
293 skySamples = []
294 for xIndex, yIndex in itertools.product(range(xNumSamples), range(yNumSamples)):
295 # -1 on the stop because Box2I is inclusive of the end point and we don't want to overlap boxes
296 xStart, xStop = xLimits[xIndex], xLimits[xIndex + 1] - 1
297 yStart, yStop = yLimits[yIndex], yLimits[yIndex + 1] - 1
298 box = geom.Box2I(geom.Point2I(xStart, yStart), geom.Point2I(xStop, yStop))
299 subImage = image.Factory(image, box)
300 subSky = sky.Factory(sky, box)
301 imageSamples.append(afwMath.makeStatistics(subImage, statistic, ctrl).getValue())
302 skySamples.append(afwMath.makeStatistics(subSky, statistic, ctrl).getValue())
303 return imageSamples, skySamples
304
305 def solveScales(self, scales):
306 """Solve multiple scales for a single scale factor
307
308 Having measured samples from the image and sky frame, we
309 fit for the scaling factor.
310
311 Parameters
312 ----------
313 scales : `list` of a `tuple` of two `numpy.ndarray` arrays
314 A `list` of the results from `measureScale` method.
315
316 Returns
317 -------
318 scale : `float`
319 Scale factor.
320 """
321 imageSamples = []
322 skySamples = []
323 for ii, ss in scales:
324 imageSamples.extend(ii)
325 skySamples.extend(ss)
326 assert len(imageSamples) == len(skySamples)
327 imageSamples = numpy.array(imageSamples)
328 skySamples = numpy.array(skySamples)
329
330 def solve(mask):
331 return afwMath.LeastSquares.fromDesignMatrix(skySamples[mask].reshape(mask.sum(), 1),
332 imageSamples[mask],
333 afwMath.LeastSquares.DIRECT_SVD).getSolution()
334
335 mask = numpy.isfinite(imageSamples) & numpy.isfinite(skySamples)
336 for ii in range(self.config.skyIter):
337 solution = solve(mask)
338 residuals = imageSamples - solution*skySamples
339 lq, uq = numpy.percentile(residuals[mask], [25, 75])
340 stdev = 0.741*(uq - lq) # Robust stdev from IQR
341 with numpy.errstate(invalid="ignore"): # suppress NAN warnings
342 bad = numpy.abs(residuals) > self.config.skyRej*stdev
343 mask[bad] = False
344
345 return solve(mask)
346
347 def subtractSkyFrame(self, image, skyBackground, scale, bgList=None):
348 """Subtract sky frame from science image
349
350 Parameters
351 ----------
353 Science image.
354 skyBackground : `lsst.afw.math.BackgroundList`
355 Sky background model.
356 scale : `float`
357 Scale to apply to background model.
358 bgList : `lsst.afw.math.BackgroundList`
359 List of backgrounds applied to image
360 """
361 if isinstance(image, afwImage.Exposure):
362 image = image.getMaskedImage()
363 if isinstance(image, afwImage.MaskedImage):
364 image = image.getImage()
365 image.scaledMinus(scale, skyBackground.getImage())
366 if bgList is not None:
367 # Append the sky frame to the list of applied background models
368 bgData = list(skyBackground[0])
369 bg = bgData[0]
370 statsImage = bg.getStatsImage().clone()
371 statsImage *= scale
372 newBg = afwMath.BackgroundMI(bg.getImageBBox(), statsImage)
373 newBgData = [newBg] + bgData[1:]
374 bgList.append(newBgData)
375
376
377def interpolate1D(method, xSample, ySample, xInterp):
378 """Interpolate in one dimension
379
380 Interpolates the curve provided by `xSample` and `ySample` at
381 the positions of `xInterp`. Automatically backs off the
382 interpolation method to achieve successful interpolation.
383
384 Parameters
385 ----------
386 method : `lsst.afw.math.Interpolate.Style`
387 Interpolation method to use.
388 xSample : `numpy.ndarray`
389 Vector of ordinates.
390 ySample : `numpy.ndarray`
391 Vector of coordinates.
392 xInterp : `numpy.ndarray`
393 Vector of ordinates to which to interpolate.
394
395 Returns
396 -------
397 yInterp : `numpy.ndarray`
398 Vector of interpolated coordinates.
399
400 """
401 if len(xSample) == 0:
402 return numpy.ones_like(xInterp)*numpy.nan
403 try:
404 return afwMath.makeInterpolate(xSample.astype(float), ySample.astype(float),
405 method).interpolate(xInterp.astype(float))
406 except Exception:
407 if method == afwMath.Interpolate.CONSTANT:
408 # We've already tried the most basic interpolation and it failed
409 return numpy.ones_like(xInterp)*numpy.nan
410 newMethod = afwMath.lookupMaxInterpStyle(len(xSample))
411 if newMethod == method:
412 newMethod = afwMath.Interpolate.CONSTANT
413 return interpolate1D(newMethod, xSample, ySample, xInterp)
414
415
416def interpolateBadPixels(array, isBad, interpolationStyle):
417 """Interpolate bad pixels in an image array
418
419 The bad pixels are modified in the array.
420
421 Parameters
422 ----------
423 array : `numpy.ndarray`
424 Image array with bad pixels.
425 isBad : `numpy.ndarray` of type `bool`
426 Boolean array indicating which pixels are bad.
427 interpolationStyle : `str`
428 Style for interpolation (see `lsst.afw.math.Background`);
429 supported values are CONSTANT, LINEAR, NATURAL_SPLINE,
430 AKIMA_SPLINE.
431 """
432 if numpy.all(isBad):
433 raise RuntimeError("No good pixels in image array")
434 height, width = array.shape
435 xIndices = numpy.arange(width, dtype=float)
436 yIndices = numpy.arange(height, dtype=float)
437 method = afwMath.stringToInterpStyle(interpolationStyle)
438 isGood = ~isBad
439 for y in range(height):
440 if numpy.any(isBad[y, :]) and numpy.any(isGood[y, :]):
441 array[y][isBad[y]] = interpolate1D(method, xIndices[isGood[y]], array[y][isGood[y]],
442 xIndices[isBad[y]])
443
444 isBad = numpy.isnan(array)
445 isGood = ~isBad
446 for x in range(width):
447 if numpy.any(isBad[:, x]) and numpy.any(isGood[:, x]):
448 array[:, x][isBad[:, x]] = interpolate1D(method, yIndices[isGood[:, x]],
449 array[:, x][isGood[:, x]], yIndices[isBad[:, x]])
450
451
453 """Configuration for FocalPlaneBackground
454
455 Note that `xSize` and `ySize` are floating-point values, as
456 the focal plane frame is usually defined in units of microns
457 or millimetres rather than pixels. As such, their values will
458 need to be revised according to each particular camera. For
459 this reason, no defaults are set for those.
460 """
461 xSize = Field(dtype=float, doc="Bin size in x")
462 ySize = Field(dtype=float, doc="Bin size in y")
463 pixelSize = Field(dtype=float, default=1.0, doc="Pixel size in same units as xSize/ySize")
464 minFrac = Field(dtype=float, default=0.1, doc="Minimum fraction of bin size for good measurement")
465 mask = ListField(dtype=str, doc="Mask planes to treat as bad",
466 default=["BAD", "SAT", "INTRP", "DETECTED", "DETECTED_NEGATIVE", "EDGE", "NO_DATA"])
467 interpolation = ChoiceField(
468 doc="how to interpolate the background values. This maps to an enum; see afw::math::Background",
469 dtype=str, default="AKIMA_SPLINE", optional=True,
470 allowed={
471 "CONSTANT": "Use a single constant value",
472 "LINEAR": "Use linear interpolation",
473 "NATURAL_SPLINE": "cubic spline with zero second derivative at endpoints",
474 "AKIMA_SPLINE": "higher-level nonlinear spline that is more robust to outliers",
475 "NONE": "No background estimation is to be attempted",
476 },
477 )
478 doSmooth = Field(dtype=bool, default=False, doc="Do smoothing?")
479 smoothScale = Field(dtype=float, default=2.0, doc="Smoothing scale, as a multiple of the bin size")
480 binning = Field(dtype=int, default=64, doc="Binning to use for CCD background model (pixels)")
481
482
483@deprecated(
484 reason="pipe_drivers is deprecated. It will be removed after v25. "
485 "Please use lsst.pipe.tasks.background.FocalPlaneBackground instead.",
486 version="v25.0",
487 category=FutureWarning,
488)
490 """Background model for a focal plane camera
491
492 We model the background empirically with the "superpixel" method: we
493 measure the background in each superpixel and interpolate between
494 superpixels to yield the model.
495
496 The principal difference between this and `lsst.afw.math.BackgroundMI`
497 is that here the superpixels are defined in the frame of the focal
498 plane of the camera which removes discontinuities across detectors.
499
500 The constructor you probably want to use is the `fromCamera` classmethod.
501
502 There are two use patterns for building a background model:
503
504 * Serial: create a `FocalPlaneBackground`, then `addCcd` for each of the
505 CCDs in an exposure.
506
507 * Parallel: create a `FocalPlaneBackground`, then `clone` it for each
508 of the CCDs in an exposure and use those to `addCcd` their respective
509 CCD image. Finally, `merge` all the clones into the original.
510
511 Once you've built the background model, you can apply it to individual
512 CCDs with the `toCcdBackground` method.
513 """
514 @classmethod
515 def fromCamera(cls, config, camera):
516 """Construct from a camera object
517
518 Parameters
519 ----------
520 config : `FocalPlaneBackgroundConfig`
521 Configuration for measuring backgrounds.
523 Camera for which to measure backgrounds.
524 """
525 cameraBox = geom.Box2D()
526 for ccd in camera:
527 for point in ccd.getCorners(afwCameraGeom.FOCAL_PLANE):
528 cameraBox.include(point)
529
530 width, height = cameraBox.getDimensions()
531 # Offset so that we run from zero
532 offset = geom.Extent2D(cameraBox.getMin())*-1
533 # Add an extra pixel buffer on either side
534 dims = geom.Extent2I(int(numpy.ceil(width/config.xSize)) + 2,
535 int(numpy.ceil(height/config.ySize)) + 2)
536 # Transform takes us from focal plane coordinates --> sample coordinates
537 transform = (geom.AffineTransform.makeTranslation(geom.Extent2D(1, 1))*
538 geom.AffineTransform.makeScaling(1.0/config.xSize, 1.0/config.ySize)*
539 geom.AffineTransform.makeTranslation(offset))
540
541 return cls(config, dims, afwGeom.makeTransform(transform))
542
543 def __init__(self, config, dims, transform, values=None, numbers=None):
544 """Constructor
545
546 Developers should note that changes to the signature of this method
547 require coordinated changes to the `__reduce__` and `clone` methods.
548
549 Parameters
550 ----------
551 config : `FocalPlaneBackgroundConfig`
552 Configuration for measuring backgrounds.
553 dims : `lsst.geom.Extent2I`
554 Dimensions for background samples.
556 Transformation from focal plane coordinates to sample coordinates.
557 values : `lsst.afw.image.ImageF`
558 Measured background values.
559 numbers : `lsst.afw.image.ImageF`
560 Number of pixels in each background measurement.
561 """
562 self.config = config
563 self.dims = dims
564 self.transform = transform
565
566 if values is None:
567 values = afwImage.ImageF(self.dims)
568 values.set(0.0)
569 else:
570 values = values.clone()
571 assert(values.getDimensions() == self.dims)
572 self._values = values
573 if numbers is None:
574 numbers = afwImage.ImageF(self.dims) # float for dynamic range and convenience
575 numbers.set(0.0)
576 else:
577 numbers = numbers.clone()
578 assert(numbers.getDimensions() == self.dims)
579 self._numbers = numbers
580
581 def __reduce__(self):
582 return self.__class__, (self.config, self.dims, self.transform, self._values, self._numbers)
583
584 def clone(self):
585 return self.__class__(self.config, self.dims, self.transform, self._values, self._numbers)
586
587 def addCcd(self, exposure):
588 """Add CCD to model
589
590 We measure the background on the CCD (clipped mean), and record
591 the results in the model. For simplicity, measurements are made
592 in a box on the CCD corresponding to the warped coordinates of the
593 superpixel rather than accounting for little rotations, etc.
594 We also record the number of pixels used in the measurement so we
595 can have a measure of confidence in each bin's value.
596
597 Parameters
598 ----------
599 exposure : `lsst.afw.image.Exposure`
600 CCD exposure to measure
601 """
602 detector = exposure.getDetector()
603 transform = detector.getTransformMap().getTransform(detector.makeCameraSys(afwCameraGeom.PIXELS),
604 detector.makeCameraSys(afwCameraGeom.FOCAL_PLANE))
605 image = exposure.getMaskedImage()
606 maskVal = image.getMask().getPlaneBitMask(self.config.mask)
607
608 # Warp the binned image to the focal plane
609 toSample = transform.then(self.transform) # CCD pixels --> focal plane --> sample
610
611 warped = afwImage.ImageF(self._values.getBBox())
612 warpedCounts = afwImage.ImageF(self._numbers.getBBox())
613 width, height = warped.getDimensions()
614
615 stats = afwMath.StatisticsControl()
616 stats.setAndMask(maskVal)
617 stats.setNanSafe(True)
618 # Iterating over individual pixels in python is usually bad because it's slow, but there aren't many.
619 pixels = itertools.product(range(width), range(height))
620 for xx, yy in pixels:
621 llc = toSample.applyInverse(geom.Point2D(xx - 0.5, yy - 0.5))
622 urc = toSample.applyInverse(geom.Point2D(xx + 0.5, yy + 0.5))
623 bbox = geom.Box2I(geom.Point2I(llc), geom.Point2I(urc))
624 bbox.clip(image.getBBox())
625 if bbox.isEmpty():
626 continue
627 subImage = image.Factory(image, bbox)
628 result = afwMath.makeStatistics(subImage, afwMath.MEANCLIP | afwMath.NPOINT, stats)
629 mean = result.getValue(afwMath.MEANCLIP)
630 num = result.getValue(afwMath.NPOINT)
631 if not numpy.isfinite(mean) or not numpy.isfinite(num):
632 continue
633 warped[xx, yy, afwImage.LOCAL] = mean*num
634 warpedCounts[xx, yy, afwImage.LOCAL] = num
635
636 self._values += warped
637 self._numbers += warpedCounts
638
639 def toCcdBackground(self, detector, bbox):
640 """Produce a background model for a CCD
641
642 The superpixel background model is warped back to the
643 CCD frame, for application to the individual CCD.
644
645 Parameters
646 ----------
648 CCD for which to produce background model.
649 bbox : `lsst.geom.Box2I`
650 Bounding box of CCD exposure.
651
652 Returns
653 -------
654 bg : `lsst.afw.math.BackgroundList`
655 Background model for CCD.
656 """
657 transform = detector.getTransformMap().getTransform(detector.makeCameraSys(afwCameraGeom.PIXELS),
658 detector.makeCameraSys(afwCameraGeom.FOCAL_PLANE))
659 binTransform = (geom.AffineTransform.makeScaling(self.config.binning)*
660 geom.AffineTransform.makeTranslation(geom.Extent2D(0.5, 0.5)))
661
662 # Binned image on CCD --> unbinned image on CCD --> focal plane --> binned focal plane
663 toSample = afwGeom.makeTransform(binTransform).then(transform).then(self.transform)
664
665 focalPlane = self.getStatsImage()
666 fpNorm = afwImage.ImageF(focalPlane.getBBox())
667 fpNorm.set(1.0)
668
669 image = afwImage.ImageF(bbox.getDimensions()//self.config.binning)
670 norm = afwImage.ImageF(image.getBBox())
671 ctrl = afwMath.WarpingControl("bilinear")
672 afwMath.warpImage(image, focalPlane, toSample.inverted(), ctrl)
673 afwMath.warpImage(norm, fpNorm, toSample.inverted(), ctrl)
674 image /= norm
675
676 mask = afwImage.Mask(image.getBBox())
677 isBad = numpy.isnan(image.getArray())
678 mask.getArray()[isBad] = mask.getPlaneBitMask("BAD")
679 image.getArray()[isBad] = image.getArray()[~isBad].mean()
680
681 return afwMath.BackgroundList(
682 (afwMath.BackgroundMI(bbox, afwImage.makeMaskedImage(image, mask)),
683 afwMath.stringToInterpStyle(self.config.interpolation),
684 afwMath.stringToUndersampleStyle("REDUCE_INTERP_ORDER"),
685 afwMath.ApproximateControl.UNKNOWN,
686 0, 0, False)
687 )
688
689 def merge(self, other):
690 """Merge with another FocalPlaneBackground
691
692 This allows multiple background models to be constructed from
693 different CCDs, and then merged to form a single consistent
694 background model for the entire focal plane.
695
696 Parameters
697 ----------
698 other : `FocalPlaneBackground`
699 Another background model to merge.
700
701 Returns
702 -------
703 self : `FocalPlaneBackground`
704 The merged background model.
705 """
706 if (self.config.xSize, self.config.ySize) != (other.config.xSize, other.config.ySize):
707 raise RuntimeError("Size mismatch: %s vs %s" % ((self.config.xSize, self.config.ySize),
708 (other.config.xSize, other.config.ySize)))
709 if self.dims != other.dims:
710 raise RuntimeError("Dimensions mismatch: %s vs %s" % (self.dims, other.dims))
711 self._values += other._values
712 self._numbers += other._numbers
713 return self
714
715 def __iadd__(self, other):
716 """Merge with another FocalPlaneBackground
717
718 Parameters
719 ----------
720 other : `FocalPlaneBackground`
721 Another background model to merge.
722
723 Returns
724 -------
725 self : `FocalPlaneBackground`
726 The merged background model.
727 """
728 return self.merge(other)
729
730 def getStatsImage(self):
731 """Return the background model data
732
733 This is the measurement of the background for each of the superpixels.
734 """
735 values = self._values.clone()
736 values /= self._numbers
737 thresh = (self.config.minFrac*
738 (self.config.xSize/self.config.pixelSize)*(self.config.ySize/self.config.pixelSize))
739 isBad = self._numbers.getArray() < thresh
740 if self.config.doSmooth:
741 array = values.getArray()
742 array[:] = smoothArray(array, isBad, self.config.smoothScale)
743 isBad = numpy.isnan(values.array)
744 if numpy.any(isBad):
745 interpolateBadPixels(values.getArray(), isBad, self.config.interpolation)
746 return values
747
748
749class MaskObjectsConfig(Config):
750 """Configuration for MaskObjectsTask"""
751 nIter = Field(dtype=int, default=3, doc="Number of iterations")
752 subtractBackground = ConfigurableField(target=measAlg.SubtractBackgroundTask,
753 doc="Background subtraction")
754 detection = ConfigurableField(target=measAlg.SourceDetectionTask, doc="Source detection")
755 detectSigma = Field(dtype=float, default=5.0, doc="Detection threshold (standard deviations)")
756 doInterpolate = Field(dtype=bool, default=True, doc="Interpolate when removing objects?")
757 interpolate = ConfigurableField(target=measAlg.SubtractBackgroundTask, doc="Interpolation")
758
759 def setDefaults(self):
760 self.detection.reEstimateBackground = False
761 self.detection.doTempLocalBackground = False
762 self.detection.doTempWideBackground = False
763 self.detection.thresholdValue = 2.5
764 self.subtractBackground.binSize = 1024
765 self.subtractBackground.useApprox = False
766 self.interpolate.binSize = 256
767 self.interpolate.useApprox = False
768
769 def validate(self):
770 if (self.detection.reEstimateBackground or
771 self.detection.doTempLocalBackground or
772 self.detection.doTempWideBackground):
773 raise RuntimeError("Incorrect settings for object masking: reEstimateBackground, "
774 "doTempLocalBackground and doTempWideBackground must be False")
775
776
777class MaskObjectsTask(Task):
778 """Iterative masking of objects on an Exposure
779
780 This task makes more exhaustive object mask by iteratively doing detection
781 and background-subtraction. The purpose of this task is to get true
782 background removing faint tails of large objects. This is useful to get a
783 clean sky estimate from relatively small number of visits.
784
785 We deliberately use the specified ``detectSigma`` instead of the PSF,
786 in order to better pick up the faint wings of objects.
787 """
788 ConfigClass = MaskObjectsConfig
789
790 def __init__(self, *args, **kwargs):
791 super().__init__(*args, **kwargs)
792 # Disposable schema suppresses warning from SourceDetectionTask.__init__
793 self.makeSubtask("detection", schema=afwTable.Schema())
794 self.makeSubtask("interpolate")
795 self.makeSubtask("subtractBackground")
796
797 def run(self, exposure, maskPlanes=None):
798 """Mask objects on Exposure
799
800 Objects are found and removed.
801
802 Parameters
803 ----------
804 exposure : `lsst.afw.image.Exposure`
805 Exposure on which to mask objects.
806 maskPlanes : iterable of `str`, optional
807 List of mask planes to remove.
808 """
809 self.findObjects(exposure)
810 self.removeObjects(exposure, maskPlanes)
811
812 def findObjects(self, exposure):
813 """Iteratively find objects on an exposure
814
815 Objects are masked with the ``DETECTED`` mask plane.
816
817 Parameters
818 ----------
819 exposure : `lsst.afw.image.Exposure`
820 Exposure on which to mask objects.
821 """
822 for _ in range(self.config.nIter):
823 bg = self.subtractBackground.run(exposure).background
824 self.detection.detectFootprints(exposure, sigma=self.config.detectSigma, clearMask=True)
825 exposure.maskedImage += bg.getImage()
826
827 def removeObjects(self, exposure, maskPlanes=None):
828 """Remove objects from exposure
829
830 We interpolate over using a background model if ``doInterpolate`` is
831 set; otherwise we simply replace everything with the median.
832
833 Parameters
834 ----------
835 exposure : `lsst.afw.image.Exposure`
836 Exposure on which to mask objects.
837 maskPlanes : iterable of `str`, optional
838 List of mask planes to remove. ``DETECTED`` will be added as well.
839 """
840 image = exposure.image
841 mask = exposure.mask
842 maskVal = mask.getPlaneBitMask("DETECTED")
843 if maskPlanes is not None:
844 maskVal |= mask.getPlaneBitMask(maskPlanes)
845 isBad = mask.array & maskVal > 0
846
847 if self.config.doInterpolate:
848 smooth = self.interpolate.fitBackground(exposure.maskedImage)
849 replace = smooth.getImageF().array[isBad]
850 mask.array &= ~mask.getPlaneBitMask(["DETECTED"])
851 else:
852 replace = numpy.median(image.array[~isBad])
853 image.array[isBad] = replace
854
855
856def smoothArray(array, bad, sigma):
857 """Gaussian-smooth an array while ignoring bad pixels
858
859 It's not sufficient to set the bad pixels to zero, as then they're treated
860 as if they are zero, rather than being ignored altogether. We need to apply
861 a correction to that image that removes the effect of the bad pixels.
862
863 Parameters
864 ----------
865 array : `numpy.ndarray` of floating-point
866 Array to smooth.
867 bad : `numpy.ndarray` of `bool`
868 Flag array indicating bad pixels.
869 sigma : `float`
870 Gaussian sigma.
871
872 Returns
873 -------
874 convolved : `numpy.ndarray`
875 Smoothed image.
876 """
877 convolved = gaussian_filter(numpy.where(bad, 0.0, array), sigma, mode="constant", cval=0.0)
878 denominator = gaussian_filter(numpy.where(bad, 0.0, 1.0), sigma, mode="constant", cval=0.0)
879 return convolved/denominator
def __init__(self, config, dims, transform, values=None, numbers=None)
Definition: background.py:543
def __init__(self, *args, **kwargs)
Definition: background.py:790
def removeObjects(self, exposure, maskPlanes=None)
Definition: background.py:827
def run(self, exposure, maskPlanes=None)
Definition: background.py:797
def backgroundToExposure(self, statsImage, bbox)
Definition: background.py:143
def subtractSkyFrame(self, image, skyBackground, scale, bgList=None)
Definition: background.py:347
def measureScale(self, image, skyBackground)
Definition: background.py:258
def interpolate1D(method, xSample, ySample, xInterp)
Definition: background.py:377
def interpolateBadPixels(array, isBad, interpolationStyle)
Definition: background.py:416
def smoothArray(array, bad, sigma)
Definition: background.py:856
def robustMean(array, rej=3.0)
Definition: background.py:18