Coverage for tests/test_mask.py: 94%

343 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-30 08:38 +0000

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 dataclasses 

15import os 

16from typing import Any 

17 

18import astropy.io.fits 

19import numpy as np 

20import pytest 

21 

22import lsst.utils.tests 

23from lsst.images import ( 

24 Box, 

25 Mask, 

26 MaskPlane, 

27 MaskSchema, 

28 get_legacy_non_cell_coadd_mask_planes, 

29 get_legacy_visit_image_mask_planes, 

30) 

31from lsst.images._mask import _guess_legacy_plane_map 

32from lsst.images.describe import Report 

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

34 

35try: 

36 from lsst.afw.image import MaskedImageReader as LegacyMaskedImageReader 

37 

38except ImportError: 

39 type LegacyMaskedImageReader = Any # type: ignore[no-redef] 

40 

41EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None) 

42 

43 

44@dataclasses.dataclass 

45class _LegacyTestData: 

46 mask: Mask 

47 reader: LegacyMaskedImageReader 

48 plane_map: dict[str, MaskPlane] 

49 

50 

51@pytest.fixture(scope="session") 

52def legacy_test_data() -> _LegacyTestData: 

53 """Return a Mask read directly from the legacy test dataset and a legacy 

54 reader for that image. 

55 

56 Skips if TESTDATA_IMAGES_DIR is unset or lsst.afw.image is unavailable. 

57 """ 

58 if EXTERNAL_DATA_DIR is None: 58 ↛ 60line 58 didn't jump to line 60 because the condition on line 58 was always true

59 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.") 

60 try: 

61 from lsst.afw.image import MaskedImageFitsReader 

62 except ImportError: 

63 pytest.skip("'lsst.afw.image' could not be imported.") 

64 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "visit_image.fits") 

65 plane_map = get_legacy_visit_image_mask_planes() 

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

67 reader = MaskedImageFitsReader(filename) 

68 return _LegacyTestData(mask=mask, reader=reader, plane_map=plane_map) 

69 

70 

71def make_mask_planes(rng: np.random.Generator, n_planes: int, n_placeholders: int) -> list[MaskPlane | None]: 

72 """Return a shuffled list of MaskPlane objects with placeholder Nones.""" 

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

74 for i in range(n_planes): 

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

76 planes.extend([None] * n_placeholders) 

77 rng.shuffle(planes) 

78 return planes 

79 

80 

81def test_schema() -> None: 

82 """Test MaskSchema construction, accessors, and basic operations.""" 

83 rng = np.random.default_rng(500) 

84 planes = make_mask_planes(rng, 17, 5) 

85 with pytest.raises(TypeError): 

86 MaskSchema.bits_per_element(np.float32) 

87 assert MaskSchema.bits_per_element(np.uint8) == 8 

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

89 assert list(schema) == planes 

90 assert len(schema) == len(planes) 

91 assert schema[5] == planes[5] 

92 assert eval(repr(schema), {"dtype": np.dtype, "MaskSchema": MaskSchema, "MaskPlane": MaskPlane}) == schema 

93 report = schema.describe() 

94 plane_table = next(t for t in report.tables if t.title == "Mask planes") 

95 assert len(plane_table.rows) == 17 

96 assert ["M5" == row[3] for row in plane_table.rows].count(True) == 1 

97 bit5 = schema.bit("M5") 

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

99 assert schema != MaskSchema(planes, np.int16) 

100 assert schema != MaskSchema(planes[:-1], np.uint8) 

101 assert schema.dtype == np.dtype(np.uint8) 

102 assert schema.mask_size == 3 

103 assert schema.names == {f"M{i}" for i in range(17)} 

104 assert schema.descriptions == {f"M{i}": f"D{i}" for i in range(17)} 

105 bit7 = schema.bit("M7") 

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

107 assert bitmask57[bit5.index] & bit5.mask 

108 assert bitmask57[bit7.index] & bit7.mask 

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

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

111 assert not bitmask57.any() 

112 splits = schema.split(np.int16) 

113 assert len(splits) == 2 

114 assert splits[0].mask_size == 1 

