@@ -63,37 +63,239 @@ def scipy_delaunay(points_np, query_points_np, areas_factor):
6363 return points , simplices_padded , mappings , split_points , splitted_mappings
6464
6565
66- def jax_delaunay (points , query_points , areas_factor = 0.5 ):
66+ # Query points are located in chunks of this size so the (chunk, N) distance
67+ # intermediate stays bounded on GPU when the likelihood is vmapped over many
68+ # live points.
69+ DELAUNAY_LOCATE_CHUNK = 1024
70+
71+ # Iteration cap of the visibility walk. On production Hilbert/Delaunay meshes
72+ # every point resolves within ~64 steps from a nearest-vertex start; a query
73+ # still unresolved at the cap (a would-be fp cycle on degenerate slivers)
74+ # falls back to its nearest-vertex mapping, the outside-hull convention.
75+ DELAUNAY_WALK_STEPS = 128
76+
77+
78+ def scipy_delaunay_tri_only (points_np ):
79+ """Minimal host-side Delaunay callback: the qhull triangulation plus the
80+ adjacency arrays the JAX-side visibility-walk point locator needs.
81+
82+ Unlike ``scipy_delaunay`` this does NOT run ``find_simplex`` or assemble
83+ mappings — that work is fixed-shape array math once the simplices are
84+ known, and runs inside the JIT program (on GPU, batched across vmap
85+ lanes) instead of serializing per lane on the host.
86+
87+ Returns
88+ -------
89+ simplices_padded : (2N, 3) int32, -1 padded (same convention as
90+ ``scipy_delaunay``).
91+ simplex_neighbors : (2N, 3) int32, -1 padded — qhull's ``tri.neighbors``:
92+ entry k of a row is the simplex opposite that row's vertex k, with
93+ -1 at the convex hull.
94+ vertex_simplex : (N,) int32 — one simplex incident to each vertex (the
95+ walk's start simplex); -1 for the rare vertex qhull excluded as
96+ coplanar/duplicate (the walk then starts from simplex 0 instead,
97+ which is equally valid — the walk converges from any start).
98+ """
99+ from scipy .spatial import Delaunay
100+
101+ N = points_np .shape [0 ]
102+ tri = Delaunay (points_np )
103+ simplices = tri .simplices .astype (np .int32 ) # (T, 3)
104+ T = simplices .shape [0 ]
105+
106+ simplices_padded = - np .ones ((2 * N , 3 ), dtype = np .int32 )
107+ simplices_padded [:T ] = simplices
108+
109+ simplex_neighbors = - np .ones ((2 * N , 3 ), dtype = np .int32 )
110+ simplex_neighbors [:T ] = tri .neighbors .astype (np .int32 )
111+
112+ vertex_simplex = - np .ones (N , dtype = np .int32 )
113+ simplex_ids = np .arange (T , dtype = np .int32 )
114+ for k in range (3 ):
115+ vertex_simplex [simplices [:, k ]] = simplex_ids
116+
117+ return simplices_padded , simplex_neighbors , vertex_simplex
118+
119+
120+ def _jax_delaunay_tables (points ):
121+ """Run the qhull-only callback with fixed output shapes."""
67122 import jax
68123 import jax .numpy as jnp
69124
70125 N = points .shape [0 ]
71- Q = query_points .shape [0 ]
72- max_simplices = 2 * N
73-
74- points_shape = jax .ShapeDtypeStruct ((N , 2 ), points .dtype )
75- simplices_padded_shape = jax .ShapeDtypeStruct ((max_simplices , 3 ), jnp .int32 )
76- mappings_shape = jax .ShapeDtypeStruct ((Q , 3 ), jnp .int32 )
77- split_points_shape = jax .ShapeDtypeStruct ((N * 4 , 2 ), points .dtype )
78- splitted_mappings_shape = jax .ShapeDtypeStruct ((N * 4 , 3 ), jnp .int32 )
79-
80126 return jax .pure_callback (
81- lambda points , qpts : scipy_delaunay (
82- np .asarray (points ), np .asarray (qpts ), areas_factor
83- ),
127+ lambda pts : scipy_delaunay_tri_only (np .asarray (pts )),
84128 (
85- points_shape ,
86- simplices_padded_shape ,
87- mappings_shape ,
88- split_points_shape ,
89- splitted_mappings_shape ,
129+ jax .ShapeDtypeStruct ((2 * N , 3 ), jnp .int32 ),
130+ jax .ShapeDtypeStruct ((2 * N , 3 ), jnp .int32 ),
131+ jax .ShapeDtypeStruct ((N ,), jnp .int32 ),
90132 ),
91133 points ,
92- query_points ,
93134 vmap_method = "sequential" ,
94135 )
95136
96137
138+ def pix_indexes_delaunay_walk_from (
139+ query_points ,
140+ points ,
141+ simplices_padded ,
142+ simplex_neighbors ,
143+ vertex_simplex ,
144+ xp = np ,
145+ ):
146+ """JAX/NumPy point location replacing ``scipy.spatial.Delaunay.find_simplex``
147+ on the JAX likelihood path, via the same visibility-walk algorithm
148+ ``find_simplex`` itself uses — so the result is exact, not approximate.
149+
150+ For each query point: find the nearest mesh vertex (brute-force distance
151+ argmin), start from a simplex incident to it, and walk: compute the
152+ signed barycentric weights of the query in the current simplex; if all
153+ are >= -1e-12 the simplex contains the point (done); otherwise step to
154+ the neighbor simplex opposite the most-negative vertex. Crossing a hull
155+ edge (neighbor -1) means the point is outside the triangulation and it
156+ falls back to the nearest-vertex mapping ``[v, -1, -1]`` — the identical
157+ convention ``scipy_delaunay`` applies via its KDTree.
158+
159+ The walk is bounded at DELAUNAY_WALK_STEPS (production meshes resolve in
160+ <= ~64); the loop runs in lockstep over a chunk of queries with done/
161+ outside masks, so it is fixed-shape and JIT/vmap-safe. Chunking over
162+ query points bounds the (chunk, N) nearest-vertex intermediate under
163+ vmap (JAX path; the NumPy path — used by the unit tests — processes the
164+ whole array with early exit). Returns a (Q, 3) int32 mapping array with
165+ the same semantics as ``pix_indexes_for_sub_slim_index_delaunay_from``.
166+ """
167+
168+ def cross (u , v ):
169+ return u [..., 0 ] * v [..., 1 ] - u [..., 1 ] * v [..., 0 ]
170+
171+ def weights_of (cur , q_chunk ):
172+ verts = simplices_padded [cur ] # (chunk, 3)
173+ a = points [verts [:, 0 ]]
174+ b = points [verts [:, 1 ]]
175+ c = points [verts [:, 2 ]]
176+ den = cross (b - a , c - a )
177+ den = xp .where (den != 0.0 , den , 1.0 )
178+ w = (
179+ xp .stack (
180+ [
181+ cross (b - q_chunk , c - q_chunk ),
182+ cross (c - q_chunk , a - q_chunk ),
183+ cross (a - q_chunk , b - q_chunk ),
184+ ],
185+ axis = 1 ,
186+ )
187+ / den [:, None ]
188+ )
189+ return verts , w
190+
191+ def walk_step (carry , q_chunk ):
192+ cur , done , outside = carry
193+ _ , w = weights_of (cur , q_chunk )
194+ minw = w .min (axis = 1 )
195+ opposite = w .argmin (axis = 1 )
196+ done = done | (~ outside & (minw >= - 1.0e-12 ))
197+ nxt = simplex_neighbors [cur , opposite ]
198+ outside = outside | (~ done & (nxt < 0 ))
199+ move = ~ done & ~ outside
200+ cur = xp .where (move , nxt .clip (min = 0 ), cur )
201+ return cur , done , outside
202+
203+ def locate_chunk (q_chunk ):
204+ # nearest mesh vertex: (chunk, N) distance intermediate
205+ d2 = ((q_chunk [:, None , :] - points [None , :, :]) ** 2 ).sum (- 1 )
206+ seed = xp .argmin (d2 , axis = 1 ).astype (xp .int32 )
207+
208+ cur = vertex_simplex [seed ].clip (min = 0 ).astype (xp .int32 )
209+ done = xp .zeros (q_chunk .shape [0 ], dtype = bool )
210+ outside = xp .zeros (q_chunk .shape [0 ], dtype = bool )
211+
212+ if xp is np :
213+ for _ in range (DELAUNAY_WALK_STEPS ):
214+ cur , done , outside = walk_step ((cur , done , outside ), q_chunk )
215+ if (done | outside ).all ():
216+ break
217+ else :
218+ import jax
219+
220+ cur , done , outside = jax .lax .fori_loop (
221+ 0 ,
222+ DELAUNAY_WALK_STEPS ,
223+ lambda _ , carry : walk_step (carry , q_chunk ),
224+ (cur , done , outside ),
225+ )
226+
227+ verts = simplices_padded [cur ]
228+ fallback = xp .stack ([seed , - xp .ones_like (seed ), - xp .ones_like (seed )], axis = 1 )
229+ return xp .where (done [:, None ], verts , fallback ).astype (xp .int32 )
230+
231+ if xp is np :
232+ return locate_chunk (query_points )
233+
234+ import jax
235+
236+ Q = query_points .shape [0 ]
237+ chunk = DELAUNAY_LOCATE_CHUNK
238+ pad = (- Q ) % chunk
239+ # pad rows sit far outside the mesh; their located rows are sliced away
240+ q_padded = xp .concatenate (
241+ [query_points , xp .full ((pad , 2 ), 1.0e9 , dtype = query_points .dtype )]
242+ )
243+ mappings = jax .lax .map (locate_chunk , q_padded .reshape (- 1 , chunk , 2 )).reshape (- 1 , 3 )
244+ return mappings [:Q ]
245+
246+
247+ def jax_delaunay (points , query_points , areas_factor = 0.5 ):
248+ """JAX-path Delaunay construction. Only the qhull triangulation runs on
249+ the host (via ``pure_callback``); point location, dual areas and split
250+ points run inside the JIT program. The return contract (values, shapes,
251+ dtypes and -1 padding conventions) is identical to ``scipy_delaunay``.
252+ """
253+ import jax .numpy as jnp
254+
255+ simplices_padded , simplex_neighbors , vertex_simplex = _jax_delaunay_tables (points )
256+
257+ mappings = pix_indexes_delaunay_walk_from (
258+ query_points = query_points ,
259+ points = points ,
260+ simplices_padded = simplices_padded ,
261+ simplex_neighbors = simplex_neighbors ,
262+ vertex_simplex = vertex_simplex ,
263+ xp = jnp ,
264+ )
265+
266+ # dual areas via masked scatter-add over the padded simplices
267+ valid = simplices_padded [:, 0 ] >= 0
268+ s = simplices_padded .clip (min = 0 )
269+ p0 , p1 , p2 = points [s [:, 0 ]], points [s [:, 1 ]], points [s [:, 2 ]]
270+ tri_cross = (p1 [:, 0 ] - p0 [:, 0 ]) * (p2 [:, 1 ] - p0 [:, 1 ]) - (
271+ p1 [:, 1 ] - p0 [:, 1 ]
272+ ) * (p2 [:, 0 ] - p0 [:, 0 ])
273+ contrib = jnp .where (valid , 0.5 * jnp .abs (tri_cross ) / 3.0 , 0.0 )
274+ areas = jnp .zeros (points .shape [0 ], dtype = points .dtype )
275+ for k in range (3 ):
276+ areas = areas .at [s [:, k ]].add (contrib )
277+
278+ split_points = split_points_from (
279+ points = points ,
280+ area_weights = areas_factor * jnp .sqrt (areas ),
281+ xp = jnp ,
282+ )
283+
284+ # Split points are seeded at their own nearest vertex (not their parent
285+ # vertex): a split point can land outside the hull, and the fallback must
286+ # then match scipy_delaunay's KDTree nearest-vertex assignment.
287+ splitted_mappings = pix_indexes_delaunay_walk_from (
288+ query_points = split_points ,
289+ points = points ,
290+ simplices_padded = simplices_padded ,
291+ simplex_neighbors = simplex_neighbors ,
292+ vertex_simplex = vertex_simplex ,
293+ xp = jnp ,
294+ )
295+
296+ return points , simplices_padded , mappings , split_points , splitted_mappings
297+
298+
97299def barycentric_dual_area_from (
98300 mesh_grid , # (N_pix, 2) vertex positions
99301 simplices , # (N_tri, 3) triangle vertex indices
@@ -231,29 +433,24 @@ def scipy_delaunay_matern(points_np, query_points_np):
231433
232434
233435def jax_delaunay_matern (points , query_points ):
234- """
235- JAX wrapper using pure_callback to run SciPy Delaunay on CPU,
236- returning only the minimal outputs needed for Matérn usage.
237- """
238- import jax
436+ """JAX-path Matérn variant: qhull-only callback + JAX point location,
437+ returning the same minimal (points, simplices_padded, mappings) contract
438+ as ``scipy_delaunay_matern``."""
239439 import jax .numpy as jnp
240440
241- N = points .shape [0 ]
242- Q = query_points .shape [0 ]
243- max_simplices = 2 * N
441+ simplices_padded , simplex_neighbors , vertex_simplex = _jax_delaunay_tables (points )
244442
245- points_shape = jax .ShapeDtypeStruct ((N , 2 ), points .dtype )
246- simplices_padded_shape = jax .ShapeDtypeStruct ((max_simplices , 3 ), jnp .int32 )
247- mappings_shape = jax .ShapeDtypeStruct ((Q , 3 ), jnp .int32 )
248-
249- return jax .pure_callback (
250- lambda pts , qpts : scipy_delaunay_matern (np .asarray (pts ), np .asarray (qpts )),
251- (points_shape , simplices_padded_shape , mappings_shape ),
252- points ,
253- query_points ,
254- vmap_method = "sequential" ,
443+ mappings = pix_indexes_delaunay_walk_from (
444+ query_points = query_points ,
445+ points = points ,
446+ simplices_padded = simplices_padded ,
447+ simplex_neighbors = simplex_neighbors ,
448+ vertex_simplex = vertex_simplex ,
449+ xp = jnp ,
255450 )
256451
452+ return points , simplices_padded , mappings
453+
257454
258455def triangle_area_xp (c0 , c1 , c2 , xp ):
259456 """
0 commit comments