Skip to content

ngmix masked pixels: neighbours and defects as separate questions - #922

Open
cailmdaley wants to merge 18 commits into
developfrom
feat/masked-pixels
Open

cailmdaley wants to merge 18 commits into
developfrom
feat/masked-pixels

Conversation

@cailmdaley

@cailmdaley cailmdaley commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Every stamp we fit with ngmix has pixels we can't use as they are: light from a neighbouring object, or a defect such as a bad column, a cosmic ray, a bleed trail or a chip edge. Metacal makes these pixels matter more than their weights suggest. It shears the image in Fourier space, where a weight map can't enter, so each masked pixel's value is sheared and spread into its neighbours before the fit ever sees a weight. This PR sets out how we decide, pixel by pixel, what ngmix sees there, and when we drop an epoch altogether.

Two questions per pixel

The design rests on one separation. We ask two independent questions of each pixel: does it belong to a neighbour, and is it a defect? Each question has its own setting.

  • Neighbours carry real light. A neighbour at a similar redshift is sheared much like the target, so how we treat its pixels is a choice about blending. BLEND_HANDLING makes that choice.
  • Defects carry no sky information, so we have to fill them with something. DEFECT_FILL chooses the fill, and the epoch cuts decide when an epoch has too many defects, or one too close to the object, to be worth keeping.

We haven't yet chosen how to handle neighbours. We have a long-standing noise fill and UberSeg, the approach DES uses, and we will pick the default by comparing the two on real tiles and on blended simulations (#813). Because the defects are treated identically under both, that comparison changes only the neighbour pixels, and it measures blending and nothing else.

Pixel BLEND_HANDLING = noisefill (default) BLEND_HANDLING = uberseg
Neighbour footprint zero weight, filled with noise pixels nearer a neighbour than the target: zero weight, light left untouched
Beyond the tile edge defect defect
Defect (zero exposure weight, nonzero flag, invalid background RMS) zero weight, filled by DEFECT_FILL same

Neighbours

We find neighbours in the coadded tile. In each object's tile stamp, we mark with −1e30 every pixel that belongs to another object's footprint. SExtractor does this itself; for Stephen Gwyn's DR6 catalogue we take the footprints from his segmentation map. We apply that one mask to every exposure of the object.

  • noisefill gives those pixels zero weight and replaces them with noise at the local background level.
  • uberseg uses the segmentation map instead. We assign each pixel to its nearest footprint, and give zero weight to the pixels nearer a neighbour than the target. We leave their light as it is, because it is real sky that metacal shears along with the target. A noise-filled patch there would be a sharp edge in the image that does not shear with it. The workflow supplies the segmentation stamps separately.

The tile stamp is also −1e30 where it runs past the tile's edge. There the exposure is not blank: it holds the object's own light, cut off. So we treat these pixels as defects, not neighbours. We identify them as the unbroken runs of entirely −1e30 rows and columns that start at the stamp's border. On a simulated tile this matches the true off-tile region for 210 of 212 objects near the edge, to within the ±1 px uncertainty in where the stamp is centred. On a DR6 patch it matches the off-image region exactly for all 32 objects.

Defects

  • noise replaces each defect pixel with noise at its background RMS.
  • interpolate fills narrow defects by Clough–Tocher interpolation from the surrounding kept pixels, averaged over the stamp's four 90° rotations. "Narrow" means at most 3 px across with clean pixels on both sides: bad columns, 3 px bleeds, single pixels. Wider holes and edge bands get the noise fill. Interpolated pixels still carry zero weight, and a one-sided zero-weight hole pulls the model fit. For a column 8 px from a 0.5″ galaxy in one high-S/N epoch, it gives c₁ = −1.3e-3. So we also give zero weight to the three rotated copies of each interpolated pixel, keeping their light, and c₁ drops to 2e-6.

Which epochs we keep

