Skip to content

DM-54556: Add VisitImage.to_legacy and supporting code.#44

Merged
TallJimbo merged 16 commits into
mainfrom
tickets/DM-54556
May 22, 2026
Merged

DM-54556: Add VisitImage.to_legacy and supporting code.#44
TallJimbo merged 16 commits into
mainfrom
tickets/DM-54556

Conversation

@TallJimbo
Copy link
Copy Markdown
Member

@TallJimbo TallJimbo commented May 20, 2026

Checklist

  • ran Jenkins
  • added a release note for user-visible changes to doc/changes

@codecov
Copy link
Copy Markdown

codecov Bot commented May 20, 2026

Codecov Report

❌ Patch coverage is 23.82979% with 179 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.30%. Comparing base (fa2e7ef) to head (58cfa82).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
python/lsst/images/_visit_image.py 15.73% 75 Missing ⚠️
tests/test_visit_image.py 2.38% 41 Missing ⚠️
python/lsst/images/fields/_base.py 30.76% 18 Missing ⚠️
tests/test_image.py 0.00% 10 Missing ⚠️
tests/test_mask.py 0.00% 10 Missing ⚠️
python/lsst/images/_observation_summary_stats.py 27.27% 8 Missing ⚠️
python/lsst/images/_image.py 50.00% 4 Missing ⚠️
tests/test_masked_image.py 62.50% 3 Missing ⚠️
python/lsst/images/fits/_common.py 77.77% 2 Missing ⚠️
python/lsst/images/psfs/_piff.py 60.00% 2 Missing ⚠️
... and 5 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #44      +/-   ##
==========================================
- Coverage   74.15%   73.30%   -0.86%     
==========================================
  Files          90       89       -1     
  Lines       10556    10718     +162     
==========================================
+ Hits         7828     7857      +29     
- Misses       2728     2861     +133     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@TallJimbo TallJimbo force-pushed the tickets/DM-54556 branch 2 times, most recently from d25761e to 46884ab Compare May 21, 2026 19:15
Copy link
Copy Markdown
Member

@timj timj left a comment

Choose a reason for hiding this comment

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

Looks good.

Comment thread python/lsst/images/_visit_image.py Outdated
Comment thread python/lsst/images/_visit_image.py Outdated
Comment thread python/lsst/images/_visit_image.py Outdated
Comment thread python/lsst/images/tests/_checks.py Outdated

@property
def is_constant(self) -> bool:
return (self._data == self._data[0, 0]).all()
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.

Maybe add a note that exact floating point comparison here is deliberate.

Comment thread python/lsst/images/fields/_base.py Outdated
pass
else:
# Image already has calibrated pixel units; return a constant.
# TODO[DM-54556]: make sure this shouldn't be 1/factor.
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 assume this ticket is blocking us using this code for DP2 and prompt so will be fixed soon?

Copy link
Copy Markdown
Member Author

@TallJimbo TallJimbo May 22, 2026

Choose a reason for hiding this comment

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

Aha, no! That is this very ticket, and I forgot to go back and deal with it before sending out for review.

It actually can't come up at all right now; we'll always have factor == 1.0 when converting from afw. So it's kind of annoying that the extra generality of lsst.images creates this hypothetical problem. But it's fixed and tested now, and that extra testing revealed another problem due to an afw limitation (in field_to_legacy_photo_calib) that I've now fixed (and then squashed; sorry).

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.

Oops. I didn't notice it was this ticket 😄