115 assert splits[1].mask_size == 1 

116 assert list(splits[0]) + list(splits[1]) == [p for p in planes if p is not None] 

117 assert len(splits[0]) == 15 

118 assert len(splits[1]) == 2 

119 

120 

121def test_schema_describe() -> None: 

122 """MaskSchema._describe yields a mask-plane table and eval-able repr.""" 

123 rng = np.random.default_rng(500) 

124 planes = make_mask_planes(rng, 3, 0) 

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

126 

127 report = schema.describe() 

128 assert isinstance(report, Report) 

129 assert report.type_name == "MaskSchema" 

130 

131 tables = [t for t in report.tables if t.title == "Mask planes"] 

132 assert len(tables) == 1 

133 table = tables[0] 

134 assert table.columns == ["Bit", "Index", "Mask", "Name", "Description"] 

135 assert len(table.rows) == 3 

136 # Names appear in the Name column (index 3). 

137 assert {row[3] for row in table.rows} == {"M0", "M1", "M2"} 

138 

139 # repr is still eval-able and round-trips. 

140 reconstructed = eval(repr(schema), {"dtype": np.dtype, "MaskSchema": MaskSchema, "MaskPlane": MaskPlane}) 

141 assert reconstructed == schema 

142 

143 

144def test_schema_from_fits_header() -> None: 

145 """Verify MaskSchema.from_fits_header inverts update_header.""" 

146 planes = [ 

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

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

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

150 ] 

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

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

153 schema.update_header(header) 

154 result = MaskSchema.from_fits_header(header) 

155 assert result.dtype == np.dtype(np.uint8) 

156 assert list(result) == planes 

157 assert result == schema 

158 

159 

160def test_schema_from_fits_header_preserves_gaps() -> None: 

161 """Verify None placeholders are reconstructed from gaps in MSKN card 

162 numbering. 

163 """ 

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

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

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

167 assert list(MaskSchema.from_fits_header(header)) == planes 

168 

169 

170def test_schema_from_fits_header_requires_cards() -> None: 

171 """Verify MaskSchema.from_fits_header raises ValueError on a header with 

172 no MSKN cards. 

173 """ 

174 with pytest.raises(ValueError): 

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

176 

177 

178def test_interpret() -> None: 

179 """Verify interpret returns correct plane names across a multi-byte 

180 schema. 

181 """ 

182 # 3 named planes padded with Nones to exceed 8 bits; A and B land in 

183 # byte 0, C lands in byte 1. 

184 planes: list[MaskPlane | None] = [ 

185 MaskPlane("A", "a"), 

186 MaskPlane("B", "b"), 

187 None, 

188 None, 

189 None, 

190 None, 

191 None, 

192 None, 

193 MaskPlane("C", "c"), 

194 ] 

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

196 assert schema.mask_size == 2 

197 

198 assert set(schema.interpret(schema.bitmask("A", "C"))) == {"A", "C"} 

199 assert set(schema.interpret(schema.bitmask("B"))) == {"B"} 

200 assert set(schema.interpret(np.zeros(schema.mask_size, dtype=schema.dtype))) == set() 

201 assert set(schema.interpret(schema.bitmask("A", "B", "C"))) == {"A", "B", "C"} 

202 

203 

204def test_basics() -> None: 

205 """Test basic Mask construction, string representation, and error 

206 conditions. 

207 """ 

208 rng = np.random.default_rng(500) 

209 planes = make_mask_planes(rng, 35, n_placeholders=5) 

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

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

212 mask = Mask( 

213 0, 

214 schema=schema, 

215 bbox=bbox, 

216 metadata={"four_and_a_half": 4.5}, 

217 ) 

218 

219 assert mask[...] is not mask 

220 assert mask.__eq__(42) == NotImplemented 

221 assert mask == mask 

222 assert ( 

223 str(mask) 

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

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

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

227 ) 

228 assert repr(mask).startswith( 

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

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

231 ), f"Repr: {mask!r}" 

232 

233 with pytest.raises(TypeError): 

234 # No bbox, size or array. 

235 Mask(0, schema=schema) 

236 

