Coverage for tests/test_mask.py: 92%

281 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-08 02:03 -0700

1# This file is part of lsst-images. 

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# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11 

12from __future__ import annotations 

13 

14import os 

15import unittest 

16 

17import astropy.io.fits 

18import numpy as np 

19 

20import lsst.utils.tests 

21from lsst.images import ( 

22 Box, 

23 Mask, 

24 MaskPlane, 

25 MaskSchema, 

26 get_legacy_non_cell_coadd_mask_planes, 

27 get_legacy_visit_image_mask_planes, 

28) 

29from lsst.images._mask import _guess_legacy_plane_map 

30from lsst.images.tests import RoundtripFits, assert_masks_equal, compare_mask_to_legacy 

31 

32DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None) 

33 

34 

35class MaskTestCase(unittest.TestCase): 

36 """Tests for Mask and its helper classes.""" 

37 

38 def setUp(self) -> None: 

39 self.maxDiff = None 

40 self.rng = np.random.default_rng(500) 

41 

42 def make_mask_planes(self, n_planes: int, n_placeholders: int) -> list[MaskPlane | None]: 

43 planes: list[MaskPlane | None] = [] 

44 for i in range(n_planes): 

45 planes.append(MaskPlane(f"M{i}", f"D{i}")) 

46 planes.extend([None] * n_placeholders) 

47 self.rng.shuffle(planes) 

48 return planes 

49 

50 def test_schema(self) -> None: 

51 """Test MaskSchema.""" 

52 self.assertEqual(MaskSchema.bits_per_element(np.uint8), 8) 

53 planes = self.make_mask_planes(17, 5) 

54 with self.assertRaises(TypeError): 

55 MaskSchema.bits_per_element(np.float32) 

56 schema = MaskSchema(planes, dtype=np.uint8) 

57 self.assertEqual(list(schema), planes) 

58 self.assertEqual(len(schema), len(planes)) 

59 self.assertEqual(schema[5], planes[5]) 

60 self.assertEqual( 

61 eval(repr(schema), {"dtype": np.dtype, "MaskSchema": MaskSchema, "MaskPlane": MaskPlane}), schema 

62 ) 

63 string = str(schema) 

64 self.assertEqual(len(string.split("\n")), 17) 

65 bit5 = schema.bit("M5") 

66 self.assertIn(f"M5 [{bit5.index}@{hex(bit5.mask)}]: D5", string) 

67 self.assertEqual(schema, MaskSchema(planes, np.uint8)) 

68 self.assertNotEqual(schema, MaskSchema(planes, np.int16)) 

69 self.assertNotEqual(schema, MaskSchema(planes[:-1], np.uint8)) 

70 self.assertEqual(schema.dtype, np.dtype(np.uint8)) 

71 self.assertEqual(schema.mask_size, 3) 

72 self.assertEqual(schema.names, {f"M{i}" for i in range(17)}) 

73 self.assertEqual(schema.descriptions, {f"M{i}": f"D{i}" for i in range(17)}) 

74 bit7 = schema.bit("M7") 

75 bitmask57 = schema.bitmask("M5", "M7") 

76 self.assertTrue(bitmask57[bit5.index] & bit5.mask) 

77 self.assertTrue(bitmask57[bit7.index] & bit7.mask) 

78 bitmask57[bit5.index] &= ~bit5.mask 

79 bitmask57[bit7.index] &= ~bit7.mask 

80 self.assertFalse(bitmask57.any()) 

81 splits = schema.split(np.int16) 

82 self.assertEqual(len(splits), 2) 

83 self.assertEqual(splits[0].mask_size, 1) 

84 self.assertEqual(splits[1].mask_size, 1) 

85 self.assertEqual(list(splits[0]) + list(splits[1]), [p for p in planes if p is not None]) 

86 self.assertEqual(len(splits[0]), 15) 

87 self.assertEqual(len(splits[1]), 2) 

88 

89 def test_schema_from_fits_header(self) -> None: 

90 """MaskSchema.from_fits_header inverts update_header, assuming the 

91 default uint8 dtype. 

92 """ 

