Coverage for tests/test_ndf_layout.py: 96%
152 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -0700
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-16 15:24 -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.
12"""Layout sanity tests for NdfOutputArchive.
14Opens files written by NdfOutputArchive with raw h5py and verifies
15the on-disk layout matches the HDS-on-HDF5 / NDF spec.
17Notes on mask routing
18---------------------
19NDF serialization stores ``Mask`` arrays as a 3-D ``uint8`` DATA primitive
20whose HDS axes are ``(x, y, mask-byte)``. The HDF5 dataset shape is reversed
21from that, following hds-v5 convention. It also writes a 2-D ``QUALITY``
22view: single-byte masks are copied directly, while wider masks collapse to
230/1 values.
24"""
26from __future__ import annotations
28import numpy as np
29import pytest
31from lsst.images import Box, Image, MaskedImage, MaskPlane, MaskSchema
32from lsst.images.tests import RoundtripNdf
34try:
35 import h5py
37 from lsst.images.ndf import _hds
39 HAVE_H5PY = True
40except ImportError:
41 HAVE_H5PY = False
44skip_no_h5py = pytest.mark.skipif(not HAVE_H5PY, reason="h5py is not installed")
47def _cls(node: h5py.Group) -> str:
48 """Return the HDS type (CLASS attribute) of an h5py group as a
49 Python str.
50 """
51 val = node.attrs.get(_hds.ATTR_CLASS)
52 if val is None: 52 ↛ 54line 52 didn't jump to line 54 because the condition on line 52 was never true
53 # Legacy fallback used by older HDS variants.
54 val = node.attrs.get("HDSTYPE")
55 if isinstance(val, bytes): 55 ↛ 57line 55 didn't jump to line 57 because the condition on line 55 was always true
56 return val.decode("ascii")
57 return str(val)
60def _hds_type(dataset: h5py.Dataset) -> str:
61 """Return the HDS primitive type string inferred from a dataset's
62 numpy dtype.
63 """
64 dataset_type = dataset.id.get_type()
65 if dataset_type.get_class() == h5py.h5t.BITFIELD:
66 return "_LOGICAL"
67 return _hds.hds_type_for_dtype(dataset.dtype)
70def _hds_shape(dataset: h5py.Dataset) -> tuple[int, ...]:
71 """Return the dataset shape in HDS/Fortran axis order."""
72 return tuple(reversed(dataset.shape))
75@skip_no_h5py
76def test_image_layout() -> None:
77 """Verify the on-disk layout produced by ``ndf.write()`` for a plain
78 Image.
79 """
80 image = Image(
81 np.arange(20, dtype=np.float32).reshape(4, 5),
82 bbox=Box.factory[10:14, 20:25],
83 )
84 with RoundtripNdf(image) as roundtrip:
85 f = roundtrip.inspect()
86 # Root group carries CLASS="NDF".
87 assert _cls(f["/"]) == "NDF"
89 # DATA_ARRAY is an ARRAY structure.
90 assert "DATA_ARRAY" in f
91 assert _cls(f["/DATA_ARRAY"]) == "ARRAY"
93 # DATA is a 2-D _REAL primitive whose shape matches the image.
94 assert "DATA" in f["/DATA_ARRAY"]
95 ds = f["/DATA_ARRAY/DATA"]
96 assert _hds_type(ds) == "_REAL"
97 assert ds.ndim == 2
98 assert ds.shape == image.array.shape
100 # ORIGIN stores bbox lower bounds as int64 in (x_min, y_min) order.
101 assert "ORIGIN" in f["/DATA_ARRAY"]
102 origin = f["/DATA_ARRAY/ORIGIN"][()]
103 assert origin.dtype == np.int64
104 assert int(origin[0]) == 20 # x_min from Box.factory[10:14, 20:25]
105 assert int(origin[1]) == 10 # y_min
107 # /MORE is the standard NDF extension container (EXT) and
108 # /MORE/LSST carries the type "LSST" matching its name.
109 assert "MORE" in f
110 assert _cls(f["/MORE"]) == "EXT"
111 assert "LSST" in f["/MORE"]
112 assert _cls(f["/MORE/LSST"]) == "LSST"
114 # Main JSON serialisation tree is present.
115 assert "JSON" in f["/MORE/LSST"]
118@skip_no_h5py
119def test_masked_image_compatible_mask_layout() -> None:
120 """Verify the on-disk layout for a MaskedImage whose mask fits in a
121 single byte.
123 Even though the mask schema has only 2 planes (which would fit in a single
124 NDF QUALITY byte), MaskedImage writes the native 3-D uint8 backing array
125 in ``/MORE/LSST/MASK`` and a direct 2-D copy in ``/QUALITY``.
126 """
127 planes = [MaskPlane("BAD", "Bad pixel"), MaskPlane("SAT", "Saturated")]
128 schema = MaskSchema(planes) # default dtype=uint8, mask_size=1
129 image = Image(
130 np.arange(20, dtype=np.float32).reshape(4, 5),
131 bbox=Box.factory[10:14, 20:25],
132 )
133 # Pass an explicit float64 Image as variance so we can verify _DOUBLE
134 # on disk (the default variance is float32, matching the image dtype).
135 variance = Image(np.ones((4, 5), dtype=np.float64), bbox=image.bbox)
136 masked = MaskedImage(image, mask_schema=schema, variance=variance)
137 masked.mask.set("BAD", image.array % 2 == 0)
138 masked.mask.set("SAT", image.array > 10)
140 with RoundtripNdf(masked) as roundtrip:
141 f = roundtrip.inspect()
142 assert "QUALITY" in f
143 assert _cls(f["/QUALITY"]) == "QUALITY"
144 assert _cls(f["/QUALITY/QUALITY"]) == "ARRAY"
145 quality_ds = f["/QUALITY/QUALITY/DATA"]
146 assert _hds_type(quality_ds) == "_UBYTE"
147 assert quality_ds.shape == image.array.shape
148 assert _hds_shape(quality_ds) == (image.array.shape[1], image.array.shape[0])
149 np.testing.assert_array_equal(quality_ds[()], masked.mask.array[:, :, 0])
150 quality_origin = f["/QUALITY/QUALITY/ORIGIN"]
151 assert _hds_type(quality_origin) == "_INTEGER"
152 assert list(quality_origin[()]) == [20, 10]
153 bad_pixel = f["/QUALITY/QUALITY/BAD_PIXEL"]
154 assert _hds_type(bad_pixel) == "_LOGICAL"
155 assert not bad_pixel[()]
156 assert f["/QUALITY/BADBITS"][()] == 255
158 # /MORE/LSST/MASK is a sub-NDF (CLASS="NDF") with a
159 # canonical DATA_ARRAY structure containing DATA + ORIGIN.
160 assert "MORE" in f
161 assert "LSST" in f["/MORE"]
162 assert "MASK" in f["/MORE/LSST"]
163 assert _cls(f["/MORE/LSST/MASK"]) == "NDF"
164 assert _cls(f["/MORE/LSST/MASK/DATA_ARRAY"]) == "ARRAY"
165 mask_ds = f["/MORE/LSST/MASK/DATA_ARRAY/DATA"]
166 assert _hds_type(mask_ds) == "_UBYTE"
167 assert mask_ds.ndim == 3
168 assert mask_ds.shape == (1, 4, 5)
169 assert _hds_shape(mask_ds) == (5, 4, 1)
170 origin = f["/MORE/LSST/MASK/DATA_ARRAY/ORIGIN"]
171 assert origin.dtype == np.int64
172 # The mask shares the parent image's bbox; the trailing mask
173 # byte axis keeps a zero origin.
174 assert list(origin[()]) == [20, 10, 0]
175 bad_pixel = f["/MORE/LSST/MASK/DATA_ARRAY/BAD_PIXEL"]
176 assert _hds_type(bad_pixel) == "_LOGICAL"
177 assert not bad_pixel[()]
179 # VARIANCE is an ARRAY structure whose DATA is _DOUBLE (float64).
180 assert "VARIANCE" in f
181 assert _cls(f["/VARIANCE"]) == "ARRAY"
182 assert "DATA" in f["/VARIANCE"]
183 assert _hds_type(f["/VARIANCE/DATA"]) == "_DOUBLE"
186@skip_no_h5py
187def test_masked_image_incompatible_mask_layout() -> None:
188 """Verify the on-disk layout for a MaskedImage with more than 8
189 mask planes.
191 A 12-plane uint8 mask has ``mask_size=2`` (two bytes per pixel), and the
192 on-disk HDS axes are ``(x, y, mask-byte)``.
193 """
194 planes = [MaskPlane(f"P{i}", f"Plane {i}") for i in range(12)]
195 schema = MaskSchema(planes) # default uint8; mask_size = ceil(12/8) = 2
196 image = Image(
197 np.arange(20, dtype=np.float32).reshape(4, 5),
198 bbox=Box.factory[10:14, 20:25],
199 )
200 masked = MaskedImage(image, mask_schema=schema)
201 masked.mask.set("P0", image.array % 2 == 0)
202 masked.mask.set("P11", image.array > 10)
203 expected_quality = np.any(masked.mask.array != 0, axis=2).astype(np.uint8)
205 with RoundtripNdf(masked) as roundtrip:
206 f = roundtrip.inspect()
207 assert "QUALITY" in f
208 assert _cls(f["/QUALITY/QUALITY"]) == "ARRAY"
209 quality_ds = f["/QUALITY/QUALITY/DATA"]
210 assert _hds_type(quality_ds) == "_UBYTE"
211 assert quality_ds.shape == image.array.shape
212 np.testing.assert_array_equal(quality_ds[()], expected_quality)
213 assert f["/QUALITY/BADBITS"][()] == 255
215 # /MORE/LSST/MASK is a sub-NDF.
216 assert "MORE" in f
217 assert "LSST" in f["/MORE"]
218 assert "MASK" in f["/MORE/LSST"]
219 assert _cls(f["/MORE/LSST/MASK"]) == "NDF"
220 assert _cls(f["/MORE/LSST/MASK/DATA_ARRAY"]) == "ARRAY"
222 ds = f["/MORE/LSST/MASK/DATA_ARRAY/DATA"]
223 assert _hds_type(ds) == "_UBYTE"
224 assert ds.ndim == 3
225 rows, cols = image.array.shape
226 assert ds.shape == (2, rows, cols)
227 assert _hds_shape(ds) == (cols, rows, 2)
228 bad_pixel = f["/MORE/LSST/MASK/DATA_ARRAY/BAD_PIXEL"]
229 assert _hds_type(bad_pixel) == "_LOGICAL"
230 assert not bad_pixel[()]
233@skip_no_h5py
234def test_masked_image_many_plane_mask_layout() -> None:
235 """Verify the on-disk layout for a MaskedImage with more than 31 planes."""
236 planes = [MaskPlane(f"P{i}", f"Plane {i}") for i in range(40)]
237 schema = MaskSchema(planes)
238 image = Image(
239 np.arange(20, dtype=np.float32).reshape(4, 5),
240 bbox=Box.factory[10:14, 20:25],
241 )
242 masked = MaskedImage(image, mask_schema=schema)
243 masked.mask.set("P0", image.array % 2 == 0)
244 masked.mask.set("P17", image.array > 10)
245 masked.mask.set("P39", image.array == 19)
246 expected_quality = np.any(masked.mask.array != 0, axis=2).astype(np.uint8)
248 with RoundtripNdf(masked) as roundtrip:
249 f = roundtrip.inspect()
250 assert "QUALITY" in f
251 assert _cls(f["/QUALITY/QUALITY"]) == "ARRAY"
252 quality_ds = f["/QUALITY/QUALITY/DATA"]
253 assert _hds_type(quality_ds) == "_UBYTE"
254 assert quality_ds.shape == image.array.shape
255 np.testing.assert_array_equal(quality_ds[()], expected_quality)
256 assert f["/QUALITY/BADBITS"][()] == 255
257 ds = f["/MORE/LSST/MASK/DATA_ARRAY/DATA"]
258 assert _hds_type(ds) == "_UBYTE"
259 assert ds.ndim == 3
260 rows, cols = image.array.shape
261 assert ds.shape == (5, rows, cols)
262 assert _hds_shape(ds) == (cols, rows, 5)