pywarper 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pywarper/__init__.py ADDED
File without changes
pywarper/arbor.py ADDED
@@ -0,0 +1,282 @@
1
+ import time
2
+
3
+ import numpy as np
4
+ from numpy.linalg import lstsq
5
+
6
+
7
+ def local_ls_registration(
8
+ nodes: np.ndarray,
9
+ top_input_pos: np.ndarray,
10
+ bot_input_pos: np.ndarray,
11
+ top_output_pos: np.ndarray,
12
+ bot_output_pos: np.ndarray,
13
+ window: float = 5.0,
14
+ max_order: int = 2
15
+ ) -> np.ndarray:
16
+ """
17
+ Applies a local least-squares polynomial transformation to each node based on nearby
18
+ surface correspondences from the top and bottom “bands” (layers).
19
+
20
+ Parameters
21
+ ----------
22
+ nodes : np.ndarray
23
+ (N, 3) array of [x, y, z] positions to be transformed.
24
+ top_input_pos : np.ndarray
25
+ (M, 3) array of [x, y, z] coordinates for the top band (original space).
26
+ bot_input_pos : np.ndarray
27
+ (M, 3) array of [x, y, z] coordinates for the bottom band (original space).
28
+ top_output_pos : np.ndarray
29
+ (M, 3) array of mapped [x, y, z] coordinates for the top band (flattened space).
30
+ bot_output_pos : np.ndarray
31
+ (M, 3) array of mapped [x, y, z] coordinates for the bottom band (flattened space).
32
+ window : float, default=5.0
33
+ Neighborhood radius (in pixels/units) for local polynomial fitting.
34
+ max_order : int, default=2
35
+ Maximum total polynomial order for the local transformation model.
36
+ For example, 2 indicates quadratic terms.
37
+
38
+ Returns
39
+ -------
40
+ np.ndarray
41
+ (N, 3) array of transformed [x, y, z] positions, the result of applying
42
+ the local least-squares fits to each node.
43
+
44
+ Notes
45
+ -----
46
+ The function constructs a polynomial basis (constant, linear, up to max_order
47
+ in both x and y), plus terms modulated by z. A least-squares system is solved
48
+ locally for each node, using points in the top and bottom surfaces within
49
+ the specified window. If insufficient points are found, the node remains
50
+ unchanged.
51
+ """
52
+ transformed_nodes = np.zeros_like(nodes)
53
+
54
+ # Combine top/bottom input positions with outputs once
55
+ top_in_xy = top_input_pos[:, :2]
56
+ bot_in_xy = bot_input_pos[:, :2]
57
+
58
+ for k, (x, y, z) in enumerate(nodes):
59
+ lx, ux = x - window, x + window
60
+ ly, uy = y - window, y + window
61
+
62
+ # Find indices once (reuse masks)
63
+ top_mask = ((top_in_xy[:, 0] >= lx) & (top_in_xy[:, 0] <= ux) &
64
+ (top_in_xy[:, 1] >= ly) & (top_in_xy[:, 1] <= uy))
65
+ bot_mask = ((bot_in_xy[:, 0] >= lx) & (bot_in_xy[:, 0] <= ux) &
66
+ (bot_in_xy[:, 1] >= ly) & (bot_in_xy[:, 1] <= uy))
67
+
68
+ in_top, out_top = top_input_pos[top_mask], top_output_pos[top_mask]
69
+ in_bot, out_bot = bot_input_pos[bot_mask], bot_output_pos[bot_mask]
70
+
71
+ this_in = np.vstack([in_top, in_bot])
72
+ this_out = np.vstack([out_top, out_bot])
73
+
74
+ if len(this_in) < 12:
75
+ transformed_nodes[k] = nodes[k]
76
+ continue
77
+
78
+ # Center coordinates (in-place avoided)
79
+ x_shift, y_shift = np.mean(this_in[:, :2], axis=0)
80
+ this_in_centered = this_in.copy()
81
+ this_out_centered = this_out.copy()
82
+
83
+ this_in_centered[:, 0] -= x_shift
84
+ this_out_centered[:, 0] -= x_shift
85
+ this_in_centered[:, 1] -= y_shift
86
+ this_out_centered[:, 1] -= y_shift
87
+
88
+ # Efficient polynomial basis creation
89
+ xin, yin, zin = this_in_centered.T
90
+ basis_cols = []
91
+
92
+ # Constant term
93
+ basis_cols.append(np.ones_like(xin))
94
+
95
+ # Linear terms
96
+ basis_cols.extend([xin, yin])
97
+
98
+ # Higher-order terms
99
+ for order in range(2, max_order + 1):
100
+ for ox in range(order + 1):
101
+ oy = order - ox
102
+ basis_cols.append((xin ** ox) * (yin ** oy))
103
+
104
+ # Stack basis columns once
105
+ base_terms = np.vstack(basis_cols).T # shape: (n_points, n_terms)
106
+
107
+ # Z-modulated terms
108
+ z_modulated = base_terms * zin[:, np.newaxis]
109
+
110
+ # Combined X matrix
111
+ X = np.hstack([base_terms, z_modulated])
112
+
113
+ # Solve linear system efficiently
114
+ T, _, _, _ = lstsq(X, this_out_centered, rcond=None)
115
+
116
+ # Build basis for current node (single-step, no insertions)
117
+ node_xy = np.array([x - x_shift, y - y_shift])
118
+ nx, ny = node_xy
119
+ basis_eval = [1.0, nx, ny]
120
+
121
+ for order in range(2, max_order + 1):
122
+ for ox in range(order + 1):
123
+ oy = order - ox
124
+ basis_eval.append((nx ** ox) * (ny ** oy))
125
+
126
+ basis_eval = np.array(basis_eval)
127
+ z_modulated_eval = z * basis_eval
128
+
129
+ final_input = np.concatenate([basis_eval, z_modulated_eval])
130
+ new_pos = final_input @ T
131
+
132
+ new_pos[0] += x_shift
133
+ new_pos[1] += y_shift
134
+ transformed_nodes[k] = new_pos
135
+
136
+ return transformed_nodes
137
+
138
+ def warp_arbor(
139
+ nodes: np.ndarray,
140
+ edges: np.ndarray,
141
+ radii: np.ndarray,
142
+ surface_mapping: dict,
143
+ conformal_jump: int = 1,
144
+ verbose: bool = False
145
+ ) -> dict:
146
+ """
147
+ Applies a local surface flattening (warp) to a neuronal arbor using the results
148
+ of previously computed surface mappings.
149
+
150
+ Parameters
151
+ ----------
152
+ nodes : np.ndarray
153
+ (N, 3) array of [x, y, z] coordinates for the arbor to be warped.
154
+ edges : np.ndarray
155
+ (E, 2) array of indices defining connectivity between nodes.
156
+ radii : np.ndarray
157
+ (N,) array of radii corresponding to each node.
158
+ surface_mapping : dict
159
+ Dictionary containing keys:
160
+ - "mapped_min_positions" : np.ndarray
161
+ (X*Y, 2) mapped coordinates for one surface band (e.g., "min" band).
162
+ - "mapped_max_positions" : np.ndarray
163
+ (X*Y, 2) mapped coordinates for the other surface band (e.g., "max" band).
164
+ - "thisVZminmesh" : np.ndarray
165
+ (X, Y) mesh representing the first surface (“min” band) in 3D space.
166
+ - "thisVZmaxmesh" : np.ndarray
167
+ (X, Y) mesh representing the second surface (“max” band) in 3D space.
168
+ - "thisx" : np.ndarray
169
+ 1D array of x-indices (possibly downsampled) used during mapping.
170
+ - "thisy" : np.ndarray
171
+ 1D array of y-indices (possibly downsampled) used during mapping.
172
+ conformal_jump : int, default=1
173
+ Step size used in the conformal mapping (downsampling factor).
174
+ verbose : bool, default=False
175
+ If True, prints timing and progress information.
176
+
177
+ Returns
178
+ -------
179
+ dict
180
+ Dictionary containing:
181
+ - "nodes": np.ndarray
182
+ (N, 3) warped [x, y, z] coordinates after applying local registration.
183
+ - "edges": np.ndarray
184
+ (E, 2) connectivity array (passed through unchanged).
185
+ - "radii": np.ndarray
186
+ (N,) radii array (passed through unchanged).
187
+ - "medVZmin": float
188
+ Median z-value of the “min” surface mesh within the region of interest.
189
+ - "medVZmax": float
190
+ Median z-value of the “max” surface mesh within the region of interest.
191
+
192
+ Notes
193
+ -----
194
+ 1. The function extracts a subregion of the surfaces according to thisx/thisy and
195
+ conformal_jump, matching the flattening step used in the mapping.
196
+ 2. Each node in `nodes` is then warped via local least-squares registration
197
+ (`local_ls_registration`), referencing top (min) and bottom (max) surfaces.
198
+ 3. The median z-values (medVZmin, medVZmax) are recorded, which often serve as
199
+ reference planes in further analyses.
200
+ """
201
+
202
+ # Unpack mappings and surfaces
203
+ mapped_min = surface_mapping["mapped_min_positions"]
204
+ mapped_max = surface_mapping["mapped_max_positions"]
205
+ VZmin = surface_mapping["thisVZminmesh"]
206
+ VZmax = surface_mapping["thisVZmaxmesh"]
207
+ thisx = surface_mapping["thisx"] + 1
208
+ thisy = surface_mapping["thisy"] + 1
209
+ # this is one ugly hack: thisx and thisy are 1-based in MATLAB
210
+ # but 0-based in Python; the rest of the code is to produce exact
211
+ # same results as MATLAB given the SAME input, that means thisx and
212
+ # thisy needs to be 1-based, but we need to shift it back to 0-based
213
+ # when slicing
214
+
215
+ # Convert MATLAB 1-based inclusive ranges to Python slices
216
+ # If thisx/thisy are consecutive integer indices:
217
+ # x_vals = np.arange(thisx[0], thisx[-1] + 1) # matches [thisx(1):thisx(end)] in MATLAB
218
+ # y_vals = np.arange(thisy[0], thisy[-1] + 1) # matches [thisy(1):thisy(end)] in MATLAB
219
+ x_vals = np.arange(thisx[0], thisx[-1] + 1, conformal_jump)
220
+ y_vals = np.arange(thisy[0], thisy[-1] + 1, conformal_jump)
221
+
222
+ # Create a meshgrid shaped like MATLAB's [tmpymesh, tmpxmesh] = meshgrid(yRange, xRange).
223
+ # This means we want shape (len(x_vals), len(y_vals)) for each array, with row=“x”, col=“y”:
224
+ tmpxmesh, tmpymesh = np.meshgrid(x_vals, y_vals, indexing="ij")
225
+ # tmpxmesh.shape == tmpymesh.shape == (len(x_vals), len(y_vals))
226
+
227
+ # Extract the corresponding subregion of the surfaces so it also has shape (len(x_vals), len(y_vals)).
228
+ # In MATLAB: tmpminmesh = thisVZminmesh(xRange, yRange)
229
+ tmp_min = VZmin[x_vals[:, None]-1, y_vals-1] # shape (len(x_vals), len(y_vals))
230
+ tmp_max = VZmax[x_vals[:, None]-1, y_vals-1] # shape (len(x_vals), len(y_vals))
231
+
232
+ # Now flatten in column-major order (like MATLAB’s A(:)) to line up with tmpxmesh(:), etc.
233
+ top_input_pos = np.column_stack([
234
+ tmpxmesh.ravel(order="F"),
235
+ tmpymesh.ravel(order="F"),
236
+ tmp_min.ravel(order="F")
237
+ ])
238
+
239
+ bot_input_pos = np.column_stack([
240
+ tmpxmesh.ravel(order="F"),
241
+ tmpymesh.ravel(order="F"),
242
+ tmp_max.ravel(order="F")
243
+ ])
244
+
245
+ # Finally, the “mapped” output is unaffected by the flattening order mismatch,
246
+ # but we keep it consistent with MATLAB’s final step:
247
+ top_output_pos = np.column_stack([
248
+ mapped_min[:, 0],
249
+ mapped_min[:, 1],
250
+ np.median(tmp_min) * np.ones(mapped_min.shape[0])
251
+ ])
252
+
253
+ bot_output_pos = np.column_stack([
254
+ mapped_max[:, 0],
255
+ mapped_max[:, 1],
256
+ np.median(tmp_max) * np.ones(mapped_max.shape[0])
257
+ ])
258
+
259
+ # return top_input_pos, bot_input_pos, top_output_pos, bot_output_pos
260
+
261
+ # Apply local least-squares registration to each node
262
+ if verbose:
263
+ print("Warping nodes...")
264
+ start_time = time.time()
265
+ warped_nodes = local_ls_registration(nodes, top_input_pos, bot_input_pos, top_output_pos, bot_output_pos)
266
+ if verbose:
267
+ print(f"Nodes warped in {time.time() - start_time:.2f} seconds.")
268
+
269
+ # Compute median Z-planes
270
+ med_VZmin = np.median(tmp_min)
271
+ med_VZmax = np.median(tmp_max)
272
+
273
+ # Build output dictionary
274
+ warped_arbor = {
275
+ 'nodes': warped_nodes,
276
+ 'edges': edges,
277
+ 'radii': radii,
278
+ 'medVZmin': med_VZmin,
279
+ 'medVZmax': med_VZmax,
280
+ }
281
+
282
+ return warped_arbor
pywarper/surface.py ADDED
@@ -0,0 +1,693 @@
1
+ import time
2
+ from typing import Optional
3
+
4
+ import numpy as np
5
+ from pygridfit import GridFit
6
+ from scipy.interpolate import RegularGridInterpolator
7
+ from scipy.signal import convolve2d
8
+ from scipy.sparse import coo_matrix, hstack, vstack
9
+ from scipy.sparse.linalg import spsolve
10
+
11
+ try:
12
+ from sksparse.cholmod import cholesky
13
+ HAS_CHOLMOD = True
14
+ except ImportError:
15
+ HAS_CHOLMOD = False
16
+ print("[Info] scikit-sparse not found. Falling back to scipy.sparse.linalg.spsolve.")
17
+
18
+
19
+ def fit_surface(
20
+ x: np.ndarray,
21
+ y: np.ndarray,
22
+ z: np.ndarray,
23
+ xmax: Optional[int] = None,
24
+ ymax: Optional[int] = None,
25
+ smoothness: int = 1,
26
+ extend: str = "warning",
27
+ interp: str = "triangle",
28
+ regularizer: str = "gradient",
29
+ solver: str = "normal",
30
+ maxiter: Optional[int] = None,
31
+ autoscale: str = "on",
32
+ xscale: float = 1.0,
33
+ yscale: float = 1.0,
34
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
35
+ """
36
+ Fits a surface to scattered data points (x, y, z) using grid-based interpolation
37
+ and smoothing. Internally uses a GridFit-based approach to produce a 2D surface.
38
+
39
+ Parameters
40
+ ----------
41
+ x : np.ndarray
42
+ The x-coordinates of input data points.
43
+ y : np.ndarray
44
+ The y-coordinates of input data points.
45
+ z : np.ndarray
46
+ The z-values at each (x, y) coordinate.
47
+ xmax : int, optional
48
+ Maximum value along the x-axis used to define the interpolation grid.
49
+ If None, the max value from x is used.
50
+ ymax : int, optional
51
+ Maximum value along the y-axis used to define the interpolation grid.
52
+ If None, the max value from y is used.
53
+ smoothness : int, default=1
54
+ Amount of smoothing applied during fitting.
55
+ extend : str, default="warning"
56
+ Determines how to handle extrapolation outside data boundaries.
57
+ Possible values include "warning", "fill", etc. (see GridFit docs).
58
+ interp : str, default="triangle"
59
+ Type of interpolation to apply (e.g., "triangle", "bilinear").
60
+ regularizer : str, default="gradient"
61
+ Regularization method used in the solver (e.g., "gradient", "laplacian").
62
+ solver : str, default="normal"
63
+ Solver backend (e.g., "normal" for normal equations).
64
+ maxiter : int, optional
65
+ Maximum number of solver iterations. If None, defaults to solver-based value.
66
+ autoscale : str, default="on"
67
+ Autoscaling setting for the solver.
68
+ xscale : float, default=1.0
69
+ Additional scaling factor applied to the x-dimension during fitting.
70
+ yscale : float, default=1.0
71
+ Additional scaling factor applied to the y-dimension during fitting.
72
+
73
+ Returns
74
+ -------
75
+ zmesh : np.ndarray
76
+ 2D array of interpolated z-values over the fitted surface.
77
+ xmesh : np.ndarray
78
+ 2D array of x-coordinates corresponding to zmesh.
79
+ ymesh : np.ndarray
80
+ 2D array of y-coordinates corresponding to zmesh.
81
+ """
82
+ if xmax is None:
83
+ xmax = np.max(x).astype(float)
84
+ if ymax is None:
85
+ ymax = np.max(y).astype(float)
86
+
87
+ xnodes = np.hstack([np.arange(1., xmax, 3), np.array([xmax])])
88
+ ynodes = np.hstack([np.arange(1., ymax, 3), np.array([ymax])])
89
+ # xnodes = np.arange(0, xmax+skip, skip)
90
+ # ynodes = np.arange(0, ymax+skip, skip)
91
+
92
+ gf = GridFit(x, y, z, xnodes, ynodes,
93
+ smoothness=smoothness,
94
+ extend=extend,
95
+ interp=interp,
96
+ regularizer=regularizer,
97
+ solver=solver,
98
+ maxiter=maxiter,
99
+ autoscale=autoscale,
100
+ xscale=xscale,
101
+ yscale=yscale,
102
+ ).fit()
103
+ zgrid = gf.zgrid
104
+
105
+ zmesh, xmesh, ymesh = resample_zgrid(
106
+ xnodes, ynodes, zgrid, xmax, ymax
107
+ )
108
+
109
+ return zmesh, xmesh, ymesh
110
+
111
+ def resample_zgrid(
112
+ xnodes: np.ndarray,
113
+ ynodes: np.ndarray,
114
+ zgrid: np.ndarray,
115
+ xMax: int,
116
+ yMax: int
117
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
118
+ """
119
+ Resamples a 2D grid (zgrid) at integer coordinates up to xMax and yMax.
120
+ Uses a linear RegularGridInterpolator under the hood.
121
+
122
+ Parameters
123
+ ----------
124
+ xnodes : np.ndarray
125
+ Sorted 1D array of x-coordinates defining the original grid.
126
+ ynodes : np.ndarray
127
+ Sorted 1D array of y-coordinates defining the original grid.
128
+ zgrid : np.ndarray
129
+ 2D array of shape (len(ynodes), len(xnodes)), representing z-values
130
+ on a regular grid with axes (y, x).
131
+ xMax : int
132
+ The maximum x-coordinate (inclusive) for the resampling.
133
+ yMax : int
134
+ The maximum y-coordinate (inclusive) for the resampling.
135
+
136
+ Returns
137
+ -------
138
+ vzmesh : np.ndarray
139
+ 2D array of shape (xMax, yMax), containing interpolated z-values at
140
+ integer (x, y) positions.
141
+ xi : np.ndarray
142
+ 2D array of shape (xMax, yMax), representing the x-coordinates used for
143
+ interpolation.
144
+ yi : np.ndarray
145
+ 2D array of shape (xMax, yMax), representing the y-coordinates used for
146
+ interpolation.
147
+
148
+ Notes
149
+ -----
150
+ In Python, arrays are typically indexed as (row, column) which maps to
151
+ (y, x) in a 2D sense. This function transposes the meshgrid from
152
+ `np.meshgrid(..., indexing='xy')` to match the MATLAB style of indexing.
153
+ """
154
+
155
+ # 0) Check that xMax, yMax are integers.
156
+ # If not, round to nearest integer.
157
+ xMax = int(xMax)
158
+ yMax = int(yMax)
159
+
160
+ # 1) Build the interpolator,
161
+ # specifying x= xnodes (ascending), y= ynodes (ascending).
162
+ # Note that in Python, the first axis in zgrid is y, second is x.
163
+ # So pass (ynodes, xnodes) in that order:
164
+ rgi = RegularGridInterpolator(
165
+ (ynodes, xnodes), # (y-axis, x-axis)
166
+ zgrid,
167
+ method="linear",
168
+ bounds_error=False,
169
+ fill_value=np.nan # or e.g. zgrid.mean()
170
+ )
171
+
172
+ # 2) Make xi, yi as in MATLAB,
173
+ # then do xi=xi', yi=yi' => shape (xMax, yMax).
174
+ xi_m, yi_m = np.meshgrid(
175
+ np.arange(1, xMax+1),
176
+ np.arange(1, yMax+1),
177
+ indexing='xy'
178
+ )
179
+ xi = xi_m.T # shape (xMax, yMax)
180
+ yi = yi_m.T # shape (xMax, yMax)
181
+
182
+ # 3) Flatten the coordinate arrays to shape (N, 2) for RGI.
183
+ XYi = np.column_stack((yi.ravel(), xi.ravel()))
184
+ # We must pass (y, x) in that order since RGI is (y-axis, x-axis).
185
+
186
+ # 4) Interpolate.
187
+ vmesh_flat = rgi(XYi) # 1D array, length xMax*yMax
188
+
189
+ # 5) Reshape to (xMax, yMax).
190
+ vzmesh = vmesh_flat.reshape((xMax, yMax))
191
+
192
+ return vzmesh, xi, yi
193
+
194
+
195
+ def calculate_diag_length(
196
+ xpos: np.ndarray,
197
+ ypos: np.ndarray,
198
+ VZmesh: np.ndarray
199
+ ) -> tuple[float, float]:
200
+ """
201
+ Computes the 3D length along the main diagonal and skew diagonal of the
202
+ surface given by VZmesh, where xpos and ypos define the coordinate axes.
203
+
204
+ Parameters
205
+ ----------
206
+ xpos : np.ndarray
207
+ 1D array of x-coordinates (length M).
208
+ ypos : np.ndarray
209
+ 1D array of y-coordinates (length N).
210
+ VZmesh : np.ndarray
211
+ 2D array of shape (M, N), representing z-values at the grid points
212
+ formed by xpos and ypos.
213
+
214
+ Returns
215
+ -------
216
+ main_diag_dist : float
217
+ The summed 3D distance along the main diagonal of the surface.
218
+ skew_diag_dist : float
219
+ The summed 3D distance along the skew (reverse) diagonal of the surface.
220
+
221
+ Notes
222
+ -----
223
+ The function interpolates coordinates along whichever dimension is larger,
224
+ then sums Euclidean distances in 3D for each diagonal path.
225
+ """
226
+ M, N = VZmesh.shape # M = len(xpos), N = len(ypos)
227
+
228
+ # Build interpolators over the regular (xpos,ypos) grid
229
+ interp_x = RegularGridInterpolator((xpos, ypos),
230
+ np.meshgrid(xpos, ypos, indexing='ij')[0],
231
+ method='linear')
232
+ interp_y = RegularGridInterpolator((xpos, ypos),
233
+ np.meshgrid(xpos, ypos, indexing='ij')[1],
234
+ method='linear')
235
+ interp_z = RegularGridInterpolator((xpos, ypos), VZmesh,
236
+ method='linear')
237
+
238
+ main_diag_dist = 0.0
239
+ skew_diag_dist = 0.0
240
+
241
+ # if N >= M, we step N times in x, and use the full range of ypos in y
242
+ if N >= M:
243
+ # Build vectors for diagonal queries, matching the MATLAB approach
244
+ x_diag = np.linspace(xpos[0], xpos[-1], N) # length N
245
+ y_main_diag = np.array(ypos) # also length N
246
+ y_skew_diag = y_main_diag[::-1]
247
+
248
+ # Evaluate on main diagonal
249
+ pts_main = np.column_stack((x_diag, y_main_diag)) # shape (N,2)
250
+ x_knots_main = interp_x(pts_main)
251
+ y_knots_main = interp_y(pts_main)
252
+ z_knots_main = interp_z(pts_main)
253
+
254
+ # Evaluate on skew diagonal
255
+ pts_skew = np.column_stack((x_diag, y_skew_diag))
256
+ x_knots_skew = interp_x(pts_skew)
257
+ y_knots_skew = interp_y(pts_skew)
258
+ z_knots_skew = interp_z(pts_skew)
259
+
260
+ # Accumulate distances
261
+ for kk in range(N - 1):
262
+ dx_main = x_knots_main[kk] - x_knots_main[kk + 1]
263
+ dy_main = y_knots_main[kk] - y_knots_main[kk + 1]
264
+ dz_main = z_knots_main[kk] - z_knots_main[kk + 1]
265
+ main_diag_dist += np.sqrt(dx_main**2 + dy_main**2 + dz_main**2)
266
+
267
+ dx_skew = x_knots_skew[kk] - x_knots_skew[kk + 1]
268
+ dy_skew = y_knots_skew[kk] - y_knots_skew[kk + 1]
269
+ dz_skew = z_knots_skew[kk] - z_knots_skew[kk + 1]
270
+ skew_diag_dist += np.sqrt(dx_skew**2 + dy_skew**2 + dz_skew**2)
271
+
272
+ else:
273
+ # M > N
274
+ y_diag = np.linspace(ypos[0], ypos[-1], M) # length M
275
+ x_main_diag = np.array(xpos) # also length M
276
+ x_skew_diag = x_main_diag[::-1]
277
+
278
+ # Evaluate on main diagonal
279
+ pts_main = np.column_stack((x_main_diag, y_diag)) # shape (M,2)
280
+ x_knots_main = interp_x(pts_main)
281
+ y_knots_main = interp_y(pts_main)
282
+ z_knots_main = interp_z(pts_main)
283
+
284
+ # Evaluate on skew diagonal
285
+ pts_skew = np.column_stack((x_skew_diag, y_diag))
286
+ x_knots_skew = interp_x(pts_skew)
287
+ y_knots_skew = interp_y(pts_skew)
288
+ z_knots_skew = interp_z(pts_skew)
289
+
290
+ # Accumulate distances
291
+ for kk in range(M - 1):
292
+ dx_main = x_knots_main[kk] - x_knots_main[kk + 1]
293
+ dy_main = y_knots_main[kk] - y_knots_main[kk + 1]
294
+ dz_main = z_knots_main[kk] - z_knots_main[kk + 1]
295
+ main_diag_dist += np.sqrt(dx_main**2 + dy_main**2 + dz_main**2)
296
+
297
+ dx_skew = x_knots_skew[kk] - x_knots_skew[kk + 1]
298
+ dy_skew = y_knots_skew[kk] - y_knots_skew[kk + 1]
299
+ dz_skew = z_knots_skew[kk] - z_knots_skew[kk + 1]
300
+ skew_diag_dist += np.sqrt(dx_skew**2 + dy_skew**2 + dz_skew**2)
301
+
302
+ return main_diag_dist, skew_diag_dist
303
+
304
+
305
+ def assign_local_coordinates(triangle: np.ndarray) -> tuple[complex, complex, complex, float]:
306
+ """
307
+ Assigns local complex coordinates (w1, w2, w3) to the three vertices of a
308
+ triangle in 3D space, used for conformal mapping calculations.
309
+
310
+ Parameters
311
+ ----------
312
+ triangle : np.ndarray
313
+ Array of shape (3, 3), where each row corresponds to a vertex in (x, y, z).
314
+ The three vertices define one triangular face.
315
+
316
+ Returns
317
+ -------
318
+ w1 : complex
319
+ Complex representation of the local coordinate for vertex 1.
320
+ w2 : complex
321
+ Complex representation of the local coordinate for vertex 2.
322
+ w3 : complex
323
+ Complex representation of the local coordinate for vertex 3.
324
+ zeta : float
325
+ A normalization factor based on the triangle’s geometry, typically used
326
+ to scale further computations (e.g., for quasi-conformal maps).
327
+
328
+ Notes
329
+ -----
330
+ Each vertex is measured relative to the first vertex, establishing a local
331
+ coordinate system. The calculations ensure an appropriate scale and orientation
332
+ for the subsequent mapping steps.
333
+ """
334
+ d12 = np.linalg.norm(triangle[0] - triangle[1])
335
+ d13 = np.linalg.norm(triangle[0] - triangle[2])
336
+ d23 = np.linalg.norm(triangle[1] - triangle[2])
337
+ y3 = ((-d12)**2 + d13**2 - d23**2) / (2 * -d12)
338
+ x3 = np.sqrt(np.maximum(0, d13**2 - y3**2))
339
+ w2 = -x3 - 1j * y3
340
+ w1 = x3 + 1j * (y3 + d12)
341
+ w3 = 1j * (-d12)
342
+ zeta = np.abs(np.real(1j * (np.conj(w2) * w1 - np.conj(w1) * w2)))
343
+ return w1, w2, w3, zeta
344
+
345
+ def conformal_map_indep_fixed_diagonals(
346
+ mainDiagDist: float,
347
+ skewDiagDist: float,
348
+ xpos: np.ndarray,
349
+ ypos: np.ndarray,
350
+ VZmesh: np.ndarray
351
+ ) -> np.ndarray:
352
+ """
353
+ Creates a quasi-conformal 2D mapping of the surface in VZmesh.
354
+ Diagonal constraints are fixed using mainDiagDist and skewDiagDist
355
+ for consistent scaling.
356
+
357
+ Parameters
358
+ ----------
359
+ mainDiagDist : float
360
+ Target distance along the main diagonal for the mapped surface.
361
+ skewDiagDist : float
362
+ Target distance along the skew (reverse) diagonal for the mapped surface.
363
+ xpos : np.ndarray
364
+ 1D array of x-coordinates (length M).
365
+ ypos : np.ndarray
366
+ 1D array of y-coordinates (length N).
367
+ VZmesh : np.ndarray
368
+ 2D array of shape (M, N), representing z-values for each (x, y).
369
+
370
+ Returns
371
+ -------
372
+ mappedPositions : np.ndarray
373
+ 2D array of shape (M*N, 2). Each row corresponds to the (x, y) position
374
+ in the conformal map for the corresponding vertex in the original mesh.
375
+
376
+ Notes
377
+ -----
378
+ The mapping is generated by splitting each cell of the grid into two triangles,
379
+ constructing a sparse system to enforce approximate conformality, and then
380
+ solving for new vertex positions subject to diagonally fixed boundaries.
381
+ The final 2D layout merges two separate diagonal constraints.
382
+ """
383
+ M, N = VZmesh.shape
384
+ xpos_new = xpos + 1
385
+ ypos_new = ypos + 1
386
+ vertexCount:int = M * N
387
+ triangleCount = (2 * M - 2) * (N - 1)
388
+
389
+ # Efficient triangulation construction
390
+ col1 = np.kron([1, 1], np.arange(M - 1)).reshape(-1, 1)
391
+ temp1 = np.kron([1, M + 1], np.ones(M - 1)).reshape(-1, 1)
392
+ temp2 = np.kron([M + 1, M], np.ones(M - 1)).reshape(-1, 1)
393
+ one_column = np.hstack([col1, col1 + temp1, col1 + temp2]).astype(int)
394
+
395
+ # Corrected broadcasting logic
396
+ one_column_tiled = np.tile(one_column, (N - 1, 1))
397
+ offsets = (np.repeat(np.arange(N - 1), 2 * M - 2) * M).reshape(-1, 1)
398
+ triangulation = (one_column_tiled + offsets).astype(int)
399
+
400
+ # Precompute arrays
401
+ row_indices = np.repeat(np.arange(triangleCount), 3)
402
+ col_indices = triangulation.flatten()
403
+
404
+ Mreal_data = np.zeros(triangleCount * 3)
405
+ Mimag_data = np.zeros(triangleCount * 3)
406
+
407
+ # Loop once to fill the data
408
+ for tri_idx in range(triangleCount):
409
+ vertices = triangulation[tri_idx]
410
+ coords = np.column_stack((
411
+ xpos_new[vertices % M],
412
+ ypos_new[vertices // M],
413
+ VZmesh[vertices % M, vertices // M]
414
+ ))
415
+ w1, w2, w3, zeta = assign_local_coordinates(coords)
416
+ denom = np.sqrt(zeta / 2)
417
+ ws = [w1, w2, w3]
418
+
419
+ idx = slice(tri_idx * 3, (tri_idx + 1) * 3)
420
+ Mreal_data[idx] = np.real(ws) / denom
421
+ Mimag_data[idx] = np.imag(ws) / denom
422
+
423
+ # Build sparse matrices in one step
424
+ Mreal_csr = coo_matrix((Mreal_data, (row_indices, col_indices)), shape=(triangleCount, vertexCount)).tocsr()
425
+ Mimag_csr = coo_matrix((Mimag_data, (row_indices, col_indices)), shape=(triangleCount, vertexCount)).tocsr()
426
+
427
+ def solve_mapping(fixed_pts, fixed_vals, free_pts):
428
+ A = vstack([
429
+ hstack([Mreal_csr[:, free_pts], -Mimag_csr[:, free_pts]]),
430
+ hstack([Mimag_csr[:, free_pts], Mreal_csr[:, free_pts]])
431
+ ])
432
+
433
+ b_real = Mreal_csr[:, fixed_pts] @ fixed_vals[:, 0] - Mimag_csr[:, fixed_pts] @ fixed_vals[:, 1]
434
+ b_imag = Mimag_csr[:, fixed_pts] @ fixed_vals[:, 0] + Mreal_csr[:, fixed_pts] @ fixed_vals[:, 1]
435
+ b = -np.concatenate([b_real, b_imag])
436
+
437
+ AtA = (A.T @ A).tocsc()
438
+ Atb = A.T @ b
439
+
440
+ if HAS_CHOLMOD:
441
+ sol = cholesky(AtA)(Atb)
442
+ else:
443
+ sol = spsolve(AtA, Atb)
444
+
445
+ num_free = len(free_pts)
446
+ mapped = np.zeros((vertexCount, 2))
447
+ mapped[fixed_pts] = fixed_vals
448
+ mapped[free_pts, 0] = sol[:num_free]
449
+ mapped[free_pts, 1] = sol[num_free:]
450
+ return mapped
451
+
452
+ diag_scale = M / np.sqrt(M**2 + N**2)
453
+ main_diag_fixed_pts = [0, vertexCount - 1]
454
+ main_diag_fixed_vals = np.array([
455
+ [xpos_new[0], ypos_new[0]],
456
+ [xpos_new[0] + mainDiagDist * diag_scale, ypos_new[0] + mainDiagDist * diag_scale * N / M]
457
+ ])
458
+ main_diag_free_pts = np.setdiff1d(np.arange(vertexCount), main_diag_fixed_pts)
459
+ mapped_main = solve_mapping(main_diag_fixed_pts, main_diag_fixed_vals, main_diag_free_pts)
460
+
461
+ skew_diag_fixed_pts = [M - 1, vertexCount - M]
462
+ skew_diag_fixed_vals = np.array([
463
+ [xpos_new[0] + skewDiagDist * diag_scale, ypos_new[0]],
464
+ [xpos_new[0], ypos_new[0] + skewDiagDist * diag_scale * N / M]
465
+ ])
466
+ skew_diag_free_pts = np.setdiff1d(np.arange(vertexCount), skew_diag_fixed_pts)
467
+ mapped_skew = solve_mapping(skew_diag_fixed_pts, skew_diag_fixed_vals, skew_diag_free_pts)
468
+
469
+ # Final averaged result
470
+ mappedPositions = (mapped_main + mapped_skew) / 2
471
+ return mappedPositions
472
+
473
+ def align_mapped_surface(
474
+ thisVZminmesh: np.ndarray,
475
+ thisVZmaxmesh: np.ndarray,
476
+ mappedMinPositions: np.ndarray,
477
+ mappedMaxPositions: np.ndarray,
478
+ xborders: list[int],
479
+ yborders: list[int],
480
+ conformal_jump: int = 1,
481
+ patch_size: int = 21
482
+ ) -> np.ndarray:
483
+ """
484
+ Shifts the second mapped surface (mappedMaxPositions) so that its local
485
+ gradients align best with those of the first (mappedMinPositions).
486
+
487
+ Parameters
488
+ ----------
489
+ thisVZminmesh : np.ndarray
490
+ 2D array of shape (X, Y), representing the first (minimum) surface.
491
+ thisVZmaxmesh : np.ndarray
492
+ 2D array of shape (X, Y), representing the second (maximum) surface.
493
+ mappedMinPositions : np.ndarray
494
+ 2D array of shape (X*Y, 2), the conformally mapped coordinates
495
+ corresponding to the min surface.
496
+ mappedMaxPositions : np.ndarray
497
+ 2D array of shape (X*Y, 2), the conformally mapped coordinates
498
+ corresponding to the max surface.
499
+ xborders : list of int
500
+ [x_min, x_max] bounding indices used to focus the alignment region.
501
+ yborders : list of int
502
+ [y_min, y_max] bounding indices used to focus the alignment region.
503
+ conformal_jump : int, default=1
504
+ Subsampling step in x and y dimensions for alignment calculations.
505
+ patch_size : int, default=21
506
+ Size of the local 2D window used for minimizing gradient differences.
507
+
508
+ Returns
509
+ -------
510
+ mappedMaxPositions : np.ndarray
511
+ Updated 2D array of shape (X*Y, 2) for the max surface,
512
+ after alignment to the min surface.
513
+
514
+ Notes
515
+ -----
516
+ This step finds an offset (shift in x and y) that best aligns local slope
517
+ features from the two surfaces, by comparing gradients in a restricted region
518
+ and choosing the position with minimal combined gradient magnitude.
519
+ """
520
+ patch_size = int(np.ceil(patch_size / conformal_jump))
521
+
522
+ # Pad surfaces to preserve shape after differencing
523
+ pad_val_min = 10 * np.max(thisVZminmesh)
524
+ pad_val_max = 10 * np.max(thisVZmaxmesh)
525
+
526
+ VZminmesh_padded = np.pad(thisVZminmesh, ((0, 1), (0, 1)), constant_values=pad_val_min)
527
+ VZmaxmesh_padded = np.pad(thisVZmaxmesh, ((0, 1), (0, 1)), constant_values=pad_val_max)
528
+
529
+ # Gradient differences (dx + i*dy)
530
+ dmin_dx = np.diff(VZminmesh_padded, axis=0)[:, :-1]
531
+ dmin_dy = np.diff(VZminmesh_padded, axis=1)[:-1, :]
532
+ dMinSurface = np.abs(dmin_dx + 1j * dmin_dy)
533
+
534
+ dmax_dx = np.diff(VZmaxmesh_padded, axis=0)[:, :-1]
535
+ dmax_dy = np.diff(VZmaxmesh_padded, axis=1)[:-1, :]
536
+ dMaxSurface = np.abs(dmax_dx + 1j * dmax_dy)
537
+
538
+ # Region of interest
539
+ x1, x2 = xborders
540
+ y1, y2 = yborders
541
+
542
+ dMinSurface_roi = dMinSurface[x1:x2+1:conformal_jump, y1:y2+1:conformal_jump]
543
+ dMaxSurface_roi = dMaxSurface[x1:x2+1:conformal_jump, y1:y2+1:conformal_jump]
544
+
545
+ combined_slope = dMinSurface_roi + dMaxSurface_roi
546
+
547
+ # Patch cost = sum of local gradients over patch
548
+ kernel = np.ones((patch_size, patch_size))
549
+ patch_costs = convolve2d(combined_slope, kernel, mode='valid')
550
+
551
+ # # Map back to flattened index in 2D mesh
552
+ # row, col are 0-based from Python
553
+ # Convert them to 1-based to mimic MATLAB
554
+ min_index = np.argmin(patch_costs)
555
+ row0, col0 = np.unravel_index(min_index, patch_costs.shape)
556
+ # (row0, col0) is 0-based, which correspond to x,y in MATLAB if the array shape is (num_x, num_y).
557
+
558
+ # Now replicate the step:
559
+ # row = round(row + (patchSize - 1)/2)
560
+ # col = round(col + (patchSize - 1)/2)
561
+ row_center_0b = int(round(row0 + (patch_size - 1) / 2))
562
+ col_center_0b = int(round(col0 + (patch_size - 1) / 2))
563
+
564
+ # Now we want the same linear index that MATLAB would get from
565
+ # sub2ind([num_x, num_y], row_center, col_center),
566
+ # except sub2ind is 1-based. In 0-based form, that is:
567
+ # linearInd = col_center_0b * num_x + row_center_0b
568
+ flat_index = col_center_0b * dMinSurface_roi.shape[0] + row_center_0b
569
+
570
+ # Then do the shift
571
+ shift_x = mappedMaxPositions[flat_index, 0] - mappedMinPositions[flat_index, 0]
572
+ shift_y = mappedMaxPositions[flat_index, 1] - mappedMinPositions[flat_index, 1]
573
+
574
+ mappedMaxPositions[:, 0] -= shift_x
575
+ mappedMaxPositions[:, 1] -= shift_y
576
+
577
+ return mappedMaxPositions
578
+
579
+
580
+ def warp_surface(
581
+ thisvzminmesh: np.ndarray,
582
+ thisvzmaxmesh: np.ndarray,
583
+ arbor_boundaries: tuple[int, int, int, int],
584
+ conformal_jump: int = 1,
585
+ verbose: bool = False
586
+ ) -> dict:
587
+ """
588
+ Generates a conformal warp of two Starburst Amacrine Cell (SAC) surfaces
589
+ (min and max) and aligns them. This function is a higher-level wrapper
590
+ that uses diagonal distance calculations, conformal mapping, and alignment.
591
+
592
+ Parameters
593
+ ----------
594
+ thisvzminmesh : np.ndarray
595
+ 2D array of shape (X, Y), representing the “minimum” / ON SAC surface.
596
+ thisvzmaxmesh : np.ndarray
597
+ 2D array of shape (X, Y), representing the “maximum” / OFF SAC surface.
598
+ arbor_boundaries : tuple of int
599
+ (xmin, xmax, ymin, ymax) specifying the region of interest
600
+ over which to warp the surfaces.
601
+ conformal_jump : int, default=1
602
+ Subsampling step for reducing the resolution during mapping
603
+ (e.g., conformal_jump=2 uses every other pixel).
604
+ verbose : bool, default=False
605
+ Whether to print timing and debug information.
606
+
607
+ Returns
608
+ -------
609
+ dict
610
+ Dictionary containing:
611
+ - "mapped_min_positions": np.ndarray
612
+ Mapped coordinates for the min surface.
613
+ - "mapped_max_positions": np.ndarray
614
+ Mapped coordinates for the max surface (aligned to min).
615
+ - "main_diag_dist": float
616
+ Average main diagonal distance used during conformal mapping.
617
+ - "skew_diag_dist": float
618
+ Average skew diagonal distance used during conformal mapping.
619
+ - "thisx": np.ndarray
620
+ Subsampled x indices used for mapping.
621
+ - "thisy": np.ndarray
622
+ Subsampled y indices used for mapping.
623
+ - "thisVZminmesh": np.ndarray
624
+ Original min surface data used in mapping (subsampled).
625
+ - "thisVZmaxmesh": np.ndarray
626
+ Original max surface data used in mapping (subsampled).
627
+
628
+ Notes
629
+ -----
630
+ This routine is tailored to Starburst Amacrine Cell layers but can be
631
+ generalized to other layered surfaces. It:
632
+ 1. Subsamples the surfaces by conformal_jump.
633
+ 2. Calculates average diagonal distances from each surface.
634
+ 3. Performs two independent conformal mappings (min and max).
635
+ 4. Aligns the “max” mapping to “min” based on local gradient differences.
636
+ 5. Returns a dictionary of intermediate results for further inspection.
637
+ """
638
+ if verbose:
639
+ print("Warping surface...")
640
+ xmin, xmax, ymin, ymax = arbor_boundaries
641
+
642
+ thisx = np.round(np.arange(np.maximum(xmin-2, 0), np.minimum(xmax+1, thisvzmaxmesh.shape[0]), conformal_jump)).astype(int)
643
+ thisy = np.round(np.arange(np.maximum(ymin-2, 0), np.minimum(ymax+1, thisvzmaxmesh.shape[1]), conformal_jump)).astype(int)
644
+
645
+ thisminmesh = thisvzminmesh[thisx[:, None], thisy]
646
+ thismaxmesh = thisvzmaxmesh[thisx[:, None], thisy]
647
+ # calculate the traveling distances on the diagonals of the two SAC surfaces
648
+ start_time = time.time()
649
+ main_diag_dist_min, skew_diag_dist_min = calculate_diag_length(thisx, thisy, thisminmesh)
650
+ main_diag_dist_max, skew_diag_dist_max = calculate_diag_length(thisx, thisy, thismaxmesh)
651
+
652
+ main_diag_dist = np.mean([main_diag_dist_min, main_diag_dist_max])
653
+ skew_diag_dist = np.mean([skew_diag_dist_min, skew_diag_dist_max])
654
+
655
+ # quasi-conformally map individual SAC surfaces to planes
656
+ if verbose:
657
+ print("Mapping min position (On SAC layer)...")
658
+ start_time = time.time()
659
+ mapped_min_positions = conformal_map_indep_fixed_diagonals(
660
+ main_diag_dist, skew_diag_dist, thisx, thisy, thisminmesh
661
+ )
662
+ if verbose:
663
+ print(f"Mapping min position completed in {time.time() - start_time:.2f} seconds.")
664
+
665
+ if verbose:
666
+ print("Mapping max position (Off SAC layer)...")
667
+ start_time = time.time()
668
+ mapped_max_positions = conformal_map_indep_fixed_diagonals(
669
+ main_diag_dist, skew_diag_dist, thisx, thisy, thismaxmesh
670
+ )
671
+ if verbose:
672
+ print(f"Mapping max position completed in {time.time() - start_time:.2f} seconds.")
673
+
674
+ xborders = [thisx.min(), thisx.max()]
675
+ yborders = [thisy.min(), thisy.max()]
676
+
677
+ # align the mapped max surface to the mapped min surface
678
+ mapped_max_positions = align_mapped_surface(
679
+ thisvzminmesh, thisvzmaxmesh,
680
+ mapped_min_positions, mapped_max_positions,
681
+ xborders, yborders, conformal_jump
682
+ )
683
+
684
+ return {
685
+ "mapped_min_positions": mapped_min_positions,
686
+ "mapped_max_positions": mapped_max_positions,
687
+ "main_diag_dist": main_diag_dist,
688
+ "skew_diag_dist": skew_diag_dist,
689
+ "thisx": thisx,
690
+ "thisy": thisy,
691
+ "thisVZminmesh": thisvzminmesh,
692
+ "thisVZmaxmesh": thisvzmaxmesh,
693
+ }
pywarper/utils.py ADDED
@@ -0,0 +1,118 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ # import scipy.spatial
5
+
6
+ def read_arbor_trace(
7
+ datapath: str,
8
+ downsample_factor: int = 1
9
+ ) -> tuple[pd.DataFrame, np.ndarray, np.ndarray, np.ndarray]:
10
+ """
11
+ Reads a neuronal arbor trace from an SWC file and optionally downsamples the data.
12
+
13
+ Parameters
14
+ ----------
15
+ datapath : str
16
+ Path to the SWC file to read.
17
+ downsample_factor : int, default=1
18
+ If greater than 1, every n-th row is selected (in row order) to reduce
19
+ the total number of points in the returned data.
20
+
21
+ Returns
22
+ -------
23
+ df : pd.DataFrame
24
+ A DataFrame containing all columns read from the SWC file:
25
+ 'n', 'type', 'x', 'y', 'z', 'radius', 'parent'.
26
+ coords : np.ndarray
27
+ An (N, 3) array of node coordinates [x, y, z].
28
+ edges : np.ndarray
29
+ An (N, 2) array where each row is [node_id, parent_id].
30
+ Note that ids and parents may be updated if downsample_factor > 1.
31
+ radii : np.ndarray
32
+ An (N,) array of radii corresponding to each node.
33
+
34
+ Notes
35
+ -----
36
+ 1. Comment lines in the SWC file (prefixed by '#') are automatically skipped.
37
+ 2. The downsampled data is re-indexed so that the 'parent' field reflects
38
+ the new node numbering.
39
+ """
40
+ # Read the SWC file into a DataFrame
41
+ df = pd.read_csv(datapath, comment='#',
42
+ names=['n', 'type', 'x', 'y', 'z', 'radius', 'parent'],
43
+ index_col=False, sep=r'\s+')
44
+
45
+ if downsample_factor > 1:
46
+ # Downsample the DataFrame by selecting every nth point
47
+ downsampled_df = df.iloc[::downsample_factor].copy()
48
+
49
+ # Update the indices
50
+ id_map = {old_id: new_id for new_id, old_id in enumerate(downsampled_df['n'], start=1)}
51
+ downsampled_df['n'] = downsampled_df['n'].map(id_map)
52
+ downsampled_df['parent'] = downsampled_df['parent'].map(lambda x: id_map.get(x, -1))
53
+
54
+ df = downsampled_df
55
+
56
+ return df, df[["x", "y", "z"]].values, df[["n", "parent"]].values, df["radius"].values
57
+
58
+
59
+ # def read_arbor_trace(datapath):
60
+
61
+ # df = pd.read_csv(datapath, comment='#',
62
+ # names=['n', 'type', 'x', 'y', 'z', 'radius', 'parent'], index_col=False, sep=r'\s+')
63
+
64
+ # return df, df[["x", "y", "z"]].values, df[["n", "parent"]].values, df["radius"].values
65
+
66
+ # def summarize_z_at_sampled_xy(x, y, z, grid_spacing=10, radius=5, percentiles=[10, 25, 50, 75, 90]):
67
+ # """
68
+ # Sample a sparse subset of original (x, y) points and compute z summary stats
69
+ # over a local neighborhood (radius).
70
+
71
+ # Parameters:
72
+ # x, y, z (np.ndarray): Original point cloud arrays (N,)
73
+ # grid_spacing (float): Sampling interval for (x, y) grid
74
+ # radius (float): Radius around each sample point to summarize z values
75
+ # percentiles (list): Percentiles to compute for z
76
+
77
+ # Returns:
78
+ # x_samp, y_samp: Sampled locations (M,)
79
+ # z_mean: mean Z value around each (x, y)
80
+ # z_stats: dict of z_pXX arrays (same length as x_samp)
81
+ # """
82
+ # # Build spatial tree on original points
83
+ # xy = np.column_stack((x, y))
84
+ # tree = scipy.spatial.cKDTree(xy)
85
+
86
+ # # Generate sparse sampling grid
87
+ # x_grid = np.arange(x.min(), x.max(), grid_spacing)
88
+ # y_grid = np.arange(y.min(), y.max(), grid_spacing)
89
+
90
+ # xx, yy = np.meshgrid(x_grid, y_grid)
91
+ # sample_points = np.column_stack((xx.ravel(), yy.ravel()))
92
+
93
+ # # For each sample point, find neighbors within radius
94
+ # neighbors = tree.query_ball_point(sample_points, r=radius)
95
+
96
+ # x_samp, y_samp = [], []
97
+ # z_mean = []
98
+ # z_percentiles = {f'z_p{p}': [] for p in percentiles}
99
+
100
+ # # Get max edges (float values)
101
+ # for (xi, yi), idxs in zip(sample_points, neighbors):
102
+ # if len(idxs) == 0:
103
+ # continue # skip interior point with no neighbors
104
+ # else:
105
+ # z_vals = z[idxs]
106
+
107
+ # x_samp.append(xi)
108
+ # y_samp.append(yi)
109
+ # z_mean.append(np.mean(z_vals))
110
+ # for p in percentiles:
111
+ # z_percentiles[f'z_p{p}'].append(np.percentile(z_vals, p))
112
+
113
+ # return (
114
+ # np.array(x_samp),
115
+ # np.array(y_samp),
116
+ # np.array(z_mean),
117
+ # {k: np.array(v) for k, v in z_percentiles.items()}
118
+ # )
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: pywarper
3
+ Version: 0.1.0
4
+ Summary: Conformal mapping-based warping of neuronal arbor morphologies.
5
+ Requires-Python: >=3.9.0
6
+ Requires-Dist: numpy>=2.0.2
7
+ Requires-Dist: pandas>=2.2.3
8
+ Requires-Dist: pygridfit>=0.1.4
9
+ Requires-Dist: scipy>=1.13.1
10
+ Requires-Dist: watermark>=2.5.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: maturin; extra == 'dev'
13
+ Requires-Dist: pytest; extra == 'dev'
14
+ Requires-Dist: ruff; extra == 'dev'
15
+ Requires-Dist: twine; extra == 'dev'
16
+ Provides-Extra: scikit-sparse
17
+ Requires-Dist: pygridfit[scikit-sparse]>=0.1.4; extra == 'scikit-sparse'
18
+ Requires-Dist: scikit-sparse>=0.4.15; extra == 'scikit-sparse'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # pywarper
22
+
23
+ `pywarper` is a Python package for conformal mapping-based warping of neuronal arbor morphologies, based on the [MATLAB implementations](https://github.com/uygarsumbul/rgc) (Sümbül, et al. 2014).
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ git clone https://github.com/berenslab/pywarper.git
29
+ pip install -e pywarper
30
+ ```
31
+
32
+ By default, `pywarper` uses `scipy.sparse.linalg.spsolve` to solve sparse matrices, which can be slow. For faster performance, you can manually install [scikit-sparse](https://github.com/scikit-sparse/scikit-sparse), as it requires additional dependencies:
33
+
34
+ ```bash
35
+ # mac
36
+ brew install suite-sparse
37
+
38
+ # debian
39
+ sudo apt-get install libsuitesparse-dev
40
+
41
+ pip install -e pywarper[scikit-sparse]
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ See the [example](https://github.com/berenslab/pywarper/blob/main/notebooks/example.ipynb) notebook for usage.
@@ -0,0 +1,7 @@
1
+ pywarper/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pywarper/arbor.py,sha256=S1YONCF3kUC-vBugJ3bDeLM4gfC6WprqvcGVX9iy8WU,10776
3
+ pywarper/surface.py,sha256=wjZges-TZrGSQMNIBUXMAhX6u4WP1t2HVsAljajzYdw,26408
4
+ pywarper/utils.py,sha256=T_QZtRs3xGIubyVdTV-GIS-1_lpxNDZdAgTCAKZZtJ0,4265
5
+ pywarper-0.1.0.dist-info/METADATA,sha256=aO3uUtv-ZyUbbiak-gY1iqu66erqGWknYwJ2GliRL2w,1509
6
+ pywarper-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
7
+ pywarper-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any