93 planes = [ 

94 MaskPlane("NO_DATA", "No data was available for this pixel."), 

95 MaskPlane("COSMIC_RAY", "A cosmic ray affected this pixel."), 

96 MaskPlane("DETECTED", "Pixel was part of a detected source."), 

97 ] 

98 schema = MaskSchema(planes, dtype=np.uint8) 

99 header = astropy.io.fits.Header() 

100 schema.update_header(header) 

101 result = MaskSchema.from_fits_header(header) 

102 self.assertEqual(result.dtype, np.dtype(np.uint8)) 

103 self.assertEqual(list(result), planes) 

104 self.assertEqual(result, schema) 

105 

106 def test_schema_from_fits_header_preserves_gaps(self) -> None: 

107 """A None placeholder between planes is reconstructed from the gap in 

108 the MSKN card numbering. 

109 """ 

110 planes: list[MaskPlane | None] = [MaskPlane("A", "a"), None, MaskPlane("B", "b")] 

111 header = astropy.io.fits.Header() 

112 MaskSchema(planes, dtype=np.uint8).update_header(header) 

113 self.assertEqual(list(MaskSchema.from_fits_header(header)), planes) 

114 

115 def test_schema_from_fits_header_requires_cards(self) -> None: 

116 """A header with no MSKN cards cannot describe a schema.""" 

117 with self.assertRaises(ValueError): 

118 MaskSchema.from_fits_header(astropy.io.fits.Header()) 

119 

120 def test_basics(self) -> None: 

121 """Test some basic Mask functionality.""" 

122 planes = self.make_mask_planes(35, n_placeholders=5) 

123 schema = MaskSchema(planes, dtype=np.uint8) 

124 bbox = Box.factory[5:50, 6:60] 

125 mask = Mask( 

126 0, 

127 schema=schema, 

128 bbox=bbox, 

129 metadata={"four_and_a_half": 4.5}, 

130 ) 

131 

132 self.assertIs(mask[...], mask) 

133 self.assertEqual(mask.__eq__(42), NotImplemented) 

134 self.assertEqual(mask, mask) 

135 self.maxDiff = None 

136 self.assertEqual( 

137 str(mask), 

138 "Mask([y=5:50, x=6:60], ['M34', 'M15', 'M29', 'M1', 'M20', 'M11', 'M13', 'M7', 'M17', 'M12', " 

139 "'M31', 'M16', 'M2', 'M3', 'M8', 'M26', 'M22', 'M5', 'M18', 'M19', 'M24', 'M21', 'M27', 'M6', " 

140 "'M28', 'M10', 'M4', 'M23', 'M0', 'M25', 'M9', 'M14', 'M33', 'M32', 'M30'])", 

141 ) 

142 self.assertTrue( 

143 repr(mask).startswith( 

144 "Mask(..., bbox=Box(y=Interval(start=5, stop=50), x=Interval(start=6, stop=60)), " 

145 "schema=MaskSchema([MaskPlane(name='M34', description='D34')" 

146 ), 

147 f"Repr: {mask!r}", 

148 ) 

149 

150 with self.assertRaises(TypeError): 

151 # No bbox, size or array. 

152 Mask(0, schema=schema) 

153 

154 with self.assertRaises(ValueError): 

155 # Box mismatch. 

156 Mask(mask.array, schema=schema, bbox=Box.factory[0:20, -5:45]) 

157 

158 with self.assertRaises(ValueError): 

159 # Shape mismatch. 

160 Mask(mask.array, schema=schema, shape=(5, 10, 5)) 

161 

162 with self.assertRaises(ValueError): 

163 # Cannot be 2-D. 

164 Mask(mask.array.reshape((2430, 5)), schema=schema, bbox=Box.factory[0:20, -5:45]) 

165 

166 def test_read_write(self) -> None: 

167 """Explicit calls to read and write fits.""" 

168 planes = self.make_mask_planes(35, n_placeholders=5) 

169 schema = MaskSchema(planes, dtype=np.uint8) 

