Skip to content

DM-53757: Implement injecting into cell coadds - #75

Open
taranu wants to merge 16 commits into
mainfrom
tickets/DM-53757
Open

taranu wants to merge 16 commits into
mainfrom
tickets/DM-53757

Conversation

@taranu

@taranu taranu commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@taranu
taranu force-pushed the tickets/DM-53757 branch 2 times, most recently from 6787211 to 4cb6622 Compare September 9, 2026 04:14
Comment thread python/lsst/source/injection/inject_engine.py Outdated
Comment thread python/lsst/source/injection/inject_engine.py Outdated
Comment thread python/lsst/source/injection/inject_engine.py
# Normalize first
# TODO: Should also deal with negative pixel values here as they are
# not valid for a PSF.
psf_array /= np.sum(psf_array)

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.

I believe lsst.afw.detection.Psf already does this for you.

@taranu taranu Sep 9, 2026 •

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.

computeKernelImage does not, and neither does compute_kernel_image in lsst.images. I checked a handful of cells in a semi-random ECDFS coadd and the sum of the PSF arrays differed from unity by O(1e-4), and the sum of the negative-valued pixels was O(1e-3), with one particular example where it was almost -0.02. But that's an issue for another ticket.

fallback_psf = galsim_psf
except BoundsError:
# TODO: Should this ever happen? If it does, it probably
# means that cell_coadd.bounds.missing is wrong.

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.

AFAIK, this is indeed impossible. But it is possible to get a missing aperture correction in a cell that is not in cell_coadd.bounds.missing.

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.

Should I try to catch a different/broader exception, then?

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.

I lean towards not having a try/except block at all when I don't know the failure mode I'm looking for, because I hate missing real bugs more than I hate processing failures that would have been fine except "that one little thing". But there are arguments for both.

)
# Get the fallback PSF if available
# Even non-cell coadds could implement something similar
# Other exposures like single visits would be more work

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.

I'm curious what you need a fallback PSF for. I think we've got a PSF anywhere we've got data.

@taranu taranu Sep 9, 2026 •

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.

I added a longer comment about this, but slightly more briefly:

For cells, the fallback PSF is only used to compute the size of the box for injection, which could overlap with other cells with valid PSFs. For example, DP2 ended up with cells where all of the visits were rejected because of a saturated star masking out >50% of the pixels, but we could still try to inject an object with its centroid in the corner of one of these cells if the neighbouring cells are fine.

This might be worth making a configurable option, along with the existing fallback to get the PSF at the nearest edge of the bbox for centroids outside the patch bounds.

One argument in favour of trying to inject wherever possible for coadds is that the injection runs for each band are independent, and we don't want to give up on one band prematurely if the injection is going to be successful in another.

Comment thread python/lsst/source/injection/inject_engine.py Outdated
Comment thread python/lsst/source/injection/inject_engine.py
Comment thread python/lsst/source/injection/inject_engine.py Outdated
Comment thread python/lsst/source/injection/inject_engine.py Outdated
Comment thread python/lsst/source/injection/inject_engine.py Outdated
pixel_scale = wcs.getPixelScale(bbox.getCenter()).asArcseconds()

if is_cell:
all_bounds = []

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Setting this up with a proper type hint will help with a downstream linting error for:

conv = galsim.Convolve(object, bounds_info[3] / aperture_correction)

Something like:

all_bounds: list[
    tuple[galsim.BoundsI, tuple[CellIJ, float, float, galsim.InterpolatedImage] | None]
] = []

To fix it though you'll need to add an assert just before you call bounds_info[3].

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Longer-term, we should probably replace this with a named tuple or dict or something. Selecting random elements from a list downstream can be fraught with danger...

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.

I'm going to take an easier solution and just drop the other elements in the tuple since I'm not using them anymore.

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.

Well I immediately needed to add more items back in, so I guess it's a Pydantic dataclass now.

if object_common_bounds.area() > 0:
if is_cell:
# Use the per-cell PSF
conv = galsim.Convolve(object, bounds_info[3] / aperture_correction)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Adding an assert here removes a linting error:

assert bounds_info is not None

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.

Erm, what linting error? You mean from VSCode?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

A Pylance error:

Object of type "None" is not subscriptable

I think because it knows nothing about the form of all_bounds, but it does know that the second element can be set to None elsewhere, so it incorrectly infers the type here.

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.

I've changed this bit but if Pylance is still complaining, I'm not inclined to do anything about it if it's not running in pre-commit or GHA.

