Coverage for tests/test_edgeBleed.py: 99%

269 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-09 10:43 +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 

22 

23import numpy as np 

24 

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 

32 

33AMP_WIDTH = 200 

34AMP_HEIGHT = 800 

35SKY = 1000.0 

36NOISE = 10.0 

37DIP = 200.0 

38 

39 

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"] 

61 

62 

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") 

72 

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 

77 

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 

81 

82 def runMasking(self): 

83 before = self.exposure.mask.array.copy() 

84 ipIsrFunctions.maskDECamEdgeBleed(self.exposure) 

85 return before, self.exposure.mask.array 

86 

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) 

92 

93 before, after = self.runMasking() 

94 

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) 

99 

100 def test_noDipIsNotMasked(self): 

101 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200) 

102 

103 before, after = self.runMasking() 

104 

105 np.testing.assert_array_equal(after, before) 

106 

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) 

111 

112 before, after = self.runMasking() 

113 

114 np.testing.assert_array_equal(after, before) 

115 

116 def test_largeFootprintIsMasked(self): 

117 # 300 x 400 = 120000 SAT pixels: tall DECam bleeds have footprints of 

118 # 1e5-2e5 pixels, so there is no upper area cut. Only x 0-49 of the 

119 # dip rows are unsaturated, still enough low pixels per row to be 

120 # detectable. 

121 self.addSatBlock(50, 400, 300, 400) 

122 self.addDip(0, AMP_HEIGHT - 100, AMP_WIDTH, 100) 

123 

124 before, after = self.runMasking() 

125 

126 expected = before.copy() 

127 expected[AMP_HEIGHT - 113:, 0:AMP_WIDTH] |= self.satBit 

128 np.testing.assert_array_equal(after, expected) 

129 

130 def test_dipInFewUsablePixelsIsMasked(self): 

131 # Saturation covers 160 of the 200 columns in the 100 rows nearest 

132 # the read edge, and the dip is in 25 of the remaining 40 columns: 

133 # 25 low pixels per row is far below the old count threshold of 30, 

134 # but 62 per cent of the usable pixels are low. 

135 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200) 

136 self.addSatBlock(40, AMP_HEIGHT - 100, 160, 100) 

137 self.addDip(0, AMP_HEIGHT - 100, 25, 100) 

138 

139 before, after = self.runMasking() 

140 

141 expected = before.copy() 

142 expected[AMP_HEIGHT - 113:, 0:AMP_WIDTH] |= self.satBit 

143 np.testing.assert_array_equal(after, expected) 

144 

145 def test_sparseLowPixelsAreNotADip(self): 

146 # 2 low pixels per row, 2 per cent of the 100 usable columns in the 

147 # check rows, is below minLowFraction: not an edge bleed. 

148 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200) 

149 self.addDip(0, AMP_HEIGHT - 100, 2, 100) 

150 

151 before, after = self.runMasking() 

152 

153 np.testing.assert_array_equal(after, before) 

154 

155 def test_shallowDipInFirstRowsIsMasked(self): 

156 # A dip in 10 of the 100 usable columns over the 4 rows nearest the 

157 # read edge: 2 per cent averaged over the 20 check rows, but 8 per 

158 # cent over the 5-row short window. 4 rows -> int(4*0.125) + 1 = 1 

159 # -> 5 rows masked. 

160 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200) 

161 self.addDip(0, AMP_HEIGHT - 4, 10, 4) 

162 

163 before, after = self.runMasking() 

164 

165 expected = before.copy() 

166 expected[AMP_HEIGHT - 5:, 0:AMP_WIDTH] |= self.satBit 

167 np.testing.assert_array_equal(after, expected) 

168 

169 def test_shortWindowCanBeDisabled(self): 

170 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200) 

171 self.addDip(0, AMP_HEIGHT - 4, 10, 4) 

172 

173 before = self.exposure.mask.array.copy() 

174 ipIsrFunctions.maskDECamEdgeBleed(self.exposure, nRowsCheckShort=0) 

175 

176 np.testing.assert_array_equal(self.exposure.mask.array, before) 

177 

178 def test_blockedRowsBeyondBorderAreMasked(self): 

179 # Both edges of amp A carry a 10-row BAD border (as DECam defects 

180 # do). A drained read register leaves rows 10-59 from the read edge 

181 # fully BAD as well, with no measurable dip beyond them: those 50 

182 # blocked rows are themselves the edge bleed, so the mask covers 

183 # them plus the margin (60 -> int(60*0.125) + 1 = 8 -> 68 rows). 