170 bbox = Box.factory[5:50, 6:60] 

171 mask = Mask( 

172 0, 

173 schema=schema, 

174 bbox=bbox, 

175 metadata={"four_and_a_half": 4.5}, 

176 ) 

177 with lsst.utils.tests.getTempFilePath(".fits") as tmpFile: 

178 mask.write(tmpFile) 

179 

180 new = Mask.read(tmpFile) 

181 self.assertEqual(new, mask) 

182 # __eq__ ignores metadata. 

183 self.assertEqual(new.metadata["four_and_a_half"], 4.5) 

184 self.assertEqual(new.metadata, mask.metadata) 

185 

186 def test_serialize_multi(self) -> None: 

187 """Test serializing a mask with more than 31 mask planes, requiring 

188 more than one HDU and EXTVER. 

189 

190 Note that serialization for simpler cases is covered by 

191 test_masked_image.py. 

192 """ 

193 planes = self.make_mask_planes(35, n_placeholders=5) 

194 schema = MaskSchema(planes, dtype=np.uint8) 

195 bbox = Box.factory[5:50, 6:60] 

196 mask = Mask(0, schema=schema, bbox=bbox, metadata={"four_and_a_half": 4.5}) 

197 shape = bbox.shape 

198 for plane in schema: 

199 if plane is not None: 

200 mask.set(plane.name, self.rng.random(shape) > 0.5) 

201 with RoundtripFits(self, mask) as roundtrip: 

202 fits = roundtrip.inspect() 

203 self.assertEqual(fits[1].header["EXTNAME"], "MASK") 

204 self.assertEqual(fits[1].header.get("EXTVER", 1), 1) 

205 self.assertEqual(fits[1].header["ZCMPTYPE"], "GZIP_2") 

206 self.assertEqual(fits[2].header["EXTNAME"], "MASK") 

207 self.assertEqual(fits[2].header["EXTVER"], 2) 

208 self.assertEqual(fits[2].header["ZCMPTYPE"], "GZIP_2") 

209 n = 0 

210 for plane in planes: 

211 if plane is not None: 

212 hdu = fits[1] if n < 31 else fits[2] 

213 self.assertEqual(hdu.header[f"MSKN{(n % 31):04d}"], plane.name) 

214 self.assertEqual(hdu.header[f"MSKM{(n % 31):04d}"], 1 << (n % 31)) 

215 self.assertEqual(hdu.header[f"MSKD{(n % 31):04d}"], plane.description) 

216 n += 1 

217 assert_masks_equal(self, mask, roundtrip.result) 

218 

219 def test_add_plane_returns_new_mask(self) -> None: 

220 """Adding a plane returns a new mask, leaves the original (and any 

221 views of it) untouched, and always reallocates the backing array. 

222 """ 

223 planes = self.make_mask_planes(3, n_placeholders=0) 

224 schema = MaskSchema(planes, dtype=np.uint8) 

225 bbox = Box.factory[5:50, 6:60] 

226 mask = Mask(0, schema=schema, bbox=bbox) 

227 m0 = self.rng.random(bbox.shape) > 0.5 

228 mask.set("M0", m0) 

229 view = mask[bbox] # shares the array and old schema with mask 

230 original_array = mask.array 

231 

232 new_mask = mask.add_plane("OUTSIDE_STENCIL", "Pixel lies outside the stencil.") 

233 

234 # The original mask and any views keep the old schema and array. 

235 self.assertNotIn("OUTSIDE_STENCIL", mask.schema.names) 

236 self.assertNotIn("OUTSIDE_STENCIL", view.schema.names) 

237 self.assertIs(mask.array, original_array) 

238 # The new mask reallocated a fresh array and carries the new plane. 

239 self.assertIsNot(new_mask.array, original_array) 

240 self.assertIn("OUTSIDE_STENCIL", new_mask.schema.names) 

241 self.assertEqual(new_mask.schema.descriptions["OUTSIDE_STENCIL"], "Pixel lies outside the stencil.") 

242 # The new plane is the fourth (overall index 3) so it lives in byte 0. 