On that note, I had to update the Black version but we really should just drop it entirely in favour of ruff only. I can do that on another ticket since I had to modernize pre-commit/GH workflows for multiprofit recently.

psf_array = psf.computeKernelImage(contained_point).array
except InvalidParameterError:
# Get the PSF at the centroid of the object
galsim_psf_centroid, aperture_correction, psf_error_args = _get_galsim_psf(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I'm not sure this still holds true in cell-space, where the aperture correction can also vary per cell if I understand correctly? The aperture correction calculated is global, determined at psf.getAveragePosition().

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.

Well, I can apply the correction per-cell, but why was it using getAveragePosition in the first place?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

As for why it was using the average position before, I don't know. Most likely something lost in the noise during development.

Comment thread python/lsst/source/injection/inject_engine.py Outdated
Comment thread python/lsst/source/injection/utils/test_utils.py Outdated
Comment thread tests/test_inject_engine.py Outdated
flux0 = np.sum(self.exposure.image.array)
self._test_inject_galsim_objects_into_exposure(self.exposure, False)

def _test_inject_galsim_objects_into_exposure(self, exposure, is_cell: bool = True):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

is_cell doesn't appear to be used in this method?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

It also doesn't look like many of the cell-specific logic is being tested in this unit test. For example, you've set things up such that CellIJ(1, 1) is missing, but nothing here verifies that no sources were incorrectly skipped due to the missing cell. E.g., via a check of psf_compute_errors perhaps?

There's also no check of the mask conversion logic that's specific to cells, which we may also want to test.

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.

There's a bit of an issue here - with non-cell coadds, if injection fails it leaves draw_size as zero. With cells, however, it's possible to successfully draw in some cells but not others. It's trivial to keep track of the number of pixels that were actually drawn but this is <= draw_size as currently defined. Should we only return the former, or both?

If we only return the original, intended draw_size then it's more difficult to test that the correct thing happened (drawing into zero pixels). I think it's less useful to know how many pixels GalSim wanted to inject into vs how many actually were, but I can see someone wanting both.

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.

The existing tests are rather minimal too. We should be checking that INJECTED_CORE was set in the right places, that the number of INJECTED pixels and the flux within them is sensible, etc.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

draw_size is intended to be the GalSim requested box size along one axis. For info on how many pixels we actually touched, we can look at our mask planes.

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.

We don't have that per-object, though. I suppose one can compare draw_size^2 and the bounds in either case to see how much area was clipped/outside of the patch bbox, but with cells there's nothing reporting if an object was only injected into one cell vs the entire bounds.

Comment thread tests/test_inject_engine.py Outdated
@@ -150,7 +170,7 @@ def test_inject_galsim_objects_into_exposure(self):
pc = self.exposure.getPhotoCalib()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

You caught the self.exposure -> exposure bug below already (thanks!) - it looks like there's another one here just above.

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 is not a bug - cell coadds don't have a getPhotoCalib. But this should probably be moved to setUp since it only depends on self.exposure and self.pc.

add_noise: bool = True,
noise_seed: int = 0,
bad_mask_names: list[str] | None = None,
injection_core_size: float = 3.0,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This needs to be an int.

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.

It's kind of misleading, though. What it actually does is make a galsim Bounds that's a single pixel and then expand it by injection_core_size//2. So passing in 2 <= injection_core_size < 4 has the exact same result and 4 <= injection_core_size < 6 gives a 5x5 box, etc.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Right - it must be an odd number such that the centroid falls within a pixel. I believe this is documented on pipelines.lsst.io somewhere, but if not, it probably should be.

if logger:
logger.debug("No area overlap for object at %s; flagging and skipping.", sky_coords)
logger.debug(
"GalSimFFTSizeError%s raised for object at index=%d and coords %s;"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think the %s after GalSimFFTSizeError is a typo here.

# While None might perhaps have been a better choice here, changing the
# return type is unnecessary given that galsim.Shear can be initialized
# with no args and correctly returns a trivial shear.
if all(np.ma.is_masked(x) for x in shear_data.values()):

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.

FYI @leeskelvin I added this because I think it's correct behaviour, although it's debatable for non-DeltaFunction profiles.

Previously I think it was falling back to the underlying value of the masked shear columns for a DeltaFunction and getting a trivial shear and applying it. I don't think that's particularly reliable and it may be why some users were having issues (see here for example) though I was never able to replicate them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants