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
« 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.
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_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
35try:
36 from lsst.afw.image import MaskedImageReader as LegacyMaskedImageReader
38except ImportError:
39 type LegacyMaskedImageReader = Any # type: ignore[no-redef]
41EXTERNAL_DATA_DIR = os.environ.get("TESTDATA_IMAGES_DIR", None)
44@dataclasses.dataclass
45class _LegacyTestData:
46 mask: Mask
47 reader: LegacyMaskedImageReader
48 plane_map: dict[str, MaskPlane]
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.
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)
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
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
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)
127 report = schema.describe()
128 assert isinstance(report, Report)
129 assert report.type_name == "MaskSchema"
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"}
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
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
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
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())
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
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"}
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 )
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}"
233 with pytest.raises(TypeError):
234 # No bbox, size or array.
235 Mask(0, schema=schema)
237 with pytest.raises(ValueError):
238 # Box mismatch.
239 Mask(mask.array, schema=schema, bbox=Box.factory[0:20, -5:45])
241 with pytest.raises(ValueError):
242 # Shape mismatch.
243 Mask(mask.array, schema=schema, shape=(5, 10, 5))
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])
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
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)
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
317 new_mask = mask.add_plane("OUTSIDE_STENCIL", "Pixel lies outside the stencil.")
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()
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
353 new_mask = mask.add_plane("OUTSIDE_STENCIL", "Pixel lies outside the stencil.")
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)
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)
377 new_mask = mask.add_planes([MaskPlane("A", "plane a"), MaskPlane("B", "plane b")])
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()
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)
398 new_mask = mask.add_planes([MaskPlane("D", "d")], drop=["B"])
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()
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)
427 # Append a block that itself contains an interior placeholder.
428 new_mask = mask.add_planes([MaskPlane("C", "c"), None, MaskPlane("D", "d")])
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()
450 with RoundtripFits(new_mask) as roundtrip:
451 assert_masks_equal(new_mask, roundtrip.result)
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"])
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.")
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)
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"
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
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)
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)
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})"
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))
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
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