243 bit = new_mask.schema.bit("OUTSIDE_STENCIL") 

244 self.assertEqual(bit.index, 0) 

245 self.assertEqual(bit.mask, 1 << 3) 

246 self.assertEqual(new_mask.schema.mask_size, 1) 

247 # Existing plane data is preserved and the new plane starts all-False. 

248 np.testing.assert_array_equal(new_mask.get("M0"), m0) 

249 self.assertFalse(new_mask.get("OUTSIDE_STENCIL").any()) 

250 

251 def test_add_plane_grows_byte(self) -> None: 

252 """Adding a ninth plane (crossing the 8-plane boundary) gives the new 

253 mask an extra byte while preserving existing plane data. 

254 """ 

255 planes = self.make_mask_planes(8, n_placeholders=0) 

256 schema = MaskSchema(planes, dtype=np.uint8) 

257 bbox = Box.factory[5:50, 6:60] 

258 mask = Mask(0, schema=schema, bbox=bbox) 

259 set_planes = {} 

260 for plane in planes: 

261 assert plane is not None 

262 boolean_mask = self.rng.random(bbox.shape) > 0.5 

263 mask.set(plane.name, boolean_mask) 

264 set_planes[plane.name] = boolean_mask 

265 

266 new_mask = mask.add_plane("OUTSIDE_STENCIL", "Pixel lies outside the stencil.") 

267 

268 # The original is unchanged; the new mask spills into a second byte. 

269 self.assertEqual(mask.schema.mask_size, 1) 

270 bit = new_mask.schema.bit("OUTSIDE_STENCIL") 

271 self.assertEqual(bit.index, 1) 

272 self.assertEqual(bit.mask, 1 << 0) 

273 self.assertEqual(new_mask.schema.mask_size, 2) 

274 self.assertEqual(new_mask.array.shape, bbox.shape + (2,)) 

275 self.assertFalse(new_mask.get("OUTSIDE_STENCIL").any()) 

276 # Every pre-existing plane keeps its data. 

277 for name, boolean_mask in set_planes.items(): 

278 np.testing.assert_array_equal(new_mask.get(name), boolean_mask) 

279 

280 def test_add_planes_multiple(self) -> None: 

281 """Several planes can be added in a single call.""" 

282 planes = self.make_mask_planes(3, n_placeholders=0) 

283 bbox = Box.factory[0:4, 0:5] 

284 mask = Mask(0, schema=MaskSchema(planes, dtype=np.uint8), bbox=bbox) 

285 m0 = self.rng.random(bbox.shape) > 0.5 

286 mask.set("M0", m0) 

287 

288 new_mask = mask.add_planes([MaskPlane("A", "plane a"), MaskPlane("B", "plane b")]) 

289 

290 self.assertEqual(set(mask.schema.names), {"M0", "M1", "M2"}) # original unchanged 

291 self.assertEqual(set(new_mask.schema.names), {"M0", "M1", "M2", "A", "B"}) 

292 np.testing.assert_array_equal(new_mask.get("M0"), m0) 

293 self.assertFalse(new_mask.get("A").any()) 

294 self.assertFalse(new_mask.get("B").any()) 

295 

296 def test_add_planes_drop_reassigns_bits(self) -> None: 

297 """Dropping a plane compacts the schema, reassigns the planes after it 

298 to lower bits, and repacks the retained pixel values by name. 

299 """ 

300 bbox = Box.factory[0:4, 0:5] 

301 schema = MaskSchema([MaskPlane("A", "a"), MaskPlane("B", "b"), MaskPlane("C", "c")], dtype=np.uint8) 

302 mask = Mask(0, schema=schema, bbox=bbox) 

303 a = self.rng.random(bbox.shape) > 0.5 

304 c = self.rng.random(bbox.shape) > 0.5 

305 mask.set("A", a) 

306 mask.set("B", self.rng.random(bbox.shape) > 0.5) 

307 mask.set("C", c) 

308 

309 new_mask = mask.add_planes([MaskPlane("D", "d")], drop=["B"]) 

310 