237 with pytest.raises(ValueError): 

238 # Box mismatch. 

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

240 

241 with pytest.raises(ValueError): 

242 # Shape mismatch. 

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

244 

245 with pytest.raises(ValueError): 

246 # Cannot be 2-D. 

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

248 

249 

250def test_read_write() -> None: 

251 """Test explicit calls to Mask.read and Mask.write through FITS.""" 

252 rng = np.random.default_rng(500) 

253 planes = make_mask_planes(rng, 35, n_placeholders=5) 

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

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

256 mask = Mask( 

257 0, 

258 schema=schema, 

259 bbox=bbox, 

260 metadata={"four_and_a_half": 4.5}, 

261 ) 

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

263 mask.write(tmpFile) 

264 new = Mask.read(tmpFile) 

265 assert new == mask 

266 # __eq__ ignores metadata. 

267 assert new.metadata["four_and_a_half"] == 4.5 

268 assert new.metadata == mask.metadata 

269 

270 

271def test_serialize_multi() -> None: 

272 """Test serializing a mask with more than 31 mask planes (multiple 

273 HDUs). 

274 """ 

275 rng = np.random.default_rng(500) 

276 planes = make_mask_planes(rng, 35, n_placeholders=5) 

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

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

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

280 shape = bbox.shape 

281 for plane in schema: 

282 if plane is not None: 

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

284 with RoundtripFits(mask) as roundtrip: 

285 fits = roundtrip.inspect() 

286 assert fits[1].header["EXTNAME"] == "MASK" 

287 assert fits[1].header.get("EXTVER", 1) == 1 

288 assert fits[1].header["ZCMPTYPE"] == "GZIP_2" 

289 assert fits[2].header["EXTNAME"] == "MASK" 

290 assert fits[2].header["EXTVER"] == 2 

291 assert fits[2].header["ZCMPTYPE"] == "GZIP_2" 

292 n = 0 

293 for plane in planes: 

294 if plane is not None: 

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

296 assert hdu.header[f"MSKN{(n % 31):04d}"] == plane.name 

297 assert hdu.header[f"MSKM{(n % 31):04d}"] == 1 << (n % 31) 

298 assert hdu.header[f"MSKD{(n % 31):04d}"] == plane.description 

299 n += 1 

300 assert_masks_equal(mask, roundtrip.result) 

301 

302 

303def test_add_plane_returns_new_mask() -> None: 

304 """Verify add_plane returns a new mask without modifying the original or 

305 its views. 

306 """ 

307 rng = np.random.default_rng(500) 

308 planes = make_mask_planes(rng, 3, n_placeholders=0) 

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

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

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

312 m0 = rng.random(bbox.shape) > 0.5 

313 mask.set("M0", m0) 

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

315 original_array = mask.array 

316 

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

318 

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

320 assert "OUTSIDE_STENCIL" not in mask.schema.names 

321 assert "OUTSIDE_STENCIL" not in view.schema.names 

322 assert mask.array is original_array 

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

324 assert new_mask.array is not original_array 

325 assert "OUTSIDE_STENCIL" in new_mask.schema.names 

326 assert new_mask.schema.descriptions["OUTSIDE_STENCIL"] == "Pixel lies outside the stencil." 

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

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

329 assert bit.index == 0 

330 assert bit.mask == 1 << 3 

331 assert new_mask.schema.mask_size == 1 

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

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

334 assert not new_mask.get("OUTSIDE_STENCIL").any() 

335 

336 

337def test_add_plane_grows_byte() -> None: 

338 """Verify adding a ninth plane crosses the 8-plane boundary into a 

339 second byte. 

340 """ 

341 rng = np.random.default_rng(500) 

342 planes = make_mask_planes(rng, 8, n_placeholders=0) 

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

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

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

346 set_planes = {} 

347 for plane in planes: 

348 assert plane is not None 

349 boolean_mask = rng.random(bbox.shape) > 0.5 

350 mask.set(plane.name, boolean_mask) 

351 set_planes[plane.name] = boolean_mask 

352 

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

354 

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

356 assert mask.schema.mask_size == 1 

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

