Skip to content

ngmix: config-selectable UberSeg neighbour masking (BLEND_HANDLING)#770

Open
cailmdaley wants to merge 9 commits into
developfrom
ngmix-uberseg
Open

ngmix: config-selectable UberSeg neighbour masking (BLEND_HANDLING)#770
cailmdaley wants to merge 9 commits into
developfrom
ngmix-uberseg

Conversation

@cailmdaley

@cailmdaley cailmdaley commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Closes #776.

What

A config-selectable UberSeg neighbour treatment for the ngmix module, wired end-to-end and off by default:

  • BLEND_HANDLING = {noisefill, uberseg} (default noisefill — the historical behaviour, byte-for-byte unchanged, asserted in tests). uberseg hard-masks (weight → 0) every stamp pixel closer to a neighbour's segmentation footprint than to the central object's, leaving the image untouched — Erin Sheldon's MEDS/ngmix treatment.
  • The segmentation input, per @aguinot's answer on UberSeg neighbour masking (Erin Sheldon) — config-selectable, validated against noise-fill #776: the tile SExtractor run emits SEGMENTATION (one config line); a CLASSIC vignetmaker run cuts integer, non-interpolated seg stamps in the coadd frame; that single stamp is overlaid unchanged on every per-epoch stamp — no reprojection (UNIONS coadds carry no field rotation vs single exposures, so residual offsets are a few px at most).
  • DILATE_NEIGHBOUR (int, default 1): binary dilation of the neighbour footprints to absorb those few-px offsets. Geometrically self-targeting: for well-separated objects the Voronoi bisector already claims the whole neighbour side and dilation adds nothing; it only bites for close blends — exactly the offset-sensitive regime.
  • NGMIX_NEIGHBOUR_FLAG propagated to the final catalogue (does the seg stamp contain any non-central object?), enabling downstream systematics splits.

Design notes

  • uberseg_weight reimplements meds._uberseg.uberseg_tree (nearest-segment Voronoi partition) with a scipy cKDTree, validated bit-for-bit against Sheldon's reference get_uberseg (only measure-zero boundary ties differ). Reimplemented rather than depended on: esheldon/meds drags the fitsio/esutil stack for a single small algorithm.
  • The central object is identified by its SExtractor NUMBER (seg labels are NUMBERs from the same run) — robust to registration offsets, which could otherwise let a neighbour's label sit on the centre pixel and invert the mask. The centre-pixel label is kept as a per-object diagnostic (warns on mismatch; the object is dropped if its footprint is absent).
  • Seg catalogue row-alignment with the tile catalogue is asserted at load (fail fast on length or NUMBER mismatch).

Why (fitting, not shearing)

Neighbour handling matters for the model fit: masking strictly along one side of a galaxy makes the weight anisotropic and lets the fit be "pulled" from the unmasked side. UberSeg reduces that pull by construction; 4-fold weight symmetrisation (@aguinot's least-biased endpoint) removes it entirely and is the declared follow-on PR — it composes with, and is orthogonal to, this one. The metacal shearing side (discontinuities, noise-fill of flagged pixels) is untouched here.

Validation & default

Unit tests cover the mask geometry (vs brute force and vs Sheldon), byte-identity of the default, dilation additivity, the fail-fast guards, and the flag reaching the final catalogue (56 ngmix/make_cat tests; full module+unit sweep green). The production default stays noisefill until a real-data A/B (FLAGS=2-inclusive B-modes) says otherwise — that comparison is tracked separately and is not a merge condition for this dormant, default-unchanged capability.

— Claude, on behalf of Cail

A new ngmix-module option BLEND_HANDLING = {noisefill, uberseg} selects how
a neighbour sharing a galaxy's stamp is treated. The default `noisefill` is
byte-for-byte the historical behaviour (flagged pixels replaced by a noise
realisation, kept at inverse-variance weight); `uberseg` instead hard-masks
(weight -> 0) every pixel closer to a neighbour's segmentation footprint than
to the central object's, leaving the image untouched.

`uberseg_weight()` reimplements Erin Sheldon's MEDS uberseg (the
`meds._uberseg.uberseg_tree` nearest-segment Voronoi partition) with a
scipy cKDTree, rather than depending on `meds` whose import drags the full
fitsio/esutil stack. The surviving central core is a single connected,
roughly circular region — emergent geometry of the partition, not a separate
aperture, faithful to MEDS `get_uberseg`.