311 # B is gone; D is appended after the retained planes. 

312 self.assertEqual(list(new_mask.schema.names), ["A", "C", "D"]) 

313 self.assertNotIn("B", new_mask.schema.names) 

314 # C moved down from bit 2 to bit 1; D takes bit 2. 

315 self.assertEqual(new_mask.schema.bit("A").mask, 1 << 0) 

316 self.assertEqual(new_mask.schema.bit("C").mask, 1 << 1) 

317 self.assertEqual(new_mask.schema.bit("D").mask, 1 << 2) 

318 # Retained pixel values follow their planes; the new plane is cleared. 

319 np.testing.assert_array_equal(new_mask.get("A"), a) 

320 np.testing.assert_array_equal(new_mask.get("C"), c) 

321 self.assertFalse(new_mask.get("D").any()) 

322 

323 def test_add_planes_with_placeholder(self) -> None: 

324 """``None`` placeholders reserve bits. A pre-existing placeholder 

325 keeps its position, and a ``None`` interleaved in the added planes 

326 stays where it is placed rather than moving to the end; both survive 

327 a round-trip. 

328 """ 

329 bbox = Box.factory[0:4, 0:5] 

330 # Schema with a pre-existing placeholder reserving bit 1. 

331 schema = MaskSchema([MaskPlane("A", "a"), None, MaskPlane("B", "b")], dtype=np.uint8) 

332 mask = Mask(0, schema=schema, bbox=bbox) 

333 a = self.rng.random(bbox.shape) > 0.5 

334 b = self.rng.random(bbox.shape) > 0.5 

335 mask.set("A", a) 

336 mask.set("B", b) 

337 

338 # Append a block that itself contains an interior placeholder. 

339 new_mask = mask.add_planes([MaskPlane("C", "c"), None, MaskPlane("D", "d")]) 

340 

341 # The pre-existing placeholder stays at bit 1; the added placeholder 

342 # stays between C and D (bit 4), not at the end. 

343 self.assertEqual( 

344 list(new_mask.schema), 

345 [MaskPlane("A", "a"), None, MaskPlane("B", "b"), MaskPlane("C", "c"), None, MaskPlane("D", "d")], 

346 ) 

347 self.assertEqual(new_mask.schema.bit("A").mask, 1 << 0) 

348 self.assertEqual(new_mask.schema.bit("B").mask, 1 << 2) 

349 self.assertEqual(new_mask.schema.bit("C").mask, 1 << 3) 

350 self.assertEqual(new_mask.schema.bit("D").mask, 1 << 5) 

351 # Retained pixel values follow their planes; new planes start cleared. 

352 np.testing.assert_array_equal(new_mask.get("A"), a) 

353 np.testing.assert_array_equal(new_mask.get("B"), b) 

354 self.assertFalse(new_mask.get("C").any()) 

355 self.assertFalse(new_mask.get("D").any()) 

356 

357 with RoundtripFits(self, new_mask) as roundtrip: 

358 assert_masks_equal(self, new_mask, roundtrip.result) 

359 

360 def test_add_planes_drop_unknown_raises(self) -> None: 

361 """Dropping a plane that does not exist is an error.""" 

362 mask = Mask(0, schema=MaskSchema([MaskPlane("A", "a")], dtype=np.uint8), bbox=Box.factory[0:2, 0:2]) 

363 with self.assertRaises(ValueError): 

364 mask.add_planes([], drop=["NOPE"]) 

365 

366 def test_add_plane_duplicate_raises(self) -> None: 

367 """Adding a plane whose name already exists is an error.""" 

368 planes = self.make_mask_planes(3, n_placeholders=0) 

369 schema = MaskSchema(planes, dtype=np.uint8) 

370 mask = Mask(0, schema=schema, bbox=Box.factory[0:4, 0:4]) 

371 with self.assertRaises(ValueError): 

372 mask.add_plane("M0", "Duplicate of an existing plane.") 

373 

374 def test_add_plane_roundtrip(self) -> None: 

375 """A runtime-added plane and its data survive a FITS round trip.""" 