358 assert bit.index == 1 

359 assert bit.mask == 1 << 0 

360 assert new_mask.schema.mask_size == 2 

361 assert new_mask.array.shape == bbox.shape + (2,) 

362 assert not new_mask.get("OUTSIDE_STENCIL").any() 

363 # Every pre-existing plane keeps its data. 

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

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

366 

367 

368def test_add_planes_multiple() -> None: 

369 """Verify add_planes adds several planes in a single call.""" 

370 rng = np.random.default_rng(500) 

371 planes = make_mask_planes(rng, 3, n_placeholders=0) 

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

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

374 m0 = rng.random(bbox.shape) > 0.5 

375 mask.set("M0", m0) 

376 

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

378 

379 assert set(mask.schema.names) == {"M0", "M1", "M2"} # original unchanged 

380 assert set(new_mask.schema.names) == {"M0", "M1", "M2", "A", "B"} 

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

382 assert not new_mask.get("A").any() 

383 assert not new_mask.get("B").any() 

384 

385 

386def test_add_planes_drop_reassigns_bits() -> None: 

387 """Verify dropping a plane compacts the schema and repacks pixel values.""" 

388 rng = np.random.default_rng(500) 

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

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

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

392 a = rng.random(bbox.shape) > 0.5 

393 c = rng.random(bbox.shape) > 0.5 

394 mask.set("A", a) 

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

396 mask.set("C", c) 

397 

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

399 

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

401 assert list(new_mask.schema.names) == ["A", "C", "D"] 

402 assert "B" not in new_mask.schema.names 

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

404 assert new_mask.schema.bit("A").mask == 1 << 0 

405 assert new_mask.schema.bit("C").mask == 1 << 1 

406 assert new_mask.schema.bit("D").mask == 1 << 2 

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

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

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

410 assert not new_mask.get("D").any() 

411 

412 

413def test_add_planes_with_placeholder() -> None: 

414 """Verify None placeholders reserve bits and survive add_planes and a 

415 FITS round-trip. 

416 """ 

417 rng = np.random.default_rng(500) 

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

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

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

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

422 a = rng.random(bbox.shape) > 0.5 

423 b = rng.random(bbox.shape) > 0.5 

424 mask.set("A", a) 

425 mask.set("B", b) 

426 

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

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

429 

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

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

432 assert list(new_mask.schema) == [ 

433 MaskPlane("A", "a"), 

434 None, 

435 MaskPlane("B", "b"), 

436 MaskPlane("C", "c"), 

437 None, 

438 MaskPlane("D", "d"), 

439 ] 

440 assert new_mask.schema.bit("A").mask == 1 << 0 

441 assert new_mask.schema.bit("B").mask == 1 << 2 

442 assert new_mask.schema.bit("C").mask == 1 << 3 

443 assert new_mask.schema.bit("D").mask == 1 << 5 

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

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

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

447 assert not new_mask.get("C").any() 

448 assert not new_mask.get("D").any() 

449 

450 with RoundtripFits(new_mask) as roundtrip: 

451 assert_masks_equal(new_mask, roundtrip.result) 

452 

453 

454def test_add_planes_drop_unknown_raises() -> None: 

455 """Verify dropping a non-existent plane raises ValueError.""" 

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

457 with pytest.raises(ValueError): 

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

459 

460 

461def test_add_plane_duplicate_raises() -> None: 

462 """Verify adding a plane whose name already exists raises ValueError.""" 

463 rng = np.random.default_rng(500) 

464 planes = make_mask_planes(rng, 3, n_placeholders=0) 

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

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

467 with pytest.raises(ValueError): 

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

469 

470 

471def test_add_plane_roundtrip() -> None: 

472 """Verify a runtime-added plane and its data survive a FITS round-trip.""" 

473 rng = np.random.default_rng(500) 

474 planes = make_mask_planes(rng, 8, n_placeholders=0) 

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

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

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

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

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

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

481 mask.write(tmpFile) 

482 new = Mask.read(tmpFile) 

483 assert new == mask 

484 assert new.schema.descriptions["OUTSIDE_STENCIL"] == "Pixel lies outside the stencil." 