184 badBit = self.exposure.mask.getPlaneBitMask("BAD") 

185 self.exposure.mask.array[:10, 0:AMP_WIDTH] |= badBit 

186 self.exposure.mask.array[AMP_HEIGHT - 60:, 0:AMP_WIDTH] |= badBit 

187 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200) 

188 

189 before, after = self.runMasking() 

190 

191 expected = before.copy() 

192 expected[AMP_HEIGHT - 68:, 0:AMP_WIDTH] |= self.satBit 

193 np.testing.assert_array_equal(after, expected) 

194 

195 def test_normalBorderIsNotABleed(self): 

196 # The same 10-row BAD border at both edges, but nothing beyond it: 

197 # the read-edge border is not counted as blocked rows. 

198 badBit = self.exposure.mask.getPlaneBitMask("BAD") 

199 self.exposure.mask.array[:10, 0:AMP_WIDTH] |= badBit 

200 self.exposure.mask.array[AMP_HEIGHT - 10:, 0:AMP_WIDTH] |= badBit 

201 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200) 

202 

203 before, after = self.runMasking() 

204 

205 np.testing.assert_array_equal(after, before) 

206 

207 def test_fewBlockedRowsNeedADip(self): 

208 # 3 blocked rows beyond the border are below minBlockedRows; with no 

209 # dip beyond them nothing is masked. 

210 badBit = self.exposure.mask.getPlaneBitMask("BAD") 

211 self.exposure.mask.array[:10, 0:AMP_WIDTH] |= badBit 

212 self.exposure.mask.array[AMP_HEIGHT - 13:, 0:AMP_WIDTH] |= badBit 

213 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200) 

214 

215 before, after = self.runMasking() 

216 

217 np.testing.assert_array_equal(after, before) 

218 

219 def test_blockedRowsExtendAMeasuredDip(self): 

220 # 30 blocked rows beyond a 10-row border, then a 40-row dip: the 

221 # height is measured from the physical edge through the dip 

222 # (80 -> int(80*0.125) + 1 = 11 -> 91 rows). 

223 badBit = self.exposure.mask.getPlaneBitMask("BAD") 

224 self.exposure.mask.array[:10, 0:AMP_WIDTH] |= badBit 

225 self.exposure.mask.array[AMP_HEIGHT - 40:, 0:AMP_WIDTH] |= badBit 

226 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200) 

227 self.addDip(0, AMP_HEIGHT - 80, AMP_WIDTH, 40) 

228 

229 before, after = self.runMasking() 

230 

231 expected = before.copy() 

232 expected[AMP_HEIGHT - 91:, 0:AMP_WIDTH] |= self.satBit 

233 np.testing.assert_array_equal(after, expected) 

234 

235 def test_footprintFarFromEdgeIsNotMasked(self): 

236 # Footprint top at row 599, 200 rows short of the read edge. 

237 self.addSatBlock(50, 400, 100, 200) 

238 self.addDip(0, AMP_HEIGHT - 100, AMP_WIDTH, 100) 

239 

240 before, after = self.runMasking() 

241 

242 np.testing.assert_array_equal(after, before) 

243 

244 def test_bottomReadEdgeBleedIsMasked(self): 

245 # Amp B reads out at the bottom. 

246 self.addSatBlock(AMP_WIDTH + 50, 0, 100, 200) 

247 self.addDip(AMP_WIDTH, 0, AMP_WIDTH, 100) 

248 

249 before, after = self.runMasking() 

250 

251 expected = before.copy() 

252 # height 100 -> margin int(100*0.125) + 1 = 13 -> 113 rows masked. 

253 expected[:113, AMP_WIDTH:2*AMP_WIDTH] |= self.satBit 

254 np.testing.assert_array_equal(after, expected) 

255 

256 def test_dipInOtherAmpIsNotMasked(self): 

257 # Footprint in amp A (top read edge); dip only in amp B's top rows, 

258 # which is not B's read edge and has no footprint. 

259 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200) 

260 self.addDip(AMP_WIDTH, AMP_HEIGHT - 100, AMP_WIDTH, 100) 

261 

262 before, after = self.runMasking() 

263 

264 np.testing.assert_array_equal(after, before) 

265 

266 def test_unusableEdgeRowsAreSkippedButMasked(self): 

267 # The 20 rows nearest the read edge are NO_DATA (as after trimming 

268 # on DECam), so the dip can only be seen from row 20 inward. The 

269 # measured height still counts from the physical edge. 

