Skip to content

Commit 294b0d5

Browse files
authored
Merge pull request #12 from berenslab/unwarp
Unwarper
2 parents 7b8c0d9 + bea7fc0 commit 294b0d5

9 files changed

Lines changed: 3170 additions & 820 deletions

File tree

notebooks/example.api.high.ipynb

Lines changed: 21 additions & 27 deletions
Large diffs are not rendered by default.

notebooks/example.api.low.ipynb

Lines changed: 19 additions & 18 deletions
Large diffs are not rendered by default.

notebooks/example.unwarp.ipynb

Lines changed: 417 additions & 0 deletions
Large diffs are not rendered by default.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "pywarper"
7-
version = "0.2.4"
7+
version = "0.2.5"
88
description = "Conformal mapping-based warping of retinal morphologies."
99
authors = []
1010
requires-python = ">=3.10.0"

pywarper/unwarper.py

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
"""Inverse mapping helpers for pywarper."""
2+
3+
from __future__ import annotations
4+
5+
from copy import deepcopy
6+
7+
import numpy as np
8+
from scipy.optimize import least_squares
9+
from skeliner.dataclass import Skeleton
10+
11+
from .utils import build_surface_correspondences, resolve_conformal_jump
12+
from .warpers import (
13+
_apply_local_ls_state,
14+
_build_local_ls_state,
15+
local_ls_registration,
16+
)
17+
18+
19+
def denormalize_nodes(
20+
nodes: np.ndarray,
21+
med_z_on: float,
22+
med_z_off: float,
23+
on_sac_pos: float = 0.0,
24+
off_sac_pos: float = 12.0,
25+
) -> np.ndarray:
26+
"""
27+
Undo `normalize_nodes` and map z back to the pre-normalized warped frame.
28+
29+
Parameters
30+
----------
31+
nodes : np.ndarray
32+
(N, 3) normalized [x, y, z] coordinates.
33+
med_z_on : float
34+
Median z-value of the ON SAC surface used during warping.
35+
med_z_off : float
36+
Median z-value of the OFF SAC surface used during warping.
37+
on_sac_pos : float, default=0.0
38+
ON surface position used in normalized space.
39+
off_sac_pos : float, default=12.0
40+
OFF surface position used in normalized space.
41+
42+
Returns
43+
-------
44+
np.ndarray
45+
(N, 3) coordinates in the pre-normalized warped frame.
46+
"""
47+
nodes = np.asarray(nodes, dtype=float)
48+
if nodes.ndim != 2 or nodes.shape[1] != 3:
49+
raise ValueError("nodes must be an (N, 3) array.")
50+
if np.isclose(off_sac_pos, on_sac_pos):
51+
raise ValueError("off_sac_pos and on_sac_pos must be different values.")
52+
53+
denormalized_nodes = nodes.copy()
54+
rel_depth = (nodes[:, 2] - on_sac_pos) / (off_sac_pos - on_sac_pos)
55+
denormalized_nodes[:, 2] = med_z_on + rel_depth * (med_z_off - med_z_on)
56+
return denormalized_nodes
57+
58+
59+
def _prepare_unwarp_inputs(
60+
nodes: np.ndarray,
61+
surface_mapping: dict,
62+
*,
63+
on_sac_pos: float,
64+
off_sac_pos: float,
65+
conformal_jump: int | None,
66+
backward_compatible: bool,
67+
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
68+
points = np.asarray(nodes, dtype=float)
69+
if points.ndim != 2 or points.shape[1] != 3:
70+
raise ValueError("nodes must be an (N, 3) array.")
71+
72+
resolved_jump = resolve_conformal_jump(surface_mapping, conformal_jump)
73+
on_input_pts, off_input_pts, on_output_pts, off_output_pts, map_med_z_on, map_med_z_off = (
74+
build_surface_correspondences(
75+
surface_mapping,
76+
conformal_jump=resolved_jump,
77+
backward_compatible=backward_compatible,
78+
)
79+
)
80+
81+
prenormed_nodes = denormalize_nodes(
82+
points,
83+
med_z_on=map_med_z_on,
84+
med_z_off=map_med_z_off,
85+
on_sac_pos=on_sac_pos,
86+
off_sac_pos=off_sac_pos,
87+
)
88+
89+
return prenormed_nodes, on_input_pts, off_input_pts, on_output_pts, off_output_pts
90+
91+
92+
def unwarp_nodes(
93+
nodes: np.ndarray,
94+
surface_mapping: dict,
95+
*,
96+
on_sac_pos: float = 0.0,
97+
off_sac_pos: float = 12.0,
98+
conformal_jump: int | None = None,
99+
backward_compatible: bool = False,
100+
method: str = "local_ls",
101+
max_evals_per_point: int = 80,
102+
convergence_tol: float = 1e-9,
103+
bound_xy_to_map: bool = True,
104+
) -> np.ndarray:
105+
"""
106+
Inverse of `warp_nodes` for point coordinates.
107+
108+
`method="local_ls"` mirrors the forward local least-squares model with
109+
swapped correspondences (flattened -> curved frame).
110+
`method="optimize"` refines per-point inverse coordinates by minimizing
111+
forward residuals (`warp_nodes(x) ~= target`).
112+
Input nodes are assumed to be normalized warped coordinates and are
113+
denormalized using the provided ON/OFF SAC reference positions.
114+
"""
115+
prenormed_nodes, on_input_pts, off_input_pts, on_output_pts, off_output_pts = (
116+
_prepare_unwarp_inputs(
117+
nodes,
118+
surface_mapping,
119+
on_sac_pos=on_sac_pos,
120+
off_sac_pos=off_sac_pos,
121+
conformal_jump=conformal_jump,
122+
backward_compatible=backward_compatible,
123+
)
124+
)
125+
126+
if method == "local_ls":
127+
# Inverse pass: swap forward correspondences (flattened -> curved frame).
128+
return local_ls_registration(
129+
prenormed_nodes,
130+
on_output_pts,
131+
off_output_pts,
132+
on_input_pts,
133+
off_input_pts,
134+
)
135+
136+
if method != "optimize":
137+
raise ValueError("method must be one of {'local_ls', 'optimize'}")
138+
139+
if max_evals_per_point <= 0:
140+
raise ValueError("max_evals_per_point must be a positive integer.")
141+
if convergence_tol <= 0:
142+
raise ValueError("convergence_tol must be a positive float.")
143+
144+
# Start from the fast approximate inverse and refine against the forward model.
145+
inverse_state = _build_local_ls_state(
146+
on_output_pts,
147+
off_output_pts,
148+
on_input_pts,
149+
off_input_pts,
150+
window=5.0,
151+
max_order=2,
152+
)
153+
initial = _apply_local_ls_state(prenormed_nodes, inverse_state, warn=False)
154+
155+
forward_state = _build_local_ls_state(
156+
on_input_pts,
157+
off_input_pts,
158+
on_output_pts,
159+
off_output_pts,
160+
window=5.0,
161+
max_order=2,
162+
)
163+
164+
if bound_xy_to_map:
165+
x_min = float(min(on_input_pts[:, 0].min(), off_input_pts[:, 0].min()))
166+
x_max = float(max(on_input_pts[:, 0].max(), off_input_pts[:, 0].max()))
167+
y_min = float(min(on_input_pts[:, 1].min(), off_input_pts[:, 1].min()))
168+
y_max = float(max(on_input_pts[:, 1].max(), off_input_pts[:, 1].max()))
169+
lower_bounds = np.array([x_min, y_min, -np.inf], dtype=float)
170+
upper_bounds = np.array([x_max, y_max, np.inf], dtype=float)
171+
else:
172+
lower_bounds = np.array([-np.inf, -np.inf, -np.inf], dtype=float)
173+
upper_bounds = np.array([np.inf, np.inf, np.inf], dtype=float)
174+
175+
recovered = np.empty_like(prenormed_nodes)
176+
for i, target in enumerate(prenormed_nodes):
177+
x0 = initial[i].astype(float, copy=True)
178+
if bound_xy_to_map:
179+
x0[:2] = np.clip(x0[:2], lower_bounds[:2], upper_bounds[:2])
180+
181+
def _residual(x: np.ndarray) -> np.ndarray:
182+
warped = _apply_local_ls_state(x[None, :], forward_state, warn=False)[0]
183+
return warped - target
184+
185+
sol = least_squares(
186+
_residual,
187+
x0=x0,
188+
bounds=(lower_bounds, upper_bounds),
189+
method="trf",
190+
max_nfev=int(max_evals_per_point),
191+
ftol=float(convergence_tol),
192+
xtol=float(convergence_tol),
193+
gtol=float(convergence_tol),
194+
)
195+
recovered[i] = sol.x
196+
197+
return recovered
198+
199+
200+
def _coerce_voxel_resolution(
201+
voxel_resolution: float
202+
| list[float | int]
203+
| tuple[float | int, float | int, float | int]
204+
) -> np.ndarray:
205+
voxel_res = np.asarray(voxel_resolution, dtype=float)
206+
if voxel_res.ndim == 0:
207+
voxel_res = np.repeat(voxel_res, 3)
208+
if voxel_res.shape != (3,):
209+
raise ValueError("voxel_resolution must be a scalar or a length-3 sequence.")
210+
if np.any(np.isclose(voxel_res, 0.0)):
211+
raise ValueError("voxel_resolution entries must be non-zero.")
212+
return voxel_res
213+
214+
215+
def unwarp_skeleton(
216+
skel: Skeleton,
217+
surface_mapping: dict,
218+
*,
219+
voxel_resolution: float
220+
| list[float | int]
221+
| tuple[float | int, float | int, float | int] = (1.0, 1.0, 1.0),
222+
on_sac_pos: float = 0.0,
223+
off_sac_pos: float = 12.0,
224+
skeleton_nodes_scale: float = 1.0,
225+
conformal_jump: int | None = None,
226+
backward_compatible: bool = False,
227+
method: str = "local_ls",
228+
max_evals_per_point: int = 80,
229+
convergence_tol: float = 1e-9,
230+
bound_xy_to_map: bool = True,
231+
) -> Skeleton:
232+
"""
233+
Inverse of `warp_skeleton` for Skeleton objects.
234+
235+
Parameters
236+
----------
237+
skel : Skeleton
238+
Warped skeleton, typically produced by `warp_skeleton`.
239+
surface_mapping : dict
240+
Surface mapping used for the forward warp.
241+
voxel_resolution : float or length-3 sequence, default=(1.0, 1.0, 1.0)
242+
Resolution that was used in `warp_skeleton` to convert warped nodes
243+
to physical units. It is undone before node-level inversion.
244+
on_sac_pos, off_sac_pos : float
245+
SAC reference positions used during normalization in the forward pass.
246+
skeleton_nodes_scale : float, default=1.0
247+
Scale factor that was used in `warp_skeleton` before warping.
248+
conformal_jump, backward_compatible
249+
Mapping options forwarded to `unwarp_nodes`.
250+
method, max_evals_per_point, convergence_tol, bound_xy_to_map
251+
Inversion options forwarded to `unwarp_nodes`.
252+
253+
Returns
254+
-------
255+
Skeleton
256+
Skeleton with recovered nodes in the original (pre-warp) node units.
257+
"""
258+
scale = float(skeleton_nodes_scale)
259+
if np.isclose(scale, 0.0):
260+
raise ValueError("skeleton_nodes_scale must be non-zero.")
261+
262+
voxel_res = _coerce_voxel_resolution(voxel_resolution)
263+
264+
# `warp_skeleton` stores nodes in physical units, so undo that first.
265+
normalized_nodes = np.asarray(skel.nodes, dtype=float) / voxel_res
266+
# `warp_skeleton` divides by this scale before returning the skeleton.
267+
normalized_nodes *= scale
268+
269+
recovered_nodes = unwarp_nodes(
270+
normalized_nodes,
271+
surface_mapping,
272+
on_sac_pos=on_sac_pos,
273+
off_sac_pos=off_sac_pos,
274+
conformal_jump=conformal_jump,
275+
backward_compatible=backward_compatible,
276+
method=method,
277+
max_evals_per_point=max_evals_per_point,
278+
convergence_tol=convergence_tol,
279+
bound_xy_to_map=bound_xy_to_map,
280+
)
281+
recovered_nodes /= scale
282+
283+
recovered_soma = deepcopy(skel.soma)
284+
recovered_soma.center = recovered_nodes[0].copy()
285+
286+
return Skeleton(
287+
soma=recovered_soma,
288+
nodes=recovered_nodes,
289+
edges=skel.edges,
290+
radii=skel.radii,
291+
ntype=skel.ntype,
292+
node2verts=skel.node2verts,
293+
vert2node=skel.vert2node,
294+
meta=skel.meta.copy(),
295+
extra=skel.extra.copy(),
296+
)

0 commit comments

Comments
 (0)