Skip to content

Make the log and spectral TV regularizers boundary-correct - #323

Merged
achael merged 23 commits into
dev-backendfrom
fix/log-reg-partial-mask
Oct 8, 2026
Merged

achael merged 23 commits into
dev-backendfrom
fix/log-reg-partial-mask

Conversation

@rohandahale

@rohandahale rohandahale commented Aug 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes incorrect boundary handling in the log-TV and spectral-TV regularizers, plus several avoidable NaNs in polarimetric gradients.

The main bug is that reg_tvlog / reg_tv2log compute TV on log(imvec) through code that pads the image with zero.

For linear flux, zero means empty sky outside the FOV, which is a sensible boundary condition.

For a log image, however:

log(I) = 0  ->  I = 1 Jy

so the regularizer was effectively placing a 1 Jy ring just outside images whose edge pixels may be 1e-4 to 1e-20 Jy.

That boundary term was not just cosmetic:

  • on a plain Gaussian, it contributes 54% of the total tvlog value;
  • its gradient has the wrong physical direction, encouraging edge pixels toward 1 Jy;
  • at stronger tvlog weights it visibly worsens reconstruction quality and pushes flux onto the FOV rim.

For example:

tvlog weight nxcorr old -> new chisq_amp old -> new rim flux old -> new
50 0.9307 -> 0.9321 9.6 -> 8.8 3.58% -> 2.89%
200 0.9167 -> 0.9263 49.7 -> 23.2 7.05% -> 4.12%
1000 0.9005 -> 0.9394 355.7 -> 146.4 11.92% -> 6.02%

At the repository's existing e2e weight of 10 the effect is tiny, which likely explains why this survived.

What changed

For the log and spectral TV families, differences that cross either the FOV boundary or an embed-mask boundary are now excluded.

At the grid edge this is implemented with edge padding, so the boundary difference is exactly zero. At mask boundaries, invalid neighbours are replaced with the current pixel before taking each individual difference.

This applies to:

  • tvlog / tv2log
  • their analytic gradients
  • the spectral TV value and gradient paths

Linear and polarimetric TV variants keep their existing zero boundary condition because zero outside the image is meaningful for those quantities.

reg_tv, reggrad_tv, reg_tv2, and reggrad_tv2 are left byte-identical to dev-backend.

Partial-mask NaNs

tvlog and tv2log also failed on partial embed masks because masked pixels were filled with epsilon_tv, which defaults to zero, and then passed through log().

Partial masks are possible at default settings whenever the prior contains exact-zero pixels.

Masked pixels are now given a positive flux / npix value before taking the log.

The important point is that the fill no longer affects the result: no retained TV edge crosses into a masked pixel, and the tests explicitly verify fill independence.

Spectral TV

The spectral regularizers had the same boundary problem, effectively imposing alpha = 0 outside the FOV.

The effect is smaller than for tvlog, but the direction is measurable. In a two-frequency test with true alpha = -1, increasing the spectral-TV weight pulled the interior solution toward zero with the old boundary condition:

weight      old alpha      new alpha
1           -0.4305        -0.4370
20          -0.4138        -0.4451
200         -0.3719        -0.4491

The new boundary keeps the interior spectral index much more stable. Other metrics improve only modestly and not uniformly, so this part is primarily a correctness fix.

Polarimetric gradient cleanup

This PR also removes three algebraically unnecessary divisions that produced 0/0 at valid zero-valued pixels:

rho*sin(psi)/tan(psi)  ->  rho*cos(psi)
vimage/iimage          ->  make_vf_image
(I*m)/I                ->  m

These occur across seven functions, including chisqgrad_vvis.

Previously, several functions produced NaNs or infinities at masked or zero-polarization pixels and relied on a later mask slice to discard them. They are now finite throughout.

For valid nonzero inputs, values agree with the old expressions to floating-point precision.


One deliberate convention remains: an isolated valid pixel with no valid TV edges contributes sqrt(epsilon_tv), as before. At the default epsilon_tv=0 this is zero and has zero gradient.

@codecov

codecov Bot commented Aug 4, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.63014% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 49.16%. Comparing base (6d36c21) to head (b32e004).

Files with missing lines Patch % Lines
ehtim/imaging/pol_imager_utils.py 97.56% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@               Coverage Diff               @@
##           dev-backend     #323      +/-   ##
===============================================
- Coverage        49.29%   49.16%   -0.13%     
===============================================
  Files               58       58              
  Lines            27776    27637     -139     
  Branches          4722     4717       -5     
===============================================
- Hits             13693    13589     -104     
+ Misses           12540    12521      -19     
+ Partials          1543     1527      -16     

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

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@rohandahale
rohandahale marked this pull request as ready for review September 21, 2026 19:07
@rohandahale rohandahale self-assigned this Sep 21, 2026
@rohandahale rohandahale added this to the 2.0 milestone Sep 21, 2026
@rohandahale
rohandahale requested a review from achael September 21, 2026 19:07
@rohandahale rohandahale changed the title Keep the log regularizers finite on a partial embed mask Make the log and spectral TV regularizers boundary-correct Sep 21, 2026

@achael achael left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

looks good. I think edge padding for all tv regularizers is probably strictly better than zero padding. So i'd suggest exploring back-porting that behavior to the standard tv, tv2, ptv regularizers as well, then reverting to having the log, spec reg terms calling them. It's ok if they are not byte-identical to the existing versions as long as they pass the finite diff tests. But let me know if you disagree.

Comment thread CHANGELOG.md
Comment thread ehtim/imaging/imager_utils.py Outdated
flux = kwargs['flux']
npix = nx * ny
if np.any(np.invert(mask)):
imvec = embed(imvec, mask, clipfloor=flux/npix)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

add a check that this clipfloor value is >=0 (it should be, but just in case)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

clipfloor is now removed. The wrappers uses only active pixels before the shared kernel embeds them. They now reject nonpositive or nonfinite flux

Comment thread ehtim/imaging/imager_utils.py Outdated
log_kwargs['flux'] = logflux
return reg_tv(xp.log(imvec), np.ones(imvec.shape, dtype=bool), **log_kwargs)
norm = logflux * psize / beam_size if kwargs.get('norm_reg', True) else 1
# compute TV of the log image

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

good catch. Would porting this exact behavior over to standard tv,tv2 meaningfully change behavior? In a case where the edge pixels are zero they should be near-identical, and I think this version with edge padding is strictly better for all versions.

So I would consider re-writing standard tv/tv2 and revert to the log versions calling them. It's ok if they are not byte-identical to the existing versions as long as they pass the finite diff tests.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I have added the boundary rule to TV, TV2, PTV, and PTV2. The new partial mask value and finite difference tests pass

Comment thread ehtim/imaging/imager_utils.py Outdated
Comment thread ehtim/imaging/imager_utils.py Outdated
Comment thread ehtim/imaging/multifreq_imager_utils.py Outdated
@rohandahale

Copy link
Copy Markdown
Collaborator Author

@achael I agree, I have changed tv, tv2, ptv, and ptv2 to drop differences across image and mask boundaries. Log and spectral TV now call the shared functions again, as do vtv and vtv2. The partial mask finite difference and JAX checks pass. Values change when edge pixels are bright, as expected.

Ready for re-review!

@rohandahale
rohandahale requested a review from achael September 28, 2026 21:25

@achael achael left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

looks good! cleaner and hopefully more stable too

@achael
achael merged commit 9d91ff0 into dev-backend Oct 8, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants