Coverage for python/lsst/images/ndf/_input_archive.py: 81%
314 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-08 09:11 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-08 09:11 +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
14__all__ = ("NdfInputArchive", "read_starlink")
16import logging
17from collections.abc import Callable, Iterator
18from contextlib import contextmanager
19from types import EllipsisType
20from typing import IO, Any, Self
22import astropy.io.fits
23import astropy.table
24import astropy.units as u
25import h5py
26import numpy as np
28from lsst.resources import ResourcePath, ResourcePathExpression
30from .._geom import Box
31from .._image import Image
32from .._mask import Mask, MaskPlane, MaskSchema
33from .._masked_image import MaskedImage
34from .._transforms import FrameSet, SkyProjection
35from .._transforms import _ast as astshim
36from .._transforms._frames import GeneralFrame
37from ..fits._common import FitsOpaqueMetadata
38from ..serialization import (
39 ArchiveInfo,
40 ArchiveReadError,
41 ArchiveTree,
42 ArrayReferenceModel,
43 InlineArrayModel,
44 InputArchive,
45 TableModel,
46 no_header_updates,
47 parameterize_tree,
48 tree_class_for_info,
49)
50from ..serialization._backends import _is_binary_stream
51from ..serialization._common import _check_format_version
52from . import _hds
53from ._common import NdfPointerModel
54from ._model import HdsPrimitive, NdfDocument
56_LOG = logging.getLogger(__name__)
58_NDF_FORMAT_VERSION = 1
59"""Container layout version this release of `NdfInputArchive` understands."""
61_LSST_EXTENSION_PREFIXES = ("/MORE/LSST", "/LSST")
62"""Fixed locations of the LSST extension structure within an NDF.
64Only these top-level locations are searched; nested pointer trees can
65therefore never be mistaken for the main one.
66"""
69def _get_lsst_primitive(
70 get_primitive: Callable[[str], HdsPrimitive | None], name: str
71) -> HdsPrimitive | None:
72 """Find a named primitive in the LSST extension structure.
74 Parameters
75 ----------
76 get_primitive
77 Callable returning the primitive at an absolute path, or `None` if
78 there is no primitive there.
79 name
80 Component name within the LSST extension, e.g. ``DATA_MODEL``.
81 """
82 for prefix in _LSST_EXTENSION_PREFIXES:
83 if (primitive := get_primitive(f"{prefix}/{name}")) is not None:
84 return primitive
85 return None
88def _read_format_version(get_primitive: Callable[[str], HdsPrimitive | None]) -> int:
89 """Read the container-layout ``FORMAT_VERSION`` from the LSST extension.
91 Absence is treated as ``1`` (legacy default).
92 """
93 primitive = _get_lsst_primitive(get_primitive, "FORMAT_VERSION")
94 if primitive is None:
95 return 1
96 # The writer emits the version as a 0-d int32 numpy array; .item()
97 # unwraps to a Python int.
98 return int(primitive.read_array().item())
101def _read_archive_info(get_primitive: Callable[[str], HdsPrimitive | None], source: str) -> ArchiveInfo:
102 """Read the schema URL and format version from the LSST extension.
104 Parameters
105 ----------
106 get_primitive
107 Callable returning the primitive at an absolute path, or `None` if
108 there is no primitive there.
109 source
110 Description of the file being read, used in error messages.
111 """
112 schema_url: str | None = None
113 if (data_model := _get_lsst_primitive(get_primitive, "DATA_MODEL")) is not None: 113 ↛ 116line 113 didn't jump to line 116 because the condition on line 113 was always true
114 lines = data_model.read_char_array()
115 schema_url = lines[0].strip() if lines else None
116 if not schema_url: 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true
117 raise ArchiveReadError(
118 f"Could not read the schema of {source} from /MORE/LSST/DATA_MODEL or /LSST/DATA_MODEL."
119 )
120 return ArchiveInfo.from_schema_url(schema_url, format_version=_read_format_version(get_primitive))
123class NdfInputArchive(InputArchive[NdfPointerModel]):
124 """Reads HDS-on-HDF5 NDF files written by `NdfOutputArchive`.
126 Instances should only be constructed via the :meth:`open` context
127 manager.
129 Parameters
130 ----------
131 file
132 Open `h5py.File` handle. Owned by the caller of :meth:`open`;
133 the archive does not close it.
134 """
136 def __init__(self, file: h5py.File) -> None:
137 self._file = file
138 self._document = NdfDocument.from_hdf5(file)
139 self._opaque_metadata = FitsOpaqueMetadata()
140 self._deserialized_pointer_cache: dict[str, Any] = {}
141 self._frame_set_cache: dict[str, FrameSet] = {}
142 self._read_opaque_fits_metadata()
143 self._check_format_version()
145 @classmethod
146 def get_basic_info(cls, path: ResourcePathExpression) -> ArchiveInfo:
147 """Read the schema URL from the ``DATA_MODEL`` scalar and the
148 ``FORMAT_VERSION`` primitive without deserializing pixel data.
150 Reading the datasets directly from the HDF5 file avoids building
151 the full internal model, which would eagerly read the
152 (potentially large) JSON tree.
154 Parameters
155 ----------
156 path
157 Path to the archive to read.
158 """
159 ospath = ResourcePath(path).ospath
161 with h5py.File(ospath, "r") as handle:
163 def get_primitive(component_path: str) -> HdsPrimitive | None:
164 node = handle.get(component_path)
165 return HdsPrimitive.from_hdf5(node) if isinstance(node, h5py.Dataset) else None
167 return _read_archive_info(get_primitive, repr(path))
169 @classmethod
170 @contextmanager
171 def open_tree(
172 cls,
173 path: ResourcePathExpression | IO[bytes],
174 *,
175 partial: bool = True,
176 **backend_kwargs: Any,
177 ) -> Iterator[tuple[Self, ArchiveTree, ArchiveInfo]]:
178 """Open the NDF file and yield ``(archive, tree, info)``.
180 The schema is read from the open document's ``DATA_MODEL`` rather than
181 a separate `get_basic_info` open. Requires the symmetric LSST JSON
182 tree; ``partial`` is accepted but not meaningful, since h5py reads
183 lazily regardless.
185 Parameters
186 ----------
187 path
188 The file resource to open, or a seekable binary stream
189 containing the file's content.
190 partial
191 Accepted for interface compatibility but not meaningful; h5py
192 reads lazily regardless.
193 **backend_kwargs
194 Backend-specific options; none are currently used.
195 """
196 with cls.open(path) as archive:
197 if archive._get_main_json_path() is None: 197 ↛ 198line 197 didn't jump to line 198 because the condition on line 197 was never true
198 raise ArchiveReadError(
199 f"{path!r} has no LSST JSON tree; only the symmetric read path is supported."
200 )
201 info = archive.info
202 tree_cls = tree_class_for_info(info, path)
203 parameterized = parameterize_tree(tree_cls, NdfPointerModel)
204 tree = archive.get_tree(parameterized)
205 yield archive, tree, info
207 @classmethod
208 @contextmanager
209 def open(cls, path: ResourcePathExpression | IO[bytes]) -> Iterator[Self]:
210 """Open an NDF file for reading and yield an `NdfInputArchive`.
212 Remote ResourcePaths are materialised locally first; fsspec-direct
213 h5py reads are a deferred follow-up.
215 Parameters
216 ----------
217 path
218 Path to the NDF file to open, or a seekable binary stream
219 containing the file's content.
220 """
221 if _is_binary_stream(path):
222 with h5py.File(path, "r") as f:
223 yield cls(f)
224 return
225 rp = ResourcePath(path)
226 with rp.as_local() as local:
227 with h5py.File(local.ospath, "r") as f:
228 yield cls(f)
230 def get_tree[T: ArchiveTree](self, model_type: type[T]) -> T:
231 """Read and validate the main Pydantic tree at ``/MORE/LSST/JSON``.
233 Parameters
234 ----------
235 model_type
236 Archive tree model type to validate the JSON tree against.
237 """
238 json_path = self._get_main_json_path()
239 if json_path is None:
240 raise ArchiveReadError(
241 "File has no /MORE/LSST/JSON tree; this is either a "
242 "Starlink-only NDF (use ndf.read_starlink() for auto-detect) or "
243 "the file was written by an unrelated tool."
244 )
245 json_text = _read_json_record(self._get_primitive(json_path), json_path)
246 return model_type.model_validate_json(json_text)
248 def deserialize_pointer[U: ArchiveTree, V](
249 self,
250 pointer: NdfPointerModel,
251 model_type: type[U],
252 deserializer: Callable[[U, InputArchive[NdfPointerModel]], V],
253 ) -> V:
254 # Cache by pointer.path so repeated dereferences reuse the same
255 # deserialised result and don't re-run the deserializer.
256 if (cached := self._deserialized_pointer_cache.get(pointer.path)) is not None:
257 return cached
258 if not self._has_model_path(pointer.path): 258 ↛ 259line 258 didn't jump to line 259 because the condition on line 258 was never true
259 raise ArchiveReadError(f"Pointer reference {pointer.path!r} not found in NDF file.")
260 primitive = self._get_primitive(pointer.path)
261 json_text = _read_json_record(primitive, pointer.path)
262 model = model_type.model_validate_json(json_text)
263 result = deserializer(model, self)
264 self._deserialized_pointer_cache[pointer.path] = result
265 if isinstance(result, FrameSet):
266 self._frame_set_cache[pointer.path] = result
267 return result
269 def get_frame_set(self, pointer: NdfPointerModel) -> FrameSet:
270 try:
271 return self._frame_set_cache[pointer.path]
272 except KeyError:
273 raise AssertionError(
274 f"Frame set at {pointer.path!r} must be deserialised via "
275 f"deserialize_pointer before any dependent transform can be."
276 ) from None
278 def get_array(
279 self,
280 model: ArrayReferenceModel | InlineArrayModel,
281 *,
282 slices: tuple[slice, ...] | EllipsisType = ...,
283 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
284 ) -> np.ndarray:
285 if isinstance(model, InlineArrayModel):
286 data: np.ndarray = np.array(model.data, dtype=model.datatype.to_numpy())
287 return data if slices is ... else data[slices]
288 if not isinstance(model.source, str) or not model.source.startswith("ndf:"):
289 raise ArchiveReadError(
290 f"NdfInputArchive cannot resolve array source {model.source!r}; "
291 f"expected an 'ndf:<HDF5-path>' reference."
292 )
293 path = model.source[len("ndf:") :]
294 if not self._has_model_path(path): 294 ↛ 295line 294 didn't jump to line 295 because the condition on line 294 was never true
295 raise ArchiveReadError(f"Array reference {path!r} not in file.")
296 primitive = self._get_primitive(path)
297 # h5py supports lazy slicing via dataset[slices].
298 if isinstance(primitive.data, h5py.Dataset): 298 ↛ 300line 298 didn't jump to line 300 because the condition on line 298 was always true
299 return primitive.data[()] if slices is ... else primitive.data[slices]
300 data = primitive.read_array()
301 return data if slices is ... else data[slices]
303 def get_table(
304 self,
305 model: TableModel,
306 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
307 ) -> astropy.table.Table:
308 result = astropy.table.Table(meta=model.meta)
309 for column_model in model.columns:
310 if isinstance(column_model.data, InlineArrayModel): 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true
311 data: Any = column_model.data.data
312 else:
313 data = self.get_array(column_model.data, strip_header=strip_header)
314 result[column_model.name] = astropy.table.Column(
315 data,
316 name=column_model.name,
317 dtype=column_model.data.datatype.to_numpy(),
318 unit=column_model.unit,
319 description=column_model.description,
320 meta=column_model.meta,
321 )
322 return result
324 def get_structured_array(
325 self,
326 model: TableModel,
327 strip_header: Callable[[astropy.io.fits.Header], None] = no_header_updates,
328 ) -> np.ndarray:
329 return self.get_table(model, strip_header).as_array()
331 def _read_opaque_fits_metadata(self) -> None:
332 if not self._has_model_path("/MORE/FITS"):
333 return
334 cards = self._get_primitive("/MORE/FITS").read_char_array()
335 # FITS Header.fromstring expects fixed-width 80-char cards
336 # concatenated; pad each card defensively so readers tolerate
337 # files written with shorter widths.
338 header = astropy.io.fits.Header.fromstring("".join(c.ljust(80) for c in cards))
339 self._opaque_metadata.add_header(header, name="", ver=1)
341 def get_opaque_metadata(self) -> FitsOpaqueMetadata:
342 return self._opaque_metadata
344 @property
345 def info(self) -> ArchiveInfo:
346 """Schema/format info read from the open document's ``DATA_MODEL``
347 (`.serialization.ArchiveInfo`).
348 """
349 return _read_archive_info(self._get_optional_primitive, repr(self._file.filename))
351 def _get_main_json_path(self) -> str | None:
352 """Return the path of the main LSST JSON tree, if present."""
353 for prefix in _LSST_EXTENSION_PREFIXES:
354 path = f"{prefix}/JSON"
355 if self._has_model_path(path):
356 return path
357 return None
359 def _check_format_version(self) -> None:
360 """Read FORMAT_VERSION from the NDF top-level structure and check it.
362 DATA_MODEL is informational only on read; the JSON tree's
363 ``schema_version`` / ``min_read_version`` drive data-model
364 compatibility.
365 """
366 _check_format_version("ndf", _read_format_version(self._get_optional_primitive), _NDF_FORMAT_VERSION)
368 def _has_model_path(self, path: str) -> bool:
369 """Return `True` if a path exists in the NDF document model."""
370 try:
371 self._document.get(path)
372 except KeyError:
373 return False
374 return True
376 def _get_primitive(self, path: str) -> HdsPrimitive:
377 """Return a primitive component from the NDF document model."""
378 node = self._document.get(path)
379 if not isinstance(node, HdsPrimitive): 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true
380 raise ArchiveReadError(f"NDF reference {path!r} is not a primitive dataset.")
381 return node
383 def _get_optional_primitive(self, path: str) -> HdsPrimitive | None:
384 """Return a primitive from the document model, or `None` if there is
385 no primitive at ``path``.
386 """
387 try:
388 node = self._document.get(path)
389 except KeyError:
390 return None
391 return node if isinstance(node, HdsPrimitive) else None
394def read_starlink[T: Any](cls_: type[T], path: ResourcePathExpression) -> T:
395 """Reconstruct an `~lsst.images.Image` or `~lsst.images.MaskedImage`
396 from a schema-less Starlink NDF.
398 Files written by this package carry a ``/MORE/LSST/JSON`` tree and are
399 read through the generic `lsst.images.serialization.read` /
400 `lsst.images.serialization.open`. A Starlink-produced NDF has no such
401 tree and therefore no schema, so it cannot go through that path; this
402 function auto-detects a minimal recognised-component set
403 (``DATA_ARRAY``, ``VARIANCE``, ``QUALITY``, ``MORE.FITS``) instead.
404 ``WCS`` is reconstructed when possible; other components are
405 logged-and-dropped.
407 Parameters
408 ----------
409 cls_
410 Expected return type; `~lsst.images.Image` and
411 `~lsst.images.MaskedImage` are the only types the auto-detect path
412 can produce.
413 path
414 File path or `lsst.resources.ResourcePathExpression`.
416 Returns
417 -------
418 object
419 The deserialized ``cls`` instance.
421 Raises
422 ------
423 ArchiveReadError
424 If the file has an LSST JSON tree (use the generic ``read`` instead)
425 or no recognised ``DATA_ARRAY`` component.
426 """
427 with NdfInputArchive.open(path) as archive:
428 if archive._get_main_json_path() is not None: 428 ↛ 429line 428 didn't jump to line 429 because the condition on line 428 was never true
429 raise ArchiveReadError(
430 f"{path!r} has an LSST JSON tree; read it with serialization.read()/open()."
431 )
432 return _read_auto_detect(cls_, archive)
435def _read_auto_detect[T: Any](cls: type[T], archive: NdfInputArchive) -> T:
436 """Reconstruct an `Image` (or `MaskedImage`) from a Starlink NDF.
438 Recognised components: ``DATA_ARRAY`` (in either simple or complex
439 form), ``VARIANCE``, ``QUALITY``, ``MORE.FITS``. Other components
440 (``WCS``, ``HISTORY``, ``AXIS``, ``LABEL``, custom ``MORE.*``,
441 ``_LOGICAL`` primitives) are warned-and-dropped.
442 """
443 f = archive._file
444 ndf_group = _locate_ndf_root(f)
446 # DATA_ARRAY is required.
447 if "DATA_ARRAY" not in ndf_group:
448 raise ArchiveReadError(f"Auto-detect read of {f.filename!r}: no DATA_ARRAY component.")
449 data_arr, bbox = _read_data_array_with_bbox(ndf_group["DATA_ARRAY"])
451 # VARIANCE / QUALITY are optional.
452 variance_arr: np.ndarray | None = None
453 variance_bbox: Any | None = None
454 if "VARIANCE" in ndf_group:
455 variance_arr, variance_bbox = _read_data_array_with_bbox(ndf_group["VARIANCE"])
456 quality_arr: np.ndarray | None = None
457 quality_bbox: Any | None = None
458 quality_badbits = 255
459 if "QUALITY" in ndf_group and isinstance(ndf_group["QUALITY"], h5py.Group):
460 q = ndf_group["QUALITY"]
461 quality_badbits = _read_quality_badbits(q)
462 if "QUALITY" in q and isinstance(q["QUALITY"], h5py.Dataset): 462 ↛ 463line 462 didn't jump to line 463 because the condition on line 462 was never true
463 quality_arr = _validate_quality_array(_hds.read_array(q["QUALITY"]))
464 quality_bbox = _make_bbox(x_min=0, y_min=0, array=quality_arr)
465 elif "QUALITY" in q and isinstance(q["QUALITY"], h5py.Group): 465 ↛ 469line 465 didn't jump to line 469 because the condition on line 465 was always true
466 quality_arr, quality_bbox = _read_data_array_with_bbox(q["QUALITY"])
467 quality_arr = _validate_quality_array(quality_arr)
469 sky_projection: SkyProjection | None = None
470 if "WCS" in ndf_group:
471 try:
472 wcs_group = ndf_group["WCS"]
473 if isinstance(wcs_group, h5py.Group) and "DATA" in wcs_group: 473 ↛ 491line 473 didn't jump to line 491 because the condition on line 473 was always true
474 wcs_lines = _hds.read_char_array(wcs_group["DATA"])
475 wcs_text = _hds.decode_ndf_ast_data(wcs_lines)
476 ast_obj = astshim.Object.fromString(wcs_text)
477 if isinstance(ast_obj, astshim.FrameSet): 477 ↛ 491line 477 didn't jump to line 491 because the condition on line 477 was always true
478 pixel_frame = GeneralFrame(unit=u.pix)
479 sky_projection = SkyProjection.from_ast_frame_set(
480 ast_obj,
481 pixel_frame,
482 pixel_bounds=bbox,
483 )
484 except Exception:
485 _LOG.warning(
486 "Could not reconstruct Projection from WCS in %s; dropping.",
487 f.filename,
488 exc_info=True,
489 )
491 unit = _read_ndf_units(ndf_group)
493 # Anything unrecognised: warn-and-drop.
494 recognised = {
495 "DATA_ARRAY",
496 "VARIANCE",
497 "QUALITY",
498 "WCS",
499 "MORE",
500 "TITLE",
501 "LABEL",
502 "UNITS",
503 "HISTORY",
504 "AXIS",
505 }
506 for name in ndf_group:
507 if name not in recognised: 507 ↛ 508line 507 didn't jump to line 508 because the condition on line 507 was never true
508 _LOG.warning(
509 "Ignoring unrecognised NDF component %s/%s during auto-detect read.",
510 ndf_group.name,
511 name,
512 )
514 # Build the requested in-memory object. Any NDF can be read as an Image;
515 # MaskedImage construction uses whatever VARIANCE/QUALITY are present and
516 # lets the MaskedImage constructor provide defaults for missing planes.
517 image = Image(data_arr, bbox=bbox, unit=unit, sky_projection=sky_projection)
518 obj: Any
519 if cls is Image:
520 obj = image
521 elif issubclass(cls, MaskedImage):
522 if quality_arr is not None:
523 schema = _make_quality_mask_schema(quality_badbits)
524 mask = Mask(quality_arr[:, :, np.newaxis], schema=schema, bbox=quality_bbox)
525 else:
526 schema = MaskSchema([MaskPlane(name="BAD", description="Bad pixel.")])
527 mask = None
528 variance = Image(variance_arr, bbox=variance_bbox) if variance_arr is not None else None
529 obj = cls(
530 image=image,
531 mask=mask,
532 mask_schema=schema if mask is None else None,
533 variance=variance,
534 )
535 else:
536 raise ArchiveReadError(
537 f"Auto-detect can produce Image or MaskedImage, but caller asked for {cls.__name__}."
538 )
539 obj._opaque_metadata = archive.get_opaque_metadata()
540 return obj
543def _read_ndf_units(ndf_group: h5py.Group) -> u.UnitBase | None:
544 """Read the NDF UNITS component, if present."""
545 if "UNITS" not in ndf_group or not isinstance(ndf_group["UNITS"], h5py.Dataset):
546 return None
547 dataset = ndf_group["UNITS"]
548 if dataset.dtype.kind != "S": 548 ↛ 549line 548 didn't jump to line 549 because the condition on line 548 was never true
549 _LOG.warning("Ignoring non-character NDF UNITS component in %s.", ndf_group.name)
550 return None
551 if dataset.ndim == 0: 551 ↛ 559line 551 didn't jump to line 559 because the condition on line 551 was always true
552 raw = dataset[()]
553 if isinstance(raw, np.bytes_): 553 ↛ 555line 553 didn't jump to line 555 because the condition on line 553 was always true
554 raw = bytes(raw)
555 if not isinstance(raw, bytes): 555 ↛ 556line 555 didn't jump to line 556 because the condition on line 555 was never true
556 return None
557 units_text = raw.decode("ascii").rstrip(" ")
558 else:
559 records = _hds.read_char_array(dataset)
560 units_text = records[0] if records else ""
561 if not units_text: 561 ↛ 562line 561 didn't jump to line 562 because the condition on line 561 was never true
562 return None
563 for kwargs in ({"format": "fits"}, {}): 563 ↛ 568line 563 didn't jump to line 568 because the loop on line 563 didn't complete
564 try:
565 return u.Unit(units_text, **kwargs)
566 except ValueError:
567 continue
568 _LOG.warning("Could not parse NDF UNITS value %r in %s.", units_text, ndf_group.name)
569 return None
572def _read_quality_badbits(quality_group: h5py.Group) -> int:
573 """Read the scalar NDF QUALITY.BADBITS value."""
574 badbits = quality_group.get("BADBITS")
575 if not isinstance(badbits, h5py.Dataset): 575 ↛ 576line 575 didn't jump to line 576 because the condition on line 575 was never true
576 return 255
577 value = np.asarray(_hds.read_array(badbits)).reshape(-1)
578 if value.size == 0: 578 ↛ 579line 578 didn't jump to line 579 because the condition on line 578 was never true
579 return 255
580 return int(value[0])
583def _validate_quality_array(quality: np.ndarray) -> np.ndarray:
584 """Return an NDF QUALITY array as a `numpy.uint8` mask plane."""
585 if quality.dtype != np.dtype(np.uint8): 585 ↛ 586line 585 didn't jump to line 586 because the condition on line 585 was never true
586 raise ArchiveReadError(f"NDF QUALITY array has dtype {quality.dtype}; expected uint8.")
587 return quality
590def _make_quality_mask_schema(badbits: int) -> MaskSchema:
591 """Create a fallback `MaskSchema` for an unnamed 8-bit QUALITY array."""
592 planes = []
593 for bit in range(8):
594 mask = 1 << bit
595 description = f"NDF QUALITY bit {bit}."
596 if badbits & mask:
597 description += " Selected by BADBITS."
598 planes.append(MaskPlane(name=f"MASK{bit}", description=description))
599 return MaskSchema(planes, dtype=np.uint8)
602def _locate_ndf_root(f: h5py.File) -> h5py.Group:
603 """Return the group representing the top-level NDF.
605 Most files have the NDF at the root group itself. A few wrap it
606 in a single-child container at the root; we accept that shape
607 too. Anything more elaborate raises.
608 """
609 root_class = f["/"].attrs.get(_hds.ATTR_CLASS)
610 if isinstance(root_class, bytes):
611 root_class = root_class.decode("ascii")
612 if root_class == "NDF": 612 ↛ 615line 612 didn't jump to line 615 because the condition on line 612 was always true
613 return f["/"]
614 # Maybe a one-level container.
615 candidates = []
616 for name, child in f["/"].items():
617 if isinstance(child, h5py.Group):
618 cls_attr = child.attrs.get(_hds.ATTR_CLASS)
619 if isinstance(cls_attr, bytes):
620 cls_attr = cls_attr.decode("ascii")
621 if cls_attr == "NDF":
622 candidates.append(name)
623 if len(candidates) == 1:
624 return f[candidates[0]]
625 raise ArchiveReadError(
626 f"Could not locate top-level NDF in {f.filename!r}; "
627 f"expected the root group or a single NDF-typed child."
628 )
631def _read_data_array_with_bbox(
632 obj: h5py.Group | h5py.Dataset,
633) -> tuple[np.ndarray, Any]:
634 """Read a DATA_ARRAY component in either simple or complex form.
636 The complex form (what our writer always produces) is an HDS
637 ARRAY structure (h5py group with CLASS="ARRAY") containing
638 ``DATA`` and ``ORIGIN`` primitives. The simple form is a bare
639 primitive dataset.
641 Returns
642 -------
643 array, bbox : tuple
644 ``array`` is the C-order numpy data (shape ``(height, width)``
645 for 2D images). ``bbox`` is constructed from the ORIGIN if
646 present, else from a default origin of (0, 0).
647 """
648 if isinstance(obj, h5py.Dataset): 648 ↛ 650line 648 didn't jump to line 650 because the condition on line 648 was never true
649 # Simple form.
650 array = _hds.read_array(obj)
651 bbox = _make_bbox(x_min=0, y_min=0, array=array)
652 return array, bbox
653 # Complex form: an HDS structure with DATA + ORIGIN.
654 data = _hds.read_array(obj["DATA"])
655 if "ORIGIN" in obj:
656 origin = _hds.read_array(obj["ORIGIN"])
657 bbox = _make_bbox(x_min=int(origin[0]), y_min=int(origin[1]), array=data)
658 else:
659 bbox = _make_bbox(x_min=0, y_min=0, array=data)
660 return data, bbox
663def _read_json_record(primitive: HdsPrimitive, path: str) -> str:
664 """Read a JSON document stored as a single _CHAR*N record.
666 Our writer always emits JSON trees as a single-element character
667 array sized to the document. Joining multiple records would lose
668 trailing whitespace inside JSON string values, since
669 `read_char_array` strips trailing spaces per record.
670 """
671 records = primitive.read_char_array()
672 if len(records) != 1: 672 ↛ 673line 672 didn't jump to line 673 because the condition on line 672 was never true
673 raise ArchiveReadError(f"Expected a single _CHAR*N record at {path!r}, got {len(records)}.")
674 return records[0]
677def _make_bbox(*, x_min: int, y_min: int, array: np.ndarray) -> Any:
678 """Build an lsst.images.Box for a 2D image array.
680 The array is C-order ``(height, width)``. NDF stores ``ORIGIN``
681 in Fortran axis order ``(x_min, y_min)``.
682 """
683 if array.ndim != 2: 683 ↛ 684line 683 didn't jump to line 684 because the condition on line 683 was never true
684 raise ArchiveReadError(f"Auto-detect read only supports 2D arrays, got ndim={array.ndim}.")
685 # Box.from_shape takes (height, width) and start=(y_start, x_start).
686 return Box.from_shape(array.shape, start=(y_min, x_min))