376 planes = self.make_mask_planes(8, n_placeholders=0) 

377 schema = MaskSchema(planes, dtype=np.uint8) 

378 bbox = Box.factory[5:50, 6:60] 

379 mask = Mask(0, schema=schema, bbox=bbox) 

380 mask = mask.add_plane("OUTSIDE_STENCIL", "Pixel lies outside the stencil.") 

381 mask.set("OUTSIDE_STENCIL", self.rng.random(bbox.shape) > 0.5) 

382 with lsst.utils.tests.getTempFilePath(".fits") as tmpFile: 

383 mask.write(tmpFile) 

384 new = Mask.read(tmpFile) 

385 self.assertEqual(new, mask) 

386 self.assertEqual(new.schema.descriptions["OUTSIDE_STENCIL"], "Pixel lies outside the stencil.") 

387 assert_masks_equal(self, new, mask) 

388 

389 def test_legacy_non_cell_coadd_plane_map(self) -> None: 

390 """The non-cell coadd map defines a distinct ``SENSOR_EDGE`` plane.""" 

391 plane_map = get_legacy_non_cell_coadd_mask_planes() 

392 self.assertIn("SENSOR_EDGE", plane_map) 

393 self.assertEqual(plane_map["SENSOR_EDGE"].name, "SENSOR_EDGE") 

394 

395 def test_guess_legacy_plane_map_coadd_discriminator(self) -> None: 

396 """``INEXACT_PSF`` routes to a coadd map; ``SENSOR_EDGE`` distinguishes 

397 non-cell coadds (which use it) from cell coadds (which use CELL_EDGE). 

398 """ 

399 non_cell = _guess_legacy_plane_map({"INEXACT_PSF": 11, "SENSOR_EDGE": 14}) 

400 self.assertIn("SENSOR_EDGE", non_cell) 

401 cell = _guess_legacy_plane_map({"INEXACT_PSF": 11}) 

402 self.assertNotIn("SENSOR_EDGE", cell) 

403 

404 @unittest.skipUnless(DATA_DIR is not None, "TESTDATA_IMAGES_DIR is not in the environment.") 

405 def test_legacy(self) -> None: 

406 """Test Mask.read_legacy, Mask.to_legacy, and Mask.from_legacy.""" 

407 assert DATA_DIR is not None, "Guaranteed by decorator." 

408 filename = os.path.join(DATA_DIR, "dp2", "legacy", "visit_image.fits") 

409 plane_map = get_legacy_visit_image_mask_planes() 

410 mask = Mask.read_legacy(filename, ext=2, plane_map=plane_map) 

411 self.assertEqual(mask.schema.names, {p.name for p in plane_map.values()}) 

412 try: 

413 from lsst.afw.image import MaskedImageFitsReader 

414 except ImportError: 

415 raise unittest.SkipTest("'lsst.afw.image' could not be imported.") from None 

416 reader = MaskedImageFitsReader(filename) 

417 self.assertEqual(mask.bbox, Box.from_legacy(reader.readBBox())) 

418 legacy_mask = reader.readMask() 

419 compare_mask_to_legacy(self, mask, legacy_mask, plane_map) 

420 compare_mask_to_legacy(self, mask, mask.to_legacy(plane_map), plane_map) 

421 assert_masks_equal(self, mask, Mask.from_legacy(legacy_mask, plane_map=plane_map)) 

422 # Write the mask out in the new format, and test that we can read it 

423 # back either way. 

424 with RoundtripFits(self, mask, storage_class="MaskV2") as roundtrip: 

425 with self.subTest(): 

426 try: 

427 import lsst.afw.image 

428 except ImportError: 

429 raise unittest.SkipTest("afw could not be imported") from None 

430 legacy_mask = roundtrip.get(storageClass="Mask") 

431 self.assertIsInstance(legacy_mask, lsst.afw.image.Mask) 

432 compare_mask_to_legacy(self, mask, legacy_mask) 

433 assert_masks_equal(self, roundtrip.result, mask) 

434 

435 

436if __name__ == "__main__": 

437 unittest.main()