Skip to content

Aij/photometry#123

Merged
capetillo merged 28 commits into
mainfrom
aij/photometry
Jul 7, 2026
Merged

Aij/photometry#123
capetillo merged 28 commits into
mainfrom
aij/photometry

Conversation

@capetillo

@capetillo capetillo commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

FEATURE: Aperture Photometry Operation

Background:
Rachel requested aperture photometry as an operation. In adding this operation, centroiding was refactored.

Implementation:

  • Adds an aperture photometry data operation that generates light curve rows, comp star metadata, diagnostics, and diagnostic JPEG for each input file with overlays where the target and comp stars are circled
  • Aperture photometry logic is broken up into utils for light curve generation, comp star selection, photometry logic, geometry, and diagnostics
  • Ranks comp stars using brightness similarity and proximity to the target (following AstroImageJ's weighting logic)
  • Refactors centroiding logic into a shared utility so both the centroiding endpoint and aperture photometry use the same centroiding logic

@jnation3406 jnation3406 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The only things that must be changed are removing fits_utils.py and using the already opened header/data in the InputDataHandler, and then some code reuse/removal stuff from the AI. More docstring comments would be nice, even if they are AI generated. I've also added some AI suggestions to some of the math heavy functions to simplify them and speed them up using numpy for math vectorization but you can ignore that if you are not interested.

@@ -0,0 +1,170 @@
from __future__ import annotations

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This isn't part of the main code path right? Just for your testing? If that is the case, I think you should put it in a separate subpackage like diagnostics or scripts or something.

Comment thread datalab/datalab_session/analysis/centroiding.py Outdated
@@ -0,0 +1,53 @@
from typing import Any, Mapping, Sequence

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This file should not exist, we already have this functionality elsewhere. Instead of passing around the InputDataHander.fits_file, pass the data handler itself. It contains an already open SCI header in .sci_hdu or SCI data in .sci_data. And you can also get the cat data out of the open file there with .get_hdu('CAT').data

log = logging.getLogger()
log.setLevel(logging.INFO)

PIXELCENTER = 0.5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would call this HALF_PIXEL

return source_max

## Fits a plane to the given points using least squares. Returns None if the points are degenerate.
def _fit_plane(points: list[tuple[float, float, float]]) -> PlaneModel | None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could just use numpy functions - pass in a numpy array of points instead:

def _fit_plane(points: list[tuple[float, float, float]]) -> PlaneModel | None:
  if len(points) < 4:
    return None
  pts = np.asarray(points, dtype=float)
  x, y, z = pts[:, 0], pts[:, 1], pts[:, 2]
  design = np.column_stack((np.ones_like(x), x, y))
  coeffs, _residuals, rank, _sv = np.linalg.lstsq(design, z, rcond=None)
  if rank < 3:            # degenerate / collinear -> matches the old pivot check
    return None
  return PlaneModel(*coeffs)

return float(ra), float(dec)


def _header_float(header: Mapping[str, Any], keys: tuple[str, ...], default: float) -> float:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This same function is defined in comparison_stars.py as well

pass


def _world_to_pixel(header: Mapping[str, Any], ra_deg: float, dec_deg: float) -> tuple[float, float]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This same function is defined in comparison_stars.py as well

raise LightCurveError("comparison_strategy must be 'auto' or 'variability_first'.")


def _load_and_validate_frames(fits_paths: Sequence[str]) -> list[FrameContext]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The image data already exists and is open in the InputDataHandler, as does the SCI header. Use those so we don't open them twice. You can also get the CAT header from it like I've described in another comment. This function is only used in one place so maybe doesn't need to be a function once its simplified.

return frames


def _parse_date_obs(value: Any, fits_path: str) -> datetime:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If you need to add date parsing I would just use dateutil.parse from the dateutil library. It covers many corner cases.

log.warning(f"rejected comparison candidate in {frame.fits_path}: {exc}")
rejected_for_target = 0
rejected_for_edge = 0
for row in rows:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I feel like this function, if it is a bottleneck, could be improved with numpy vectorization by pre-calculating the angular_distance for all rows and filtering them in/out by proximity to target with masks and stuff.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is definitely worth considering when we've got the algorithm stable - but maybe this should be its own later PR.

@capetillo
capetillo requested a review from jnation3406 June 11, 2026 16:36

@jnation3406 jnation3406 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Looks good, but I see a few of my original comments were never changed.

return brightness_weight * norm_brightness + distance_weight * norm_distance


_astroimagej_comparison_weight = astroimagej_comparison_weight

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I still don't understand this

class CentroidResult:
x: float
y: float
background: float

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

the background and peak are fields of the BackgroundModel so don't need to be included in the CentroidResult twice.


iteration -= 1

return CentroidResult(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It's easier to see here that you are including information twice

"net_source_counts": net_source,
"source_uncertainty": source_uncertainty,
"mean_background_per_pixel": mean_background_per_pixel,
"peak_pixel_value": background_model.source_peak,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this right? I would assume peak pixel is for the target not the background model

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Or maybe we have information in the background model that does not belong

capetillo and others added 5 commits June 23, 2026 15:54
… and

make calibration fixes.

This commit refines the aperture photometry feature:

- Target measurement never drops a frame: on centroid failure or a recenter
  drift beyond ~6px from the WCS position, measure at the WCS position (with the
  background re-estimated there). Keeps supernovae embedded in host-galaxy light.
- Per-frame arcsec aperture option: radii can be given in arcsec and are converted
  to pixels from each frame's plate scale, so a fixed on-sky aperture works across
  mixed telescopes and instruments.
- Comparison stars: require presence in >=80% of frames (not all) so heterogeneous
  multi-telescope sets still yield an ensemble; rank candidates by their own
  measured instrumental magnitude against the measured target (shared zero point).
  Drop inconsistent (e.g. blended) catalog matches before they bias the calibration.
- Parametrise background star rejection convergence for potential future use.

Further PR Review cleanups: remove unused imports, extract float-coercion helper,
drop the redundant CentroidResult.background field, convert leftover comment blocks
to docstrings, delete stray debug JPEGs from test_outputs.

Reproduces the NGC 7331 SN rp light curve to <=0.016 mag (pixel aperture) /
<=0.0045 mag (arcsec) across single- and multi-telescope frames.
Confirmed that aperture-photometry, utils, and analysis test suites pass.
- Reuse comparison-star measurements from ranking instead of re-measuring
- Guard zero target counts in calibration (was a ZeroDivisionError)
- Remove the unused comparison_strategy parameter
- Refactor gain/read-noise header lookups into fits_metadata helpers

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds an Aperture Photometry feature to the DataLab session pipeline, including a new data operation that produces calibrated light-curve rows, comparison-star metadata, and per-frame diagnostics/overlay JPEGs, while refactoring centroiding into shared utilities.

Changes:

  • Introduces an aperture photometry light-curve generation pipeline (target measurement, comparison-star selection, calibration, diagnostics).
  • Refactors centroiding into a shared utils.centroiding module and updates the existing centroiding analysis endpoint to use it.
  • Adds new utilities (photometry, geometry, FITS metadata helpers) plus extensive unit/integration tests for the new behavior.

Reviewed changes

Copilot reviewed 13 out of 16 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
datalab/datalab_session/utils/photometry.py Adds aperture photometry measurement + fractional pixel overlap helper.
datalab/datalab_session/utils/photometry_diagnostics.py Adds diagnostic overlay JPEG generation and comparison-star validation diagnostics.
datalab/datalab_session/utils/geometry.py Adds pixel/sky distance helpers used by comparison selection and clustering.
datalab/datalab_session/utils/fits_metadata.py Adds WCS/header helpers and aperture unit scaling (px vs arcsec).
datalab/datalab_session/utils/comparison_stars.py Adds comparison-star measurement and selection/ranking logic.
datalab/datalab_session/utils/centroiding.py Adds shared centroiding + background model implementation for reuse.
datalab/datalab_session/utils/aperture_light_curve.py Implements end-to-end aperture photometry light-curve pipeline + diagnostics output.
datalab/datalab_session/tests/test_utils.py Adds unit tests for new geometry/photometry/plane-fit helpers.
datalab/datalab_session/tests/test_operations.py Adds operation-level tests for the new Aperture Photometry operation wiring.
datalab/datalab_session/tests/test_aperture_photometry.py Adds comprehensive pipeline tests (synthetic frames + real FITS fixtures).
datalab/datalab_session/tests/test_analysis.py Updates centroiding tests for the refactored centroiding result shape.
datalab/datalab_session/data_operations/aperture_photometry.py Adds new data operation that runs the pipeline and formats output for the frontend.
datalab/datalab_session/analysis/centroiding.py Switches analysis endpoint to call the shared centroiding utility.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +621 to +625
"""
Builds comp star candidates from the source catalogs across valid frames.

Returns candidates that are present in all frames and are not too close to the target or the edge of the image.
"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

done

"second_hdu_magnitude": float(np.median(np.asarray(cluster["mags"], dtype=float))),
"source_catalog_by_frame": dict(cluster["source_catalog_by_frame"]),
"frame_coverage": len(cluster["frame_paths"]),
"isolation_px": isolation,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

done

Comment thread datalab/datalab_session/data_operations/aperture_photometry.py Outdated
self.set_output(output, is_raw=True)
self.set_operation_progress(AperturePhotometry.PROGRESS_STEPS['OUTPUT_PERCENTAGE_COMPLETION'])
self.set_status('COMPLETED')
log.info(f"Aperture Photometry output: {output}")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

done

Comment on lines +34 to +37
bck_cnt = float(max(int(background_model.effective_pixels), 1))
if background_model.effective_pixels <= 0.0:
raise error_class("Background annulus does not contain any valid pixels.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

done

if background_model.effective_pixels <= 0.0:
raise error_class("Background annulus does not contain any valid pixels.")

mean_background_per_pixel = max(background_model.mean, 0.0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

documented only

- Rename isolation_px -> isolation_arcsec (field held arcsec)
- Guard non-positive/non-finite gain in measure_aperture
- Log an output summary instead of the full payload
- Fix stale coverage docstring, document constant background, typo
@capetillo
capetillo merged commit 0687e1a into main Jul 7, 2026
3 checks passed
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.

5 participants