Comment thread python/lsst/images/_visit_image.py Outdated
# legacy metadata conponent, to make sure we have everything.
if isinstance(self._opaque_metadata, FitsOpaqueMetadata):
result_info.getMetadata().update(self._opaque_metadata.headers[ExtensionKey()])
result_info.getMetadata().update(self.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.

In some sense it might be preferable to namespace the modern non-FITS metadata so that it can't overwrite a pre-existing FITS header.

So something like:

result_info.getMetadata().update({f"LSST IMAGE {k.upper()}": v for k, v in self.metadata.items()})

and then from_legacy can reverse that.

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.

Good idea! I've taken it a step further and mapped each item it to a pair of LSST IMAGES KEY {n} and LSST IMAGES VALUE {n} cards, so we can preserve case, too.

Testing this reminded me that I hadn't been thinking of round-tripping VisitImage through the Exposure file format as a goal, because I don't think we necessarily need it for the Prompt or DP2 transition. That revealed a few new issues related to unit conversion (we have to fight Astropy to let us read and write non-standard BUNIT values) and data IDs (since rewriting should drop the LSST BUTLER DATAID keys, but we want those on read). Please take a look at the two new commits.

Comment thread python/lsst/images/fields/_base.py Outdated
pass
else:
# Image already has calibrated pixel units; return a constant.
# TODO[DM-54556]: make sure this shouldn't be 1/factor.
Copy link
Copy Markdown
Member Author

@TallJimbo TallJimbo May 22, 2026

Choose a reason for hiding this comment

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

Aha, no! That is this very ticket, and I forgot to go back and deal with it before sending out for review.

It actually can't come up at all right now; we'll always have factor == 1.0 when converting from afw. So it's kind of annoying that the extra generality of lsst.images creates this hypothetical problem. But it's fixed and tested now, and that extra testing revealed another problem due to an afw limitation (in field_to_legacy_photo_calib) that I've now fixed (and then squashed; sorry).

Comment thread python/lsst/images/_visit_image.py Outdated
# legacy metadata conponent, to make sure we have everything.
if isinstance(self._opaque_metadata, FitsOpaqueMetadata):
result_info.getMetadata().update(self._opaque_metadata.headers[ExtensionKey()])
result_info.getMetadata().update(self.metadata)
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.

Good idea! I've taken it a step further and mapped each item it to a pair of LSST IMAGES KEY {n} and LSST IMAGES VALUE {n} cards, so we can preserve case, too.

Testing this reminded me that I hadn't been thinking of round-tripping VisitImage through the Exposure file format as a goal, because I don't think we necessarily need it for the Prompt or DP2 transition. That revealed a few new issues related to unit conversion (we have to fight Astropy to let us read and write non-standard BUNIT values) and data IDs (since rewriting should drop the LSST BUTLER DATAID keys, but we want those on read). Please take a look at the two new commits.

visit = _extract_or_check_header("LSST BUTLER DATAID VISIT", visit, primary_header, None, int)
visit = _extract_or_check_header(
"LSST BUTLER DATAID VISIT", visit, primary_header, obs_info.visit_id, int
)
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.

@timj I had to change this to get the visit ID from the ObservationInfo when LSST BUTLER DATAID VISIT isn't (as is the case in the tests when we write a VisitImage as an lsst.afw.image.Exposure, at least when that uses a data ID that doesn't have "visit" in it. I wanted to make sure this is okay, since it seemed like you might have had a reason for not falling back to it before.

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.

Oh. Right. I think you have to use obs_info.exposure_id because the ObservationInfo visit_id is not what you actually want and now we know that visi ID and exposure ID are meant to be the same.

Copy link
Copy Markdown
Member

@timj timj left a comment

Choose a reason for hiding this comment

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

The new metadata header writing looks good to me.

Comment thread python/lsst/images/fits/_common.py Outdated
strip_butler_cards(primary_header)
metadata: dict[str, Any] = {}
for n in itertools.count():
if (key := header.pop(f"LSST IMAGES KEY {n + 1}", None)) is None:
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.

Technically a Astropy FITS Header can have a value of None but if you've written an undefined value into the header card you probably have other issues.

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.

Well, it's not a such a crazy thing to do if you consider the fact that we're trying to round-trip a dict that may well have a None value. I'll switch to ... as the sentinal value.

TallJimbo added 15 commits May 22, 2026 14:39
Since these aren't typed upstream, this does very little for static
analysis, but Sphinx uses these types, too, and it's just a better
pattern to establish.
None of this test would run without afw.
Since the concrete field types that can support to_legacy already do,
this just moves a type-analysis error into a runtime error (which is
what we want).  That in turn helps us add a PhotoCalib converter to
the base class.
'band' is a new required attribute.

'ID' is just stuffed into the metadata for now - we'd like to retire it
someday, but as long as we need to be able to roundtrip a legacy
VisitImage we'll need to carry it around.
This provides testing for code in obs_base.
All code using this module was removed on DM-54976.
This will inevitably drop background information and photometric
scalings attached to already-calibrated images (because there's nowhere
to put that information on a legacy Exposure), but it preserves
everything else.
@TallJimbo TallJimbo merged commit 9e1bbef into main May 22, 2026
15 of 17 checks passed
@TallJimbo TallJimbo deleted the tickets/DM-54556 branch May 22, 2026 19:12
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.

2 participants