Skip to content
Open
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: 0 additions & 1 deletion python/lsst/pipe/tasks/brightStarSubtraction/__init__.py

This file was deleted.

596 changes: 0 additions & 596 deletions python/lsst/pipe/tasks/extended_psf.py

This file was deleted.

2 changes: 2 additions & 0 deletions python/lsst/pipe/tasks/extended_psf/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .extended_psf_candidates import *
from .extended_psf_cutout import *
371 changes: 371 additions & 0 deletions python/lsst/pipe/tasks/extended_psf/extended_psf_candidates.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,371 @@
# This file is part of pipe_tasks.
#
# Developed for the LSST Data Management System.
# This product includes software developed by the LSST Project
# (https://www.lsst.org).
# See the COPYRIGHT file at the top-level directory of this distribution
# for details of code ownership.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

from __future__ import annotations

__all__ = [
"ExtendedPsfCandidateInfo",
"ExtendedPsfCandidateSerializationModel",
"ExtendedPsfCandidatesSerializationModel",
"ExtendedPsfCandidate",
"ExtendedPsfCandidates",
]

import functools
from collections.abc import Sequence
from types import EllipsisType
from typing import Any

from astro_metadata_translator import ObservationInfo
from pydantic import BaseModel, Field

from lsst.images import (
Box,
Image,
ImageSerializationModel,
Mask,
MaskedImage,
MaskedImageSerializationModel,
MaskSchema,
Projection,
fits,
)
from lsst.images.serialization import ArchiveTree, InputArchive, MetadataValue, OutputArchive, Quantity
from lsst.images.utils import is_none
from lsst.resources import ResourcePathExpression


class ExtendedPsfCandidateInfo(BaseModel):
"""Information about a star in an `ExtendedPsfCandidate`.

Attributes
----------
visit : `int`, optional
The visit during which the star was observed.
detector : `int`, optional
The detector on which the star was observed.
ref_id : `int`, optional
The reference catalog ID for the star.
ref_mag : `float`, optional
The reference magnitude for the star.
position_x : `float`, optional
The x-coordinate of the star in the focal plane.
position_y : `float`, optional
The y-coordinate of the star in the focal plane.
focal_plane_radius : `~lsst.images.utils.Quantity`, optional
The radius of the star from the center of the focal plane.
focal_plane_angle : `~lsst.images.utils.Quantity`, optional
The angle of the star in the focal plane, measured from the +x axis.
"""

visit: int | None = None
detector: int | None = None
ref_id: int | None = None
ref_mag: float | None = None
position_x: float | None = None
position_y: float | None = None
focal_plane_radius: Quantity | None = None
focal_plane_angle: Quantity | None = None

def __str__(self) -> str:
attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
return f"ExtendedPsfCandidateInfo({attrs})"

__repr__ = __str__
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If you define __repr__ instead, str will automatically delegate to it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

In this case, it seems not though. For my testing on an epc.star_info object, I have:

epc = epcs[0]
print(epc.star_info)
epc.star_info

returning:

visit=2025102600226 detector=178 ref_id=4980307157180392448 ref_mag=16.378769863064512 position_x=3485.5599400703168 position_y=2400.2426411600445 focal_plane_radius=<Quantity 300.59689709 mm> focal_plane_angle=<Quantity 1.52257155 rad>

ExtendedPsfCandidateInfo(visit=2025102600226, detector=178, ref_id=4980307157180392448, ref_mag=16.378769863064512, position_x=3485.5599400703168, position_y=2400.2426411600445, focal_plane_radius=<Quantity 300.59689709 mm>, focal_plane_angle=<Quantity 1.52257155 rad>)

It looks like the delegation rule is only true when the inherited implementation is object.str. I forgot why I did it this way originally, but revisiting it now, I realized that I don't like the default pydantic __str__, without any commas between the items, nor identifying the storage class name at the beginning.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ah, that makes sense; I wasn't think about whether the __str__ Pydantic provided.



class ExtendedPsfCandidateSerializationModel[P: BaseModel](MaskedImageSerializationModel[P]):
"""A Pydantic model to represent a serialized `ExtendedPsfCandidate`."""

psf_kernel_image: ImageSerializationModel[P] | None = Field(
default=None,
exclude_if=is_none,
description="Kernel image of the PSF at the cutout center.",
)
star_info: ExtendedPsfCandidateInfo = Field(description="Information about the star in the cutout.")


class ExtendedPsfCandidatesSerializationModel[P: BaseModel](ArchiveTree):
"""A Pydantic model to represent serialized `ExtendedPsfCandidates`."""

candidates: list[ExtendedPsfCandidateSerializationModel[P]] = Field(
default_factory=list,
description="The candidate cutouts in this collection.",
)


class ExtendedPsfCandidate(MaskedImage):
"""A cutout centered on a star, with associated metadata.

Parameters
----------
image : `~lsst.images.Image`
The main data image for this star cutout.
mask : `~lsst.images.Mask`, optional
Bitmask that annotates the main image's pixels.
variance : `~lsst.images.Image`, optional
Per-pixel variance estimates for the image.
mask_schema : `~lsst.images.MaskSchema`, optional
Schema for the mask, required if a mask is provided.
projection : `~lsst.images.Projection`, optional
Projection to map pixels to the sky.
obs_info : `~astro_metadata_translator.ObservationInfo`, optional
Standardized description of visit metadata.
metadata : `dict` [`str`, `MetadataValue`], optional
Additional metadata to associate with this cutout.
psf_kernel_image : `~lsst.images.Image`, optional
Kernel image of the PSF at the cutout center.
star_info : `ExtendedPsfCandidateInfo`, optional
Information about the star in the cutout.

Attributes
----------
psf_kernel_image : `~lsst.images.Image`
Kernel image of the PSF at the cutout center.
star_info : `ExtendedPsfCandidateInfo`
Information about the star in this cutout.
"""

def __init__(
self,
image: Image,
*,
mask: Mask | None = None,
variance: Image | None = None,
mask_schema: MaskSchema | None = None,
projection: Projection | None = None,
obs_info: ObservationInfo | None = None,
metadata: dict[str, MetadataValue] | None = None,
psf_kernel_image: Image | None = None,
star_info: ExtendedPsfCandidateInfo | None = None,
):
super().__init__(
image,
mask=mask,
variance=variance,
mask_schema=mask_schema,
projection=projection,
obs_info=obs_info,
metadata=metadata,
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It turns out Image and Mask can hold an ObservationInfo directly, too, so it's possible that this model is going to end up duplicating that information on serialization.

But looking deeper, this is mostly an upstream problem; we should be treating the ObservationInfo in MaskedImage more like we treat Projection - making sure we set it on all component images at construction, and then telling the components not to save it in serialize. And we're very inconsistent about that in both MaskedImage and VisitImage. I think if that got fixed, you could remove obs_info from your serialization model here, because you'd get one on the MaskedImage serialization model.

I'd like to get this all worked out before you start saving things in the new format, but I don't want to keep blocking you. Since I think you'll need an lsst.images branch for this ticket after all - for the frame issue - how about I add a commit fixing all of these upstream issues to that branch, and then adjusting here downstream?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That sounds good, please do make use of the prior lsst.images branch if helpful. More than happy to wait a little longer to get this right from the outset.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This led to a fair amount of simplification inside brightStarStamps.py beyond just obs_info, thank you.


self._psf_kernel_image = psf_kernel_image
self._star_info = star_info or ExtendedPsfCandidateInfo()

def __getitem__(self, bbox: Box | EllipsisType) -> ExtendedPsfCandidate:
if bbox is ...:
return self
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This if block needs to move before the super() call, I think. This was a common problem in the lsst.images types that we recently fixed.

super().__getitem__(bbox)
return self._transfer_metadata(
ExtendedPsfCandidate(
# Projection and obs_info propagate from the image.
self.image[bbox],
mask=self.mask[bbox],
variance=self.variance[bbox],
psf_kernel_image=self.psf_kernel_image,
star_info=self.star_info,
),
bbox=bbox,
)

def __str__(self) -> str:
return f"ExtendedPsfCandidate({self.image!s}, {list(self.mask.schema.names)}, {self.star_info})"

def __repr__(self) -> str:
return (
f"ExtendedPsfCandidate({self.image!r}, mask_schema={self.mask.schema!r}, "
f"star_info={self.star_info!r})"
)

@property
def psf_kernel_image(self) -> Image:
"""Kernel image of the PSF at the cutout center."""
if self._psf_kernel_image is None:
raise RuntimeError("No PSF kernel image is attached to this ExtendedPsfCandidate.")
return self._psf_kernel_image

@property
def star_info(self) -> ExtendedPsfCandidateInfo:
"""Return the ExtendedPsfCandidateInfo associated with this star."""
return self._star_info

def copy(self) -> ExtendedPsfCandidate:
"""Deep-copy the star cutout, metadata, and star info."""
return self._transfer_metadata(
ExtendedPsfCandidate(
image=self._image.copy(),
mask=self._mask.copy(),
variance=self._variance.copy(),
psf_kernel_image=self._psf_kernel_image,
star_info=self._star_info.model_copy(),
),
copy=True,
)

def serialize(self, archive: OutputArchive[Any]) -> ExtendedPsfCandidateSerializationModel:
masked_image_model = super().serialize(archive)
serialized_psf_kernel_image = (
archive.serialize_direct(
"psf_kernel_image",
functools.partial(self._psf_kernel_image.serialize, save_projection=False),
)
if self._psf_kernel_image is not None
else None
)
return ExtendedPsfCandidateSerializationModel(
**masked_image_model.model_dump(),
psf_kernel_image=serialized_psf_kernel_image,
star_info=self.star_info,
)

@staticmethod
def _get_archive_tree_type[P: BaseModel](
pointer_type: type[P],
) -> type[ExtendedPsfCandidateSerializationModel[P]]:
return ExtendedPsfCandidateSerializationModel[pointer_type]

@staticmethod
def deserialize(
model: ExtendedPsfCandidateSerializationModel[Any],
archive: InputArchive[Any],
*,
bbox: Box | None = None,
) -> ExtendedPsfCandidate:
masked_image = MaskedImage.deserialize(model, archive, bbox=bbox)
psf_kernel_image = (
Image.deserialize(model.psf_kernel_image, archive) if model.psf_kernel_image is not None else None
)
return ExtendedPsfCandidate(
masked_image.image,
mask=masked_image.mask,
variance=masked_image.variance,
psf_kernel_image=psf_kernel_image,
star_info=model.star_info,
)._finish_deserialize(model)


class ExtendedPsfCandidates(Sequence[ExtendedPsfCandidate]):
"""A collection of star cutouts.

Parameters
----------
candidates : `Iterable` [`ExtendedPsfCandidate`]
Collection of `ExtendedPsfCandidate` instances.
metadata : `dict` [`str`, `MetadataValue`], optional
Global metadata associated with the collection.

Attributes
----------
metadata : `dict` [`str`, `MetadataValue`]
Global metadata associated with the collection.
ref_id_map : `dict` [`int`, `ExtendedPsfCandidate`]
A mapping from reference IDs to `ExtendedPsfCandidate` objects.
Only includes candidates with valid reference IDs.
"""

def __init__(
self,
candidates: Sequence[ExtendedPsfCandidate],
metadata: dict[str, MetadataValue] | None = None,
):
self._candidates = list(candidates)
self._metadata = {} if metadata is None else dict(metadata)
self._ref_id_map = {
candidate.star_info.ref_id: candidate
for candidate in self
if candidate.star_info.ref_id is not None
}

def __len__(self):
return len(self._candidates)

def __getitem__(self, index):
if isinstance(index, slice):
return ExtendedPsfCandidates(self._candidates[index], metadata=self._metadata)
return self._candidates[index]

def __iter__(self):
return iter(self._candidates)

def __str__(self) -> str:
return f"ExtendedPsfCandidates(length={len(self)})"

__repr__ = __str__

@property
def metadata(self):
"""Return the collection's global metadata as a dict."""
return self._metadata

@property
def ref_id_map(self):
"""Map reference IDs to `ExtendedPsfCandidate` objects."""
return self._ref_id_map

@classmethod
def read_fits(cls, url: ResourcePathExpression) -> ExtendedPsfCandidates:
"""Read a collection from a FITS file.

Parameters
----------
url
URL of the file to read; may be any type supported by
`lsst.resources.ResourcePath`.
"""
return fits.read(cls, url).deserialized

def write_fits(self, filename: str) -> None:
"""Write the collection to a FITS file.

Parameters
----------
filename
Name of the file to write to. Must not already exist.
"""
fits.write(self, filename)

def serialize(self, archive: OutputArchive[Any]) -> ExtendedPsfCandidatesSerializationModel:
return ExtendedPsfCandidatesSerializationModel(
candidates=[
archive.serialize_direct(f"candidate_{index}", candidate.serialize)
for index, candidate in enumerate(self._candidates)
],
metadata=self._metadata,
)

@staticmethod
def deserialize(
model: ExtendedPsfCandidatesSerializationModel[Any],
archive: InputArchive[Any],
) -> ExtendedPsfCandidates:
return ExtendedPsfCandidates(
[
ExtendedPsfCandidate.deserialize(candidate_model, archive)
for candidate_model in model.candidates
],
metadata=model.metadata,
)

@staticmethod
def _get_archive_tree_type[P: BaseModel](
pointer_type: type[P],
) -> type[ExtendedPsfCandidatesSerializationModel[P]]:
return ExtendedPsfCandidatesSerializationModel[pointer_type]
Loading
Loading