Skip to content

Commit 89a5fbd

Browse files
authored
Merge pull request #368 from PyAutoLabs/feature/delaunay-qhull-callback
refactor: qhull-only Delaunay callback, exact JAX visibility-walk point location
2 parents b0f368c + 041d5f6 commit 89a5fbd

2 files changed

Lines changed: 365 additions & 37 deletions

File tree

autoarray/inversion/mesh/interpolator/delaunay.py

Lines changed: 234 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
97299
def 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

233435
def 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

258455
def triangle_area_xp(c0, c1, c2, xp):
259456
"""

0 commit comments

Comments
 (0)