Plumbed runner -> Ngmix -> do_ngmix_metacal -> make_ngmix_observation ->
prepare_ngmix_weights; Postage_stamp gains a per-epoch `segs` list. The
segmentation-map *source* is not wired here (it is plumbing-gated: the seg
map is the coadd's, ngmix fits per-epoch stamps); selecting `uberseg`
without a seg map raises with a pointer to the gating issue.

Tests: synthetic two-object stamp asserts the mask geometry (neighbour-side
zeroed, central core single-connected, matches brute-force nearest-segment),
the noisefill default is byte-identical to the legacy noise-fill, and uberseg
leaves the image untouched while zeroing neighbour/flagged weight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018DLfuwQzavQQ9GbcKZQPc5
… rationale

Name Erin Sheldon, the esheldon/meds repo, the MacCrann/Zuntz lineage, and DES
use in the uberseg_weight docstring, and state that the reimplementation was
validated bit-for-bit against his reference get_uberseg. Correct the dependency
note: fitsio is already in the ShapePipe stack; esutil (+ a C-extension build)
is what import meds would add. The argument for reimplementing is proportionality
(a 5-line scipy cKDTree partition vs the whole MEDS file-format library), now
backed by the validation harness in the uberseg fiber.

Docstring-only; no behaviour change. Local checkpoint — not pushed (pending the
next session's read of Axel's comment).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K35XrdS1Bn8EKp4VJGYpp6
Base automatically changed from ngmix_v2.0 to develop July 6, 2026 06:39
cailmdaley and others added 7 commits July 12, 2026 22:07
# Conflicts:
#	src/shapepipe/modules/ngmix_package/ngmix.py
… flag

Populate the UberSeg segmentation transport end to end, closing the gap
where selecting BLEND_HANDLING=uberseg raised for lack of a seg source
(shapepipe#776).

- Tile_cat loads a coadd-frame segmentation VIGNET catalogue (seg_cat_path),
  one integer stamp per object, row-aligned to the tile catalogue. It is
  overlaid unchanged on every epoch (no per-epoch reprojection): prepare_
  postage_stamps copies the coadd seg onto each surviving epoch, MegaCam-
  flipped to match its galaxy stamp so the overlay stays registered.
- central_seg_label identifies the central object from the seg stamp's centre
  pixel rather than a plumbed SExtractor NUMBER, keeping the stamp self-
  describing and robust to a few-pixel coadd-vs-epoch offset. A sky centre
  pixel fails fast; the object is dropped by the per-object try/except.
- uberseg_weight gains dilate_neighbour: binary-dilation iterations enlarging
  the neighbour mask to absorb that offset. Additive over the validated
  Voronoi partition; dilate_neighbour=0 recovers the pure Sheldon mask.
- seg_has_neighbour computes a per-object blend flag (non-central footprint
  present), carried through compile_results into every shear HDU.
- Ngmix fails fast at construction when uberseg is selected without
  seg_cat_path, replacing the deep per-epoch error as the primary guard.

The default noisefill path is byte-for-byte unchanged; every new argument is
optional and ignored off the uberseg branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…maker run

Wire the coadd-seg UberSeg inputs through the runner and example pipeline.

- ngmix_runner reads SEG_VIGNET_PATH (optional, required for uberseg; missing
  file -> error) and DILATE_NEIGHBOUR (int, default 1), passing them to Ngmix.
- config_tile_Sx.ini emits the coadd SEGMENTATION check image alongside
  BACKGROUND (CLI CHECKIMAGE overrides the .sex file, so no .sex edit).
- config_tile_PiViVi_canfar_sx.ini adds VIGNETMAKER_RUNNER_RUN_3, a CLASSIC/PIX
  run cutting 51x51 integer seg stamps from segmentation{NUM}.fits at the tile
  catalogue centres — the same grid as the coadd VIGNET, so the overlay is
  well-defined.
- config_tile_Ng_template.ini documents BLEND_HANDLING, SEG_VIGNET_PATH and
  DILATE_NEIGHBOUR (commented; uberseg requires the seg path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Propagate the per-object blend flag (shapepipe#776) from the ngmix catalogue
into the final catalogue as NGMIX_NEIGHBOUR_FLAG — a galaxy-only, OBJECT/
SHEAR-less metadata column alongside NGMIX_N_EPOCH and NGMIX_MCAL_TYPES_FAIL,
enabling the systematics tests Axel requested. Added to final_cat.param so it
survives the final selection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend test_ngmix_uberseg with:
- central_seg_label: reads the centre-pixel label (not "label 1"), and fails
  fast on a sky centre.
- seg_has_neighbour: true iff a non-central footprint is present.
- uberseg dilation: dilate=0 is bit-identical to the pure Sheldon mask (and
  the O(N^2) brute force); dilate>0 zeros a strict superset once the grown
  neighbour footprint crosses the Voronoi bisector into the central cell.
- noisefill ignores seg/dilate_neighbour kwargs (additive-safety).

Add neighbour_flag to the compile_results / make_cat synthetic fixtures
(test_ngmix, test_make_cat) so the new per-object column round-trips, and
assert NGMIX_NEIGHBOUR_FLAG lands in the final catalogue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two Hypothesis property files that drive make_cat's
SaveCatalogue._save_ngmix_data still built synthetic ngmix catalogues on
the pre-#776 column set. Since the coadd-seg UberSeg work, _save_ngmix_data
reads "neighbour_flag" unconditionally and emits NGMIX_NEIGHBOUR_FLAG, so
those fixtures were one column short of the real writer contract:

- test_psf_averaging_properties.py: the writer's get_data()["neighbour_flag"]
  raised on the column-less cat, failing test_absent_obj_id_keeps_sentinel_fill.
- test_psf_grammar_properties.py: same missing-column read, and once fed the
  column the emitted NGMIX_NEIGHBOUR_FLAG was off-grammar for GRAMMAR_RE /
  FROZEN_GRAMMAR_RE.

On a failing property Hypothesis runs its full shrink search, which is what
made the sweep look hung at ~21% for minutes rather than fail promptly.

Fix, matching the update commit 3bd759c already made to test_ngmix /
test_make_cat: add "neighbour_flag" to each file's NGMIX key + INT key lists
and base/measured row, add NGMIX_NEIGHBOUR_FLAG to the averaging test's
_SENTINELS (so its zero-fill is now covered) and to the grammar regexes +
frozen-grammar accept list (so the new metadata column is a recognised token).
Additive only; no assertion weakened. Full tests/module tests/unit sweep:
332 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxsRFJfbvpnx7mgAtqfJqe
The central segmentation label is now the object's SExtractor NUMBER
(obj_id) everywhere — uberseg mask and neighbour flag alike. Seg labels
are NUMBERs from the same SE run, so obj_id names the central footprint
directly and cannot be stolen by a neighbour when a few-pixel coadd-vs-
epoch overlay offset lands a neighbour's label on the centre pixel
(which would invert the mask). This also unifies the neighbour flag's
semantics across blend_handling modes.

central_seg_label is demoted to a diagnostic: Ngmix._check_central_seg_label
cross-checks the centre pixel against obj_id, warns on a mismatch (a
registration signal) but proceeds with obj_id, and raises if the seg
stamp contains obj_id nowhere (object dropped by the per-object
try/except, loud in the log).

Tile_cat.get_data now fails fast if the seg VIGNET catalogue is not
row-aligned to the tile catalogue (row-count mismatch, or a NUMBER
column that disagrees) — a silent misalignment would hand every object
the wrong footprint.

Regression tests: uberseg __init__ without SEG_VIGNET_PATH, the runner's
FileNotFoundError on a missing seg file, and the three _check_central_seg_label
outcomes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@aguinot

aguinot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Hello,
I just want to mentioned that I looked again at the DES Y6 config file and they do not symmetrize the uberseg weights. I am not sure I know why.. I am discussion with the people who did the analysis to understand this change.

@aguinot

aguinot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Regarding the implementation, I see that you are doing a pure python with KDTree. What is the compute time? Is it trackable?

@cailmdaley

Copy link
Copy Markdown
Contributor Author

here's what Claude found, scipy cKDTree uses Cython and the inner loop is C++:

┌───────────────────────────────────┬────────────┬──────────────────┬───────┐
│              n_neigh              │ ours (μs)  │ Sheldon's C (μs) │ ratio │
├───────────────────────────────────┼────────────┼──────────────────┼───────┤
│ 0 (isolated, 45% of real objects) │ 26.6 ± 0.1 │ 39.7 ± 0.1       │ 0.67× │
├───────────────────────────────────┼────────────┼──────────────────┼───────┤
│ 1 (35% of real objects)           │ 990 ± 71   │ 1094 ± 44        │ 0.91× │
├───────────────────────────────────┼────────────┼──────────────────┼───────┤
│ 2 (14% of real objects)           │ 1059 ± 77  │ 1155 ± 69        │ 0.92× │
├───────────────────────────────────┼────────────┼──────────────────┼───────┤
│ 3 (4% of real objects)            │ 1168 ± 88  │ 1206 ± 46        │ 0.97× │
├───────────────────────────────────┼────────────┼──────────────────┼───────┤
│ 4 (rare)                          │ 1214 ± 52  │ 1226 ± 58        │ 0.99× │
└───────────────────────────────────┴────────────┴──────────────────┴───────┘

We compared uberseg_weight()'s cKDTree implementation against your meds._uberseg.uberseg_tree reference at production stamp size (51×51px), averaging timing over 20 independent random neighbour geometries per neighbour count to get a stable estimate, and also summed the cost over every real object (30,435) in one actual UNIONS tile using real catalogue positions. The two are effectively tied — ours runs 5–10% faster in the realistic 0–2-neighbour regime that covers 94% of real objects (isolated objects, the 45% majority, are ~33% faster for us specifically), and the whole real tile's UberSeg pass costs under 20 seconds either way.

@cailmdaley

Copy link
Copy Markdown
Contributor Author

thank you for the comment and checking with DES folks, let me know if you have any more comments on this PR!

@cailmdaley cailmdaley marked this pull request as ready for review July 14, 2026 00:25
@aguinot

aguinot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Interesting, I was not expecting scipy to be faster, good to know!

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.

UberSeg neighbour masking (Erin Sheldon) — config-selectable, validated against noise-fill

2 participants