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