We drop an exposure (epoch) from the fit when:

  • a noise-filled defect pixel lies within EPOCH_CENTRAL_DEFECT_RADIUS (10 px) of the object's centre, or, under DEFECT_FILL = interpolate, an interpolated pixel lies within EPOCH_INTERPOLATED_DEFECT_RADIUS (7 px);
  • more than EPOCH_MASKED_FRACTION_CUT (1/3) of the stamp is defect.

We veto central defects because a fill near the object biases its shape. Metacal shears a noise-filled hole in the galaxy's light, but not the sky around it. We chose each radius as the smallest at which columns, 3 px bleeds, single pixels and edge bands keep |m| < 1% and |c| < 5e-4 on galaxies of half-light radius 0.3″ and 0.5″ through a 0.7″ PSF. At 9 px, a noise-filled column on the 0.5″ galaxy already gives m₁ = −1.75% and c₁ = −1.1e-3. Interpolation disturbs the light less, so its radius is smaller. The bias matters because CFHT observes at a fixed orientation: detector-fixed defects like bad columns line up the same way in every epoch, so their biases add coherently rather than averaging away.

Neighbour pixels never enter these cuts. The neighbour mask comes from the tile, so it is the same in every epoch. A cut that counted it would drop every epoch of a blended object, and the object would vanish from the catalogue. We saw exactly that on a simulated tile of 21,851 objects when neighbours were counted as defects: the 10 px veto lost 31% of them. With this PR's cuts the same tile loses 89 objects (0.41%). Of those, 88 lie within 10 px of the tile edge, a strip the neighbouring tile also covers; we expect them to be measured there, but haven't checked. Two further simulated tiles lose none.

Open decisions

Simulations have no cosmic rays, bad columns or satellite trails, so their defect statistics can't settle these. The epoch-cut logs from 20–50 real tiles will; every run records how many epochs each cut drops.

  1. The central-veto radius. On the same simulated tile the veto drops 123 of 64,662 epochs at 10 px. Most of the 89 lost objects are edge objects that the masked-fraction cut removes anyway.
  2. The masked-fraction threshold: 1/3, or DES's 10%.
  3. noise or interpolate as the default fill.