485 assert_masks_equal(new, mask) 

486 

487 

488def test_legacy_non_cell_coadd_plane_map() -> None: 

489 """Verify the non-cell coadd map defines a distinct SENSOR_EDGE plane.""" 

490 plane_map = get_legacy_non_cell_coadd_mask_planes() 

491 assert "SENSOR_EDGE" in plane_map 

492 assert plane_map["SENSOR_EDGE"].name == "SENSOR_EDGE" 

493 

494 

495def test_guess_legacy_plane_map_coadd_discriminator() -> None: 

496 """Verify INEXACT_PSF routes to a coadd map and SENSOR_EDGE discriminates 

497 non-cell from cell. 

498 """ 

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

500 assert "SENSOR_EDGE" in non_cell 

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

502 assert "SENSOR_EDGE" not in cell 

503 

504 

505def test_legacy(legacy_test_data: _LegacyTestData) -> None: 

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

507 assert legacy_test_data.mask.schema.names == {p.name for p in legacy_test_data.plane_map.values()} 

508 assert legacy_test_data.mask.bbox == Box.from_legacy(legacy_test_data.reader.readBBox()) 

509 legacy_mask = legacy_test_data.reader.readMask() 

510 compare_mask_to_legacy(legacy_test_data.mask, legacy_mask, legacy_test_data.plane_map) 

511 compare_mask_to_legacy( 

512 legacy_test_data.mask, 

513 legacy_test_data.mask.to_legacy(legacy_test_data.plane_map), 

514 legacy_test_data.plane_map, 

515 ) 

516 assert_masks_equal( 

517 legacy_test_data.mask, Mask.from_legacy(legacy_mask, plane_map=legacy_test_data.plane_map) 

518 ) 

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

520 with RoundtripFits(legacy_test_data.mask, storage_class="MaskV2") as roundtrip: 

521 pass 

522 assert_masks_equal(roundtrip.result, legacy_test_data.mask) 

523 

524 

525def test_legacy_butler_read(legacy_test_data: _LegacyTestData) -> None: 

526 """Test that a round-tripped MaskV2 can be read back as a legacy afw 

527 Mask via Butler. 

528 """ 

529 with RoundtripFits(legacy_test_data.mask, storage_class="MaskV2") as roundtrip: 

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

531 assert isinstance(legacy_mask, lsst.afw.image.Mask) 

532 compare_mask_to_legacy(legacy_test_data.mask, legacy_mask) 

533 

534 

535def test_mask_repr_str_pinned() -> None: 

536 """Mask str/repr match their documented forms.""" 

537 rng = np.random.default_rng(7) 

538 schema = MaskSchema(make_mask_planes(rng, 3, 0), dtype=np.uint8) 

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

540 assert str(mask) == f"Mask({mask.bbox!s}, {list(mask.schema.names)})" 

541 assert repr(mask) == f"Mask(..., bbox={mask.bbox!r}, schema={mask.schema!r})" 

542 

543 

544def test_mask_describe_detail_reports_set_pixel_counts() -> None: 

545 """detail=True adds a 'Set pixels' column with per-plane counts.""" 

546 schema = MaskSchema([MaskPlane("BAD", "bad"), MaskPlane("DET", "detected")], dtype=np.uint8) 

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

548 mask.set("BAD", mask.get("BAD") | (np.arange(20).reshape(4, 5) < 3)) 

549 

550 # Cheap path: no counts, no extra column. 

551 plain = mask.describe().children["schema"] 

552 plain_table = next(t for t in plain.tables if t.title == "Mask planes") 

553 assert "Set pixels" not in plain_table.columns 

554 

555 # Detailed path: 'Set pixels' column present with correct counts. 

556 detailed = mask.describe(detail=True).children["schema"] 

557 table = next(t for t in detailed.tables if t.title == "Mask planes") 

558 assert table.columns[-1] == "Set pixels" 

559 counts = {row[3]: row[-1] for row in table.rows} # row[3] is the Name column 

560 assert counts["BAD"] == 3 

561 assert counts["DET"] == 0