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 +0 -0
- pywarper/arbor.py +282 -0
- pywarper/surface.py +693 -0
- pywarper/utils.py +118 -0
- pywarper-0.1.0.dist-info/METADATA +46 -0
- pywarper-0.1.0.dist-info/RECORD +7 -0
- pywarper-0.1.0.dist-info/WHEEL +4 -0
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,,
|