Coverage for tests/test_edgeBleed.py: 98%
187 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-30 11:30 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-30 11:30 +0000
1# This file is part of ip_isr.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
21import unittest
23import numpy as np
25import lsst.geom as geom
26import lsst.afw.cameraGeom as cameraGeom
27import lsst.afw.image as afwImage
28import lsst.utils.tests
29import lsst.ip.isr.isrFunctions as ipIsrFunctions
30from lsst.ip.isr.isrTask import IsrTaskConfig
31from lsst.ip.isr.masking import DECamEdgeBleedMaskTask
33AMP_WIDTH = 200
34AMP_HEIGHT = 800
35SKY = 1000.0
36NOISE = 10.0
37DIP = 200.0
40def makeDetector():
41 """Two side-by-side amplifiers: A reads out at the top, B at the bottom."""
42 camBuilder = cameraGeom.Camera.Builder("testCam")
43 detBuilder = camBuilder.add("testDet", 0)
44 detBuilder.setBBox(geom.Box2I(geom.Point2I(0, 0), geom.Extent2I(2*AMP_WIDTH, AMP_HEIGHT)))
45 detBuilder.setPixelSize(geom.Extent2D(0.015, 0.015))
46 detBuilder.setOrientation(cameraGeom.Orientation())
47 for name, x0, corner in (("A", 0, cameraGeom.ReadoutCorner.UL),
48 ("B", AMP_WIDTH, cameraGeom.ReadoutCorner.LL)):
49 bbox = geom.Box2I(geom.Point2I(x0, 0), geom.Extent2I(AMP_WIDTH, AMP_HEIGHT))
50 amp = cameraGeom.Amplifier.Builder()
51 amp.setName(name)
52 amp.setBBox(bbox)
53 amp.setRawBBox(bbox)
54 amp.setRawDataBBox(bbox)
55 amp.setReadoutCorner(corner)
56 amp.setGain(1.0)
57 amp.setReadNoise(5.0)
58 amp.setSaturation(60000.0)
59 detBuilder.append(amp)
60 return camBuilder.finish()["testDet"]
63class MaskDECamEdgeBleedTestCase(lsst.utils.tests.TestCase):
64 def setUp(self):
65 self.detector = makeDetector()
66 self.exposure = afwImage.ExposureF(self.detector.getBBox())
67 self.exposure.setDetector(self.detector)
68 rng = np.random.default_rng(12345)
69 self.exposure.image.array[:] = SKY + rng.normal(0.0, NOISE, self.exposure.image.array.shape)
70 self.exposure.variance.array[:] = NOISE**2
71 self.satBit = self.exposure.mask.getPlaneBitMask("SAT")
73 def addSatBlock(self, x0, y0, width, height):
74 """Mark a width x height block as SAT (image value irrelevant)."""
75 self.exposure.mask.array[y0:y0 + height, x0:x0 + width] |= self.satBit
76 self.exposure.image.array[y0:y0 + height, x0:x0 + width] = 60000.0
78 def addDip(self, x0, y0, width, height):
79 """Depress a width x height block below sky."""
80 self.exposure.image.array[y0:y0 + height, x0:x0 + width] -= DIP
82 def runMasking(self):
83 before = self.exposure.mask.array.copy()
84 ipIsrFunctions.maskDECamEdgeBleed(self.exposure)
85 return before, self.exposure.mask.array
87 def test_topReadEdgeBleedIsMasked(self):
88 # 100 x 200 = 20000 SAT pixels touching the top edge of amp A.
89 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200)
90 # Dip in the 100 rows nearest the top edge, full width of amp A.
91 self.addDip(0, AMP_HEIGHT - 100, AMP_WIDTH, 100)
93 before, after = self.runMasking()
95 # height 100 -> margin int(100*0.125) + 1 = 13 -> 113 rows masked.
96 expected = before.copy()
97 expected[AMP_HEIGHT - 113:, 0:AMP_WIDTH] |= self.satBit
98 np.testing.assert_array_equal(after, expected)
100 def test_noDipIsNotMasked(self):
101 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200)
103 before, after = self.runMasking()
105 np.testing.assert_array_equal(after, before)
107 def test_smallFootprintIsNotMasked(self):
108 # 50 x 100 = 5000 SAT pixels: below satMinArea.
109 self.addSatBlock(50, AMP_HEIGHT - 100, 50, 100)
110 self.addDip(0, AMP_HEIGHT - 100, AMP_WIDTH, 100)
112 before, after = self.runMasking()
114 np.testing.assert_array_equal(after, before)
116 def test_largeFootprintIsNotMasked(self):
117 # 300 x 400 = 120000 SAT pixels: above satMaxArea. Only x 0-49 of the
118 # dip rows are unsaturated, still enough low pixels per row to be
119 # detectable, so only the area cut prevents masking.
120 self.addSatBlock(50, 400, 300, 400)
121 self.addDip(0, AMP_HEIGHT - 100, AMP_WIDTH, 100)
123 before, after = self.runMasking()
125 np.testing.assert_array_equal(after, before)
127 def test_footprintFarFromEdgeIsNotMasked(self):
128 # Footprint top at row 599, 200 rows short of the read edge.
129 self.addSatBlock(50, 400, 100, 200)
130 self.addDip(0, AMP_HEIGHT - 100, AMP_WIDTH, 100)
132 before, after = self.runMasking()
134 np.testing.assert_array_equal(after, before)
136 def test_bottomReadEdgeBleedIsMasked(self):
137 # Amp B reads out at the bottom.
138 self.addSatBlock(AMP_WIDTH + 50, 0, 100, 200)
139 self.addDip(AMP_WIDTH, 0, AMP_WIDTH, 100)
141 before, after = self.runMasking()
143 expected = before.copy()
144 # height 100 -> margin int(100*0.125) + 1 = 13 -> 113 rows masked.
145 expected[:113, AMP_WIDTH:2*AMP_WIDTH] |= self.satBit
146 np.testing.assert_array_equal(after, expected)
148 def test_dipInOtherAmpIsNotMasked(self):
149 # Footprint in amp A (top read edge); dip only in amp B's top rows,
150 # which is not B's read edge and has no footprint.
151 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200)
152 self.addDip(AMP_WIDTH, AMP_HEIGHT - 100, AMP_WIDTH, 100)
154 before, after = self.runMasking()
156 np.testing.assert_array_equal(after, before)
158 def test_unusableEdgeRowsAreSkippedButMasked(self):
159 # The 20 rows nearest the read edge are NO_DATA (as after trimming
160 # on DECam), so the dip can only be seen from row 20 inward. The
161 # measured height still counts from the physical edge.
162 noDataBit = self.exposure.mask.getPlaneBitMask("NO_DATA")
163 self.exposure.mask.array[AMP_HEIGHT - 20:, 0:AMP_WIDTH] |= noDataBit
164 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200)
165 self.addDip(0, AMP_HEIGHT - 100, AMP_WIDTH, 80)
167 before, after = self.runMasking()
169 expected = before.copy()
170 expected[AMP_HEIGHT - 113:, 0:AMP_WIDTH] |= self.satBit
171 np.testing.assert_array_equal(after, expected)
173 def test_suspectPixelsCountAsLow(self):
174 # DECam flags the rows nearest the read edge SUSPECT; those pixels
175 # must still be counted when confirming the dip.
176 suspectBit = self.exposure.mask.getPlaneBitMask("SUSPECT")
177 self.exposure.mask.array[AMP_HEIGHT - 35:, 0:AMP_WIDTH] |= suspectBit
178 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200)
179 self.addDip(0, AMP_HEIGHT - 100, AMP_WIDTH, 100)
181 before, after = self.runMasking()
183 expected = before.copy()
184 expected[AMP_HEIGHT - 113:, 0:AMP_WIDTH] |= self.satBit
185 np.testing.assert_array_equal(after, expected)
187 def test_nonDefaultParameters(self):
188 # A 100-row footprint ending 150 rows short of the edge is only a
189 # candidate with a larger approachRows; a 50 ADU dip is only seen
190 # with a smaller nSigma; a zero marginFraction masks height + 1 rows.
191 self.addSatBlock(50, AMP_HEIGHT - 250, 200, 100)
192 self.exposure.image.array[AMP_HEIGHT - 100:, 0:AMP_WIDTH] -= 50.0
194 before = self.exposure.mask.array.copy()
195 ipIsrFunctions.maskDECamEdgeBleed(self.exposure, approachRows=150, nSigma=3.0,
196 marginFraction=0.0)
198 expected = before.copy()
199 expected[AMP_HEIGHT - 101:, 0:AMP_WIDTH] |= self.satBit
200 np.testing.assert_array_equal(self.exposure.mask.array, expected)
202 def test_alternateSaturatedMaskName(self):
203 self.exposure.mask.addMaskPlane("MYSAT")
204 mySatBit = self.exposure.mask.getPlaneBitMask("MYSAT")
205 self.exposure.mask.array[AMP_HEIGHT - 200:, 50:150] |= mySatBit
206 self.addDip(0, AMP_HEIGHT - 100, AMP_WIDTH, 100)
208 before = self.exposure.mask.array.copy()
209 ipIsrFunctions.maskDECamEdgeBleed(self.exposure, saturatedMaskName="MYSAT")
211 expected = before.copy()
212 expected[AMP_HEIGHT - 113:, 0:AMP_WIDTH] |= mySatBit
213 np.testing.assert_array_equal(self.exposure.mask.array, expected)
215 def test_partialAmpIsSkipped(self):
216 # An exposure covering only the bottom 600 rows of amp B has a
217 # qualifying footprint and dip, but the amp is not fully contained
218 # so nothing is masked (rather than failing on the sub-image).
219 self.addSatBlock(AMP_WIDTH + 50, 0, 100, 200)
220 self.addDip(AMP_WIDTH, 0, AMP_WIDTH, 100)
221 partial = geom.Box2I(geom.Point2I(AMP_WIDTH, 0), geom.Extent2I(AMP_WIDTH, 600))
222 subExposure = self.exposure[partial]
224 before = subExposure.mask.array.copy()
225 ipIsrFunctions.maskDECamEdgeBleed(subExposure)
227 np.testing.assert_array_equal(subExposure.mask.array, before)
229 def test_noDetectorRaises(self):
230 exposure = afwImage.ExposureF(geom.Box2I(geom.Point2I(0, 0), geom.Extent2I(50, 50)))
231 with self.assertRaises(RuntimeError):
232 ipIsrFunctions.maskDECamEdgeBleed(exposure)
235class DECamEdgeBleedMaskTaskTestCase(lsst.utils.tests.TestCase):
236 def setUp(self):
237 self.detector = makeDetector()
238 self.exposure = afwImage.ExposureF(self.detector.getBBox())
239 self.exposure.setDetector(self.detector)
240 rng = np.random.default_rng(12345)
241 self.exposure.image.array[:] = SKY + rng.normal(0.0, NOISE, self.exposure.image.array.shape)
242 self.exposure.variance.array[:] = NOISE**2
243 self.satBit = self.exposure.mask.getPlaneBitMask("SAT")
245 def test_defaults(self):
246 config = DECamEdgeBleedMaskTask.ConfigClass()
247 self.assertEqual(config.satMinArea, 10000)
248 self.assertEqual(config.satMaxArea, 100000)
249 self.assertEqual(config.approachRows, 20)
250 self.assertEqual(config.nSigma, 5.0)
251 self.assertEqual(config.nRowsCheck, 20)
252 self.assertEqual(config.minLowPixelsPerRow, 30)
253 self.assertEqual(config.minLowPixelsExtent, 10)
254 self.assertEqual(config.marginFraction, 0.125)
255 self.assertEqual(config.saturatedMaskName, "SAT")
256 config.validate()
258 def test_validateExtentBelowPerRow(self):
259 config = DECamEdgeBleedMaskTask.ConfigClass()
260 config.minLowPixelsExtent = config.minLowPixelsPerRow
261 with self.assertRaises(ValueError):
262 config.validate()
264 def test_runMasksEdgeBleed(self):
265 self.exposure.mask.array[AMP_HEIGHT - 200:, 50:150] |= self.satBit
266 self.exposure.image.array[AMP_HEIGHT - 100:, 0:AMP_WIDTH] -= DIP
267 before = self.exposure.mask.array.copy()
269 task = DECamEdgeBleedMaskTask()
270 task.run(self.exposure)
272 expected = before.copy()
273 expected[AMP_HEIGHT - 113:, 0:AMP_WIDTH] |= self.satBit
274 np.testing.assert_array_equal(self.exposure.mask.array, expected)
276 def test_retargetIntoIsrTaskConfig(self):
277 config = IsrTaskConfig()
278 config.doCameraSpecificMasking = True
279 config.masking.retarget(DECamEdgeBleedMaskTask)
280 config.masking.nSigma = 3.0
281 config.validate()
282 self.assertEqual(config.masking.nSigma, 3.0)
283 self.assertIs(config.masking.target, DECamEdgeBleedMaskTask)
286class MemoryTester(lsst.utils.tests.MemoryTestCase):
287 pass
290def setup_module(module):
291 lsst.utils.tests.init()
294if __name__ == "__main__": 294 ↛ 295line 294 didn't jump to line 295 because the condition on line 294 was never true
295 lsst.utils.tests.init()
296 unittest.main()