Add a polygon-clipping kernel for 2D conservative weights - #59
Merged
Conversation
`weights_conservative_2d` sweeps the grid lines of both grids and accumulates boundary integrals, which is fully general but forces a sequential walk along each line and a pass over the output grid whose cost does not shrink with the input grid. When the output grid is a uniform, axis-aligned lattice, the overlap of an input cell with an output cell can be computed directly by clipping, and the output grid is never swept. Each input cell is clipped against only the output cells its bounding box touches, so the work per cell is bounded and every cell is independent of every other. `weights_conservative_2d_clipping` returns the same `(indices_input, indices_output, values)` triple as the sweep, so the two are interchangeable. Each input cell writes into its own slice of the result, given by a prefix sum of per-cell upper bounds, so the output does not depend on the thread schedule and no `typed.List` accumulation is needed. Signed areas are used throughout, so an input cell wound in the opposite sense to its grid is handled correctly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #59 +/- ##
========================================
Coverage 99.51% 99.51%
========================================
Files 44 46 +2
Lines 2049 2472 +423
========================================
+ Hits 2039 2460 +421
- Misses 10 12 +2
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Adds tests for the rejected-grid branches of `grid_is_uniform_rectilinear`, for a zero-area input cell, for cells hanging off the lower corner of the output grid, and for a thin diagonal cell whose bounding box spans far more output cells than it overlaps. Drops the two early exits after the `x` clips: the candidate cells come from the cell's bounding box, so it always overlaps the slab being clipped against in `x` and those clips cannot empty the polygon. Only the `y` clips need the guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The conservative builders emit several fragments per distinct `(input, output)` pair, and `_weights_to_arrays` merges them by sorting. That shrinks the result by the mean multiplicity, which makes every subsequent `regrid_from_weights` cheaper, but the sort is not free: on a 401x401 scene resampled onto a 2048x1040 sensor it is 10 s of an 18 s build. Merging therefore only pays for itself when the weights are reused. For a caller whose grid changes on every call, each set of weights is applied once and there is nothing to amortize the sort against. `coalesce=False` returns the fragments as built; the result is equivalent, since applying the weights sums duplicates during the scatter-add either way. The clipping kernel emits each pair exactly once, so the merge finds nothing to do there and only costs the sort. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`regrid()` builds its weights and applies them exactly once, so merging
repeated `(input, output)` pairs costs a sort with nothing to amortize it
against. Skipping the merge leaves more pairs for the scatter-add to sum,
but that is much the cheaper half: measured on a distorted grid resampled
onto a lattice, build plus one apply is about twice as fast.
201 -> 256 0.146 s -> 0.072 s
401 -> 512 0.495 s -> 0.259 s
401 -> 1024 1.292 s -> 0.644 s
`weights()` keeps `coalesce=True`, since weights obtained that way are
meant to be reused. The two defaults now differ, so `regrid()` and
`weights()` followed by `regrid_from_weights()` sum the same fragments in
a different order and agree to rounding rather than bitwise;
`test_regrid_from_weights` asks for the same representation on both sides
so that it can keep checking bitwise equality.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 23, 2026
`weights(method="conservative")` now selects between the two 2D kernels
automatically: the clipping kernel when every output grid is a uniform,
axis-aligned lattice, and the sweep otherwise. Measured against the
sweep on a distorted scene:
201 -> 512x512 0.319 s -> 0.041 s 7.7x
401 -> 1024x1024 1.155 s -> 0.148 s 7.8x
401 -> 2048x2080 3.454 s -> 0.324 s 10.7x
The gate inspects every orthogonal element, so a call uses one kernel
throughout and the result does not depend on which element is looked at.
The perturbation is decided along with the kernel. Clipping needs the
lattice exactly and resolves degeneracies on its own, so perturbing
would both break the test and be unnecessary; the coordinates are
normalized without it, and only normalized again with it when falling
back to the sweep.
Results move slightly on the clipping path as a result: applying both
operators to the same scene agrees to about 1e-5 of the peak, which is
the perturbation no longer being applied. At the 2048x2080 size the
sweep also emits 6 negative weights where clipping emits none, so this
is a correctness improvement as well as a faster one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The kernel open-coded the shoelace term in two places: once as a loop over the clipped polygon's edges, and once expanded across the four corners of an input cell. `regridding.geometry.area_triangle` already computes exactly that term, and is inlined always, so reusing it costs nothing and keeps the sign convention in one place. That convention is load-bearing here: handling a cell wound in the opposite sense to its grid depends on the areas being signed, and a second copy of the formula is how the two would drift apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_clipping_applicable` inspected every orthogonal element, which meant comparing a 4.26M-vertex grid against a broadcast of its own first column twelve times for an ESIS-shaped call: 84.5 ms, or 26% of the 0.325 s build it was gating. The output grids are usually broadcast across the orthogonal axes, so every element reads the same memory. A zero stride along those axes guarantees it, so one check answers for all of them; anything else falls back to checking each element as before. 84.5 ms -> 7.1 ms, or 2.2% of the build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reserved slot count claimed a bound of eight vertices, which holds only for a convex cell: the intersection of two convex regions has only the edges of its two operands. A cell which is not convex, but whose edges do not cross, is a legitimate shape that a strong enough distortion produces, and it reaches twelve. The boundary of the result is made of pieces of the cell's edges and of the quadrilateral's: each edge of the quadrilateral meets the convex cell in at most one segment, giving four, and each edge of the cell meets the quadrilateral in at most two, giving eight. A randomized search over 600k quadrilaterals reached eleven, against eight for the convex ones. Reserves sixteen slots, and covers the case with a grid whose middle vertex is dragged inside its neighbors: the weights still conserve, and still agree with the sweep. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A new kernel for 2D first-order conservative weights, based on polygon clipping instead of the line sweep, plus the plumbing to use it.
weights_conservative_2dsweeps the grid lines of both grids and accumulates boundary integrals. That is fully general, but it forces a sequential walk along each line and a pass over the output grid whose cost does not shrink as the input grid shrinks.When the output grid is a uniform, axis-aligned lattice, the overlap of an input cell with an output cell can be computed directly by clipping, and the output grid is never swept. Each input cell is clipped only against the output cells its bounding box touches, so the work per cell is bounded and every cell is independent of every other.
weights(method="conservative")now selects between the two automatically: clipping when every output grid qualifies, the sweep otherwise. The gate inspects every orthogonal element, so a call uses one kernel throughout.Performance
Through
weights(), scattering a rotated and distorted scene onto a lattice, both kernels warm:Row sums are exactly 1.0 for both. Applying both operators to the same scene agrees to about 1e-5 of the peak; that difference is the perturbation, which the clipping path no longer needs (see below). At the largest size the sweep emits 6 negative weights and clipping emits none — negative weights are impossible for overlap volumes, so this is a correctness difference as well.
The crossover is well below any realistic size: an earlier probe found the sweep ahead on a 40x40 output grid, but by 512x512 clipping is already 7.7x faster and the margin grows with the output grid, since the advantage comes from never sweeping it.
End to end, through optika
Measured on the ESIS-I model:
optika.systems.LinearSystem.weights()with a 401x401 scene onto a 2048x1040 sensor, 12 orthogonal elements (4 channels x 3 spectral cells)._coalesce(argsort + reduceat)_normalize_input_output_coordinates(incl.perturb)9x end to end, but note where it comes from: more of it is in
_coalescethan in the kernel._coalescemerges duplicate(input, output)pairs with annp.argsort, so its cost scales with the number of triples emitted, and the sweep emits about four times as many (7 994 348 against 1 998 583 on the synthetic case above) because it accumulates a signed contribution from each grid separately.So roughly 6 s of the saving is the kernel, roughly 9 s is the cheaper coalesce that follows from emitting fewer fragments, and about 1 s is skipping the perturbation. The sweep cannot recover that last part on this geometry, since the sensor lattice is exactly integer-aligned and therefore degenerate (see open question 3).
The clipping kernel emits no duplicates
Each input cell visits each candidate output cell exactly once, so every
(input, output)pair is emitted once and_weights_to_arrayshas nothing to merge. Measured on the same ESIS case:The sweep's raw output is exactly 4.0x its coalesced output; clipping's is 1.0x. So the coalesce costs clipping 0.89 s and removes nothing, and skipping it for this kernel would take the end-to-end ratio against today's default from 9x to about 15x.
weights()now takes acoalescekeyword (defaultTrue, so existing behaviour is unchanged) to make this a choice rather than a fixed cost. It pays for itself when the weights are reused (the Level-4 MART inversion caches and re-applies them), but for single-use weights it is dead loss, sinceregrid_from_weightssums duplicates during the scatter-add anyway. Like-for-like withcoalesce=False, the ratio here is 8.20 s against 1.16 s, or 7.1x.coalesce=Falsereturns the fragments as built. The result is equivalent, sinceregrid_from_weightssums duplicates during the scatter-add either way, and there are tests covering that both forms preserve each input cell's total weight and regrid a scene to the same answer.Incidentally the two kernels agree on the coalesced triple count to 76 parts in 22.5 million, which is a reassuring independent check at full scale.
Two further caveats on how far this generalises. The sweep is faster than clipping on a small output grid (see the table under open question 3); the advantage here comes from never sweeping the output grid, so it grows with the number of output cells relative to input cells. And
optika.systems.LinearSystem.weights(), which is the caller that motivated this, takes about 20 s on a comparable problem, but the kernel is only about 1 s of that per orthogonal element: it calls the kernel once per channel and wavelength and also evaluates its distortion, vignetting, and effective-area models. So the end-to-end speedup for that caller will be well short of 13x.Two correctness differences
Both are covered by tests, and both are cases where the sweep gives a wrong answer that a conservation check does not catch, since row sums stay at 1.0.
1. Grids wound in the opposite sense. Reversing one axis of the vertex array does not move any cell, it only renumbers them and flips the sign of their areas, so the weights must simply permute. Against that ground truth:
2. Degenerate grids. Resampling a grid onto itself must give the identity matrix. Without perturbation:
Clipping handles coincident vertices and collinear edges exactly, so it does not need
perturbat all. That matters beyond correctness: the perturbation is a source of run-to-run variation for anything downstream that seeds the global RNG, and this kernel removes the need for it rather than inheriting it.Implementation notes
typed.Listaccumulation.grid_is_uniform_rectilinear()gates the kernel and raises if the output grid does not qualify.regrid()is left alone deliberately: it builds weights and applies them once, so it is the clearest case for defaulting tocoalesce=False, but that is a behaviour change for every existing caller and belongs with question 1 below.Open questions for review
method="conservative"onto a lattice will shift by that much. Flag if you would rather this were opt-in behind a keyword for a release first.perturboff:MemoryErrorShifting the lattice by half a cell breaks the degeneracy and changes nothing else, and it takes the sweep from 1.1 billion triples to 2 336. The 8x8 case is the striking one: a problem whose correct answer is 68 triples ran for 42 minutes and then exhausted memory. So the sweep terminates rather than looping forever, but on exactly-aligned grids it emits roughly a million times more weight triples than the problem has, which is what produced the
MemoryErrorI hit at larger sizes.The public
weights()enablesperturbby default in 2D, so callers do not normally meet this, butperturb=Falseon aligned grids looks unsafe. Happy to split this out into its own issue.Note also from that table that the sweep is faster than clipping on a small output grid. The advantage here comes from never sweeping the output grid, so it grows with the number of output cells relative to input cells and only becomes decisive on something detector-sized.
Checks
_clipping.py,_weights.py, and_weights_conservative.pyall at 100% patch coverage; the sweep kernel remains at 100% too, so the new routing did not cost it coverageblack --check,ruff check, and pyright 1.1.411 all clean🤖 Generated with Claude Code