Technical notes

  • Settings. BLEND_HANDLING (noisefill | uberseg), DEFECT_FILL (noise | interpolate), EPOCH_CENTRAL_DEFECT_RADIUS, EPOCH_INTERPOLATED_DEFECT_RADIUS, EPOCH_MASKED_FRACTION_CUT.
  • What changes for noisefill. Before this PR, every −1e30 marker, neighbour or off-tile, counted towards a single 1/3 masked-fraction cut, and there was no central veto. Now the cut also counts zero-weight and bad-RMS pixels and ignores neighbour markers, and the central veto is on by default. Under the default noise fill, each kept epoch's weights and filled pixels are unchanged, and a test pins this. Which epochs we keep does change, so both arms of the noisefill/UberSeg comparison have to run on this code, not against an older catalogue.
  • Known approximation. A neighbour footprint that fully covers the row or column next to an off-tile band extends the band, and we read its pixels as off-tile.
  • Not included. Symmetrising neighbour weights (A/B: 4-fold weight symmetrisation — shape impact and S/N cost #815) and subtracting neighbours with a multi-object fit (MOF) remain options to compare later. We tried and rejected a symmetrised defect fill: it quadruples the filled area near the object, and m for a 3 px bleed 10 px from a 0.5″ galaxy goes from −0.64% to −2.7%. The code is kept on feat/symmetrized-defect-fill.
  • Evidence for the epoch-cut numbers: /n17data/cdaley/scratch/dr6seg/veto_cost/ on candide.

Supersedes #915 and #916. Part of #602.

🤖 Generated with Claude Code

cailmdaley and others added 8 commits September 26, 2026 07:07
…defects

Defects (nonzero flag, zero weight, invalid RMS) must be zero-weighted and
noise-filled under every BLEND_HANDLING, with neighbour pixels left raw
under uberseg. The masked-fraction cut must count that same raw set, and an
epoch with a defect closer than EPOCH_CENTRAL_DEFECT_RADIUS to the stamp
centre must be dropped, at a strict boundary and at the configured radius.
The runner must pass both cut options on, the HSM centroid must ignore raw
defect values, each epoch must keep its own OFFSET, and every tile must log
its epoch-cut tally.

The science test recovers the full 2x2 response for columns, 3-px bleeds,
single pixels and edge bands at the veto radius, on 0.3" and 0.5" galaxies
through round and elliptical 0.7" PSFs, and requires |c1|, |c2| < 5e-4 and
|m11|, |m22| < 1%. Flagged pixels in the simulation hold a hot value, so an
unfilled defect shows up. A column two pixels inside the radius is the
positive control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAc1ywyd9Sbbra6po2iuaA
…ral ones

Metacal shears the whole stamp without reading the weights, so a defect
left raw under uberseg reached the fit. Every defect (nonzero flag, zero
weight, invalid RMS) is now zero-weighted and noise-filled whatever
BLEND_HANDLING is; uberseg only zeroes the weight of neighbour-side pixels
and keeps their light. The option that meant "no neighbour treatment" is
renamed from noisefill to none, and the retired name is rejected.

The defect set is not symmetrized: at the veto radius the one-sided fill
leaves |c| <= 3e-4, while a four-fold mask would quadruple m (-2.7% against
-0.64% for a 3-px bleed at 10 px on a 0.5" galaxy) and, through an elliptical
PSF, still leave c1 = 7.3e-4.

An epoch is dropped when a defect lies closer than
EPOCH_CENTRAL_DEFECT_RADIUS (default 10 px) to the stamp centre, the
smallest radius at which columns, 3-px bleeds, single pixels and edge bands
give |m11|, |m22| < 1% and |c1|, |c2| < 5e-4 on 0.3" and 0.5" galaxies
through a 0.7" PSF. The masked-fraction cut counts the same raw defect set;
EPOCH_MASKED_FRACTION_CUT (default 1/3) makes it configurable. Each tile
logs "epoch cuts: considered=N masked_fraction=A central_veto=B
objects_emptied=C". The HSM centroid is measured on the filled image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAc1ywyd9Sbbra6po2iuaA
prepare_postage_stamps already holds the object's galaxy vignet dict, so the
per-epoch OFFSET is read from it instead of a second sqlitedict lookup. The
pixel scale sets only the centroid-prior width; the comments that also
credited it with a noise window are corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAc1ywyd9Sbbra6po2iuaA
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The interpolation fills only short defect runs (at most 3 px along a row or
column, with clean pixels at both ends); anything wider, and runs reaching
the stamp border, stays noise-filled. The interpolant must reproduce linear
planes without reading defect values, commute with quarter turns of the
stamp, and apply one operator to the science and metacal noise images. The
weight is zero on the defects and on the quarter-turn orbit of the
interpolated pixels, whose light stays. Each defect is vetoed at the radius
of its own fill: EPOCH_INTERPOLATED_DEFECT_RADIUS for interpolated pixels,
EPOCH_CENTRAL_DEFECT_RADIUS for noise-filled ones. The runner and Ngmix pass
DEFECT_FILL to the epoch cuts and to metacal; noise stays the default.

The science test recovers the full response matrix for interpolated columns,
full and finite 3-px bleeds and single pixels at the interpolated radius, on
0.3" and 0.5" galaxies through round and elliptical 0.7" PSFs and on a 0.7"
galaxy through a 0.9" PSF, and for noise-filled wide defects at the noise
radius. A bleed three pixels inside the radius is the positive control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAc1ywyd9Sbbra6po2iuaA
… veto

DEFECT_FILL = interpolate fills short defect runs (at most 3 px along a row
or column, with clean pixels at both ends: columns, 3-px bleeds, single
pixels) with a Clough-Tocher interpolant of the clean pixels within 4 px.
The interpolant is averaged over the four quarter turns of the stamp, and
each turn's triangulation serves the science image and the metacal noise
image alike. Wider holes and runs reaching the stamp border keep the noise
fill. noise stays the default and is unchanged.

Interpolation restores the object's light, so the remaining bias is the
hole in the likelihood: a one-sided zero-weight column 8 px from a 0.5"
galaxy still gives c1 = -1.3e-3. The weight is therefore also zero on the
quarter-turn orbit of the interpolated pixels (their light stays), which
brings that column to c1 = +2e-6. Symmetrizing the fill instead would trade
true light for interpolated light over four times the area.

Each defect pixel is vetoed at the radius of its own fill:
EPOCH_INTERPOLATED_DEFECT_RADIUS (default 7 px) for interpolated pixels and
EPOCH_CENTRAL_DEFECT_RADIUS (10 px) for noise-filled ones. 7 px is the
smallest radius at which interpolated columns, 3-px bleeds and single pixels
give |m11|, |m22| < 1% and |c1|, |c2| < 5e-4 on 0.3", 0.5" and 0.7" galaxies,
round and elliptical PSFs; it does not scale with galaxy size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QAc1ywyd9Sbbra6po2iuaA
Brings the decision record to what this branch does:
- central_defect_veto: committed option fixed_radii (was disabled); the
  10 px EPOCH_CENTRAL_DEFECT_RADIUS is pinned by a Values entry on its
  module constant, now tagged.
- epoch_masked_fraction_cut: the cut counts the defect set against
  EPOCH_MASKED_FRACTION_CUT (no longer [HARDCODED] on flags).
- defect_fill: defects are noise-filled under every BLEND_HANDLING; the
  docstring [LINT] this branch fixes is dropped.
- blend_handling: the no-treatment option is BLEND_HANDLING = none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings develop (#875's decision record) in through the updated base, and
amends the record for this branch:
- defect_fill: the interpolate option is implemented (DEFECT_FILL =
  interpolate); noise stays the committed fill.
- central_defect_veto: EPOCH_INTERPOLATED_DEFECT_RADIUS = 7 joins the
  Values, on its now-tagged module constant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cailmdaley added a commit that referenced this pull request Sep 28, 2026
Brings the tile segmentation map for UberSeg into #922, so the masked-pixels
work sits in one place. feat/uberseg-seg-map is built on #897
(feat/unions-catalogue-tile-detection), so this provisionally carries #897's
content too (UNIONS catalogue detection, TILE_UNIQUE_ID, ngmix row chunks)
until #897 lands on develop.

No textual conflicts: ngmix.py's row-selection chunking (#897) and the epoch
cuts, defect fill and constants (#915/#916) touch disjoint hunks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cailmdaley and others added 7 commits September 29, 2026 16:11
Conflicts: the ngmix runner imports both the epoch-cut defaults and
write_empty_tile_output; Ngmix.process logs the epoch-cut tally and then
calls log_run_health. Develop's run-health test stubs prepare_postage_stamps
with the epoch-cut kwargs and tally and sets the epoch-cut attributes; the
defect-fill process tests give their vignettes an OFFSET and their logger
warning/error methods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The neighbour option keeps develop's name, noisefill, in the code, the runner
config key, tests, astra.yaml and the committed universe; the name none is
dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SExtractor's -1e30 markers in the tile VIGNET form their own per-epoch mask
(stamp.neighbours): a footprint 3 px from the centre, or covering 45% of the
stamp, drops no epoch; noisefill zero-weights and noise-fills exactly the
marked pixels, bit for bit as develop does; uberseg leaves them raw and
weighted; a flagged column near the centre is still vetoed; and
do_ngmix_metacal hands each epoch's mask to make_ngmix_observation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
prepare_postage_stamps wrote the tile VIGNET's -1e30 markers (other
detections' footprints) into each epoch's flag stamp as 2**10, so
defect_mask counted them: they fed the masked-fraction cut and the central
veto. Every epoch shares the tile VIGNET, so a neighbour within
EPOCH_CENTRAL_DEFECT_RADIUS dropped every epoch of the object.

The markers are now their own per-epoch mask, stamp.neighbours
(MegaCam-flipped with the epoch), threaded through do_ngmix_metacal and
make_ngmix_observation to prepare_ngmix_weights. The epoch cuts count
genuine defects only. Under noisefill the marked pixels get weight 0 and
the defects' noise realisation, the output noisefill gave when they arrived
as flags; under uberseg they are ignored and the seg-based weight handles
neighbours. Defects are treated identically under both. Docstrings, @sc
contracts, runner comments and the astra.yaml decisions say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SExtractor writes -1e30 into the tile VIGNET beyond the tile's edge as well
as on other detections' footprints. Beyond the edge the epoch holds the
object's own light, cut off, so treating those pixels as neighbours exempted
edge objects from both epoch cuts and measured them with a noise-filled band
near their centre.

split_tile_markers classifies the markers geometrically on the flipped tile
VIGNET: the union of stamp rows and columns that are entirely -1e30 (the
off-image part of a rectangle clip) is off-tile, the rest is the neighbour
mask. Off-tile pixels are flagged OFF_TILE_FLAG (2**10) in the epoch's flag
stamp, so under both blend handlings they are zero-weighted, filled, and
counted by the masked-fraction cut and the central veto. Docstrings, @sc
contracts, runner comments and astra.yaml say so.

Tests: an object 3 px from the tile edge loses every epoch; a band 12 px
away passes the default cuts and is vetoed at radius 13; a corner's L-shaped
off-tile region is flagged exactly and a border-touching neighbour stays a
neighbour; off-tile pixels are filled under both blend handlings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under DEFECT_FILL = interpolate and BLEND_HANDLING = noisefill, the
Clough-Tocher support included marked neighbour pixels that noisefill
replaces with noise, so a defect beside a bright neighbour was filled with
light the image no longer contains. The support now excludes ~clean (the
defects and the removed neighbour pixels); the NaN fallback to the noise
fill covers degenerate supports. Under uberseg the neighbour light stays in
the image and the support is unchanged. interpolate_defects names its
second argument `excluded`.

Test: a flagged pixel next to a 1e4 marked neighbour interpolates to the
kept-pixel interpolant (1.4, against 3102 with the neighbour in the
support) under noisefill, and from the raw neighbour light under uberseg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fill_defects used np.where, which promotes a float32 stamp to float64;
develop's noisefill kept the galaxy stamp's dtype. The filled image is cast
back to the input dtype, so noisefill stays bit-identical to develop on
float32 stamps. The develop-equivalence test gains a float32 case and
checks dtypes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cailmdaley and others added 3 commits September 30, 2026 01:57
…amp border

Every entirely -1e30 stamp row or column counted as off-tile. In an edge
stamp, a neighbour footprint that covers the on-image part of a row (a
wide DR6 footprint running from the tile edge) completes that row, so the
neighbour's pixels became OFF_TILE_FLAG defects: counted by the epoch
cuts, interpolated under DEFECT_FILL = interpolate, filled under uberseg.
Only the runs of marked rows and columns that start at a stamp border are
off-tile now.

Tests build tile VIGNETs with the catalogue-mode converter: on the real
202.301 DR6 patch every stamp splits exactly into off-image pixels and
seg-map neighbour pixels, and the edge case above keeps its footprint in
the neighbour mask through prepare_postage_stamps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbT81hykcvtuefzB3FhYvY
… converter

The neighbour-marking rationale and the converter's BIG comment describe
the markers as ngmix neighbours under flag 2**10; ngmix splits them into
off-tile defects and a neighbour mask. The masking text names the tile
VIGNET, not SExtractor, as the markers' carrier, since catalogue mode
paints them from the DR6 segmentation map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BbT81hykcvtuefzB3FhYvY
@cailmdaley cailmdaley changed the title ngmix masked pixels: independent neighbour and defect handling (UberSeg, noise fill, interpolation, epoch cuts) ngmix masked pixels: neighbours and defects as separate questions Sep 30, 2026
@cailmdaley
cailmdaley marked this pull request as ready for review September 30, 2026 01:09

This branch has not been deployed

No deployments
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.

1 participant