270 noDataBit = self.exposure.mask.getPlaneBitMask("NO_DATA") 

271 self.exposure.mask.array[AMP_HEIGHT - 20:, 0:AMP_WIDTH] |= noDataBit 

272 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200) 

273 self.addDip(0, AMP_HEIGHT - 100, AMP_WIDTH, 80) 

274 

275 before, after = self.runMasking() 

276 

277 expected = before.copy() 

278 expected[AMP_HEIGHT - 113:, 0:AMP_WIDTH] |= self.satBit 

279 np.testing.assert_array_equal(after, expected) 

280 

281 def test_suspectPixelsCountAsLow(self): 

282 # DECam flags the rows nearest the read edge SUSPECT; those pixels 

283 # must still be counted when confirming the dip. 

284 suspectBit = self.exposure.mask.getPlaneBitMask("SUSPECT") 

285 self.exposure.mask.array[AMP_HEIGHT - 35:, 0:AMP_WIDTH] |= suspectBit 

286 self.addSatBlock(50, AMP_HEIGHT - 200, 100, 200) 

287 self.addDip(0, AMP_HEIGHT - 100, AMP_WIDTH, 100) 

288 

289 before, after = self.runMasking() 

290 

291 expected = before.copy() 

292 expected[AMP_HEIGHT - 113:, 0:AMP_WIDTH] |= self.satBit 

293 np.testing.assert_array_equal(after, expected) 

294 

295 def test_nonDefaultParameters(self): 

296 # A 100-row footprint ending 150 rows short of the edge is only a 

297 # candidate with a larger approachRows; a 50 ADU dip is only seen 

298 # with a smaller nSigma; a zero marginFraction masks height + 1 rows. 

299 self.addSatBlock(50, AMP_HEIGHT - 250, 200, 100) 

300 self.exposure.image.array[AMP_HEIGHT - 100:, 0:AMP_WIDTH] -= 50.0 

301 

302 before = self.exposure.mask.array.copy() 

303 ipIsrFunctions.maskDECamEdgeBleed(self.exposure, approachRows=150, nSigma=3.0, 

304 marginFraction=0.0) 

305 

306 expected = before.copy() 

307 expected[AMP_HEIGHT - 101:, 0:AMP_WIDTH] |= self.satBit 

308 np.testing.assert_array_equal(self.exposure.mask.array, expected) 

309 

310 def test_alternateSaturatedMaskName(self): 

311 self.exposure.mask.addMaskPlane("MYSAT") 

312 mySatBit = self.exposure.mask.getPlaneBitMask("MYSAT") 

313 self.exposure.mask.array[AMP_HEIGHT - 200:, 50:150] |= mySatBit 

314 self.addDip(0, AMP_HEIGHT - 100, AMP_WIDTH, 100) 

315 

316 before = self.exposure.mask.array.copy() 

317 ipIsrFunctions.maskDECamEdgeBleed(self.exposure, saturatedMaskName="MYSAT") 

318 

319 expected = before.copy() 

320 expected[AMP_HEIGHT - 113:, 0:AMP_WIDTH] |= mySatBit 

321 np.testing.assert_array_equal(self.exposure.mask.array, expected) 

322 

323 def test_partialAmpIsSkipped(self): 

324 # An exposure covering only the bottom 600 rows of amp B has a 

325 # qualifying footprint and dip, but the amp is not fully contained 

326 # so nothing is masked (rather than failing on the sub-image). 

327 self.addSatBlock(AMP_WIDTH + 50, 0, 100, 200) 

328 self.addDip(AMP_WIDTH, 0, AMP_WIDTH, 100) 

329 partial = geom.Box2I(geom.Point2I(AMP_WIDTH, 0), geom.Extent2I(AMP_WIDTH, 600)) 

330 subExposure = self.exposure[partial] 

331 

332 before = subExposure.mask.array.copy() 

333 ipIsrFunctions.maskDECamEdgeBleed(subExposure) 

334 

335 np.testing.assert_array_equal(subExposure.mask.array, before) 

336 

337 def test_noDetectorRaises(self): 

338 exposure = afwImage.ExposureF(geom.Box2I(geom.Point2I(0, 0), geom.Extent2I(50, 50))) 

339 with self.assertRaises(RuntimeError): 

340 ipIsrFunctions.maskDECamEdgeBleed(exposure) 

341 

342 

343class DECamEdgeBleedMaskTaskTestCase(lsst.utils.tests.TestCase): 

344 def setUp(self): 

345 self.detector = makeDetector() 

