Loading [MathJax]/extensions/tex2jax.js
LSST Applications g0fba68d861+05816baf74,g1ec0fe41b4+f536777771,g1fd858c14a+a9301854fb,g35bb328faa+fcb1d3bbc8,g4af146b050+a5c07d5b1d,g4d2262a081+6e5fcc2a4e,g53246c7159+fcb1d3bbc8,g56a49b3a55+9c12191793,g5a012ec0e7+3632fc3ff3,g60b5630c4e+ded28b650d,g67b6fd64d1+ed4b5058f4,g78460c75b0+2f9a1b4bcd,g786e29fd12+cf7ec2a62a,g8352419a5c+fcb1d3bbc8,g87b7deb4dc+7b42cf88bf,g8852436030+e5453db6e6,g89139ef638+ed4b5058f4,g8e3bb8577d+d38d73bdbd,g9125e01d80+fcb1d3bbc8,g94187f82dc+ded28b650d,g989de1cb63+ed4b5058f4,g9d31334357+ded28b650d,g9f33ca652e+50a8019d8c,gabe3b4be73+1e0a283bba,gabf8522325+fa80ff7197,gb1101e3267+d9fb1f8026,gb58c049af0+f03b321e39,gb665e3612d+2a0c9e9e84,gb89ab40317+ed4b5058f4,gcf25f946ba+e5453db6e6,gd6cbbdb0b4+bb83cc51f8,gdd1046aedd+ded28b650d,gde0f65d7ad+941d412827,ge278dab8ac+d65b3c2b70,ge410e46f29+ed4b5058f4,gf23fb2af72+b7cae620c0,gf5e32f922b+fcb1d3bbc8,gf67bdafdda+ed4b5058f4,w.2025.16
LSST Data Management Base Package
All Classes Namespaces Files Functions Variables Typedefs Enumerations Enumerator Properties Friends Macros Modules Pages
isrFunctions.py
Go to the documentation of this file.
2# LSST Data Management System
3# Copyright 2008, 2009, 2010 LSST Corporation.
4#
5# This product includes software developed by the
6# LSST Project (http://www.lsst.org/).
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License as published by
10# the Free Software Foundation, either version 3 of the License, or
11# (at your option) any later version.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the LSST License Statement and
19# the GNU General Public License along with this program. If not,
20# see <http://www.lsstcorp.org/LegalNotices/>.
21#
22
23__all__ = [
24 "applyGains",
25 "attachTransmissionCurve",
26 "biasCorrection",
27 "brighterFatterCorrection",
28 "checkFilter",
29 "compareCameraKeywords",
30 "countMaskedPixels",
31 "createPsf",
32 "darkCorrection",
33 "flatCorrection",
34 "fluxConservingBrighterFatterCorrection",
35 "gainContext",
36 "getPhysicalFilter",
37 "growMasks",
38 "maskITLEdgeBleed",
39 "maskITLSatSag",
40 "maskITLDip",
41 "illuminationCorrection",
42 "interpolateDefectList",
43 "interpolateFromMask",
44 "makeThresholdMask",
45 "saturationCorrection",
46 "setBadRegions",
47 "transferFlux",
48 "transposeMaskedImage",
49 "trimToMatchCalibBBox",
50 "updateVariance",
51 "widenSaturationTrails",
52 "getExposureGains",
53 "getExposureReadNoises",
54]
55
56import logging
57import math
58import numpy
59
60import lsst.geom
61import lsst.afw.image as afwImage
62import lsst.afw.detection as afwDetection
63import lsst.afw.math as afwMath
64import lsst.meas.algorithms as measAlg
65import lsst.afw.cameraGeom as camGeom
66
67from lsst.afw.geom import SpanSet, Stencil
68from lsst.meas.algorithms.detection import SourceDetectionTask
69
70from contextlib import contextmanager
71
72from .defects import Defects
73
74
75def createPsf(fwhm):
76 """Make a double Gaussian PSF.
77
78 Parameters
79 ----------
80 fwhm : scalar
81 FWHM of double Gaussian smoothing kernel.
82
83 Returns
84 -------
85 psf : `lsst.meas.algorithms.DoubleGaussianPsf`
86 The created smoothing kernel.
87 """
88 ksize = 4*int(fwhm) + 1
89 return measAlg.DoubleGaussianPsf(ksize, ksize, fwhm/(2*math.sqrt(2*math.log(2))))
90
91
92def transposeMaskedImage(maskedImage):
93 """Make a transposed copy of a masked image.
94
95 Parameters
96 ----------
97 maskedImage : `lsst.afw.image.MaskedImage`
98 Image to process.
99
100 Returns
101 -------
102 transposed : `lsst.afw.image.MaskedImage`
103 The transposed copy of the input image.
104 """
105 transposed = maskedImage.Factory(lsst.geom.Extent2I(maskedImage.getHeight(), maskedImage.getWidth()))
106 transposed.getImage().getArray()[:] = maskedImage.getImage().getArray().T
107 transposed.getMask().getArray()[:] = maskedImage.getMask().getArray().T
108 transposed.getVariance().getArray()[:] = maskedImage.getVariance().getArray().T
109 return transposed
110
111
112def interpolateDefectList(maskedImage, defectList, fwhm, fallbackValue=None,
113 maskNameList=None, useLegacyInterp=True):
114 """Interpolate over defects specified in a defect list.
115
116 Parameters
117 ----------
118 maskedImage : `lsst.afw.image.MaskedImage`
119 Image to process.
120 defectList : `lsst.meas.algorithms.Defects`
121 List of defects to interpolate over.
122 fwhm : `float`
123 FWHM of double Gaussian smoothing kernel.
124 fallbackValue : scalar, optional
125 Fallback value if an interpolated value cannot be determined.
126 If None, then the clipped mean of the image is used.
127 maskNameList : `list [string]`
128 List of the defects to interpolate over (used for GP interpolator).
129 useLegacyInterp : `bool`
130 Use the legacy interpolation (polynomial interpolation) if True. Use
131 Gaussian Process interpolation if False.
132
133 Notes
134 -----
135 The ``fwhm`` parameter is used to create a PSF, but the underlying
136 interpolation code (`lsst.meas.algorithms.interpolateOverDefects`) does
137 not currently make use of this information in legacy Interpolation, but use
138 if for the Gaussian Process as an estimation of the correlation lenght.
139 """
140 psf = createPsf(fwhm)
141 if fallbackValue is None:
142 fallbackValue = afwMath.makeStatistics(maskedImage.getImage(), afwMath.MEANCLIP).getValue()
143 if 'INTRP' not in maskedImage.getMask().getMaskPlaneDict():
144 maskedImage.getMask().addMaskPlane('INTRP')
145
146 # Hardcoded fwhm value. PSF estimated latter in step1,
147 # not in ISR.
148 if useLegacyInterp:
149 kwargs = {}
150 fwhm = fwhm
151 else:
152 # tested on a dozens of images and looks a good set of
153 # hyperparameters, but cannot guarrenty this is optimal,
154 # need further testing.
155 kwargs = {"bin_spacing": 20,
156 "threshold_dynamic_binning": 2000,
157 "threshold_subdivide": 20000}
158 fwhm = 15
159
160 measAlg.interpolateOverDefects(maskedImage, psf, defectList,
161 fallbackValue=fallbackValue,
162 useFallbackValueAtEdge=True,
163 fwhm=fwhm,
164 useLegacyInterp=useLegacyInterp,
165 maskNameList=maskNameList, **kwargs)
166 return maskedImage
167
168
169def makeThresholdMask(maskedImage, threshold, growFootprints=1, maskName='SAT'):
170 """Mask pixels based on threshold detection.
171
172 Parameters
173 ----------
174 maskedImage : `lsst.afw.image.MaskedImage`
175 Image to process. Only the mask plane is updated.
176 threshold : scalar
177 Detection threshold.
178 growFootprints : scalar, optional
179 Number of pixels to grow footprints of detected regions.
180 maskName : str, optional
181 Mask plane name, or list of names to convert
182
183 Returns
184 -------
185 defectList : `lsst.meas.algorithms.Defects`
186 Defect list constructed from pixels above the threshold.
187 """
188 # find saturated regions
189 thresh = afwDetection.Threshold(threshold)
190 fs = afwDetection.FootprintSet(maskedImage, thresh)
191
192 if growFootprints > 0:
193 fs = afwDetection.FootprintSet(fs, rGrow=growFootprints, isotropic=False)
194 fpList = fs.getFootprints()
195
196 # set mask
197 mask = maskedImage.getMask()
198 bitmask = mask.getPlaneBitMask(maskName)
199 afwDetection.setMaskFromFootprintList(mask, fpList, bitmask)
200
201 return Defects.fromFootprintList(fpList)
202
203
204def growMasks(mask, radius=0, maskNameList=['BAD'], maskValue="BAD"):
205 """Grow a mask by an amount and add to the requested plane.
206
207 Parameters
208 ----------
209 mask : `lsst.afw.image.Mask`
210 Mask image to process.
211 radius : scalar
212 Amount to grow the mask.
213 maskNameList : `str` or `list` [`str`]
214 Mask names that should be grown.
215 maskValue : `str`
216 Mask plane to assign the newly masked pixels to.
217 """
218 if radius > 0:
219 spans = SpanSet.fromMask(mask, mask.getPlaneBitMask(maskNameList))
220 # Use MANHATTAN for equivalence with 'isotropic=False` footprint grows,
221 # but CIRCLE is probably better and might be just as fast.
222 spans = spans.dilated(radius, Stencil.MANHATTAN)
223 spans = spans.clippedTo(mask.getBBox())
224 spans.setMask(mask, mask.getPlaneBitMask(maskValue))
225
226
227def maskITLEdgeBleed(ccdExposure, badAmpDict,
228 fpCore, itlEdgeBleedSatMinArea=10000,
229 itlEdgeBleedSatMaxArea=100000,
230 itlEdgeBleedThreshold=5000.,
231 itlEdgeBleedModelConstant=0.02,
232 saturatedMaskName="SAT", log=None):
233 """Mask edge bleeds in ITL detectors.
234
235 Parameters
236 ----------
237 ccdExposure : `lsst.afw.image.Exposure`
238 Exposure to apply masking to.
239 badAmpDict : `dict` [`str`, `bool`]
240 Dictionary of amplifiers, keyed by name, value is True if
241 amplifier is fully masked.
242 fpCore : `lsst.afw.detection._detection.Footprint`
243 Footprint of saturated core.
244 itlEdgeBleedThreshold : `float`, optional
245 Threshold above median sky background for edge bleed detection
246 (electron units).
247 itlEdgeBleedModelConstant : `float`, optional
248 Constant in the decaying exponential in the edge bleed masking.
249 saturatedMaskName : `str`, optional
250 Mask name for saturation.
251 log : `logging.Logger`, optional
252 Logger to handle messages.
253 """
254
255 log = log if log else logging.getLogger(__name__)
256
257 # Get median of amplifier saturation level
258 satLevel = numpy.nanmedian([ccdExposure.metadata[f"LSST ISR SATURATION LEVEL {amp.getName()}"]
259 for amp in ccdExposure.getDetector() if not badAmpDict[amp.getName()]])
260
261 # 1. we check if there are several cores in the footprint:
262 # Get centroid of saturated core
263 xCore, yCore = fpCore.getCentroid()
264 # Turn the Y detector coordinate into Y footprint coordinate
265 yCoreFP = int(yCore) - fpCore.getBBox().getMinY()
266 # Now test if there is one or more cores by checking if the slice at the
267 # center is full of saturated pixels or has several segments of saturated
268 # columns (i.e. several cores with trails)
269 checkCoreNbRow = fpCore.getSpans().asArray()[yCoreFP, :]
270 nbCore = 0
271 indexSwitchTrue = []
272 indexSwitchFalse = []
273 if checkCoreNbRow[0]:
274 # If the slice starts with saturated pixels
275 inSatSegment = True
276 nbCore = 1
277 indexSwitchTrue.append(0)
278 else:
279 # If the slice starts with non saturated pixels
280 inSatSegment = False
281
282 for i, value in enumerate(checkCoreNbRow):
283 if value:
284 if not inSatSegment:
285 indexSwitchTrue.append(i)
286 # nbCore is the number of detected cores.
287 nbCore += 1
288 inSatSegment = True
289 elif inSatSegment:
290 indexSwitchFalse.append(i)
291 inSatSegment = False
292
293 # 1. we look for edge bleed in saturated cores in the footprint
294 if nbCore == 2:
295 # we now estimate the x coordinates of the edges of the subfootprint
296 # for each core
297 xEdgesCores = [0]
298 xEdgesCores.append(int((indexSwitchTrue[1] + indexSwitchFalse[0])/2))
299 xEdgesCores.append(fpCore.getSpans().asArray().shape[1])
300 # Get the X and Y footprint coordinates of the cores
301 for i in range(nbCore):
302 subfp = fpCore.getSpans().asArray()[:, xEdgesCores[i]:xEdgesCores[i+1]]
303 xCoreFP = int(xEdgesCores[i] + numpy.argmax(numpy.sum(subfp, axis=0)))
304 # turn into X coordinate in detector space
305 xCore = xCoreFP + fpCore.getBBox().getMinX()
306 # get Y footprint coordinate of the core
307 # by trimming the edges where edge bleeds are potentially dominant
308 if subfp.shape[0] <= 200:
309 yCoreFP = int(numpy.argmax(numpy.sum(subfp, axis=1)))
310 else:
311 yCoreFP = int(numpy.argmax(numpy.sum(subfp[100:-100, :],
312 axis=1)))
313 yCoreFP = 100+yCoreFP
314
315 # Estimate the width of the saturated core
316 widthSat = numpy.sum(subfp[int(yCoreFP), :])
317
318 subfpArea = numpy.sum(subfp)
319 if subfpArea > itlEdgeBleedSatMinArea and subfpArea < itlEdgeBleedSatMaxArea:
320 _applyMaskITLEdgeBleed(ccdExposure, xCore,
321 satLevel, widthSat,
322 itlEdgeBleedThreshold,
323 itlEdgeBleedModelConstant,
324 saturatedMaskName, log)
325 elif nbCore > 2:
326 # TODO DM-49736: support N cores in saturated footprint
327 log.warning(
328 "Too many (%d) cores in saturated footprint to mask edge bleeds.",
329 nbCore,
330 )
331 else:
332 # Get centroid of saturated core
333 xCore, yCore = fpCore.getCentroid()
334 # Turn the Y detector coordinate into Y footprint coordinate
335 yCoreFP = yCore - fpCore.getBBox().getMinY()
336 # Get the number of saturated columns around the centroid
337 widthSat = numpy.sum(fpCore.getSpans().asArray()[int(yCoreFP), :])
338 _applyMaskITLEdgeBleed(ccdExposure, xCore,
339 satLevel, widthSat, itlEdgeBleedThreshold,
340 itlEdgeBleedModelConstant, saturatedMaskName, log)
341
342
343def _applyMaskITLEdgeBleed(ccdExposure, xCore,
344 satLevel, widthSat,
345 itlEdgeBleedThreshold=5000.,
346 itlEdgeBleedModelConstant=0.03,
347 saturatedMaskName="SAT", log=None):
348 """Apply ITL edge bleed masking model.
349
350 Parameters
351 ----------
352 ccdExposure : `lsst.afw.image.Exposure`
353 Exposure to apply masking to.
354 xCore: `int`
355 X coordinate of the saturated core.
356 satLevel: `float`
357 Minimum saturation level of the detector.
358 widthSat: `float`
359 Width of the saturated core.
360 itlEdgeBleedThreshold : `float`, optional
361 Threshold above median sky background for edge bleed detection
362 (electron units).
363 itlEdgeBleedModelConstant : `float`, optional
364 Constant in the decaying exponential in the edge bleed masking.
365 saturatedMaskName : `str`, optional
366 Mask name for saturation.
367 log : `logging.Logger`, optional
368 Logger to handle messages.
369 """
370 log = log if log else logging.getLogger(__name__)
371
372 maskedImage = ccdExposure.maskedImage
373 xmax = maskedImage.image.array.shape[1]
374 saturatedBit = maskedImage.mask.getPlaneBitMask(saturatedMaskName)
375
376 for amp in ccdExposure.getDetector():
377 # Select the 2 top and bottom amplifiers around the saturated
378 # core with a potential edge bleed by selecting the amplifiers
379 # that have the same X coordinate as the saturated core.
380 # As we don't care about the Y coordinate, we set it to the
381 # center of the BBox.
382 yBox = amp.getBBox().getCenter()[1]
383 if amp.getBBox().contains(xCore, yBox):
384
385 # Get the amp name
386 ampName = amp.getName()
387
388 # Because in ITLs the edge bleed happens on both edges
389 # of the detector, we make a cutout around
390 # both the top and bottom
391 # edge bleed candidates around the saturated core.
392 # We flip the cutout of the top amplifier
393 # to then work with the same coordinates for both.
394 # The way of selecting top vs bottom amp
395 # is very specific to ITL.
396 if ampName[:2] == 'C1':
397 sliceImage = maskedImage.image.array[:200, :]
398 sliceMask = maskedImage.mask.array[:200, :]
399 elif ampName[:2] == 'C0':
400 sliceImage = numpy.flipud(maskedImage.image.array[-200:, :])
401 sliceMask = numpy.flipud(maskedImage.mask.array[-200:, :])
402
403 # The middle columns of edge bleeds often have
404 # high counts, so we check there is an edge bleed
405 # by looking at a small image up to 50 pixels from the edge
406 # and around the saturated columns
407 # of the saturated core, and checking its median is
408 # above the sky background by itlEdgeBleedThreshold
409
410 # If the centroid is too close to the edge of the detector
411 # (within 5 pixels), we set the limit to the mean check
412 # to the edge of the detector
413 lowerRangeSmall = int(xCore)-5
414 upperRangeSmall = int(xCore)+5
415 if lowerRangeSmall < 0:
416 lowerRangeSmall = 0
417 if upperRangeSmall > xmax:
418 upperRangeSmall = xmax
419 ampImageBG = numpy.median(maskedImage[amp.getBBox()].image.array)
420 edgeMedian = numpy.median(sliceImage[:50, lowerRangeSmall:upperRangeSmall])
421 if edgeMedian > (ampImageBG + itlEdgeBleedThreshold):
422
423 log.info("Found edge bleed around column %d", xCore)
424
425 # We need an estimate of the maximum width
426 # of the edge bleed for our masking model
427 # so we now estimate it by measuring the width of
428 # areas above 60 percent of the saturation level
429 # close to the edge,
430 # in a cutout up to 100 pixels from the edge,
431 # with a width of around the width of an amplifier.
432 subImageXMin = int(xCore)-250
433 subImageXMax = int(xCore)+250
434 if subImageXMin < 0:
435 subImageXMin = 0
436 elif subImageXMax > xmax:
437 subImageXMax = xmax
438
439 subImage = sliceImage[:100, subImageXMin:subImageXMax]
440 maxWidthEdgeBleed = numpy.max(numpy.sum(subImage > 0.45*satLevel,
441 axis=1))
442
443 # Mask edge bleed with a decaying exponential model
444 for y in range(200):
445 edgeBleedHalfWidth = \
446 int(((maxWidthEdgeBleed)*numpy.exp(-itlEdgeBleedModelConstant*y)
447 + widthSat)/2.)
448 lowerRange = int(xCore)-edgeBleedHalfWidth
449 upperRange = int(xCore)+edgeBleedHalfWidth
450 # If the edge bleed model goes outside the detector
451 # we set the limit for the masking
452 # to the edge of the detector
453 if lowerRange < 0:
454 lowerRange = 0
455 if upperRange > xmax:
456 upperRange = xmax
457 sliceMask[y, lowerRange:upperRange] |= saturatedBit
458
459
460def maskITLSatSag(ccdExposure, fpCore, saturatedMaskName="SAT"):
461 """Mask columns presenting saturation sag in saturated footprints in
462 ITL detectors.
463
464 Parameters
465 ----------
466 ccdExposure : `lsst.afw.image.Exposure`
467 Exposure to apply masking to.
468 fpCore : `lsst.afw.detection._detection.Footprint`
469 Footprint of saturated core.
470 saturatedMaskName : `str`, optional
471 Mask name for saturation.
472 """
473
474 # TODO DM-49736: add a flux level check to apply masking
475
476 maskedImage = ccdExposure.maskedImage
477 saturatedBit = maskedImage.mask.getPlaneBitMask(saturatedMaskName)
478
479 cc = numpy.sum(fpCore.getSpans().asArray(), axis=0)
480 # Mask full columns that have 20 percent of the height of the footprint
481 # saturated
482 columnsToMaskFP = numpy.where(cc > fpCore.getSpans().asArray().shape[0]/5.)
483
484 columnsToMask = [x + int(fpCore.getBBox().getMinX()) for x in columnsToMaskFP]
485 maskedImage.mask.array[:, columnsToMask] |= saturatedBit
486
487
488def maskITLDip(exposure, detectorConfig, maskPlaneNames=["SUSPECT", "ITL_DIP"], log=None):
489 """Add mask bits according to the ITL dip model.
490
491 Parameters
492 ----------
493 exposure : `lsst.afw.image.Exposure`
494 Exposure to do ITL dip masking.
495 detectorConfig : `lsst.ip.isr.overscanAmpConfig.OverscanDetectorConfig`
496 Configuration for this detector.
497 maskPlaneNames : `list [`str`], optional
498 Name of the ITL Dip mask planes.
499 log : `logging.Logger`, optional
500 If not set, a default logger will be used.
501 """
502 if detectorConfig.itlDipBackgroundFraction == 0.0:
503 # Nothing to do.
504 return
505
506 if log is None:
507 log = logging.getLogger(__name__)
508
509 thresh = afwDetection.Threshold(
510 exposure.mask.getPlaneBitMask("SAT"),
511 afwDetection.Threshold.BITMASK,
512 )
513 fpList = afwDetection.FootprintSet(exposure.mask, thresh).getFootprints()
514
515 heights = numpy.asarray([fp.getBBox().getHeight() for fp in fpList])
516
517 largeHeights, = numpy.where(heights >= detectorConfig.itlDipMinHeight)
518
519 if len(largeHeights) == 0:
520 return
521
522 # Get the approximate image background.
523 approxBackground = numpy.median(exposure.image.array)
524 maskValue = exposure.mask.getPlaneBitMask(maskPlaneNames)
525
526 maskBak = exposure.mask.array.copy()
527 nMaskedCols = 0
528
529 for index in largeHeights:
530 fp = fpList[index]
531 center = fp.getCentroid()
532
533 nSat = numpy.sum(fp.getSpans().asArray(), axis=0)
534 width = numpy.sum(nSat > detectorConfig.itlDipMinHeight)
535
536 if width < detectorConfig.itlDipMinWidth:
537 continue
538
539 width = numpy.clip(width, None, detectorConfig.itlDipMaxWidth)
540
541 dipMax = detectorConfig.itlDipBackgroundFraction * approxBackground * width
542
543 # Assume sky-noise dominated; we could add in read noise here.
544 if dipMax < detectorConfig.itlDipMinBackgroundNoiseFraction * numpy.sqrt(approxBackground):
545 continue
546
547 minCol = int(center.getX() - (detectorConfig.itlDipWidthScale * width) / 2.)
548 maxCol = int(center.getX() + (detectorConfig.itlDipWidthScale * width) / 2.)
549 minCol = numpy.clip(minCol, 0, None)
550 maxCol = numpy.clip(maxCol, None, exposure.mask.array.shape[1] - 1)
551
552 log.info(
553 "Found ITL dip (width %d; bkg %.2f); masking column %d to %d",
554 width,
555 approxBackground,
556 minCol,
557 maxCol,
558 )
559
560 exposure.mask.array[:, minCol: maxCol + 1] |= maskValue
561
562 nMaskedCols += (maxCol - minCol + 1)
563
564 if nMaskedCols > detectorConfig.itlDipMaxColsPerImage:
565 log.warning(
566 "Too many (%d) columns would be masked on this image from dip masking; restoring original mask.",
567 nMaskedCols,
568 )
569 exposure.mask.array[:, :] = maskBak
570
571
572def interpolateFromMask(maskedImage, fwhm, growSaturatedFootprints=1,
573 maskNameList=['SAT'], fallbackValue=None, useLegacyInterp=True):
574 """Interpolate over defects identified by a particular set of mask planes.
575
576 Parameters
577 ----------
578 maskedImage : `lsst.afw.image.MaskedImage`
579 Image to process.
580 fwhm : `float`
581 FWHM of double Gaussian smoothing kernel.
582 growSaturatedFootprints : scalar, optional
583 Number of pixels to grow footprints for saturated pixels.
584 maskNameList : `List` of `str`, optional
585 Mask plane name.
586 fallbackValue : scalar, optional
587 Value of last resort for interpolation.
588
589 Notes
590 -----
591 The ``fwhm`` parameter is used to create a PSF, but the underlying
592 interpolation code (`lsst.meas.algorithms.interpolateOverDefects`) does
593 not currently make use of this information.
594 """
595 mask = maskedImage.getMask()
596
597 if growSaturatedFootprints > 0 and "SAT" in maskNameList:
598 # If we are interpolating over an area larger than the original masked
599 # region, we need to expand the original mask bit to the full area to
600 # explain why we interpolated there.
601 growMasks(mask, radius=growSaturatedFootprints, maskNameList=['SAT'], maskValue="SAT")
602
603 thresh = afwDetection.Threshold(mask.getPlaneBitMask(maskNameList), afwDetection.Threshold.BITMASK)
604 fpSet = afwDetection.FootprintSet(mask, thresh)
605 defectList = Defects.fromFootprintList(fpSet.getFootprints())
606
607 interpolateDefectList(maskedImage, defectList, fwhm, fallbackValue=fallbackValue,
608 maskNameList=maskNameList, useLegacyInterp=useLegacyInterp)
609
610 return maskedImage
611
612
613def saturationCorrection(maskedImage, saturation, fwhm, growFootprints=1, interpolate=True, maskName='SAT',
614 fallbackValue=None, useLegacyInterp=True):
615 """Mark saturated pixels and optionally interpolate over them
616
617 Parameters
618 ----------
619 maskedImage : `lsst.afw.image.MaskedImage`
620 Image to process.
621 saturation : scalar
622 Saturation level used as the detection threshold.
623 fwhm : `float`
624 FWHM of double Gaussian smoothing kernel.
625 growFootprints : scalar, optional
626 Number of pixels to grow footprints of detected regions.
627 interpolate : Bool, optional
628 If True, saturated pixels are interpolated over.
629 maskName : str, optional
630 Mask plane name.
631 fallbackValue : scalar, optional
632 Value of last resort for interpolation.
633
634 Notes
635 -----
636 The ``fwhm`` parameter is used to create a PSF, but the underlying
637 interpolation code (`lsst.meas.algorithms.interpolateOverDefects`) does
638 not currently make use of this information.
639 """
640 defectList = makeThresholdMask(
641 maskedImage=maskedImage,
642 threshold=saturation,
643 growFootprints=growFootprints,
644 maskName=maskName,
645 )
646 if interpolate:
647 interpolateDefectList(maskedImage, defectList, fwhm, fallbackValue=fallbackValue,
648 maskNameList=[maskName], useLegacyInterp=useLegacyInterp)
649
650 return maskedImage
651
652
653def trimToMatchCalibBBox(rawMaskedImage, calibMaskedImage):
654 """Compute number of edge trim pixels to match the calibration data.
655
656 Use the dimension difference between the raw exposure and the
657 calibration exposure to compute the edge trim pixels. This trim
658 is applied symmetrically, with the same number of pixels masked on
659 each side.
660
661 Parameters
662 ----------
663 rawMaskedImage : `lsst.afw.image.MaskedImage`
664 Image to trim.
665 calibMaskedImage : `lsst.afw.image.MaskedImage`
666 Calibration image to draw new bounding box from.
667
668 Returns
669 -------
670 replacementMaskedImage : `lsst.afw.image.MaskedImage`
671 ``rawMaskedImage`` trimmed to the appropriate size.
672
673 Raises
674 ------
675 RuntimeError
676 Raised if ``rawMaskedImage`` cannot be symmetrically trimmed to
677 match ``calibMaskedImage``.
678 """
679 nx, ny = rawMaskedImage.getBBox().getDimensions() - calibMaskedImage.getBBox().getDimensions()
680 if nx != ny:
681 raise RuntimeError("Raw and calib maskedImages are trimmed differently in X and Y.")
682 if nx % 2 != 0:
683 raise RuntimeError("Calibration maskedImage is trimmed unevenly in X.")
684 if nx < 0:
685 raise RuntimeError("Calibration maskedImage is larger than raw data.")
686
687 nEdge = nx//2
688 if nEdge > 0:
689 replacementMaskedImage = rawMaskedImage[nEdge:-nEdge, nEdge:-nEdge, afwImage.LOCAL]
690 SourceDetectionTask.setEdgeBits(
691 rawMaskedImage,
692 replacementMaskedImage.getBBox(),
693 rawMaskedImage.getMask().getPlaneBitMask("EDGE")
694 )
695 else:
696 replacementMaskedImage = rawMaskedImage
697
698 return replacementMaskedImage
699
700
701def biasCorrection(maskedImage, biasMaskedImage, trimToFit=False):
702 """Apply bias correction in place.
703
704 Parameters
705 ----------
706 maskedImage : `lsst.afw.image.MaskedImage`
707 Image to process. The image is modified by this method.
708 biasMaskedImage : `lsst.afw.image.MaskedImage`
709 Bias image of the same size as ``maskedImage``
710 trimToFit : `Bool`, optional
711 If True, raw data is symmetrically trimmed to match
712 calibration size.
713
714 Raises
715 ------
716 RuntimeError
717 Raised if ``maskedImage`` and ``biasMaskedImage`` do not have
718 the same size.
719
720 """
721 if trimToFit:
722 maskedImage = trimToMatchCalibBBox(maskedImage, biasMaskedImage)
723
724 if maskedImage.getBBox(afwImage.LOCAL) != biasMaskedImage.getBBox(afwImage.LOCAL):
725 raise RuntimeError("maskedImage bbox %s != biasMaskedImage bbox %s" %
726 (maskedImage.getBBox(afwImage.LOCAL), biasMaskedImage.getBBox(afwImage.LOCAL)))
727 maskedImage -= biasMaskedImage
728
729
730def darkCorrection(maskedImage, darkMaskedImage, expScale, darkScale, invert=False, trimToFit=False):
731 """Apply dark correction in place.
732
733 Parameters
734 ----------
735 maskedImage : `lsst.afw.image.MaskedImage`
736 Image to process. The image is modified by this method.
737 darkMaskedImage : `lsst.afw.image.MaskedImage`
738 Dark image of the same size as ``maskedImage``.
739 expScale : scalar
740 Dark exposure time for ``maskedImage``.
741 darkScale : scalar
742 Dark exposure time for ``darkMaskedImage``.
743 invert : `Bool`, optional
744 If True, re-add the dark to an already corrected image.
745 trimToFit : `Bool`, optional
746 If True, raw data is symmetrically trimmed to match
747 calibration size.
748
749 Raises
750 ------
751 RuntimeError
752 Raised if ``maskedImage`` and ``darkMaskedImage`` do not have
753 the same size.
754
755 Notes
756 -----
757 The dark correction is applied by calculating:
758 maskedImage -= dark * expScaling / darkScaling
759 """
760 if trimToFit:
761 maskedImage = trimToMatchCalibBBox(maskedImage, darkMaskedImage)
762
763 if maskedImage.getBBox(afwImage.LOCAL) != darkMaskedImage.getBBox(afwImage.LOCAL):
764 raise RuntimeError("maskedImage bbox %s != darkMaskedImage bbox %s" %
765 (maskedImage.getBBox(afwImage.LOCAL), darkMaskedImage.getBBox(afwImage.LOCAL)))
766
767 scale = expScale / darkScale
768 if not invert:
769 maskedImage.scaledMinus(scale, darkMaskedImage)
770 else:
771 maskedImage.scaledPlus(scale, darkMaskedImage)
772
773
774def updateVariance(maskedImage, gain, readNoise, replace=True):
775 """Set the variance plane based on the image plane.
776
777 The maskedImage must have units of `adu` (if gain != 1.0) or
778 electron (if gain == 1.0). This routine will always produce a
779 variance plane in the same units as the image.
780
781 Parameters
782 ----------
783 maskedImage : `lsst.afw.image.MaskedImage`
784 Image to process. The variance plane is modified.
785 gain : scalar
786 The amplifier gain in electron/adu.
787 readNoise : scalar
788 The amplifier read noise in electron/pixel.
789 replace : `bool`, optional
790 Replace the current variance? If False, the image
791 variance will be added to the current variance plane.
792 """
793 var = maskedImage.variance
794 if replace:
795 var[:, :] = maskedImage.image
796 else:
797 var[:, :] += maskedImage.image
798 var /= gain
799 var += (readNoise/gain)**2
800
801
802def flatCorrection(maskedImage, flatMaskedImage, scalingType, userScale=1.0, invert=False, trimToFit=False):
803 """Apply flat correction in place.
804
805 Parameters
806 ----------
807 maskedImage : `lsst.afw.image.MaskedImage`
808 Image to process. The image is modified.
809 flatMaskedImage : `lsst.afw.image.MaskedImage`
810 Flat image of the same size as ``maskedImage``
811 scalingType : str
812 Flat scale computation method. Allowed values are 'MEAN',
813 'MEDIAN', or 'USER'.
814 userScale : scalar, optional
815 Scale to use if ``scalingType='USER'``.
816 invert : `Bool`, optional
817 If True, unflatten an already flattened image.
818 trimToFit : `Bool`, optional
819 If True, raw data is symmetrically trimmed to match
820 calibration size.
821
822 Raises
823 ------
824 RuntimeError
825 Raised if ``maskedImage`` and ``flatMaskedImage`` do not have
826 the same size or if ``scalingType`` is not an allowed value.
827 """
828 if trimToFit:
829 maskedImage = trimToMatchCalibBBox(maskedImage, flatMaskedImage)
830
831 if maskedImage.getBBox(afwImage.LOCAL) != flatMaskedImage.getBBox(afwImage.LOCAL):
832 raise RuntimeError("maskedImage bbox %s != flatMaskedImage bbox %s" %
833 (maskedImage.getBBox(afwImage.LOCAL), flatMaskedImage.getBBox(afwImage.LOCAL)))
834
835 # Figure out scale from the data
836 # Ideally the flats are normalized by the calibration product pipeline,
837 # but this allows some flexibility in the case that the flat is created by
838 # some other mechanism.
839 if scalingType in ('MEAN', 'MEDIAN'):
840 scalingType = afwMath.stringToStatisticsProperty(scalingType)
841 flatScale = afwMath.makeStatistics(flatMaskedImage.image, scalingType).getValue()
842 elif scalingType == 'USER':
843 flatScale = userScale
844 else:
845 raise RuntimeError('%s : %s not implemented' % ("flatCorrection", scalingType))
846
847 if not invert:
848 maskedImage.scaledDivides(1.0/flatScale, flatMaskedImage)
849 else:
850 maskedImage.scaledMultiplies(1.0/flatScale, flatMaskedImage)
851
852
853def illuminationCorrection(maskedImage, illumMaskedImage, illumScale, trimToFit=True):
854 """Apply illumination correction in place.
855
856 Parameters
857 ----------
858 maskedImage : `lsst.afw.image.MaskedImage`
859 Image to process. The image is modified.
860 illumMaskedImage : `lsst.afw.image.MaskedImage`
861 Illumination correction image of the same size as ``maskedImage``.
862 illumScale : scalar
863 Scale factor for the illumination correction.
864 trimToFit : `Bool`, optional
865 If True, raw data is symmetrically trimmed to match
866 calibration size.
867
868 Raises
869 ------
870 RuntimeError
871 Raised if ``maskedImage`` and ``illumMaskedImage`` do not have
872 the same size.
873 """
874 if trimToFit:
875 maskedImage = trimToMatchCalibBBox(maskedImage, illumMaskedImage)
876
877 if maskedImage.getBBox(afwImage.LOCAL) != illumMaskedImage.getBBox(afwImage.LOCAL):
878 raise RuntimeError("maskedImage bbox %s != illumMaskedImage bbox %s" %
879 (maskedImage.getBBox(afwImage.LOCAL), illumMaskedImage.getBBox(afwImage.LOCAL)))
880
881 maskedImage.scaledDivides(1.0/illumScale, illumMaskedImage)
882
883
884def brighterFatterCorrection(exposure, kernel, maxIter, threshold, applyGain, gains=None):
885 """Apply brighter fatter correction in place for the image.
886
887 Parameters
888 ----------
889 exposure : `lsst.afw.image.Exposure`
890 Exposure to have brighter-fatter correction applied. Modified
891 by this method.
892 kernel : `numpy.ndarray`
893 Brighter-fatter kernel to apply.
894 maxIter : scalar
895 Number of correction iterations to run.
896 threshold : scalar
897 Convergence threshold in terms of the sum of absolute
898 deviations between an iteration and the previous one.
899 applyGain : `Bool`
900 If True, then the exposure values are scaled by the gain prior
901 to correction.
902 gains : `dict` [`str`, `float`]
903 A dictionary, keyed by amplifier name, of the gains to use.
904 If gains is None, the nominal gains in the amplifier object are used.
905
906 Returns
907 -------
908 diff : `float`
909 Final difference between iterations achieved in correction.
910 iteration : `int`
911 Number of iterations used to calculate correction.
912
913 Notes
914 -----
915 This correction takes a kernel that has been derived from flat
916 field images to redistribute the charge. The gradient of the
917 kernel is the deflection field due to the accumulated charge.
918
919 Given the original image I(x) and the kernel K(x) we can compute
920 the corrected image Ic(x) using the following equation:
921
922 Ic(x) = I(x) + 0.5*d/dx(I(x)*d/dx(int( dy*K(x-y)*I(y))))
923
924 To evaluate the derivative term we expand it as follows:
925
926 0.5 * ( d/dx(I(x))*d/dx(int(dy*K(x-y)*I(y)))
927 + I(x)*d^2/dx^2(int(dy* K(x-y)*I(y))) )
928
929 Because we use the measured counts instead of the incident counts
930 we apply the correction iteratively to reconstruct the original
931 counts and the correction. We stop iterating when the summed
932 difference between the current corrected image and the one from
933 the previous iteration is below the threshold. We do not require
934 convergence because the number of iterations is too large a
935 computational cost. How we define the threshold still needs to be
936 evaluated, the current default was shown to work reasonably well
937 on a small set of images. For more information on the method see
938 DocuShare Document-19407.
939
940 The edges as defined by the kernel are not corrected because they
941 have spurious values due to the convolution.
942 """
943 image = exposure.getMaskedImage().getImage()
944
945 # The image needs to be units of electrons/holes
946 with gainContext(exposure, image, applyGain, gains):
947
948 kLx = numpy.shape(kernel)[0]
949 kLy = numpy.shape(kernel)[1]
950 kernelImage = afwImage.ImageD(kLx, kLy)
951 kernelImage.getArray()[:, :] = kernel
952 tempImage = afwImage.ImageD(image, deep=True)
953
954 nanIndex = numpy.isnan(tempImage.getArray())
955 tempImage.getArray()[nanIndex] = 0.
956
957 outImage = afwImage.ImageD(image.getDimensions())
958 corr = numpy.zeros(image.array.shape, dtype=numpy.float64)
959 prev_image = numpy.zeros(image.array.shape, dtype=numpy.float64)
960 convCntrl = afwMath.ConvolutionControl(False, True, 1)
961 fixedKernel = afwMath.FixedKernel(kernelImage)
962
963 # Define boundary by convolution region. The region that the
964 # correction will be calculated for is one fewer in each dimension
965 # because of the second derivative terms.
966 # NOTE: these need to use integer math, as we're using start:end as
967 # numpy index ranges.
968 startX = kLx//2
969 endX = -kLx//2
970 startY = kLy//2
971 endY = -kLy//2
972
973 for iteration in range(maxIter):
974
975 afwMath.convolve(outImage, tempImage, fixedKernel, convCntrl)
976 tmpArray = tempImage.getArray()
977 outArray = outImage.getArray()
978
979 with numpy.errstate(invalid="ignore", over="ignore"):
980 # First derivative term
981 gradTmp = numpy.gradient(tmpArray[startY:endY, startX:endX])
982 gradOut = numpy.gradient(outArray[startY:endY, startX:endX])
983 first = (gradTmp[0]*gradOut[0] + gradTmp[1]*gradOut[1])[1:-1, 1:-1]
984
985 # Second derivative term
986 diffOut20 = numpy.diff(outArray, 2, 0)[startY:endY, startX + 1:endX - 1]
987 diffOut21 = numpy.diff(outArray, 2, 1)[startY + 1:endY - 1, startX:endX]
988 second = tmpArray[startY + 1:endY - 1, startX + 1:endX - 1]*(diffOut20 + diffOut21)
989
990 corr[startY + 1:endY - 1, startX + 1:endX - 1] = 0.5*(first + second)
991
992 tmpArray[:, :] = image.getArray()[:, :]
993 tmpArray[nanIndex] = 0.
994 tmpArray[startY:endY, startX:endX] += corr[startY:endY, startX:endX]
995
996 if iteration > 0:
997 diff = numpy.sum(numpy.abs(prev_image - tmpArray), dtype=numpy.float64)
998
999 if diff < threshold:
1000 break
1001 prev_image[:, :] = tmpArray[:, :]
1002
1003 image.getArray()[startY + 1:endY - 1, startX + 1:endX - 1] += \
1004 corr[startY + 1:endY - 1, startX + 1:endX - 1]
1005
1006 return diff, iteration
1007
1008
1009def transferFlux(cFunc, fStep, correctionMode=True):
1010 """Take the input convolved deflection potential and the flux array
1011 to compute and apply the flux transfer into the correction array.
1012
1013 Parameters
1014 ----------
1015 cFunc: `numpy.array`
1016 Deflection potential, being the convolution of the flux F with the
1017 kernel K.
1018 fStep: `numpy.array`
1019 The array of flux values which act as the source of the flux transfer.
1020 correctionMode: `bool`
1021 Defines if applying correction (True) or generating sims (False).
1022
1023 Returns
1024 -------
1025 corr:
1026 BFE correction array
1027 """
1028
1029 if cFunc.shape != fStep.shape:
1030 raise RuntimeError(f'transferFlux: array shapes do not match: {cFunc.shape}, {fStep.shape}')
1031
1032 # set the sign of the correction and set its value for the
1033 # time averaged solution
1034 if correctionMode:
1035 # negative sign if applying BFE correction
1036 factor = -0.5
1037 else:
1038 # positive sign if generating BFE simulations
1039 factor = 0.5
1040
1041 # initialise the BFE correction image to zero
1042 corr = numpy.zeros(cFunc.shape, dtype=numpy.float64)
1043
1044 # Generate a 2D mesh of x,y coordinates
1045 yDim, xDim = cFunc.shape
1046 y = numpy.arange(yDim, dtype=int)
1047 x = numpy.arange(xDim, dtype=int)
1048 xc, yc = numpy.meshgrid(x, y)
1049
1050 # process each axis in turn
1051 for ax in [0, 1]:
1052
1053 # gradient of phi on right/upper edge of pixel
1054 diff = numpy.diff(cFunc, axis=ax)
1055
1056 # expand array back to full size with zero gradient at the end
1057 gx = numpy.zeros(cFunc.shape, dtype=numpy.float64)
1058 yDiff, xDiff = diff.shape
1059 gx[:yDiff, :xDiff] += diff
1060
1061 # select pixels with either positive gradients on the right edge,
1062 # flux flowing to the right/up
1063 # or negative gradients, flux flowing to the left/down
1064 for i, sel in enumerate([gx > 0, gx < 0]):
1065 xSelPixels = xc[sel]
1066 ySelPixels = yc[sel]
1067 # and add the flux into the pixel to the right or top
1068 # depending on which axis we are handling
1069 if ax == 0:
1070 xPix = xSelPixels
1071 yPix = ySelPixels+1
1072 else:
1073 xPix = xSelPixels+1
1074 yPix = ySelPixels
1075 # define flux as the either current pixel value or pixel
1076 # above/right
1077 # depending on whether positive or negative gradient
1078 if i == 0:
1079 # positive gradients, flux flowing to higher coordinate values
1080 flux = factor * fStep[sel]*gx[sel]
1081 else:
1082 # negative gradients, flux flowing to lower coordinate values
1083 flux = factor * fStep[yPix, xPix]*gx[sel]
1084 # change the fluxes of the donor and receiving pixels
1085 # such that flux is conserved
1086 corr[sel] -= flux
1087 corr[yPix, xPix] += flux
1088
1089 # return correction array
1090 return corr
1091
1092
1093def fluxConservingBrighterFatterCorrection(exposure, kernel, maxIter, threshold, applyGain,
1094 gains=None, correctionMode=True):
1095 """Apply brighter fatter correction in place for the image.
1096
1097 This version presents a modified version of the algorithm
1098 found in ``lsst.ip.isr.isrFunctions.brighterFatterCorrection``
1099 which conserves the image flux, resulting in improved
1100 correction of the cores of stars. The convolution has also been
1101 modified to mitigate edge effects.
1102
1103 Parameters
1104 ----------
1105 exposure : `lsst.afw.image.Exposure`
1106 Exposure to have brighter-fatter correction applied. Modified
1107 by this method.
1108 kernel : `numpy.ndarray`
1109 Brighter-fatter kernel to apply.
1110 maxIter : scalar
1111 Number of correction iterations to run.
1112 threshold : scalar
1113 Convergence threshold in terms of the sum of absolute
1114 deviations between an iteration and the previous one.
1115 applyGain : `Bool`
1116 If True, then the exposure values are scaled by the gain prior
1117 to correction.
1118 gains : `dict` [`str`, `float`]
1119 A dictionary, keyed by amplifier name, of the gains to use.
1120 If gains is None, the nominal gains in the amplifier object are used.
1121 correctionMode : `Bool`
1122 If True (default) the function applies correction for BFE. If False,
1123 the code can instead be used to generate a simulation of BFE (sign
1124 change in the direction of the effect)
1125
1126 Returns
1127 -------
1128 diff : `float`
1129 Final difference between iterations achieved in correction.
1130 iteration : `int`
1131 Number of iterations used to calculate correction.
1132
1133 Notes
1134 -----
1135 Modified version of ``lsst.ip.isr.isrFunctions.brighterFatterCorrection``.
1136
1137 This correction takes a kernel that has been derived from flat
1138 field images to redistribute the charge. The gradient of the
1139 kernel is the deflection field due to the accumulated charge.
1140
1141 Given the original image I(x) and the kernel K(x) we can compute
1142 the corrected image Ic(x) using the following equation:
1143
1144 Ic(x) = I(x) + 0.5*d/dx(I(x)*d/dx(int( dy*K(x-y)*I(y))))
1145
1146 Improved algorithm at this step applies the divergence theorem to
1147 obtain a pixelised correction.
1148
1149 Because we use the measured counts instead of the incident counts
1150 we apply the correction iteratively to reconstruct the original
1151 counts and the correction. We stop iterating when the summed
1152 difference between the current corrected image and the one from
1153 the previous iteration is below the threshold. We do not require
1154 convergence because the number of iterations is too large a
1155 computational cost. How we define the threshold still needs to be
1156 evaluated, the current default was shown to work reasonably well
1157 on a small set of images.
1158
1159 Edges are handled in the convolution by padding. This is still not
1160 a physical model for the edge, but avoids discontinuity in the correction.
1161
1162 Author of modified version: Lance.Miller@physics.ox.ac.uk
1163 (see DM-38555).
1164 """
1165 image = exposure.getMaskedImage().getImage()
1166
1167 # The image needs to be units of electrons/holes
1168 with gainContext(exposure, image, applyGain, gains):
1169
1170 # get kernel and its shape
1171 kLy, kLx = kernel.shape
1172 kernelImage = afwImage.ImageD(kLx, kLy)
1173 kernelImage.getArray()[:, :] = kernel
1174 tempImage = afwImage.ImageD(image, deep=True)
1175
1176 nanIndex = numpy.isnan(tempImage.getArray())
1177 tempImage.getArray()[nanIndex] = 0.
1178
1179 outImage = afwImage.ImageD(image.getDimensions())
1180 corr = numpy.zeros(image.array.shape, dtype=numpy.float64)
1181 prevImage = numpy.zeros(image.array.shape, dtype=numpy.float64)
1182 convCntrl = afwMath.ConvolutionControl(False, False, 1)
1183 fixedKernel = afwMath.FixedKernel(kernelImage)
1184
1185 # set the padding amount
1186 # ensure we pad by an even amount larger than the kernel
1187 kLy = 2 * ((1+kLy)//2)
1188 kLx = 2 * ((1+kLx)//2)
1189
1190 # The deflection potential only depends on the gradient of
1191 # the convolution, so we can subtract the mean, which then
1192 # allows us to pad the image with zeros and avoid wrap-around effects
1193 # (although still not handling the image edges with a physical model)
1194 # This wouldn't be great if there were a strong image gradient.
1195 imYdimension, imXdimension = tempImage.array.shape
1196 imean = numpy.mean(tempImage.getArray()[~nanIndex], dtype=numpy.float64)
1197 # subtract mean from image
1198 tempImage -= imean
1199 tempImage.array[nanIndex] = 0.0
1200 padArray = numpy.pad(tempImage.getArray(), ((0, kLy), (0, kLx)))
1201 outImage = afwImage.ImageD(numpy.pad(outImage.getArray(), ((0, kLy), (0, kLx))))
1202 # Convert array to afw image so afwMath.convolve works
1203 padImage = afwImage.ImageD(padArray.shape[1], padArray.shape[0])
1204 padImage.array[:] = padArray
1205
1206 for iteration in range(maxIter):
1207
1208 # create deflection potential, convolution of flux with kernel
1209 # using padded counts array
1210 afwMath.convolve(outImage, padImage, fixedKernel, convCntrl)
1211 tmpArray = tempImage.getArray()
1212 outArray = outImage.getArray()
1213
1214 # trim convolution output back to original shape
1215 outArray = outArray[:imYdimension, :imXdimension]
1216
1217 # generate the correction array, with correctionMode set as input
1218 corr[...] = transferFlux(outArray, tmpArray, correctionMode=correctionMode)
1219
1220 # update the arrays for the next iteration
1221 tmpArray[:, :] = image.getArray()[:, :]
1222 tmpArray += corr
1223 tmpArray[nanIndex] = 0.
1224 # update padded array
1225 # subtract mean
1226 tmpArray -= imean
1227 tempImage.array[nanIndex] = 0.
1228 padArray = numpy.pad(tempImage.getArray(), ((0, kLy), (0, kLx)))
1229
1230 if iteration > 0:
1231 diff = numpy.sum(numpy.abs(prevImage - tmpArray), dtype=numpy.float64)
1232
1233 if diff < threshold:
1234 break
1235 prevImage[:, :] = tmpArray[:, :]
1236
1237 image.getArray()[:] += corr[:]
1238
1239 return diff, iteration
1240
1241
1242@contextmanager
1243def gainContext(exp, image, apply, gains=None, invert=False, isTrimmed=True):
1244 """Context manager that applies and removes gain.
1245
1246 Parameters
1247 ----------
1248 exp : `lsst.afw.image.Exposure`
1249 Exposure to apply/remove gain.
1250 image : `lsst.afw.image.Image`
1251 Image to apply/remove gain.
1252 apply : `bool`
1253 If True, apply and remove the amplifier gain.
1254 gains : `dict` [`str`, `float`], optional
1255 A dictionary, keyed by amplifier name, of the gains to use.
1256 If gains is None, the nominal gains in the amplifier object are used.
1257 invert : `bool`, optional
1258 Invert the gains (e.g. convert electrons to adu temporarily)?
1259 isTrimmed : `bool`, optional
1260 Is this a trimmed exposure?
1261
1262 Yields
1263 ------
1264 exp : `lsst.afw.image.Exposure`
1265 Exposure with the gain applied.
1266 """
1267 # check we have all of them if provided because mixing and matching would
1268 # be a real mess
1269 if gains and apply is True:
1270 ampNames = [amp.getName() for amp in exp.getDetector()]
1271 for ampName in ampNames:
1272 if ampName not in gains.keys():
1273 raise RuntimeError(f"Gains provided to gain context, but no entry found for amp {ampName}")
1274
1275 if apply:
1276 ccd = exp.getDetector()
1277 for amp in ccd:
1278 sim = image.Factory(image, amp.getBBox() if isTrimmed else amp.getRawBBox())
1279 if gains:
1280 gain = gains[amp.getName()]
1281 else:
1282 gain = amp.getGain()
1283 if invert:
1284 sim /= gain
1285 else:
1286 sim *= gain
1287
1288 try:
1289 yield exp
1290 finally:
1291 if apply:
1292 ccd = exp.getDetector()
1293 for amp in ccd:
1294 sim = image.Factory(image, amp.getBBox() if isTrimmed else amp.getRawBBox())
1295 if gains:
1296 gain = gains[amp.getName()]
1297 else:
1298 gain = amp.getGain()
1299 if invert:
1300 sim *= gain
1301 else:
1302 sim /= gain
1303
1304
1305def attachTransmissionCurve(exposure, opticsTransmission=None, filterTransmission=None,
1306 sensorTransmission=None, atmosphereTransmission=None):
1307 """Attach a TransmissionCurve to an Exposure, given separate curves for
1308 different components.
1309
1310 Parameters
1311 ----------
1312 exposure : `lsst.afw.image.Exposure`
1313 Exposure object to modify by attaching the product of all given
1314 ``TransmissionCurves`` in post-assembly trimmed detector coordinates.
1315 Must have a valid ``Detector`` attached that matches the detector
1316 associated with sensorTransmission.
1317 opticsTransmission : `lsst.afw.image.TransmissionCurve`
1318 A ``TransmissionCurve`` that represents the throughput of the optics,
1319 to be evaluated in focal-plane coordinates.
1320 filterTransmission : `lsst.afw.image.TransmissionCurve`
1321 A ``TransmissionCurve`` that represents the throughput of the filter
1322 itself, to be evaluated in focal-plane coordinates.
1323 sensorTransmission : `lsst.afw.image.TransmissionCurve`
1324 A ``TransmissionCurve`` that represents the throughput of the sensor
1325 itself, to be evaluated in post-assembly trimmed detector coordinates.
1326 atmosphereTransmission : `lsst.afw.image.TransmissionCurve`
1327 A ``TransmissionCurve`` that represents the throughput of the
1328 atmosphere, assumed to be spatially constant.
1329
1330 Returns
1331 -------
1332 combined : `lsst.afw.image.TransmissionCurve`
1333 The TransmissionCurve attached to the exposure.
1334
1335 Notes
1336 -----
1337 All ``TransmissionCurve`` arguments are optional; if none are provided, the
1338 attached ``TransmissionCurve`` will have unit transmission everywhere.
1339 """
1340 combined = afwImage.TransmissionCurve.makeIdentity()
1341 if atmosphereTransmission is not None:
1342 combined *= atmosphereTransmission
1343 if opticsTransmission is not None:
1344 combined *= opticsTransmission
1345 if filterTransmission is not None:
1346 combined *= filterTransmission
1347 detector = exposure.getDetector()
1348 fpToPix = detector.getTransform(fromSys=camGeom.FOCAL_PLANE,
1349 toSys=camGeom.PIXELS)
1350 combined = combined.transformedBy(fpToPix)
1351 if sensorTransmission is not None:
1352 combined *= sensorTransmission
1353 exposure.getInfo().setTransmissionCurve(combined)
1354 return combined
1355
1356
1357def applyGains(exposure, normalizeGains=False, ptcGains=None, isTrimmed=True):
1358 """Scale an exposure by the amplifier gains.
1359
1360 Parameters
1361 ----------
1362 exposure : `lsst.afw.image.Exposure`
1363 Exposure to process. The image is modified.
1364 normalizeGains : `Bool`, optional
1365 If True, then amplifiers are scaled to force the median of
1366 each amplifier to equal the median of those medians.
1367 ptcGains : `dict`[`str`], optional
1368 Dictionary keyed by amp name containing the PTC gains.
1369 isTrimmed : `bool`, optional
1370 Is the input image trimmed?
1371 """
1372 ccd = exposure.getDetector()
1373 ccdImage = exposure.getMaskedImage()
1374
1375 medians = []
1376 for amp in ccd:
1377 if isTrimmed:
1378 sim = ccdImage.Factory(ccdImage, amp.getBBox())
1379 else:
1380 sim = ccdImage.Factory(ccdImage, amp.getRawBBox())
1381 if ptcGains:
1382 sim *= ptcGains[amp.getName()]
1383 else:
1384 sim *= amp.getGain()
1385
1386 if normalizeGains:
1387 medians.append(numpy.median(sim.getImage().getArray()))
1388
1389 if normalizeGains:
1390 median = numpy.median(numpy.array(medians))
1391 for index, amp in enumerate(ccd):
1392 if isTrimmed:
1393 sim = ccdImage.Factory(ccdImage, amp.getBBox())
1394 else:
1395 sim = ccdImage.Factory(ccdImage, amp.getRawBBox())
1396 if medians[index] != 0.0:
1397 sim *= median/medians[index]
1398
1399
1401 """Grow the saturation trails by an amount dependent on the width of the
1402 trail.
1403
1404 Parameters
1405 ----------
1406 mask : `lsst.afw.image.Mask`
1407 Mask which will have the saturated areas grown.
1408 """
1409
1410 extraGrowDict = {}
1411 for i in range(1, 6):
1412 extraGrowDict[i] = 0
1413 for i in range(6, 8):
1414 extraGrowDict[i] = 1
1415 for i in range(8, 10):
1416 extraGrowDict[i] = 3
1417 extraGrowMax = 4
1418
1419 if extraGrowMax <= 0:
1420 return
1421
1422 saturatedBit = mask.getPlaneBitMask("SAT")
1423
1424 xmin, ymin = mask.getBBox().getMin()
1425 width = mask.getWidth()
1426
1427 thresh = afwDetection.Threshold(saturatedBit, afwDetection.Threshold.BITMASK)
1428 fpList = afwDetection.FootprintSet(mask, thresh).getFootprints()
1429
1430 for fp in fpList:
1431 for s in fp.getSpans():
1432 x0, x1 = s.getX0(), s.getX1()
1433
1434 extraGrow = extraGrowDict.get(x1 - x0 + 1, extraGrowMax)
1435 if extraGrow > 0:
1436 y = s.getY() - ymin
1437 x0 -= xmin + extraGrow
1438 x1 -= xmin - extraGrow
1439
1440 if x0 < 0:
1441 x0 = 0
1442 if x1 >= width - 1:
1443 x1 = width - 1
1444
1445 mask.array[y, x0:x1+1] |= saturatedBit
1446
1447
1448def setBadRegions(exposure, badStatistic="MEDIAN"):
1449 """Set all BAD areas of the chip to the average of the rest of the exposure
1450
1451 Parameters
1452 ----------
1453 exposure : `lsst.afw.image.Exposure`
1454 Exposure to mask. The exposure mask is modified.
1455 badStatistic : `str`, optional
1456 Statistic to use to generate the replacement value from the
1457 image data. Allowed values are 'MEDIAN' or 'MEANCLIP'.
1458
1459 Returns
1460 -------
1461 badPixelCount : scalar
1462 Number of bad pixels masked.
1463 badPixelValue : scalar
1464 Value substituted for bad pixels.
1465
1466 Raises
1467 ------
1468 RuntimeError
1469 Raised if `badStatistic` is not an allowed value.
1470 """
1471 if badStatistic == "MEDIAN":
1472 statistic = afwMath.MEDIAN
1473 elif badStatistic == "MEANCLIP":
1474 statistic = afwMath.MEANCLIP
1475 else:
1476 raise RuntimeError("Impossible method %s of bad region correction" % badStatistic)
1477
1478 mi = exposure.getMaskedImage()
1479 mask = mi.getMask()
1480 BAD = mask.getPlaneBitMask("BAD")
1481 INTRP = mask.getPlaneBitMask("INTRP")
1482
1484 sctrl.setAndMask(BAD)
1485 value = afwMath.makeStatistics(mi, statistic, sctrl).getValue()
1486
1487 maskArray = mask.getArray()
1488 imageArray = mi.getImage().getArray()
1489 badPixels = numpy.logical_and((maskArray & BAD) > 0, (maskArray & INTRP) == 0)
1490 imageArray[:] = numpy.where(badPixels, value, imageArray)
1491
1492 return badPixels.sum(), value
1493
1494
1495def checkFilter(exposure, filterList, log):
1496 """Check to see if an exposure is in a filter specified by a list.
1497
1498 The goal of this is to provide a unified filter checking interface
1499 for all filter dependent stages.
1500
1501 Parameters
1502 ----------
1503 exposure : `lsst.afw.image.Exposure`
1504 Exposure to examine.
1505 filterList : `list` [`str`]
1506 List of physical_filter names to check.
1507 log : `logging.Logger`
1508 Logger to handle messages.
1509
1510 Returns
1511 -------
1512 result : `bool`
1513 True if the exposure's filter is contained in the list.
1514 """
1515 if len(filterList) == 0:
1516 return False
1517 thisFilter = exposure.getFilter()
1518 if thisFilter is None:
1519 log.warning("No FilterLabel attached to this exposure!")
1520 return False
1521
1522 thisPhysicalFilter = getPhysicalFilter(thisFilter, log)
1523 if thisPhysicalFilter in filterList:
1524 return True
1525 elif thisFilter.bandLabel in filterList:
1526 if log:
1527 log.warning("Physical filter (%s) should be used instead of band %s for filter configurations"
1528 " (%s)", thisPhysicalFilter, thisFilter.bandLabel, filterList)
1529 return True
1530 else:
1531 return False
1532
1533
1534def getPhysicalFilter(filterLabel, log):
1535 """Get the physical filter label associated with the given filterLabel.
1536
1537 If ``filterLabel`` is `None` or there is no physicalLabel attribute
1538 associated with the given ``filterLabel``, the returned label will be
1539 "Unknown".
1540
1541 Parameters
1542 ----------
1543 filterLabel : `lsst.afw.image.FilterLabel`
1544 The `lsst.afw.image.FilterLabel` object from which to derive the
1545 physical filter label.
1546 log : `logging.Logger`
1547 Logger to handle messages.
1548
1549 Returns
1550 -------
1551 physicalFilter : `str`
1552 The value returned by the physicalLabel attribute of ``filterLabel`` if
1553 it exists, otherwise set to \"Unknown\".
1554 """
1555 if filterLabel is None:
1556 physicalFilter = "Unknown"
1557 log.warning("filterLabel is None. Setting physicalFilter to \"Unknown\".")
1558 else:
1559 try:
1560 physicalFilter = filterLabel.physicalLabel
1561 except RuntimeError:
1562 log.warning("filterLabel has no physicalLabel attribute. Setting physicalFilter to \"Unknown\".")
1563 physicalFilter = "Unknown"
1564 return physicalFilter
1565
1566
1567def countMaskedPixels(maskedIm, maskPlane):
1568 """Count the number of pixels in a given mask plane.
1569
1570 Parameters
1571 ----------
1572 maskedIm : `~lsst.afw.image.MaskedImage`
1573 Masked image to examine.
1574 maskPlane : `str`
1575 Name of the mask plane to examine.
1576
1577 Returns
1578 -------
1579 nPix : `int`
1580 Number of pixels in the requested mask plane.
1581 """
1582 maskBit = maskedIm.mask.getPlaneBitMask(maskPlane)
1583 nPix = numpy.where(numpy.bitwise_and(maskedIm.mask.array, maskBit))[0].flatten().size
1584 return nPix
1585
1586
1587def getExposureGains(exposure):
1588 """Get the per-amplifier gains used for this exposure.
1589
1590 Parameters
1591 ----------
1592 exposure : `lsst.afw.image.Exposure`
1593 The exposure to find gains for.
1594
1595 Returns
1596 -------
1597 gains : `dict` [`str` `float`]
1598 Dictionary of gain values, keyed by amplifier name.
1599 Returns empty dict when detector is None.
1600 """
1601 det = exposure.getDetector()
1602 if det is None:
1603 return dict()
1604
1605 metadata = exposure.getMetadata()
1606 gains = {}
1607 for amp in det:
1608 ampName = amp.getName()
1609 # The key may use the new LSST ISR or the old LSST prefix
1610 if (key1 := f"LSST ISR GAIN {ampName}") in metadata:
1611 gains[ampName] = metadata[key1]
1612 elif (key2 := f"LSST GAIN {ampName}") in metadata:
1613 gains[ampName] = metadata[key2]
1614 else:
1615 gains[ampName] = amp.getGain()
1616 return gains
1617
1618
1620 """Get the per-amplifier read noise used for this exposure.
1621
1622 Parameters
1623 ----------
1624 exposure : `lsst.afw.image.Exposure`
1625 The exposure to find read noise for.
1626
1627 Returns
1628 -------
1629 readnoises : `dict` [`str` `float`]
1630 Dictionary of read noise values, keyed by amplifier name.
1631 Returns empty dict when detector is None.
1632 """
1633 det = exposure.getDetector()
1634 if det is None:
1635 return dict()
1636
1637 metadata = exposure.getMetadata()
1638 readnoises = {}
1639 for amp in det:
1640 ampName = amp.getName()
1641 # The key may use the new LSST ISR or the old LSST prefix
1642 if (key1 := f"LSST ISR READNOISE {ampName}") in metadata:
1643 readnoises[ampName] = metadata[key1]
1644 elif (key2 := f"LSST READNOISE {ampName}") in metadata:
1645 readnoises[ampName] = metadata[key2]
1646 else:
1647 readnoises[ampName] = amp.getReadNoise()
1648 return readnoises
1649
1650
1651def isTrimmedExposure(exposure):
1652 """Check if the unused pixels (pre-/over-scan pixels) have
1653 been trimmed from an exposure.
1654
1655 Parameters
1656 ----------
1657 exposure : `lsst.afw.image.Exposure`
1658 The exposure to check.
1659
1660 Returns
1661 -------
1662 result : `bool`
1663 True if the image is trimmed, else False.
1664 """
1665 return exposure.getDetector().getBBox() == exposure.getBBox()
1666
1667
1668def isTrimmedImage(image, detector):
1669 """Check if the unused pixels (pre-/over-scan pixels) have
1670 been trimmed from an image
1671
1672 Parameters
1673 ----------
1674 image : `lsst.afw.image.Image`
1675 The image to check.
1676 detector : `lsst.afw.cameraGeom.Detector`
1677 The detector associated with the image.
1678
1679 Returns
1680 -------
1681 result : `bool`
1682 True if the image is trimmed, else False.
1683 """
1684 return detector.getBBox() == image.getBBox()
1685
1686
1688 doRaiseOnCalibMismatch,
1689 cameraKeywordsToCompare,
1690 exposureMetadata,
1691 calib,
1692 calibName,
1693 log=None,
1694):
1695 """Compare header keywords to confirm camera states match.
1696
1697 Parameters
1698 ----------
1699 doRaiseOnCalibMismatch : `bool`
1700 Raise on calibration mismatch? Otherwise, log a warning.
1701 cameraKeywordsToCompare : `list` [`str`]
1702 List of camera keywords to compare.
1703 exposureMetadata : `lsst.daf.base.PropertyList`
1704 Header for the exposure being processed.
1705 calib : `lsst.afw.image.Exposure` or `lsst.ip.isr.IsrCalib`
1706 Calibration to be applied.
1707 calibName : `str`
1708 Calib type for log message.
1709 log : `logging.Logger`, optional
1710 Logger to handle messages.
1711 """
1712 try:
1713 calibMetadata = calib.metadata
1714 except AttributeError:
1715 return
1716
1717 log = log if log else logging.getLogger(__name__)
1718
1719 missingKeywords = []
1720 for keyword in cameraKeywordsToCompare:
1721 exposureValue = exposureMetadata.get(keyword, None)
1722 if exposureValue is None:
1723 log.debug("Sequencer keyword %s not found in exposure metadata.", keyword)
1724 continue
1725
1726 calibValue = calibMetadata.get(keyword, None)
1727
1728 # We don't log here if there is a missing keyword.
1729 if calibValue is None:
1730 missingKeywords.append(keyword)
1731 continue
1732
1733 if exposureValue != calibValue:
1734 if doRaiseOnCalibMismatch:
1735 raise RuntimeError(
1736 "Sequencer mismatch for %s [%s]: exposure: %s calib: %s",
1737 calibName,
1738 keyword,
1739 exposureValue,
1740 calibValue,
1741 )
1742 else:
1743 log.warning(
1744 "Sequencer mismatch for %s [%s]: exposure: %s calib: %s",
1745 calibName,
1746 keyword,
1747 exposureValue,
1748 calibValue,
1749 )
1750 exposureMetadata[f"ISR {calibName.upper()} SEQUENCER MISMATCH"] = True
1751
1752 if missingKeywords:
1753 log.info(
1754 "Calibration %s missing keywords %s, which were not checked.",
1755 calibName,
1756 ",".join(missingKeywords),
1757 )
Parameters to control convolution.
A kernel created from an Image.
Definition Kernel.h:471
Pass parameters to a Statistics object.
Definition Statistics.h:83
Statistics makeStatistics(lsst::afw::image::Image< Pixel > const &img, lsst::afw::image::Mask< image::MaskPixel > const &msk, int const flags, StatisticsControl const &sctrl=StatisticsControl())
Handle a watered-down front-end to the constructor (no variance)
Definition Statistics.h:361
void convolve(OutImageT &convolvedImage, InImageT const &inImage, KernelT const &kernel, ConvolutionControl const &convolutionControl=ConvolutionControl())
Convolve an Image or MaskedImage with a Kernel, setting pixels of an existing output image.
Property stringToStatisticsProperty(std::string const property)
Conversion function to switch a string to a Property (see Statistics.h)
gainContext(exp, image, apply, gains=None, invert=False, isTrimmed=True)
compareCameraKeywords(doRaiseOnCalibMismatch, cameraKeywordsToCompare, exposureMetadata, calib, calibName, log=None)
interpolateDefectList(maskedImage, defectList, fwhm, fallbackValue=None, maskNameList=None, useLegacyInterp=True)
setBadRegions(exposure, badStatistic="MEDIAN")
countMaskedPixels(maskedIm, maskPlane)
maskITLEdgeBleed(ccdExposure, badAmpDict, fpCore, itlEdgeBleedSatMinArea=10000, itlEdgeBleedSatMaxArea=100000, itlEdgeBleedThreshold=5000., itlEdgeBleedModelConstant=0.02, saturatedMaskName="SAT", log=None)
attachTransmissionCurve(exposure, opticsTransmission=None, filterTransmission=None, sensorTransmission=None, atmosphereTransmission=None)
flatCorrection(maskedImage, flatMaskedImage, scalingType, userScale=1.0, invert=False, trimToFit=False)
illuminationCorrection(maskedImage, illumMaskedImage, illumScale, trimToFit=True)
growMasks(mask, radius=0, maskNameList=['BAD'], maskValue="BAD")
maskITLSatSag(ccdExposure, fpCore, saturatedMaskName="SAT")
transferFlux(cFunc, fStep, correctionMode=True)
biasCorrection(maskedImage, biasMaskedImage, trimToFit=False)
checkFilter(exposure, filterList, log)
darkCorrection(maskedImage, darkMaskedImage, expScale, darkScale, invert=False, trimToFit=False)
fluxConservingBrighterFatterCorrection(exposure, kernel, maxIter, threshold, applyGain, gains=None, correctionMode=True)
brighterFatterCorrection(exposure, kernel, maxIter, threshold, applyGain, gains=None)
makeThresholdMask(maskedImage, threshold, growFootprints=1, maskName='SAT')
transposeMaskedImage(maskedImage)
interpolateFromMask(maskedImage, fwhm, growSaturatedFootprints=1, maskNameList=['SAT'], fallbackValue=None, useLegacyInterp=True)
isTrimmedImage(image, detector)
maskITLDip(exposure, detectorConfig, maskPlaneNames=["SUSPECT", "ITL_DIP"], log=None)
updateVariance(maskedImage, gain, readNoise, replace=True)
applyGains(exposure, normalizeGains=False, ptcGains=None, isTrimmed=True)
getPhysicalFilter(filterLabel, log)
_applyMaskITLEdgeBleed(ccdExposure, xCore, satLevel, widthSat, itlEdgeBleedThreshold=5000., itlEdgeBleedModelConstant=0.03, saturatedMaskName="SAT", log=None)
saturationCorrection(maskedImage, saturation, fwhm, growFootprints=1, interpolate=True, maskName='SAT', fallbackValue=None, useLegacyInterp=True)
trimToMatchCalibBBox(rawMaskedImage, calibMaskedImage)