Skip to content

DM-54912: add photometric_scaling to VisitImage#39

Merged
TallJimbo merged 13 commits into
mainfrom
tickets/DM-54912
May 19, 2026
Merged

DM-54912: add photometric_scaling to VisitImage#39
TallJimbo merged 13 commits into
mainfrom
tickets/DM-54912

Conversation

@TallJimbo
Copy link
Copy Markdown
Member

@TallJimbo TallJimbo commented May 15, 2026

Checklist

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

@codecov
Copy link
Copy Markdown

codecov Bot commented May 15, 2026

Codecov Report

❌ Patch coverage is 17.46725% with 189 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.01%. Comparing base (7121ff8) to head (4a3c4db).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
tests/test_visit_image.py 19.04% 68 Missing ⚠️
python/lsst/images/_visit_image.py 19.75% 65 Missing ⚠️
python/lsst/images/tests/_checks.py 11.76% 15 Missing ⚠️
python/lsst/images/fields/_chebyshev.py 0.00% 11 Missing ⚠️
python/lsst/images/fields/_concrete.py 25.00% 9 Missing ⚠️
python/lsst/images/_image.py 16.66% 5 Missing ⚠️
python/lsst/images/fits/_common.py 20.00% 4 Missing ⚠️
...thon/lsst/images/tests/extract_legacy_test_data.py 0.00% 4 Missing ⚠️
python/lsst/images/_masked_image.py 0.00% 3 Missing ⚠️
python/lsst/images/fields/_product.py 0.00% 2 Missing ⚠️
... and 2 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #39      +/-   ##
==========================================
- Coverage   76.11%   75.01%   -1.10%     
==========================================
  Files          91       91              
  Lines       10607    10805     +198     
==========================================
+ Hits         8073     8105      +32     
- Misses       2534     2700     +166     

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

@TallJimbo
Copy link
Copy Markdown
Member Author

Note to self: if this lands before #37, update the docs there. If it lands after, update the docs here.

Copy link
Copy Markdown

@natelust natelust left a comment

Choose a reason for hiding this comment

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

There are lots of things to consider as quick design thoughts. I am pretty ok with whatever you come up with for an answer, but I wanted to bring things up before it gets baked in forever. I do think some of the nests of branches are a bit of a rats nest, but I am not sure if there is a create alternative.

Comment thread python/lsst/images/fields/_chebyshev.py Outdated
unit: astropy.units.UnitBase | None = None,
bounds: Bounds | None = None,
) -> ChebyshevField:
"""Convert from a legacy `lsst.afw.math.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 think these need proper docstrings, someone running this would have no idea what the other things are without reading the code.

bounds: Bounds | 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

@@ -70,9 +77,9 @@ def field_from_legacy(

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.

Comment thread python/lsst/images/fields/_concrete.py Outdated
def field_from_legacy_photo_calib(
legacy_photo_calib: LegacyPhotoCalib,
bounds: Bounds,
post_isr_unit: astropy.units.UnitBase = astropy.units.electron,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

It seems a little weird to bake this directly into the parameter name

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.

I've been wondering about calling it instrumental_unit instead, and it sounds like I should do that (note that I can't just a call it "unit" because the full unit is something like nJy/electron; it's just the denominator I'm trying to name).

bounds: Bounds,
post_isr_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).

compare_aperture_corrections_to_legacy(
tc, aperture_corrections, legacy_exposure.info.getApCorrMap(), tiny_bbox
)
if (photometric_scaling := alternates.get("photometic_scaling", ...)) is not ...:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Is this because None is a valid return type?

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.

Correct.

Comment thread tests/test_visit_image.py
legacy_exposure: Any
plane_map: dict[str, MaskPlane]
visit_image: VisitImage
unit: u.UnitBase
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Is this actually a mixing for final data classes, so you specify these as class attribute here, or are they really class attributes?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Or are you just setting these for typing reasons?

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.

Mostly just typing reasons (even though this file isn't formally type-checked, I like reducing editor squiggles where I can).

Comment thread tests/test_visit_image.py
Requires legacy code.
"""

@classmethod
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 seems like an extremely strange pattern of class creation. Why does this need to be class level, is it to stop multiple reads or something? Why does this not become standard class instantiation or something?

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.

This is how you get unittest to do test-setup only once (or at least only once per process). It is indeed to reduce I/O in this case.

I don't really know why unittest doesn't use constructors for initialization; presumably they wanted to use them for something else. But that's the pattern.

Comment thread python/lsst/images/_visit_image.py Outdated
return self._photometric_scaling

@photometric_scaling.setter
def photometric_scaling(self, value: Field | 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.

Is it really true all Field s are valid?

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.

Yes. This is a deliberate change from afw (all fields were valid there, too, but PhotoCalib added another level of indirection that I'm deliberately dropping).

Comment thread python/lsst/images/_visit_image.py Outdated
An image with the given units. Will always share most attributes
with self, with the `image` and `variance` copied only if they
need to be modified (i.e. they do not already have the right
units).
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A sometimes copied method is worse than a never copied or always copied method in my opinion, but I get it might be an efficiency thing, but I can see this becoming no end a source of bugs for us in the future.

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'd usually very much agree. I've done it this way here because I think it's going to be used almost all of the time in a

visit_image = visit_image.convert_unit(...)

sense where you blow away what you had before and are really wanting to simulate an in-place operation as much as possible for efficiency. But we can't do that directly because in-place changing the units of an array that might be shared is an even worse problem (IMO).

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 really not sure I agree on this one. I agree with the reasoning of what you said, but this is such a giant gaping hole in an otherwise really great api I think that something somewhere has been overlooked or there has to be a better pattern. I see what you said in your example, but I can also see someone doing:

converted_image = visit_image.conver_unit(...)
function_on_new(converted_image)  # mutating
use_old_image(visit_image)

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 think what I'm missing is a better alternative. The only one I can think of is to always copy everything by default, with a kwarg to switch back to the current behavior (which I want to preserve as an option because I know it's the right one for the use case I know about). That would make the behavior a lot more explicit, but more verbose in the usual case.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The only other thing I can think of is an abstraction with COW

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 tried a few things before things got to this stage, and I think I've convinced myself that COW is just impossible with Numpy.

Anyhow, I've switched to the kwarg control proposed above. I worry a bit that it's extra logic to maintain that won't get used (i.e. we'll always pass the options to make it behave like it was before), but even in that case it is easier to read the code and see what's going on.

@TallJimbo TallJimbo merged commit ef08fe4 into main May 19, 2026
17 of 19 checks passed
@TallJimbo TallJimbo deleted the tickets/DM-54912 branch May 19, 2026 21:45
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