Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changes/DM-54912.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added a `photometric_scaling` component to `VisitImage`.
8 changes: 7 additions & 1 deletion python/lsst/images/_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def __init__(
super().__init__(metadata)
if isinstance(array_or_fill, np.ndarray):
if dtype is not None:
array = np.array(array_or_fill, dtype=dtype)
array = np.array(array_or_fill, dtype=dtype, copy=None)
else:
array = array_or_fill
if bbox is None:
Expand Down Expand Up @@ -483,6 +483,12 @@ def _read_legacy_hdu(
unit: astropy.units.UnitBase | None = None
if (fits_unit := hdu.header.pop("BUNIT", None)) is not None:
unit = astropy.units.Unit(fits_unit, format="fits")
if opaque_metadata.get_instrumental_unit() == astropy.units.electron:
# Fix incorrect BUNIT='adu' in LSST preliminary_visit_image.
if unit == astropy.units.adu:
unit = astropy.units.electron
if unit == astropy.units.adu**2:
unit = astropy.units.electron**2
dx: int = hdu.header.pop("LTV1")
dy: int = hdu.header.pop("LTV2")
start = YX(y=-dy, x=-dx)
Expand Down
2 changes: 1 addition & 1 deletion python/lsst/images/_mask.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ def __init__(
if start is not None:
start = tuple(start)
if isinstance(array_or_fill, np.ndarray):
array = np.array(array_or_fill, dtype=schema.dtype)
array = np.array(array_or_fill, dtype=schema.dtype, copy=None)
if array.ndim != 3:
raise ValueError("Mask array must be 3-d.")
if bbox is None:
Expand Down
8 changes: 5 additions & 3 deletions python/lsst/images/_masked_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ def read_fits(cls, url: ResourcePathExpression, *, bbox: Box | None = None) -> M
def from_legacy(
legacy: Any,
*,
unit: astropy.units.Unit | None = None,
unit: astropy.units.UnitBase | None = None,
plane_map: Mapping[str, MaskPlane] | None = None,
) -> MaskedImage:
"""Convert from an `lsst.afw.image.MaskedImage` instance.
Expand Down Expand Up @@ -426,13 +426,15 @@ def _read_legacy_hdus(
hdu_list: astropy.io.fits.HDUList,
uri: ResourcePathExpression,
*,
opaque_metadata: fits.FitsOpaqueMetadata | None = None,
preserve_quantization: bool = False,
plane_map: Mapping[str, MaskPlane] | None = None,
component: Literal["image", "mask", "variance"] | None,
fits_wcs_frame: Frame | None = None,
) -> Any:
opaque_metadata = fits.FitsOpaqueMetadata()
opaque_metadata.extract_legacy_primary_header(hdu_list[0].header)
if opaque_metadata is None:
opaque_metadata = fits.FitsOpaqueMetadata()
opaque_metadata.extract_legacy_primary_header(hdu_list[0].header)
image_bintable_hdu: astropy.io.fits.BinTableHDU | None = None
variance_bintable_hdu: astropy.io.fits.BinTableHDU | None = None
result: Any
Expand Down
222 changes: 215 additions & 7 deletions python/lsst/images/_visit_image.py

Large diffs are not rendered by default.

55 changes: 48 additions & 7 deletions python/lsst/images/fields/_chebyshev.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,12 +250,32 @@ def _get_archive_tree_type(

@staticmethod
def from_legacy(
legacy: LegacyChebyshevBoundedField, unit: astropy.units.UnitBase | None = None
legacy: LegacyChebyshevBoundedField,
unit: astropy.units.UnitBase | None = None,
bounds: Bounds | None = None,
) -> ChebyshevField:
"""Convert from a legacy `lsst.afw.math.ChebyshevBoundedField`."""
return ChebyshevField(
bounds=Box.from_legacy(legacy.getBBox()), coefficients=legacy.getCoefficients(), unit=unit
)
"""Convert from a legacy `lsst.afw.math.ChebyshevBoundedField`.

Parameters
----------
legacy
Legacy field to convert.
unit
The units of the returned field (`lsst.afw.math.BoundedField`
objects do not know their units).
bounds
The bounds of the returned field, if they should be different from
the bounding box of ``legacy``.
"""
bbox = Box.from_legacy(legacy.getBBox())
if bounds is not None:
if bounds.bbox != bbox:
raise ValueError(
"Custom bounds when converting a ChebyshevBoundedField must not change the bbox."
)
else:
bounds = bbox
return ChebyshevField(bounds=bounds, coefficients=legacy.getCoefficients(), unit=unit)

def to_legacy(self) -> LegacyChebyshevBoundedField:
"""Convert to a legacy `lsst.afw.math.ChebyshevBoundedField`."""
Expand All @@ -266,9 +286,22 @@ def to_legacy(self) -> LegacyChebyshevBoundedField:
@staticmethod
def from_legacy_background(
legacy_background: LegacyBackground,
bounds: Bounds | None = None,
unit: astropy.units.UnitBase | None = None,
) -> ChebyshevField:
"""Convert from a legacy `lsst.afw.math.BackgroundMI` instance."""
"""Convert from a legacy `lsst.afw.math.BackgroundMI` instance.

Parameters
----------
legacy
Legacy background object to convert.
bounds
The bounds of the returned field, if they should be different from
the bounding box of ``legacy_background``.
unit
The units of the returned field (`lsst.afw.math.Background`
objects do not know their units).
"""
from lsst.afw.math import ApproximateControl

approx_control = legacy_background.getBackgroundControl().getApproximateControl()
Expand All @@ -281,8 +314,16 @@ def from_legacy_background(
weight = None
x = legacy_background.getBinCentersX()
y = legacy_background.getBinCentersY()
bbox = Box.from_legacy(legacy_background.getImageBBox())
if bounds is not None:
if bounds.bbox != bbox:
raise ValueError(
"Custom bounds when converting a Chebyshev background must not change the bbox."
)
else:
bounds = bbox
return ChebyshevField.fit(
Box.from_legacy(legacy_background.getImageBBox()),
bounds,
stats_image.image.array,
x=x,
y=y,
Expand Down
86 changes: 82 additions & 4 deletions python/lsst/images/fields/_concrete.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,32 @@
"FieldSerializationModel",
"field_from_legacy",
"field_from_legacy_background",
"field_from_legacy_photo_calib",
)

from typing import TYPE_CHECKING, Annotated, Any

import astropy.units
import numpy as np
import pydantic

from .._geom import Bounds
from ._chebyshev import ChebyshevField, ChebyshevFieldSerializationModel
from ._product import ProductField, ProductFieldSerializationModel
from ._spline import SplineField, SplineFieldSerializationModel
from ._sum import SumField, SumFieldSerializationModel

if TYPE_CHECKING:
try:
from lsst.afw.image import PhotoCalib as LegacyPhotoCalib
from lsst.afw.math import BackgroundList as LegacyBackgroundList
from lsst.afw.math import BackgroundMI as LegacyBackground
from lsst.afw.math import BoundedField as LegacyBoundedField
except ImportError:
type LegacyBoundedField = Any # type: ignore[no-redef]
type LegacyBackground = Any # type: ignore[no-redef]
type LegacyBackgroundList = Any # type: ignore[no-redef]
type LegacyPhotoCalib = Any # type: ignore[no-redef]


# Since Sphinx can't handle doc links to type aliases, whenever we annotate
Expand All @@ -61,29 +66,55 @@


def field_from_legacy(
legacy_bounded_field: LegacyBoundedField, unit: astropy.units.UnitBase | None = None
legacy_bounded_field: LegacyBoundedField,
bounds: Bounds | None = None,
unit: astropy.units.UnitBase | None = None,
) -> Field:
"""Convert a legacy `lsst.afw.math.BoundedField` subclass to a `BaseField`
object.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same complaint here


Parameters
----------
legacy_bounded_field
Legacy field to convert.
bounds
The bounds of the returned field, if they should be different from
the bounding box of ``legacy``.
unit
The units of the returned field (`lsst.afw.math.BoundedField`
objects do not know their units).
"""
from lsst.afw.math import ChebyshevBoundedField, ProductBoundedField

match legacy_bounded_field:
case ChebyshevBoundedField():
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm 99% sure if you wanted to remove the (), you could just do afwMath.ChebyshevBoundedField , etc. Might make it clearer too, but the import is right above so not needed, just wanted to bring up the stylistic choice.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would actually turn this into an issubclass check instead of an isinstance check.

return ChebyshevField.from_legacy(legacy_bounded_field, unit=unit)
return ChebyshevField.from_legacy(legacy_bounded_field, unit=unit, bounds=bounds)
case ProductBoundedField():
return ProductField.from_legacy(legacy_bounded_field, unit=unit)
return ProductField.from_legacy(legacy_bounded_field, unit=unit, bounds=bounds)
case _:
raise NotImplementedError(
f"Conversion from {type(legacy_bounded_field).__name__} is not supported."
)


def field_from_legacy_background(
legacy_background: LegacyBackground | LegacyBackgroundList, unit: astropy.units.UnitBase | None = None
legacy_background: LegacyBackground | LegacyBackgroundList,
bounds: Bounds | None = None,
unit: astropy.units.UnitBase | None = None,
) -> Field:
"""Convert a legacy `lsst.afw.math.Background` or
`lsst.afw.math.BackgroundList` instance to a `BaseField` object.

Parameters
----------
legacy_background
Legacy background object to convert.
bounds
The bounds of the returned field, if they should be different from
the bounding box of ``legacy_background``.
unit
The units of the returned field (`lsst.afw.math.Background`
objects do not know their units).
"""
from lsst.afw.math import ApproximateControl, BackgroundList

Expand All @@ -95,3 +126,50 @@ def field_from_legacy_background(
return SplineField.from_legacy_background(legacy_background, unit=unit)
else:
return ChebyshevField.from_legacy_background(legacy_background, unit=unit)


def field_from_legacy_photo_calib(
legacy_photo_calib: LegacyPhotoCalib,
bounds: Bounds,
instrumental_unit: astropy.units.UnitBase = astropy.units.electron,
) -> Field | None:
"""Convert a legacy `lsst.afw.image.PhotoCalib` into a `BaseField` object.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I strongly dislike the name BaseField to represent some kind of unit conversion. It makes it seem like it is the base class instance for all Fields. Or is it that, and you didn't specialize anything?

Copy link
Copy Markdown
Member Author

@TallJimbo TallJimbo May 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is indeed the base class for all fields. It's confusing because BaseField is the ABC, while Field is a type alias to a union of all the concrete field types; we need to use the former in docs (because Sphinx can't handle type aliases) but we want to use the latter in annotations to get better static analysis.

In afw, PhotoCalib is a nonpolymorphic class that has a BoundedField, but it doesn't really add anything on top of it except an expectation of the units. And here all the field types have explicit units, so I decided to just not have that extra level of indirection.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but later if you need that concept in some other sense it gets ambiguous, and I can't really ask the interpreter (type) to understand what the thing is if I have one

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm just pretty confident we're not going to need that concept in the future. I don't think it's fundamentally different from using an Image to represent both [the image plane of] science data and a flat field.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And you can't think of anytime there would be Field types that would not be appropriate for being a photoclaib?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There might be some that wouldn't be very useful - e.g. since it's multiplicative, I don't really see a sum of fields ever being used. But I don't think there could ever be one that actually couldn't be used because it would be problematic. The whole point of the BaseField hierarchy is that you don't care what subclass you get, and we only have an enumerated closed hierarchy for serialization (i.e. because otherwise you need a plugin system to deserialize and that gets really messy).


Parameters
----------
legacy_photo_calib
Calibration object to convert.
bounds
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if it is not too late in api, I think it would be better if this was the same legacy, units, bounds ordering the other methods share, or switch them there, I don't care.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some real differences here:

  • a PhotoCalib doesn't always have a bbox for bounds to default to;
  • the units don't have the same meaning (see other thread; I'll rename to instrumental_unit here, too).

But it probably makes sense to at least make bounds the second argument everywhere.

Bounds of the returned field.
instrumental_unit
The instrumental units the legacy calibration transforms from. These
will be used as the denominator of the units of the returned field,
with ``astropy.units.nJy`` as the numerator.

Returns
-------
`BaseField` | `None`
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kind of feels awkward that some objects will have an attribute you can call, and sometimes it will be None (the object that consumes this). It means various code everywhere will contain a if obj.photometic_field is not None (or whatever variables are called. It feels like a very polluted api, but I'm not sure I have the solution at the moment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could pretty easily switch to a property that checks to see if the internal value is None and raises for you. If we think most of the time it will be present, that is probably a better interface.

In fact, allowing it to be None at all is necessary only because we don't generally have a useful PhotoCalib when converting a legacy visit_image to the new type (because it'll be a trivial one), and we need that butler auto-conversion to work (even if it can't be complete). But whenever we write new visit_image datasets in the new format, we'll make sure we have that information by pulling it from elsewhere. So someday we might be able to remove the possibility that it could be None entirely.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I gave this a shot - making the property do the check for None, I mean - and I think it's better to mostly leave it as-is; there are enough cases where it might be None that the try/except you'd need to do is worse, at least for now. What I have done is make it so you can't set it to None via the property. That's not worth a whole lot, but it's one less thing to have to deprecate when we can make them always not None (note that the property getter can be changed to always return not None without any deprecation).

A field that transforms instrumental units to ``nJy``, or `None` if
the given calibration object was an identity mapping for a legacy
image that already had ``nJy`` pixels.
"""
calibration_mean = legacy_photo_calib.getCalibrationMean()
if legacy_photo_calib._isConstant:
if calibration_mean == 1.0:
# This image's pixels have been calibrated to nJy
# already, which means the calibration *from* post-ISR
# electrons that we want is stored elsewhere.
return None
else:
return ChebyshevField(
bounds,
np.array([[calibration_mean]], dtype=np.float64),
unit=astropy.units.nJy / instrumental_unit,
)
else:
normalized_field = field_from_legacy(
legacy_photo_calib.computeScaledCalibration(),
unit=astropy.units.nJy / instrumental_unit,
bounds=bounds,
)
return normalized_field * calibration_mean
22 changes: 18 additions & 4 deletions python/lsst/images/fields/_product.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,28 @@ def _get_archive_tree_type(

@staticmethod
def from_legacy(
legacy: LegacyProductBoundedField, unit: astropy.units.UnitBase | None = None
legacy: LegacyProductBoundedField,
unit: astropy.units.UnitBase | None = None,
bounds: Bounds | None = None,
) -> ProductField:
"""Convert from a legacy `lsst.afw.math.ProductBoundedField`."""
"""Convert from a legacy `lsst.afw.math.ProductBoundedField`.

Parameters
----------
legacy
Legacy field to convert.
unit
The units of the returned field (`lsst.afw.math.BoundedField`
objects do not know their units).
bounds
The bounds of the returned field, if they should be different from
the bounding box of ``legacy``.
"""
from ._concrete import field_from_legacy

legacy_factors = legacy.getFactors()
operands = [field_from_legacy(f) for f in legacy_factors[:-1]]
operands.append(field_from_legacy(legacy_factors[-1], unit=unit))
operands = [field_from_legacy(f, bounds=bounds) for f in legacy_factors[:-1]]
operands.append(field_from_legacy(legacy_factors[-1], unit=unit, bounds=bounds))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like an awkward asymmetry and some weird special caring that units must happen to be on the last field.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They can be anywhere; the problem is picking an arbitrary one to add them to when we don't know anything else about what each operand represents.

return ProductField(operands)

def to_legacy(self) -> LegacyProductBoundedField:
Expand Down
18 changes: 17 additions & 1 deletion python/lsst/images/fields/_spline.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,22 @@ def _get_archive_tree_type(
@staticmethod
def from_legacy_background(
legacy_background: LegacyBackground,
bounds: Bounds | None = None,
unit: astropy.units.UnitBase | None = None,
) -> SplineField:
"""Convert from a legacy `lsst.afw.math.BackgroundMI` instance.

Parameters
----------
legacy
Legacy background object to convert.
bounds
The bounds of the returned field, if they should be different from
the bounding box of ``legacy_background``.
unit
The units of the returned field (`lsst.afw.math.Background`
objects do not know their units).

Notes
-----
`SplineField.render` and the `lsst.afw` background interpolator both
Expand All @@ -216,7 +228,11 @@ def from_legacy_background(
x = legacy_background.getBinCentersX()
y = legacy_background.getBinCentersY()
return SplineField(
Box.from_legacy(legacy_background.getImageBBox()), stats_image.image.array, x=x, y=y, unit=unit
Box.from_legacy(legacy_background.getImageBBox()) if bounds is None else bounds,
stats_image.image.array,
x=x,
y=y,
unit=unit,
)

def _make_1d_interpolator(self, loc: np.ndarray, val: np.ndarray) -> Akima1DInterpolator | None:
Expand Down
19 changes: 17 additions & 2 deletions python/lsst/images/fields/_sum.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,27 @@ def _get_archive_tree_type(
@staticmethod
def from_legacy_background(
legacy_background: LegacyBackgroundList,
bounds: Bounds | None = None,
unit: astropy.units.UnitBase | None = None,
) -> SumField:
"""Convert from a legacy `lsst.afw.math.BackgroundList` instance."""
"""Convert from a legacy `lsst.afw.math.BackgroundList` instance.

Parameters
----------
legacy
Legacy background object to convert.
bounds
The bounds of the returned field, if they should be different from
the bounding box of ``legacy_background``.
unit
The units of the returned field (`lsst.afw.math.BackgroundList`
objects do not know their units).
"""
from ._concrete import field_from_legacy_background

return SumField([field_from_legacy_background(b, unit) for b, *_ in legacy_background])
return SumField(
[field_from_legacy_background(b, bounds=bounds, unit=unit) for b, *_ in legacy_background]
)


class SumFieldSerializationModel(ArchiveTree):
Expand Down
9 changes: 9 additions & 0 deletions python/lsst/images/fits/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,15 @@ def subset(self, bbox: Box) -> FitsOpaqueMetadata:
# Docstring inherited.
return FitsOpaqueMetadata(headers=self.headers)

def get_instrumental_unit(self) -> astropy.units.UnitBase | None:
"""Extract the ``LSST ISR UNIT`` key from the primary header (if it
exists) and wrap it with Astropy.
"""
if (primary_header := self.headers.get(ExtensionKey())) is not None:
if (instrumental_unit_str := primary_header.get("LSST ISR UNITS")) is not None:
return astropy.units.Unit(instrumental_unit_str)
return None


def add_offset_wcs(header: astropy.io.fits.Header, *, x: int | float, y: int | float, key: str = "A") -> None:
"""Add a trivial FITS WCS to a header that applies the appropriate offset
Expand Down
Loading
Loading