346 self.exposure = afwImage.ExposureF(self.detector.getBBox()) 

347 self.exposure.setDetector(self.detector) 

348 rng = np.random.default_rng(12345) 

349 self.exposure.image.array[:] = SKY + rng.normal(0.0, NOISE, self.exposure.image.array.shape) 

350 self.exposure.variance.array[:] = NOISE**2 

351 self.satBit = self.exposure.mask.getPlaneBitMask("SAT") 

352 

353 def test_defaults(self): 

354 config = DECamEdgeBleedMaskTask.ConfigClass() 

355 self.assertEqual(config.satMinArea, 10000) 

356 self.assertEqual(config.approachRows, 20) 

357 self.assertEqual(config.nSigma, 5.0) 

358 self.assertEqual(config.nRowsCheck, 20) 

359 self.assertEqual(config.nRowsCheckShort, 5) 

360 self.assertEqual(config.minUsablePixelsPerRow, 30) 

361 self.assertEqual(config.minLowFraction, 0.03) 

362 self.assertEqual(config.minLowFractionExtent, 0.01) 

363 self.assertEqual(config.minBlockedRows, 5) 

364 self.assertEqual(config.marginFraction, 0.125) 

365 self.assertEqual(config.saturatedMaskName, "SAT") 

366 config.validate() 

367 

368 def test_validateExtentFractionNotAboveDipFraction(self): 

369 config = DECamEdgeBleedMaskTask.ConfigClass() 

370 config.minLowFractionExtent = 2*config.minLowFraction 

371 with self.assertRaises(ValueError): 

372 config.validate() 

373 

374 def test_validateFractionsInRange(self): 

375 config = DECamEdgeBleedMaskTask.ConfigClass() 

376 config.minLowFraction = 1.5 

377 with self.assertRaises(ValueError): 

378 config.validate() 

379 

380 def test_runPassesNewParameters(self): 

381 # minLowFraction above the 62 per cent dip fraction of a 25-column dip 

382 # in 40 usable columns must switch masking off through the task. 

383 self.exposure.mask.array[AMP_HEIGHT - 200:, 50:150] |= self.satBit 

384 self.exposure.mask.array[AMP_HEIGHT - 100:, 40:AMP_WIDTH] |= self.satBit 

385 self.exposure.image.array[AMP_HEIGHT - 100:, 0:25] -= DIP 

386 before = self.exposure.mask.array.copy() 

387 

388 config = DECamEdgeBleedMaskTask.ConfigClass() 

389 config.minLowFraction = 0.9 

390 config.minLowFractionExtent = 0.5 

391 DECamEdgeBleedMaskTask(config=config).run(self.exposure) 

392 np.testing.assert_array_equal(self.exposure.mask.array, before) 

393 

394 DECamEdgeBleedMaskTask().run(self.exposure) 

395 expected = before.copy() 

396 expected[AMP_HEIGHT - 113:, 0:AMP_WIDTH] |= self.satBit 

397 np.testing.assert_array_equal(self.exposure.mask.array, expected) 

398 

399 def test_runMasksEdgeBleed(self): 

400 self.exposure.mask.array[AMP_HEIGHT - 200:, 50:150] |= self.satBit 

401 self.exposure.image.array[AMP_HEIGHT - 100:, 0:AMP_WIDTH] -= DIP 

402 before = self.exposure.mask.array.copy() 

403 

404 task = DECamEdgeBleedMaskTask() 

405 task.run(self.exposure) 

406 

407 expected = before.copy() 

408 expected[AMP_HEIGHT - 113:, 0:AMP_WIDTH] |= self.satBit 

409 np.testing.assert_array_equal(self.exposure.mask.array, expected) 

410 

411 def test_retargetIntoIsrTaskConfig(self): 

412 config = IsrTaskConfig() 

413 config.doCameraSpecificMasking = True 

414 config.masking.retarget(DECamEdgeBleedMaskTask) 

415 config.masking.nSigma = 3.0 

416 config.validate() 

417 self.assertEqual(config.masking.nSigma, 3.0) 

418 self.assertIs(config.masking.target, DECamEdgeBleedMaskTask) 

419 

420 

421class MemoryTester(lsst.utils.tests.MemoryTestCase): 

422 pass 

423 

424 

425def setup_module(module): 

426 lsst.utils.tests.init() 

427 

428 

429if __name__ == "__main__": 429 ↛ 430line 429 didn't jump to line 430 because the condition on line 429 was never true

430 lsst.utils.tests.init() 

431 unittest.main()