Coverage for tests/test_mask.py: 96%
460 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-14 19:06 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-14 19:06 +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.
12from __future__ import annotations
14import dataclasses
15import os
16from typing import Any
18import astropy.io.fits
19import numpy as np
20import pytest
22import lsst.utils.tests
23from lsst.images import (
24 Box,
25 Mask,
26 MaskPlane,
27 MaskSchema,
28 get_legacy_difference_image_mask_planes,
29 get_legacy_non_cell_coadd_mask_planes,
30 get_legacy_optional_mask_planes,
31 get_legacy_template_mask_planes,
32 get_legacy_visit_image_mask_planes,
33)
34from lsst.images._mask import _guess_legacy_plane_map
35from lsst.images.describe import Report
36from lsst.images.tests import ( # noqa: F401
37 RoundtripFits,
38 assert_masks_equal,
39 compare_mask_to_legacy,
40 reset_afw_mask_planes,
41)
43try:
44 from lsst.afw.image import MaskedImageReader as LegacyMaskedImageReader
46except ImportError:
47 type LegacyMaskedImageReader = Any # type: ignore[no-redef]
49EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None)
52@dataclasses.dataclass
53class _LegacyTestData:
54 mask: Mask
55 reader: LegacyMaskedImageReader
56 plane_map: dict[str, MaskPlane]
59@pytest.fixture
60def legacy_test_data(reset_afw_mask_planes: None) -> _LegacyTestData: # noqa: F811
61 """Return a Mask read directly from the legacy test dataset and a legacy
62 reader for that image.
64 Skips if TESTDATA_IMAGES_DIR is unset or lsst.afw.image is unavailable.
65 """
66 # reset_afw_mask_planes will have already skipped if afw is not available.
67 from lsst.afw.image import MaskedImageFitsReader
69 if EXTERNAL_DATA_DIR is None: 69 ↛ 71line 69 didn't jump to line 71 because the condition on line 69 was always true
70 pytest.skip("TESTDATA_IMAGES_DIR is not in the environment.")
71 filename = os.path.join(EXTERNAL_DATA_DIR, "dp2", "legacy", "visit_image.fits")
72 plane_map = get_legacy_visit_image_mask_planes()
73 mask = Mask.read_legacy(filename, ext=2, plane_map=plane_map)
74 reader = MaskedImageFitsReader(filename)
75 return _LegacyTestData(mask=mask, reader=reader, plane_map=plane_map)
78def make_mask_planes(rng: np.random.Generator, n_planes: int, n_placeholders: int) -> list[MaskPlane | None]:
79 """Return a shuffled list of MaskPlane objects with placeholder Nones."""
80 planes: list[MaskPlane | None] = []
81 for i in range(n_planes):
82 planes.append(MaskPlane(f"M{i}", f"D{i}"))
83 planes.extend([None] * n_placeholders)
84 rng.shuffle(planes)
85 return planes
88def test_schema() -> None:
89 """Test MaskSchema construction, accessors, and basic operations."""
90 rng = np.random.default_rng(500)
91 planes = make_mask_planes(rng, 17, 5)
92 with pytest.raises(TypeError):
93 MaskSchema.bits_per_element(np.float32)
94 assert MaskSchema.bits_per_element(np.uint8) == 8
95 schema = MaskSchema(planes, dtype=np.uint8)
96 assert list(schema) == planes
97 assert len(schema) == len(planes)
98 assert schema[5] == planes[5]
99 assert eval(repr(schema), {"dtype": np.dtype, "MaskSchema": MaskSchema, "MaskPlane": MaskPlane}) == schema
100 report = schema.describe()
101 plane_table = next(t for t in report.tables if t.title == "Mask planes")
102 assert len(plane_table.rows) == 17
103 assert ["M5" == row[3] for row in plane_table.rows].count(True) == 1
104 bit5 = schema.bit("M5")
105 assert schema == MaskSchema(planes, np.uint8)
106 assert schema != MaskSchema(planes, np.int16)
107 assert schema != MaskSchema(planes[:-1], np.uint8)
108 assert schema.dtype == np.dtype(np.uint8)
109 assert schema.mask_size == 3
110 assert schema.names == {f"M{i}" for i in range(17)}
111 assert schema.descriptions == {f"M{i}": f"D{i}" for i in range(17)}
112 bit7 = schema.bit("M7")
113 bitmask57 = schema.bitmask("M5", "M7")
114 assert bitmask57[bit5.index] & bit5.mask
115 assert bitmask57[bit7.index] & bit7.mask
116 bitmask57[bit5.index] &= ~bit5.mask
117 bitmask57[bit7.index] &= ~bit7.mask
118 assert not bitmask57.any()
119 splits = schema.split(np.int16)
120 assert len(splits) == 2
121 assert splits[0].mask_size == 1
122 assert splits[1].mask_size == 1
123 assert list(splits[0]) + list(splits[1]) == [p for p in planes if p is not None]
124 assert len(splits[0]) == 15
125 assert len(splits[1]) == 2
128def test_schema_describe() -> None:
129 """MaskSchema._describe yields a mask-plane table and eval-able repr."""
130 rng = np.random.default_rng(500)
131 planes = make_mask_planes(rng, 3, 0)
132 schema = MaskSchema(planes, dtype=np.uint8)
134 report = schema.describe()
135 assert isinstance(report, Report)
136 assert report.type_name == "MaskSchema"
138 tables = [t for t in report.tables if t.title == "Mask planes"]
139 assert len(tables) == 1
140 table = tables[0]
141 assert table.columns == ["Bit", "Index", "Mask", "Name", "Description"]
142 assert len(table.rows) == 3
143 # Names appear in the Name column (index 3).
144 assert {row[3] for row in table.rows} == {"M0", "M1", "M2"}
146 # repr is still eval-able and round-trips.
147 reconstructed = eval(repr(schema), {"dtype": np.dtype, "MaskSchema": MaskSchema, "MaskPlane": MaskPlane})
148 assert reconstructed == schema
151def test_schema_from_fits_header() -> None:
152 """Verify MaskSchema.from_fits_header inverts update_header."""
153 planes = [
154 MaskPlane("NO_DATA", "No data was available for this pixel."),
155 MaskPlane("COSMIC_RAY", "A cosmic ray affected this pixel."),
156 MaskPlane("DETECTED", "Pixel was part of a detected source."),
157 ]
158 schema = MaskSchema(planes, dtype=np.uint8)
159 header = astropy.io.fits.Header()
160 schema.update_header(header)
161 result = MaskSchema.from_fits_header(header)
162 assert result.dtype == np.dtype(np.uint8)
163 assert list(result) == planes
164 assert result == schema
167def test_schema_from_fits_header_preserves_gaps() -> None:
168 """Verify None placeholders are reconstructed from gaps in MSKN card
169 numbering.
170 """
171 planes: list[MaskPlane | None] = [MaskPlane("A", "a"), None, MaskPlane("B", "b")]
172 header = astropy.io.fits.Header()
173 MaskSchema(planes, dtype=np.uint8).update_header(header)
174 assert list(MaskSchema.from_fits_header(header)) == planes
177def test_schema_from_fits_header_requires_cards() -> None:
178 """Verify MaskSchema.from_fits_header raises ValueError on a header with
179 no MSKN cards.
180 """
181 with pytest.raises(ValueError, match="no MSKN cards"):
182 MaskSchema.from_fits_header(astropy.io.fits.Header())
185def test_interpret() -> None:
186 """Verify interpret returns correct plane names across a multi-byte
187 schema.
188 """
189 # 3 named planes padded with Nones to exceed 8 bits; A and B land in
190 # byte 0, C lands in byte 1.
191 planes: list[MaskPlane | None] = [
192 MaskPlane("A", "a"),
193 MaskPlane("B", "b"),
194 None,
195 None,
196 None,
197 None,
198 None,
199 None,
200 MaskPlane("C", "c"),
201 ]
202 schema = MaskSchema(planes, dtype=np.uint8)
203 assert schema.mask_size == 2
205 assert set(schema.interpret(schema.bitmask("A", "C"))) == {"A", "C"}
206 assert set(schema.interpret(schema.bitmask("B"))) == {"B"}
207 assert set(schema.interpret(np.zeros(schema.mask_size, dtype=schema.dtype))) == set()
208 assert set(schema.interpret(schema.bitmask("A", "B", "C"))) == {"A", "B", "C"}
211def test_basics() -> None:
212 """Test basic Mask construction, string representation, and error
213 conditions.
214 """
215 rng = np.random.default_rng(500)
216 planes = make_mask_planes(rng, 35, n_placeholders=5)
217 schema = MaskSchema(planes, dtype=np.uint8)
218 bbox = Box.factory[5:50, 6:60]
219 mask = Mask(
220 0,
221 schema=schema,
222 bbox=bbox,
223 metadata={"four_and_a_half": 4.5},
224 )
226 assert mask[...] is not mask
227 assert mask.__eq__(42) == NotImplemented
228 assert mask == mask
229 assert (
230 str(mask)
231 == "Mask([y=5:50, x=6:60], ['M34', 'M15', 'M29', 'M1', 'M20', 'M11', 'M13', 'M7', 'M17', 'M12', "
232 "'M31', 'M16', 'M2', 'M3', 'M8', 'M26', 'M22', 'M5', 'M18', 'M19', 'M24', 'M21', 'M27', 'M6', "
233 "'M28', 'M10', 'M4', 'M23', 'M0', 'M25', 'M9', 'M14', 'M33', 'M32', 'M30'])"
234 )
235 assert repr(mask).startswith(
236 "Mask(..., bbox=Box(y=Interval(start=5, stop=50), x=Interval(start=6, stop=60)), "
237 "schema=MaskSchema([MaskPlane(name='M34', description='D34')"
238 ), f"Repr: {mask!r}"
240 with pytest.raises(TypeError):
241 # No bbox, size or array.
242 Mask(0, schema=schema)
244 with pytest.raises(ValueError, match="Explicit bbox shape"):
245 # Box mismatch.
246 Mask(mask.array, schema=schema, bbox=Box.factory[0:20, -5:45])
248 with pytest.raises(ValueError, match="Explicit shape"):
249 # Shape mismatch.
250 Mask(mask.array, schema=schema, shape=(5, 10, 5))
252 with pytest.raises(ValueError, match="Mask array must be 3-d"):
253 # Cannot be 2-D.
254 Mask(mask.array.reshape((2430, 5)), schema=schema, bbox=Box.factory[0:20, -5:45])
257def test_read_write() -> None:
258 """Test explicit calls to Mask.read and Mask.write through FITS."""
259 rng = np.random.default_rng(500)
260 planes = make_mask_planes(rng, 35, n_placeholders=5)
261 schema = MaskSchema(planes, dtype=np.uint8)
262 bbox = Box.factory[5:50, 6:60]
263 mask = Mask(
264 0,
265 schema=schema,
266 bbox=bbox,
267 metadata={"four_and_a_half": 4.5},
268 )
269 with lsst.utils.tests.getTempFilePath(".fits") as tmpFile:
270 mask.write(tmpFile)
271 new = Mask.read(tmpFile)
272 assert new == mask
273 # __eq__ ignores metadata.
274 assert new.metadata["four_and_a_half"] == 4.5
275 assert new.metadata == mask.metadata
278def test_serialize_multi() -> None:
279 """Test serializing a mask with more than 31 mask planes (multiple
280 HDUs).
281 """
282 rng = np.random.default_rng(500)
283 planes = make_mask_planes(rng, 35, n_placeholders=5)
284 schema = MaskSchema(planes, dtype=np.uint8)
285 bbox = Box.factory[5:50, 6:60]
286 mask = Mask(0, schema=schema, bbox=bbox, metadata={"four_and_a_half": 4.5})
287 shape = bbox.shape
288 for plane in schema:
289 if plane is not None:
290 mask.set(plane.name, rng.random(shape) > 0.5)
291 with RoundtripFits(mask) as roundtrip:
292 fits = roundtrip.inspect()
293 assert fits[1].header["EXTNAME"] == "MASK"
294 assert fits[1].header.get("EXTVER", 1) == 1
295 assert fits[1].header["ZCMPTYPE"] == "GZIP_2"
296 assert fits[2].header["EXTNAME"] == "MASK"
297 assert fits[2].header["EXTVER"] == 2
298 assert fits[2].header["ZCMPTYPE"] == "GZIP_2"
299 n = 0
300 for plane in planes:
301 if plane is not None:
302 hdu = fits[1] if n < 31 else fits[2]
303 assert hdu.header[f"MSKN{(n % 31):04d}"] == plane.name
304 assert hdu.header[f"MSKM{(n % 31):04d}"] == 1 << (n % 31)
305 assert hdu.header[f"MSKD{(n % 31):04d}"] == plane.description
306 n += 1
307 assert_masks_equal(mask, roundtrip.result)
310def test_add_plane_returns_new_mask() -> None:
311 """Verify add_plane returns a new mask without modifying the original or
312 its views.
313 """
314 rng = np.random.default_rng(500)
315 planes = make_mask_planes(rng, 3, n_placeholders=0)
316 schema = MaskSchema(planes, dtype=np.uint8)
317 bbox = Box.factory[5:50, 6:60]
318 mask = Mask(0, schema=schema, bbox=bbox)
319 m0 = rng.random(bbox.shape) > 0.5
320 mask.set("M0", m0)
321 view = mask[bbox] # shares the array and old schema with mask
322 original_array = mask.array
324 new_mask = mask.add_plane("OUTSIDE_STENCIL", "Pixel lies outside the stencil.")
326 # The original mask and any views keep the old schema and array.
327 assert "OUTSIDE_STENCIL" not in mask.schema.names
328 assert "OUTSIDE_STENCIL" not in view.schema.names
329 assert mask.array is original_array
330 # The new mask reallocated a fresh array and carries the new plane.
331 assert new_mask.array is not original_array
332 assert "OUTSIDE_STENCIL" in new_mask.schema.names
333 assert new_mask.schema.descriptions["OUTSIDE_STENCIL"] == "Pixel lies outside the stencil."
334 # The new plane is the fourth (overall index 3) so it lives in byte 0.
335 bit = new_mask.schema.bit("OUTSIDE_STENCIL")
336 assert bit.index == 0
337 assert bit.mask == 1 << 3
338 assert new_mask.schema.mask_size == 1
339 # Existing plane data is preserved and the new plane starts all-False.
340 np.testing.assert_array_equal(new_mask.get("M0"), m0)
341 assert not new_mask.get("OUTSIDE_STENCIL").any()
344def test_add_plane_grows_byte() -> None:
345 """Verify adding a ninth plane crosses the 8-plane boundary into a
346 second byte.
347 """
348 rng = np.random.default_rng(500)
349 planes = make_mask_planes(rng, 8, n_placeholders=0)
350 schema = MaskSchema(planes, dtype=np.uint8)
351 bbox = Box.factory[5:50, 6:60]
352 mask = Mask(0, schema=schema, bbox=bbox)
353 set_planes = {}
354 for plane in planes:
355 assert plane is not None
356 boolean_mask = rng.random(bbox.shape) > 0.5
357 mask.set(plane.name, boolean_mask)
358 set_planes[plane.name] = boolean_mask
360 new_mask = mask.add_plane("OUTSIDE_STENCIL", "Pixel lies outside the stencil.")
362 # The original is unchanged; the new mask spills into a second byte.
363 assert mask.schema.mask_size == 1
364 bit = new_mask.schema.bit("OUTSIDE_STENCIL")
365 assert bit.index == 1
366 assert bit.mask == 1 << 0
367 assert new_mask.schema.mask_size == 2
368 assert new_mask.array.shape == bbox.shape + (2,)
369 assert not new_mask.get("OUTSIDE_STENCIL").any()
370 # Every pre-existing plane keeps its data.
371 for name, boolean_mask in set_planes.items():
372 np.testing.assert_array_equal(new_mask.get(name), boolean_mask)
375def test_add_planes_multiple() -> None:
376 """Verify add_planes adds several planes in a single call."""
377 rng = np.random.default_rng(500)
378 planes = make_mask_planes(rng, 3, n_placeholders=0)
379 bbox = Box.factory[0:4, 0:5]
380 mask = Mask(0, schema=MaskSchema(planes, dtype=np.uint8), bbox=bbox)
381 m0 = rng.random(bbox.shape) > 0.5
382 mask.set("M0", m0)
384 new_mask = mask.add_planes([MaskPlane("A", "plane a"), MaskPlane("B", "plane b")])
386 assert set(mask.schema.names) == {"M0", "M1", "M2"} # original unchanged
387 assert set(new_mask.schema.names) == {"M0", "M1", "M2", "A", "B"}
388 np.testing.assert_array_equal(new_mask.get("M0"), m0)
389 assert not new_mask.get("A").any()
390 assert not new_mask.get("B").any()
393def test_add_planes_drop_reassigns_bits() -> None:
394 """Verify dropping a plane compacts the schema and repacks pixel values."""
395 rng = np.random.default_rng(500)
396 bbox = Box.factory[0:4, 0:5]
397 schema = MaskSchema([MaskPlane("A", "a"), MaskPlane("B", "b"), MaskPlane("C", "c")], dtype=np.uint8)
398 mask = Mask(0, schema=schema, bbox=bbox)
399 a = rng.random(bbox.shape) > 0.5
400 c = rng.random(bbox.shape) > 0.5
401 mask.set("A", a)
402 mask.set("B", rng.random(bbox.shape) > 0.5)
403 mask.set("C", c)
405 new_mask = mask.add_planes([MaskPlane("D", "d")], drop=["B"])
407 # B is gone; D is appended after the retained planes.
408 assert list(new_mask.schema.names) == ["A", "C", "D"]
409 assert "B" not in new_mask.schema.names
410 # C moved down from bit 2 to bit 1; D takes bit 2.
411 assert new_mask.schema.bit("A").mask == 1 << 0
412 assert new_mask.schema.bit("C").mask == 1 << 1
413 assert new_mask.schema.bit("D").mask == 1 << 2
414 # Retained pixel values follow their planes; the new plane is cleared.
415 np.testing.assert_array_equal(new_mask.get("A"), a)
416 np.testing.assert_array_equal(new_mask.get("C"), c)
417 assert not new_mask.get("D").any()
420def test_add_planes_with_placeholder() -> None:
421 """Verify None placeholders reserve bits and survive add_planes and a
422 FITS round-trip.
423 """
424 rng = np.random.default_rng(500)
425 bbox = Box.factory[0:4, 0:5]
426 # Schema with a pre-existing placeholder reserving bit 1.
427 schema = MaskSchema([MaskPlane("A", "a"), None, MaskPlane("B", "b")], dtype=np.uint8)
428 mask = Mask(0, schema=schema, bbox=bbox)
429 a = rng.random(bbox.shape) > 0.5
430 b = rng.random(bbox.shape) > 0.5
431 mask.set("A", a)
432 mask.set("B", b)
434 # Append a block that itself contains an interior placeholder.
435 new_mask = mask.add_planes([MaskPlane("C", "c"), None, MaskPlane("D", "d")])
437 # The pre-existing placeholder stays at bit 1; the added placeholder
438 # stays between C and D (bit 4), not at the end.
439 assert list(new_mask.schema) == [
440 MaskPlane("A", "a"),
441 None,
442 MaskPlane("B", "b"),
443 MaskPlane("C", "c"),
444 None,
445 MaskPlane("D", "d"),
446 ]
447 assert new_mask.schema.bit("A").mask == 1 << 0
448 assert new_mask.schema.bit("B").mask == 1 << 2
449 assert new_mask.schema.bit("C").mask == 1 << 3
450 assert new_mask.schema.bit("D").mask == 1 << 5
451 # Retained pixel values follow their planes; new planes start cleared.
452 np.testing.assert_array_equal(new_mask.get("A"), a)
453 np.testing.assert_array_equal(new_mask.get("B"), b)
454 assert not new_mask.get("C").any()
455 assert not new_mask.get("D").any()
457 with RoundtripFits(new_mask) as roundtrip:
458 assert_masks_equal(new_mask, roundtrip.result)
461def test_add_planes_drop_unknown_raises() -> None:
462 """Verify dropping a non-existent plane raises ValueError."""
463 mask = Mask(0, schema=MaskSchema([MaskPlane("A", "a")], dtype=np.uint8), bbox=Box.factory[0:2, 0:2])
464 with pytest.raises(ValueError, match="Cannot drop mask planes"):
465 mask.add_planes([], drop=["NOPE"])
468def test_add_plane_duplicate_raises() -> None:
469 """Verify adding a plane whose name already exists raises ValueError."""
470 rng = np.random.default_rng(500)
471 planes = make_mask_planes(rng, 3, n_placeholders=0)
472 schema = MaskSchema(planes, dtype=np.uint8)
473 mask = Mask(0, schema=schema, bbox=Box.factory[0:4, 0:4])
474 with pytest.raises(ValueError, match="already exists"):
475 mask.add_plane("M0", "Duplicate of an existing plane.")
478def test_add_plane_roundtrip() -> None:
479 """Verify a runtime-added plane and its data survive a FITS round-trip."""
480 rng = np.random.default_rng(500)
481 planes = make_mask_planes(rng, 8, n_placeholders=0)
482 schema = MaskSchema(planes, dtype=np.uint8)
483 bbox = Box.factory[5:50, 6:60]
484 mask = Mask(0, schema=schema, bbox=bbox)
485 mask = mask.add_plane("OUTSIDE_STENCIL", "Pixel lies outside the stencil.")
486 mask.set("OUTSIDE_STENCIL", rng.random(bbox.shape) > 0.5)
487 with lsst.utils.tests.getTempFilePath(".fits") as tmpFile:
488 mask.write(tmpFile)
489 new = Mask.read(tmpFile)
490 assert new == mask
491 assert new.schema.descriptions["OUTSIDE_STENCIL"] == "Pixel lies outside the stencil."
492 assert_masks_equal(new, mask)
495def test_legacy_non_cell_coadd_plane_map() -> None:
496 """Verify the non-cell coadd map defines a distinct SENSOR_EDGE plane."""
497 plane_map = get_legacy_non_cell_coadd_mask_planes()
498 assert "SENSOR_EDGE" in plane_map
499 assert plane_map["SENSOR_EDGE"].name == "SENSOR_EDGE"
502def test_legacy_optional_plane_map() -> None:
503 """Verify the source injection planes are optional, and so belong to no
504 image's plane map.
505 """
506 optional = get_legacy_optional_mask_planes()
507 assert set(optional) == {
508 "INJECTED",
509 "INJECTED_CORE",
510 "INJECTED_TEMPLATE",
511 "INJECTED_CORE_TEMPLATE",
512 }
513 for old_name, plane in optional.items():
514 assert plane.name == old_name
515 for plane_map in (
516 get_legacy_visit_image_mask_planes(),
517 get_legacy_difference_image_mask_planes(),
518 get_legacy_non_cell_coadd_mask_planes(),
519 get_legacy_template_mask_planes(),
520 ):
521 assert not (plane_map.keys() & optional.keys())
524def test_from_legacy_adds_optional_planes_in_use(reset_afw_mask_planes: None) -> None: # noqa: F811
525 """Verify an optional plane joins the schema when the legacy mask has
526 pixels set in it, and costs nothing when it does not.
527 """
528 # reset_afw_mask_planes will have already skipped if afw is not available.
529 from lsst.afw.image import Mask as LegacyMask
531 legacy = LegacyMask(5, 4)
532 injected = np.arange(20).reshape(4, 5) < 3
533 for old_name in ("INJECTED", "INJECTED_CORE"):
534 legacy.addMaskPlane(old_name)
535 legacy.array[injected] |= legacy.getPlaneBitMask("INJECTED")
536 plane_map = get_legacy_visit_image_mask_planes()
538 mask = Mask.from_legacy(legacy, plane_map=plane_map)
540 assert "INJECTED" in mask.schema.names
541 np.testing.assert_array_equal(mask.get("INJECTED"), injected)
542 # INJECTED_CORE is defined but unused, so it takes up no bit.
543 assert "INJECTED_CORE" not in mask.schema.names
544 assert len(mask.schema) == len(plane_map) + 1
545 # The plane keeps its legacy name on the way back out.
546 round_tripped = mask.to_legacy(plane_map)
547 np.testing.assert_array_equal(
548 (round_tripped.array & round_tripped.getPlaneBitMask("INJECTED")).astype(bool), injected
549 )
552def test_legacy_template_plane_map() -> None:
553 """Verify the template map has the coadd planes and HIGH_VARIANCE."""
554 plane_map = get_legacy_template_mask_planes()
555 non_cell_coadd = get_legacy_non_cell_coadd_mask_planes()
556 assert non_cell_coadd.keys() < plane_map.keys()
557 assert plane_map["HIGH_VARIANCE"].name == "HIGH_VARIANCE"
560def test_guess_legacy_plane_map_coadd_discriminator() -> None:
561 """Verify INEXACT_PSF routes to a coadd map, SENSOR_EDGE discriminates
562 non-cell from cell, and HIGH_VARIANCE marks a template.
563 """
564 non_cell = _guess_legacy_plane_map({"INEXACT_PSF": 11, "SENSOR_EDGE": 14})
565 assert "SENSOR_EDGE" in non_cell
566 assert "HIGH_VARIANCE" not in non_cell
567 cell = _guess_legacy_plane_map({"INEXACT_PSF": 11})
568 assert "SENSOR_EDGE" not in cell
569 template = _guess_legacy_plane_map({"INEXACT_PSF": 11, "SENSOR_EDGE": 14, "HIGH_VARIANCE": 15})
570 assert "HIGH_VARIANCE" in template
573def test_to_legacy_keeps_unmapped_planes(reset_afw_mask_planes: None) -> None: # noqa: F811
574 """Verify a plane the map does not name keeps its pixels and its name.
576 A template is a warped coadd stored as a difference image, so it has
577 CLIPPED set while the difference image map has no entry for it.
578 """
579 plane_map = get_legacy_non_cell_coadd_mask_planes()
580 mask = Mask(0, schema=MaskSchema(list(plane_map.values())), bbox=Box.factory[0:4, 0:5])
581 clipped = np.arange(20).reshape(4, 5) < 3
582 saturated = np.arange(20).reshape(4, 5) > 17
583 mask.set("CLIPPED", clipped)
584 mask.set("SATURATED", saturated)
586 legacy = mask.to_legacy(get_legacy_difference_image_mask_planes())
588 # The unmapped coadd plane is written under the name it already has.
589 assert "CLIPPED" in legacy.getMaskPlaneDict()
590 np.testing.assert_array_equal((legacy.array & legacy.getPlaneBitMask("CLIPPED")).astype(bool), clipped)
591 # A mapped plane still comes back under its legacy name.
592 np.testing.assert_array_equal((legacy.array & legacy.getPlaneBitMask("SAT")).astype(bool), saturated)
593 assert "SATURATED" not in legacy.getMaskPlaneDict()
594 assert_masks_equal(Mask.from_legacy(legacy, plane_map=plane_map), mask)
597def test_legacy_round_trip_with_optional_plane(reset_afw_mask_planes: None) -> None: # noqa: F811
598 """Verify a legacy mask survives a round trip through `Mask`, with the
599 mapped planes renamed both ways and an optional plane carried as it is.
600 """
601 # reset_afw_mask_planes will have already skipped if afw is not available.
602 from lsst.afw.image import Mask as LegacyMask
604 legacy = LegacyMask(5, 4)
605 rows = {"SAT": 0, "INTRP": 1, "INJECTED": 2}
606 pixels = {}
607 for old_name, row in rows.items():
608 legacy.addMaskPlane(old_name)
609 selection = np.zeros(legacy.array.shape, dtype=bool)
610 selection[row, :] = True
611 legacy.array[selection] |= legacy.getPlaneBitMask(old_name)
612 pixels[old_name] = selection
613 # Defined by some other image in this process, but unused here.
614 legacy.addMaskPlane("INJECTED_CORE")
615 plane_map = get_legacy_visit_image_mask_planes()
617 mask = Mask.from_legacy(legacy, plane_map=plane_map)
619 assert {"SATURATED", "INTERPOLATED", "INJECTED"} <= set(mask.schema.names)
620 assert "INJECTED_CORE" not in mask.schema.names
621 np.testing.assert_array_equal(mask.get("SATURATED"), pixels["SAT"])
622 np.testing.assert_array_equal(mask.get("INJECTED"), pixels["INJECTED"])
624 round_tripped = mask.to_legacy(plane_map)
626 compare_mask_to_legacy(mask, round_tripped, plane_map)
627 for old_name, selection in pixels.items():
628 np.testing.assert_array_equal(
629 (round_tripped.array & round_tripped.getPlaneBitMask(old_name)).astype(bool), selection
630 )
631 # The mapped planes go back out under their legacy names, and the
632 # optional plane under the only name it has.
633 assert "SATURATED" not in round_tripped.getMaskPlaneDict()
634 assert "INJECTED" in round_tripped.getMaskPlaneDict()
637def test_read_legacy_adds_optional_planes_in_use(reset_afw_mask_planes: None) -> None: # noqa: F811
638 """Verify reading a legacy FITS mask resolves the optional planes the
639 same way converting one in memory does.
640 """
641 # reset_afw_mask_planes will have already skipped if afw is not available.
642 from lsst.afw.image import MaskedImageF
644 # `read_legacy` reads the MASK extension of a masked image or exposure.
645 masked_image = MaskedImageF(5, 4)
646 legacy = masked_image.mask
647 injected = np.arange(20).reshape(4, 5) < 3
648 for old_name in ("INJECTED", "INJECTED_CORE"):
649 legacy.addMaskPlane(old_name)
650 legacy.array[injected] |= legacy.getPlaneBitMask("INJECTED")
652 with lsst.utils.tests.getTempFilePath(".fits") as tmpFile:
653 masked_image.writeFits(tmpFile)
654 mask = Mask.read_legacy(tmpFile, ext=2, plane_map=get_legacy_visit_image_mask_planes())
656 assert "INJECTED" in mask.schema.names
657 np.testing.assert_array_equal(mask.get("INJECTED"), injected)
658 assert "INJECTED_CORE" not in mask.schema.names
661def test_legacy(legacy_test_data: _LegacyTestData) -> None:
662 """Test Mask.read_legacy, Mask.to_legacy, and Mask.from_legacy."""
663 assert legacy_test_data.mask.schema.names == {p.name for p in legacy_test_data.plane_map.values()}
664 assert legacy_test_data.mask.bbox == Box.from_legacy(legacy_test_data.reader.readBBox())
665 legacy_mask = legacy_test_data.reader.readMask()
666 compare_mask_to_legacy(legacy_test_data.mask, legacy_mask, legacy_test_data.plane_map)
667 compare_mask_to_legacy(
668 legacy_test_data.mask,
669 legacy_test_data.mask.to_legacy(legacy_test_data.plane_map),
670 legacy_test_data.plane_map,
671 )
672 assert_masks_equal(
673 legacy_test_data.mask, Mask.from_legacy(legacy_mask, plane_map=legacy_test_data.plane_map)
674 )
675 # Write the mask out in the new format, and test that we can read it back.
676 with RoundtripFits(legacy_test_data.mask, storage_class="MaskV2") as roundtrip:
677 pass
678 assert_masks_equal(roundtrip.result, legacy_test_data.mask)
681def test_legacy_butler_read(legacy_test_data: _LegacyTestData) -> None:
682 """Test that a round-tripped MaskV2 can be read back as a legacy afw
683 Mask via Butler.
684 """
685 with RoundtripFits(legacy_test_data.mask, storage_class="MaskV2") as roundtrip:
686 legacy_mask = roundtrip.get(storageClass="Mask")
687 assert isinstance(legacy_mask, lsst.afw.image.Mask)
688 compare_mask_to_legacy(legacy_test_data.mask, legacy_mask)
691def test_mask_repr_str_pinned() -> None:
692 """Mask str/repr match their documented forms."""
693 rng = np.random.default_rng(7)
694 schema = MaskSchema(make_mask_planes(rng, 3, 0), dtype=np.uint8)
695 mask = Mask(0, schema=schema, bbox=Box.factory[0:5, 0:4])
696 assert str(mask) == f"Mask({mask.bbox!s}, {list(mask.schema.names)})"
697 assert repr(mask) == f"Mask(..., bbox={mask.bbox!r}, schema={mask.schema!r})"
700def test_mask_describe_detail_reports_set_pixel_counts() -> None:
701 """detail=True adds a 'Set pixels' column with per-plane counts."""
702 schema = MaskSchema([MaskPlane("BAD", "bad"), MaskPlane("DET", "detected")], dtype=np.uint8)
703 mask = Mask(0, schema=schema, bbox=Box.factory[0:4, 0:5])
704 mask.set("BAD", mask.get("BAD") | (np.arange(20).reshape(4, 5) < 3))
706 # Cheap path: no counts, no extra column.
707 plain = mask.describe().children["schema"]
708 plain_table = next(t for t in plain.tables if t.title == "Mask planes")
709 assert "Set pixels" not in plain_table.columns
711 # Detailed path: 'Set pixels' column present with correct counts.
712 detailed = mask.describe(detail=True).children["schema"]
713 table = next(t for t in detailed.tables if t.title == "Mask planes")
714 assert table.columns[-1] == "Set pixels"
715 counts = {row[3]: row[-1] for row in table.rows} # row[3] is the Name column
716 assert counts["BAD"] == 3
717 assert counts["DET"] == 0
720def test_compare() -> None:
721 """Test Mask.compare added/removed counts for a shared plane."""
722 schema = MaskSchema([MaskPlane("A", "dA"), MaskPlane("B", "dB")], dtype=np.uint8)
723 m1 = Mask(0, schema=schema, bbox=Box.factory[0:2, 0:2])
724 m1.set("A", np.array([[True, False], [False, True]]))
725 m2 = Mask(0, schema=schema, bbox=Box.factory[0:2, 0:2])
726 m2.set("A", np.array([[True, True], [False, False]]))
727 # A: m1 set at (0,0),(1,1); m2 set at (0,0),(0,1)
728 assert m1.compare(m2) == {"A": (1, 1)}
731def test_compare_identical_is_empty() -> None:
732 """Test that identical masks yield an empty difference."""
733 schema = MaskSchema([MaskPlane("A", "dA")], dtype=np.uint8)
734 m1 = Mask(0, schema=schema, bbox=Box.factory[0:2, 0:1])
735 m1.set("A", np.array([[True], [False]]))
736 m2 = Mask(0, schema=schema, bbox=Box.factory[0:2, 0:1])
737 m2.set("A", np.array([[True], [False]]))
738 assert m1.compare(m2) == {}
741def test_compare_one_sided_planes() -> None:
742 """Test that planes present in only one mask are counted fully."""
743 schema_a = MaskSchema([MaskPlane("A", "dA")], dtype=np.uint8)
744 schema_b = MaskSchema([MaskPlane("B", "dB")], dtype=np.uint8)
745 m1 = Mask(0, schema=schema_a, bbox=Box.factory[0:2, 0:1])
746 m1.set("A", np.array([[True], [False]]))
747 m2 = Mask(0, schema=schema_b, bbox=Box.factory[0:2, 0:1])
748 m2.set("B", np.array([[True], [True]]))
749 assert m1.compare(m2) == {"A": (1, 0), "B": (0, 2)}
752def test_compare_bbox_mismatch_raises() -> None:
753 """Test that differing bounding boxes raise ValueError."""
754 schema = MaskSchema([MaskPlane("A", "dA")], dtype=np.uint8)
755 m1 = Mask(0, schema=schema, bbox=Box.factory[0:2, 0:1])
756 m2 = Mask(0, schema=schema, bbox=Box.factory[0:3, 0:1])
757 with pytest.raises(ValueError, match="same bounding box"):
758 m1.compare(m2)
761def test_compare_mask_to_legacy_notes_plane_diff(reset_afw_mask_planes: None) -> None: # noqa: F811
762 """Assert that a legacy-mask mismatch adds a per-plane +/- note."""
763 schema = MaskSchema([MaskPlane("A", "dA")], dtype=np.uint8)
764 mask = Mask(0, schema=schema, bbox=Box.factory[0:2, 0:1])
765 mask.set("A", np.array([[True], [False]]))
766 legacy = mask.to_legacy()
767 mask2 = Mask(0, schema=schema, bbox=Box.factory[0:2, 0:1])
768 mask2.set("A", np.array([[True], [True]]))
769 with pytest.raises(AssertionError) as excinfo:
770 compare_mask_to_legacy(mask2, legacy)
771 notes = excinfo.value.__notes__ or []
772 assert any("mask[A]: +1 -0" in n for n in notes)