From 400b0b3dde597d4b11ada31dfd87097f1fa81d9a Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 19 Jun 2024 18:34:17 +0200 Subject: [PATCH 01/31] initial copy/paste from dredge --- .../sortingcomponents/dredge.py | 604 ++++++++++++++++++ .../sortingcomponents/motion_estimation.py | 3 + 2 files changed, 607 insertions(+) create mode 100644 src/spikeinterface/sortingcomponents/dredge.py diff --git a/src/spikeinterface/sortingcomponents/dredge.py b/src/spikeinterface/sortingcomponents/dredge.py new file mode 100644 index 0000000000..fd56715fa9 --- /dev/null +++ b/src/spikeinterface/sortingcomponents/dredge.py @@ -0,0 +1,604 @@ +""" +Re-implementation or copy-paste of DREDge +https://github.com/evarol/dredge + +For historical reason, some function from the DREDge package where implemeneted +in spikeinterface in the motion_estimation.py before the DREDge package itself! + +Here a copy/paste (and small rewriting) of some functions from DREDge. + +The main entry for this function are still: + + * motion = estimate_motion((recording, ..., method='dredge_lfp') + * motion = estimate_motion((recording, ..., method='dredge_ap') + +but here the original functions from Charlie, Julien and Erdem have been ported for an +easier maintenance instead of making DREDge a dependency of spikeinterface. +""" + +# TODO +# discuss the get_windows in dredge +# remove get_motion_estimate() ??? + + +import numpy as np + + +def dredge_online_lfp( + lfp_recording, + rigid=True, + chunk_len_s=10.0, + max_disp_um=500, + # nonrigid window construction arguments + win_shape="gaussian", + win_step_um=800, + win_scale_um=850, + win_margin_um=None, + max_dt_s=None, + # weighting arguments + mincorr=0.8, + mincorr_percentile=None, + mincorr_percentile_nneighbs=20, + soft=False, + # low-level arguments + thomas_kw=None, + xcorr_kw=None, + # misc + save_full=False, + device=None, + pbar=True, +): + """Online registration of a preprocessed LFP recording + + Arguments + --------- + lfp_recording : spikeinterface BaseRecording object + Preprocessed LFP recording. The temporal resolution of this recording will + be the target resolution of the registration, so definitely use SpikeInterface + to resample your recording to, say, 250Hz (or a value you like) rather than + estimating motion at the original frequency (which may be high). + rigid : boolean, optional + If True, window-related arguments are ignored and we do rigid registration + chunk_len_s : float + Length of chunks (in seconds) that the recording is broken into for online + registration. The computational speed of the method is a function of the + number of samples this corresponds to, and things can get slow if it is + set high enough that the number of samples per chunk is bigger than ~10,000. + But, it can't be set too low or the algorithm doesn't have enough data + to work with. The default is set assuming sampling rate of 250Hz, leading + to 2500 samples per chunk. + max_dt_s : float + Time-bins farther apart than this value in seconds will not be cross-correlated. + Set this to at least `chunk_len_s`. + max_disp_um : number, optional + This is the ceiling on the possible displacement estimates. It should be + set to a number which is larger than the allowed displacement in a single + chunk. Setting it as small as possible (while following that rule) can speed + things up and improve the result by making it impossible to estimate motion + which is too big. + win_shape, win_step_um, win_scale_um, win_margin_um : float + Nonrigid window-related arguments + The depth domain will be broken up into windows with shape controlled by win_shape, + spaced by win_step_um at a margin of win_margin_um from the boundary, and with + width controlled by win_scale_um. + mincorr : float in [0,1] + Minimum correlation between pairs of frames such that they will be included + in the optimization of the displacement estimates. + mincorr_percentile, mincorr_percentile_nneighbs + If mincorr_percentile is set to a number in [0, 100], then mincorr will be replaced + by this percentile of the correlations of neighbors within mincorr_percentile_nneighbs + time bins of each other. + device : string or torch.device + Controls torch device + + Returns + ------- + me : motion_util.MotionEstimate + A motion estimate object. me.displacement is the displacement trace, but this object + includes methods for getting the displacement at different times and depths; see + the documentation in the motion_util.py file. + extra : dict + Dict containing extra info for debugging + """ + geom = lfp_recording.get_channel_locations() + fs = lfp_recording.get_sampling_frequency() + T_total = lfp_recording.get_num_samples() + T_chunk = min(int(np.floor(fs * chunk_len_s)), T_total) + + # kwarg defaults and handling + # need lfp-specific defaults + xcorr_kw = xcorr_kw if xcorr_kw is not None else {} + thomas_kw = thomas_kw if thomas_kw is not None else {} + full_xcorr_kw = dict( + rigid=rigid, + bin_um=np.median(np.diff(geom[:, 1])), + max_disp_um=max_disp_um, + pbar=False, + device=device, + **xcorr_kw, + ) + threshold_kw = dict( + mincorr_percentile_nneighbs=mincorr_percentile_nneighbs, + in_place=True, + soft=soft, + # max_dt_s=weights_kw["max_dt_s"], # max_dt not implemented for lfp at this point + max_dt_s=max_dt_s, + bin_s=1 / fs, # only relevant for max_dt_s + ) + + # get windows + windows, window_centers = get_windows( + geom, + win_step_um, + win_scale_um, + spatial_bin_centers=geom[:, 1], + margin_um=win_margin_um, + win_shape=win_shape, + zero_threshold=1e-5, + rigid=rigid, + ) + B = len(windows) + extra = dict(window_centers=window_centers, windows=windows) + + # -- allocate output and initialize first chunk + P_online = np.empty((B, T_total), dtype=np.float32) + # below, t0 is start of prev chunk, t1 start of cur chunk, t2 end of cur + t0, t1 = 0, T_chunk + traces0 = lfp_recording.get_traces(start_frame=t0, end_frame=t1) + Ds0, Cs0, max_disp_um = xcorr_windows( + traces0.T, windows, geom[:, 1], win_scale_um, **full_xcorr_kw + ) + full_xcorr_kw["max_disp_um"] = max_disp_um + Ss0, mincorr0 = threshold_correlation_matrix( + Cs0, + mincorr=mincorr, + mincorr_percentile=mincorr_percentile, + **threshold_kw, + ) + if save_full: + extra["D"] = [Ds0] + extra["C"] = [Cs0] + extra["S"] = [Ss0] + extra["D01"] = [] + extra["C01"] = [] + extra["S01"] = [] + extra["mincorrs"] = [mincorr0] + extra["max_disp_um"] = max_disp_um + P_online[:, t0:t1], _ = thomas_solve(Ds0, Ss0, **thomas_kw) + + # -- loop through chunks + chunk_starts = range(T_chunk, T_total, T_chunk) + if pbar: + chunk_starts = trange( + T_chunk, + T_total, + T_chunk, + desc=f"Online chunks [{chunk_len_s}s each]", + ) + for t1 in chunk_starts: + t2 = min(T_total, t1 + T_chunk) + traces1 = lfp_recording.get_traces(start_frame=t1, end_frame=t2) + + # cross-correlations between prev/cur chunks + # these are T1, T0 shaped + Ds10, Cs10, _ = xcorr_windows( + traces1.T, + windows, + geom[:, 1], + win_scale_um, + raster_b=traces0.T, + **full_xcorr_kw, + ) + + # cross-correlation in current chunk + Ds1, Cs1, _ = xcorr_windows( + traces1.T, windows, geom[:, 1], win_scale_um, **full_xcorr_kw + ) + Ss1, mincorr1 = threshold_correlation_matrix( + Cs1, + mincorr_percentile=mincorr_percentile, + mincorr=mincorr, + **threshold_kw, + ) + Ss10, _ = threshold_correlation_matrix( + Cs10, mincorr=mincorr1, t_offset_bins=T_chunk, **threshold_kw + ) + extra["mincorrs"].append(mincorr1) + + if save_full: + extra["D"].append(Ds1) + extra["C"].append(Cs1) + extra["S"].append(Ss1) + extra["D01"].append(Ds10) + extra["C01"].append(Cs10) + extra["S01"].append(Ss10) + + # solve online problem + P_online[:, t1:t2], _ = thomas_solve( + Ds1, + Ss1, + P_prev=P_online[:, t0:t1], + Ds_curprev=Ds10, + Us_curprev=Ss10, + Ds_prevcur=-Ds10.transpose(0, 2, 1), + Us_prevcur=Ss10.transpose(0, 2, 1), + **thomas_kw, + ) + + # update loop vars + t0, t1 = t1, t2 + traces0 = traces1 + + # -- convert to motion estimate and return + me = get_motion_estimate( + P_online, + time_bin_centers_s=lfp_recording.get_times(0), + spatial_bin_centers_um=window_centers, + ) + return me, extra + + + +# -- functions from dredgelib + + + +def thomas_solve( + Ds, + Us, + lambda_t=DEFAULT_LAMBDA_T, + lambda_s=1.0, + eps=DEFAULT_EPS, + P_prev=None, + Ds_prevcur=None, + Us_prevcur=None, + Ds_curprev=None, + Us_curprev=None, + pbar=False, + bandwidth=None, +): + """Block tridiagonal algorithm, special cased to our setting + + This code solves for the displacement estimates across the nonrigid windows, + given blockwise, pairwise (BxTxT) displacement and weights arrays `Ds` and `Us`. + + If `lambda_t>0`, a temporal prior is applied to "fill the gaps", effectively + interpolating through time to avoid artifacts in low-signal areas. Setting this + to 0 can lead to numerical warnings and should be done with care. + + If `lambda_s>0`, a spatial prior is applied. This can help fill gaps more + meaningfully in the nonrigid case, using information from the neighboring nonrigid + windows to inform the estimate in an untrusted region of a given window. + + If arguments `P_prev,Ds_prevcur,Us_prevcur` are supplied, this code handles the + online case. The return value will be the new chunk's displacement estimate, + solving the online registration problem. + """ + Ds = np.asarray(Ds, dtype=np.float64) + Us = np.asarray(Us, dtype=np.float64) + online = P_prev is not None + online_kw_rhs = online_kw_hess = lambda b: {} + if online: + assert Ds_prevcur is not None + assert Us_prevcur is not None + online_kw_rhs = lambda b: dict( # noqa + Pb_prev=P_prev[b].astype(np.float64, copy=False), + Db_prevcur=Ds_prevcur[b].astype(np.float64, copy=False), + Ub_prevcur=Us_prevcur[b].astype(np.float64, copy=False), + Db_curprev=Ds_curprev[b].astype(np.float64, copy=False), + Ub_curprev=Us_curprev[b].astype(np.float64, copy=False), + ) + online_kw_hess = lambda b: dict( # noqa + Ub_prevcur=Us_prevcur[b].astype(np.float64, copy=False), + Ub_curprev=Us_curprev[b].astype(np.float64, copy=False), + ) + + B, T, T_ = Ds.shape + assert T == T_ + assert Us.shape == Ds.shape + + # figure out which temporal bins are included in the problem + # these are used to figure out where epsilon can be added + # for numerical stability without changing the solution + had_weights = (Us > 0).any(axis=2) + had_weights[~had_weights.any(axis=1)] = 1 + + # temporal prior matrix + L_t = [laplacian(T, eps=eps, lambd=lambda_t, ridge_mask=w) for w in had_weights] + extra = dict(L_t=L_t) + + # just solve independent problems when there's no spatial regularization + # not that there's much overhead to the backward pass etc but might as well + if B == 1 or lambda_s == 0: + P = np.zeros((B, T)) + extra["HU"] = np.zeros((B, T, T)) + for b in range(B): + P[b], extra["HU"][b] = newton_solve_rigid( + Ds[b], Us[b], L_t[b], **online_kw_rhs(b) + ) + return P, extra + + # spatial prior is a sparse, block tridiagonal kronecker product + # the first and last diagonal blocks are + Lambda_s_diagb = laplacian(T, eps=eps, lambd=lambda_s / 2, ridge_mask=had_weights[0]) + # and the off-diagonal blocks are + Lambda_s_offdiag = laplacian(T, eps=0, lambd=-lambda_s / 2) + + # initialize block-LU stuff and forward variable + alpha_hat_b = ( + L_t[0] + + Lambda_s_diagb + + neg_hessian_likelihood_term(Us[0], **online_kw_hess(0)) + ) + targets = np.c_[ + Lambda_s_offdiag, newton_rhs(Us[0], Ds[0], **online_kw_rhs(0)) + ] + res = solve(alpha_hat_b, targets, assume_a="pos") + assert res.shape == (T, T + 1) + gamma_hats = [res[:, :T]] + ys = [res[:, T]] + + # forward pass + for b in (trange(1, B, desc="Solve") if pbar else range(1, B)): + if b < B - 1: + Lambda_s_diagb = laplacian(T, eps=eps, lambd=lambda_s, ridge_mask=had_weights[b]) + else: + Lambda_s_diagb = laplacian(T, eps=eps, lambd=lambda_s / 2, ridge_mask=had_weights[b]) + + Ab = ( + L_t[b] + + Lambda_s_diagb + + neg_hessian_likelihood_term(Us[b], **online_kw_hess(b)) + ) + alpha_hat_b = Ab - Lambda_s_offdiag @ gamma_hats[b - 1] + targets[:, T] = newton_rhs(Us[b], Ds[b], **online_kw_rhs(b)) + targets[:, T] -= Lambda_s_offdiag @ ys[b - 1] + res = solve(alpha_hat_b, targets) + assert res.shape == (T, T + 1) + gamma_hats.append(res[:, :T]) + ys.append(res[:, T]) + + # back substitution + xs = [None] * B + xs[-1] = ys[-1] + for b in range(B - 2, -1, -1): + xs[b] = ys[b] - gamma_hats[b] @ xs[b + 1] + + # un-vectorize + P = np.concatenate(xs).reshape(B, T) + + return P, extra + + + +def threshold_correlation_matrix( + Cs, + mincorr=0.0, + mincorr_percentile=None, + mincorr_percentile_nneighbs=20, + max_dt_s=0, + in_place=False, + bin_s=1, + t_offset_bins=None, + T=None, + soft=True, +): + if mincorr_percentile is not None: + diags = [ + np.diagonal(Cs, offset=j, axis1=1, axis2=2).ravel() + for j in range(1, mincorr_percentile_nneighbs) + ] + mincorr = np.percentile( + np.concatenate(diags), + mincorr_percentile, + ) + + # need abs to avoid -0.0s which cause numerical issues + if in_place: + Ss = Cs + if soft: + Ss[Ss < mincorr] = 0 + else: + Ss = (Ss >= mincorr).astype(Cs.dtype) + np.square(Ss, out=Ss) + else: + if soft: + Ss = np.square((Cs >= mincorr) * Cs) + else: + Ss = (Cs >= mincorr).astype(Cs.dtype) + if ( + max_dt_s is not None + and max_dt_s > 0 + and T is not None + and max_dt_s < T + ): + tt0 = bin_s * np.arange(T) + tt1 = tt0 + if t_offset_bins: + tt1 = tt0 + t_offset_bins + dt = tt1[:, None] - tt0[None, :] + mask = (np.abs(dt) <= max_dt_s).astype(Ss.dtype) + Ss *= mask[None] + return Ss, mincorr + + +def xcorr_windows( + raster_a, + windows, + spatial_bin_edges_um, + win_scale_um, + raster_b=None, + rigid=False, + bin_um=1, + max_disp_um=None, + max_dt_bins=None, + pbar=True, + centered=True, + normalized=True, + masks=None, + device=None, +): + """Main computational function + + Compute pairwise (time x time) maximum cross-correlation and displacement + matrices in each nonrigid window. + """ + if device is None: + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + if max_disp_um is None: + if rigid: + max_disp_um = int(spatial_bin_edges_um.ptp() // 4) + else: + max_disp_um = int(win_scale_um // 4) + + max_disp_bins = int(max_disp_um // bin_um) + slices = get_window_domains(windows) + B, D = windows.shape + D_, T0 = raster_a.shape + assert D == D_ + + # torch versions on device + windows_ = torch.as_tensor(windows, dtype=torch.float, device=device) + raster_a_ = torch.as_tensor(raster_a, dtype=torch.float, device=device) + if raster_b is not None: + assert raster_b.shape[0] == D + T1 = raster_b.shape[1] + raster_b_ = torch.as_tensor(raster_b, dtype=torch.float, device=device) + else: + T1 = T0 + raster_b_ = raster_a_ + if masks is not None: + masks = torch.as_tensor(masks, dtype=torch.float, device=device) + + # estimate each window's displacement + Ds = np.zeros((B, T0, T1), dtype=np.float32) + Cs = np.zeros((B, T0, T1), dtype=np.float32) + block_iter = trange(B, desc="Cross correlation") if pbar else range(B) + for b in block_iter: + window = windows_[b] + + # we search for the template (windowed part of raster a) + # within a larger-than-the-window neighborhood in raster b + targ_low = slices[b].start - max_disp_bins + b_low = max(0, targ_low) + targ_high = slices[b].stop + max_disp_bins + b_high = min(D, targ_high) + padding = max(b_low - targ_low, targ_high - b_high) + + # arithmetic to compute the lags in um corresponding to + # corr argmaxes + n_left = padding + slices[b].start - b_low + n_right = padding + b_high - slices[b].stop + poss_disp = -np.arange(-n_left, n_right + 1) * bin_um + + Ds[b], Cs[b] = calc_corr_decent_pair( + raster_a_[slices[b]], + raster_b_[b_low:b_high], + weights=window[slices[b]], + masks=None if masks is None else masks[slices[b]], + xmasks=None if masks is None else masks[b_low:b_high], + disp=padding, + possible_displacement=poss_disp, + device=device, + centered=centered, + normalized=normalized, + max_dt_bins=max_dt_bins, + ) + + return Ds, Cs, max_disp_um + + +def calc_corr_decent_pair( + raster_a, + raster_b, + weights=None, + masks=None, + xmasks=None, + disp=None, + batch_size=512, + normalized=True, + centered=True, + possible_displacement=None, + max_dt_bins=None, + device=None, +): + """Weighted pairwise cross-correlation + + Calculate TxT normalized xcorr and best displacement matrices + Given a DxT raster, this computes normalized cross correlations for + all pairs of time bins at offsets in the range [-disp, disp], by + increments of step_size. Then it finds the best one and its + corresponding displacement, resulting in two TxT matrices: one for + the normxcorrs at the best displacement, and the matrix of the best + displacements. + + Arguments + --------- + raster : DxT array + batch_size : int + How many raster rows to xcorr against the whole raster + at once. + step_size : int + Displacement increment. Not implemented yet but easy to do. + disp : int + Maximum displacement + device : torch device + Returns: D, C: TxT arrays + """ + D, Ta = raster_a.shape + D_, Tb = raster_b.shape + + # sensible default: at most half the domain. + if disp is None: + disp == D // 2 + + # range of displacements + if D == D_: + if possible_displacement is None: + possible_displacement = np.arange(-disp, disp + 1) + else: + assert possible_displacement is not None + assert disp is not None + + # pick torch device if unset + if device is None: + device = ( + torch.device("cuda") + if torch.cuda.is_available() + else torch.device("cpu") + ) + + # process rasters into the tensors we need for conv2ds below + # convert to TxD device floats + raster_a = torch.as_tensor(raster_a.T, dtype=torch.float32, device=device) + # normalize over depth for normalized (uncentered) xcorrs + raster_b = torch.as_tensor(raster_b.T, dtype=torch.float32, device=device) + + D = np.zeros((Ta, Tb), dtype=np.float32) + C = np.zeros((Ta, Tb), dtype=np.float32) + for i in range(0, Ta, batch_size): + for j in range(0, Tb, batch_size): + dt_bins = min( + abs(i - j), abs(i + batch_size - j), abs(i - j - batch_size) + ) + if max_dt_bins and dt_bins > max_dt_bins: + continue + weights_ = weights + if masks is not None: + weights_ = masks.T[i : i + batch_size] * weights + corr = normxcorr1d( + raster_a[i : i + batch_size], + raster_b[j : j + batch_size], + weights=weights_, + xmasks=None if xmasks is None else xmasks.T[j : j + batch_size], + padding=disp, + normalized=normalized, + centered=centered, + ) + max_corr, best_disp_inds = torch.max(corr, dim=2) + best_disp = possible_displacement[best_disp_inds.cpu()] + D[i : i + batch_size, j : j + batch_size] = best_disp.T + C[i : i + batch_size, j : j + batch_size] = max_corr.cpu().T + + return D, C \ No newline at end of file diff --git a/src/spikeinterface/sortingcomponents/motion_estimation.py b/src/spikeinterface/sortingcomponents/motion_estimation.py index 3134d68681..42fd12383d 100644 --- a/src/spikeinterface/sortingcomponents/motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion_estimation.py @@ -602,7 +602,9 @@ def get_windows(rigid, bin_um, contact_pos, spatial_bin_edges, margin_um, win_st Here by default we use gaussian window. """ + # TODO remove bin_um bin_centers = spatial_bin_edges[:-1] + bin_um / 2.0 + # bin_centers = 0.5 * (spatial_bin_edges[1:] + spatial_bin_edges[:-1]) n = bin_centers.size if rigid: @@ -611,6 +613,7 @@ def get_windows(rigid, bin_um, contact_pos, spatial_bin_edges, margin_um, win_st middle = (spatial_bin_edges[0] + spatial_bin_edges[-1]) / 2.0 non_rigid_window_centers = np.array([middle]) else: + # TODO put a warning assert win_sigma_um >= win_step_um, f"win_sigma_um too low {win_sigma_um} compared to win_step_um {win_step_um}" min_ = np.min(contact_pos) - margin_um From ddf094bff9138d373ce70a6698fd449d4a1642ee Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Fri, 21 Jun 2024 11:53:09 +0200 Subject: [PATCH 02/31] Move motion files into new motion subfolder --- src/spikeinterface/sortingcomponents/motion/__init__.py | 0 src/spikeinterface/sortingcomponents/{ => motion}/dredge.py | 0 .../sortingcomponents/{ => motion}/motion_estimation.py | 0 .../sortingcomponents/{ => motion}/motion_interpolation.py | 0 src/spikeinterface/sortingcomponents/{ => motion}/motion_utils.py | 0 .../{ => motion}/tests/test_motion_estimation.py | 0 .../{ => motion}/tests/test_motion_interpolation.py | 0 .../sortingcomponents/{ => motion}/tests/test_motion_utils.py | 0 8 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/spikeinterface/sortingcomponents/motion/__init__.py rename src/spikeinterface/sortingcomponents/{ => motion}/dredge.py (100%) rename src/spikeinterface/sortingcomponents/{ => motion}/motion_estimation.py (100%) rename src/spikeinterface/sortingcomponents/{ => motion}/motion_interpolation.py (100%) rename src/spikeinterface/sortingcomponents/{ => motion}/motion_utils.py (100%) rename src/spikeinterface/sortingcomponents/{ => motion}/tests/test_motion_estimation.py (100%) rename src/spikeinterface/sortingcomponents/{ => motion}/tests/test_motion_interpolation.py (100%) rename src/spikeinterface/sortingcomponents/{ => motion}/tests/test_motion_utils.py (100%) diff --git a/src/spikeinterface/sortingcomponents/motion/__init__.py b/src/spikeinterface/sortingcomponents/motion/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/spikeinterface/sortingcomponents/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py similarity index 100% rename from src/spikeinterface/sortingcomponents/dredge.py rename to src/spikeinterface/sortingcomponents/motion/dredge.py diff --git a/src/spikeinterface/sortingcomponents/motion_estimation.py b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py similarity index 100% rename from src/spikeinterface/sortingcomponents/motion_estimation.py rename to src/spikeinterface/sortingcomponents/motion/motion_estimation.py diff --git a/src/spikeinterface/sortingcomponents/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion/motion_interpolation.py similarity index 100% rename from src/spikeinterface/sortingcomponents/motion_interpolation.py rename to src/spikeinterface/sortingcomponents/motion/motion_interpolation.py diff --git a/src/spikeinterface/sortingcomponents/motion_utils.py b/src/spikeinterface/sortingcomponents/motion/motion_utils.py similarity index 100% rename from src/spikeinterface/sortingcomponents/motion_utils.py rename to src/spikeinterface/sortingcomponents/motion/motion_utils.py diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py similarity index 100% rename from src/spikeinterface/sortingcomponents/tests/test_motion_estimation.py rename to src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_interpolation.py similarity index 100% rename from src/spikeinterface/sortingcomponents/tests/test_motion_interpolation.py rename to src/spikeinterface/sortingcomponents/motion/tests/test_motion_interpolation.py diff --git a/src/spikeinterface/sortingcomponents/tests/test_motion_utils.py b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_utils.py similarity index 100% rename from src/spikeinterface/sortingcomponents/tests/test_motion_utils.py rename to src/spikeinterface/sortingcomponents/motion/tests/test_motion_utils.py From a6424f2a1933b58c65878bca7ddd790a4ac78439 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Fri, 21 Jun 2024 15:04:58 +0200 Subject: [PATCH 03/31] Reorganize dredge function with already existing --- src/spikeinterface/preprocessing/motion.py | 7 +- .../sortingcomponents/motion/__init__.py | 4 + .../sortingcomponents/motion/dredge.py | 315 ++++++++++++++++-- .../motion/motion_estimation.py | 168 ++-------- .../sortingcomponents/motion/motion_utils.py | 123 +++++++ .../motion/tests/test_motion_estimation.py | 21 +- 6 files changed, 469 insertions(+), 169 deletions(-) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index 8023bd4367..1e825f9c73 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -239,8 +239,8 @@ def correct_motion( * :py:func:`~spikeinterface.sortingcomponents.peak_detection.detect_peaks` * :py:func:`~spikeinterface.sortingcomponents.peak_selection.select_peaks` * :py:func:`~spikeinterface.sortingcomponents.peak_localization.localize_peaks` - * :py:func:`~spikeinterface.sortingcomponents.motion_estimation.estimate_motion` - * :py:func:`~spikeinterface.sortingcomponents.motion_interpolation.interpolate_motion` + * :py:func:`~spikeinterface.sortingcomponents.motion.motion_estimation.estimate_motion` + * :py:func:`~spikeinterface.sortingcomponents.motion.motion_interpolation.interpolate_motion` Possible presets : {} @@ -282,8 +282,7 @@ def correct_motion( from spikeinterface.sortingcomponents.peak_detection import detect_peaks, detect_peak_methods from spikeinterface.sortingcomponents.peak_selection import select_peaks from spikeinterface.sortingcomponents.peak_localization import localize_peaks, localize_peak_methods - from spikeinterface.sortingcomponents.motion_estimation import estimate_motion - from spikeinterface.sortingcomponents.motion_interpolation import InterpolateMotionRecording + from spikeinterface.sortingcomponents.motion import estimate_motion, InterpolateMotionRecording from spikeinterface.core.node_pipeline import ExtractDenseWaveforms, run_node_pipeline # get preset params and update if necessary diff --git a/src/spikeinterface/sortingcomponents/motion/__init__.py b/src/spikeinterface/sortingcomponents/motion/__init__.py index e69de29bb2..2a045bd108 100644 --- a/src/spikeinterface/sortingcomponents/motion/__init__.py +++ b/src/spikeinterface/sortingcomponents/motion/__init__.py @@ -0,0 +1,4 @@ +from .motion_utils import Motion +from .motion_estimation import estimate_motion +from .motion_interpolation import (correct_motion_on_peaks, interpolate_motion_on_traces, + InterpolateMotionRecording, interpolate_motion) diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index fd56715fa9..b77b87d45a 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -15,15 +15,14 @@ but here the original functions from Charlie, Julien and Erdem have been ported for an easier maintenance instead of making DREDge a dependency of spikeinterface. """ +import warnings -# TODO -# discuss the get_windows in dredge -# remove get_motion_estimate() ??? - - +from tqdm.auto import trange import numpy as np +from .motion_utils import Motion, get_windows, get_window_domains, scipy_conv1d + def dredge_online_lfp( lfp_recording, rigid=True, @@ -34,7 +33,7 @@ def dredge_online_lfp( win_step_um=800, win_scale_um=850, win_margin_um=None, - max_dt_s=None, + time_horizon_s=None, # weighting arguments mincorr=0.8, mincorr_percentile=None, @@ -44,7 +43,7 @@ def dredge_online_lfp( thomas_kw=None, xcorr_kw=None, # misc - save_full=False, + extra_outputs=False, device=None, pbar=True, ): @@ -67,7 +66,7 @@ def dredge_online_lfp( But, it can't be set too low or the algorithm doesn't have enough data to work with. The default is set assuming sampling rate of 250Hz, leading to 2500 samples per chunk. - max_dt_s : float + time_horizon_s : float Time-bins farther apart than this value in seconds will not be cross-correlated. Set this to at least `chunk_len_s`. max_disp_um : number, optional @@ -121,9 +120,9 @@ def dredge_online_lfp( mincorr_percentile_nneighbs=mincorr_percentile_nneighbs, in_place=True, soft=soft, - # max_dt_s=weights_kw["max_dt_s"], # max_dt not implemented for lfp at this point - max_dt_s=max_dt_s, - bin_s=1 / fs, # only relevant for max_dt_s + # time_horizon_s=weights_kw["time_horizon_s"], # max_dt not implemented for lfp at this point + time_horizon_s=time_horizon_s, + bin_s=1 / fs, # only relevant for time_horizon_s ) # get windows @@ -138,7 +137,9 @@ def dredge_online_lfp( rigid=rigid, ) B = len(windows) - extra = dict(window_centers=window_centers, windows=windows) + + if extra_outputs: + extra = dict(window_centers=window_centers, windows=windows) # -- allocate output and initialize first chunk P_online = np.empty((B, T_total), dtype=np.float32) @@ -155,7 +156,7 @@ def dredge_online_lfp( mincorr_percentile=mincorr_percentile, **threshold_kw, ) - if save_full: + if extra_outputs: extra["D"] = [Ds0] extra["C"] = [Cs0] extra["S"] = [Ss0] @@ -205,7 +206,7 @@ def dredge_online_lfp( ) extra["mincorrs"].append(mincorr1) - if save_full: + if extra_outputs: extra["D"].append(Ds1) extra["C"].append(Cs1) extra["S"].append(Ss1) @@ -230,17 +231,131 @@ def dredge_online_lfp( traces0 = traces1 # -- convert to motion estimate and return - me = get_motion_estimate( - P_online, - time_bin_centers_s=lfp_recording.get_times(0), - spatial_bin_centers_um=window_centers, + motion = Motion( + displacement=[P_online], + temporal_bins_s=[lfp_recording.get_times(0)], + spatial_bin_um=[window_centers] ) - return me, extra + + if extra_outputs: + return motion, extra + else: + return motion # -- functions from dredgelib +DEFAULT_LAMBDA_T = 1.0 +DEFAULT_EPS = 1e-3 + +# -- linear algebra, Newton method solver, block tridiagonal (Thomas) solver + + +def laplacian(n, wink=True, eps=DEFAULT_EPS, lambd=1.0, ridge_mask=None): + """Construct a discrete Laplacian operator (plus eps*identity).""" + lap = np.zeros((n, n)) + if ridge_mask is None: + diag = lambd + eps + else: + diag = lambd + eps * ridge_mask + np.fill_diagonal(lap, diag) + if wink: + lap[0, 0] -= 0.5 * lambd + lap[-1, -1] -= 0.5 * lambd + # fill diagonal using a for loop for space reasons when this is large + for i in range(n - 1): + lap[i, i + 1] -= 0.5 * lambd + lap[i + 1, i] -= 0.5 * lambd + return lap + + + +def neg_hessian_likelihood_term(Ub, Ub_prevcur=None, Ub_curprev=None): + """Newton step coefficients + + The negative Hessian of the non-regularized cost function inside a nonrigid block. + Together with the term arising from the regularization, this constructs the + coefficients matrix in our linear problem. + """ + negHUb = -Ub - Ub.T + diagonal_terms = np.diagonal(negHUb) + Ub.sum(1) + Ub.sum(0) + if Ub_prevcur is None: + np.fill_diagonal(negHUb, diagonal_terms) + else: + diagonal_terms += Ub_prevcur.sum(0) + Ub_curprev.sum(1) + np.fill_diagonal(negHUb, diagonal_terms) + return negHUb + + +def newton_rhs( + Db, + Ub, + Pb_prev=None, + Db_prevcur=None, + Ub_prevcur=None, + Db_curprev=None, + Ub_curprev=None, +): + """Newton step right hand side + + The gradient at P=0 of the cost function, which is the right hand side of Newton's method. + """ + UDb = Ub * Db + grad_at_0 = UDb.sum(1) - UDb.sum(0) + + # batch case + if Pb_prev is None: + return grad_at_0 + + # online case + align_term = (Ub_prevcur.T + Ub_curprev) @ Pb_prev + rhs = ( + align_term + + grad_at_0 + + (Ub_curprev * Db_curprev).sum(1) + - (Ub_prevcur * Db_prevcur).sum(0) + ) + + return rhs + + +def newton_solve_rigid( + D, + U, + Sigma0inv, + Pb_prev=None, + Db_prevcur=None, + Ub_prevcur=None, + Db_curprev=None, + Ub_curprev=None, +): + """Solve the rigid Newton step + + D is TxT displacement, U is TxT subsampling or soft weights matrix. + """ + from scipy.linalg import solve, lstsq + + negHU = neg_hessian_likelihood_term( + U, + Ub_prevcur=Ub_prevcur, + Ub_curprev=Ub_curprev, + ) + targ = newton_rhs( + D, + U, + Pb_prev=Pb_prev, + Db_prevcur=Db_prevcur, + Ub_prevcur=Ub_prevcur, + Db_curprev=Db_curprev, + Ub_curprev=Ub_curprev, + ) + try: + p = solve(Sigma0inv + negHU, targ, assume_a="pos") + except np.linalg.LinAlgError: + warnings.warn("Singular problem, using least squares.") + p, *_ = lstsq(Sigma0inv + negHU, targ) + return p, negHU def thomas_solve( @@ -274,6 +389,8 @@ def thomas_solve( online case. The return value will be the new chunk's displacement estimate, solving the online registration problem. """ + from scipy.linalg import solve + Ds = np.asarray(Ds, dtype=np.float64) Us = np.asarray(Us, dtype=np.float64) online = P_prev is not None @@ -376,7 +493,7 @@ def threshold_correlation_matrix( mincorr=0.0, mincorr_percentile=None, mincorr_percentile_nneighbs=20, - max_dt_s=0, + time_horizon_s=0, in_place=False, bin_s=1, t_offset_bins=None, @@ -407,17 +524,17 @@ def threshold_correlation_matrix( else: Ss = (Cs >= mincorr).astype(Cs.dtype) if ( - max_dt_s is not None - and max_dt_s > 0 + time_horizon_s is not None + and time_horizon_s > 0 and T is not None - and max_dt_s < T + and time_horizon_s < T ): tt0 = bin_s * np.arange(T) tt1 = tt0 if t_offset_bins: tt1 = tt0 + t_offset_bins dt = tt1[:, None] - tt0[None, :] - mask = (np.abs(dt) <= max_dt_s).astype(Ss.dtype) + mask = (np.abs(dt) <= time_horizon_s).astype(Ss.dtype) Ss *= mask[None] return Ss, mincorr @@ -443,6 +560,8 @@ def xcorr_windows( Compute pairwise (time x time) maximum cross-correlation and displacement matrices in each nonrigid window. """ + import torch + if device is None: device = torch.device("cuda" if torch.cuda.is_available() else "cpu") @@ -546,6 +665,8 @@ def calc_corr_decent_pair( device : torch device Returns: D, C: TxT arrays """ + import torch + D, Ta = raster_a.shape D_, Tb = raster_b.shape @@ -601,4 +722,148 @@ def calc_corr_decent_pair( D[i : i + batch_size, j : j + batch_size] = best_disp.T C[i : i + batch_size, j : j + batch_size] = max_corr.cpu().T - return D, C \ No newline at end of file + return D, C + + +# TODO charlie sam : at the moment this is a duplicate with small differences see motion_estimate.py same function +def normxcorr1d( + template, + x, + weights=None, + xmasks=None, + centered=True, + normalized=True, + padding="same", + conv_engine="torch", +): + """normxcorr1d: Normalized cross-correlation, optionally weighted + + The API is like torch's F.conv1d, except I have accidentally + changed the position of input/weights -- template acts like weights, + and x acts like input. + + Returns the cross-correlation of `template` and `x` at spatial lags + determined by `mode`. Useful for estimating the location of `template` + within `x`. + + This might not be the most efficient implementation -- ideas welcome. + It uses a direct convolutional translation of the formula + corr = (E[XY] - EX EY) / sqrt(var X * var Y) + + This also supports weights! In that case, the usual adaptation of + the above formula is made to the weighted case -- and all of the + normalizations are done per block in the same way. + + Arguments + --------- + template : tensor, shape (num_templates, length) + The reference template signal + x : tensor, 1d shape (length,) or 2d shape (num_inputs, length) + The signal in which to find `template` + weights : tensor, shape (length,) + Will use weighted means, variances, covariances if supplied. + centered : bool + If true, means will be subtracted (per weighted patch). + normalized : bool + If true, normalize by the variance (per weighted patch). + padding : int, optional + How far to look? if unset, we'll use half the length + conv_engine : string, one of "torch", "numpy" + What library to use for computing cross-correlations. + If numpy, falls back to the scipy correlate function. + + Returns + ------- + corr : tensor + """ + + + if conv_engine == "torch": + import torch + import torch.nn.functional as F + conv1d = F.conv1d + npx = torch + elif conv_engine == "numpy": + conv1d = scipy_conv1d + npx = np + else: + raise ValueError(f"Unknown conv_engine {conv_engine}") + + x = npx.atleast_2d(x) + num_templates, lengtht = template.shape + num_inputs, lengthx = x.shape + + # generalize over weighted / unweighted case + device_kw = {} if conv_engine == "numpy" else dict(device=x.device) + if xmasks is None: + onesx = npx.ones((1, 1, lengthx), dtype=x.dtype, **device_kw) + wx = x[:, None, :] + else: + assert xmasks.shape == x.shape + onesx = xmasks[:, None, :] + wx = x[:, None, :] * onesx + no_weights = weights is None + if no_weights: + weights = npx.ones((1, 1, lengtht), dtype=x.dtype, **device_kw) + wt = template[:, None, :] + else: + if weights.shape == (lengtht,): + weights = weights[None, None] + elif weights.shape == (num_templates, lengtht): + weights = weights[:, None, :] + else: + assert False + wt = template[:, None, :] * weights + x = x[:, None, :] + template = template[:, None, :] + + # conv1d valid rule: + # (B,1,L),(O,1,L)->(B,O,L) + # below, we always put x on the LHS, templates on the RHS, so this reads + # (num_inputs, 1, lengthx), (num_templates, 1, lengtht) -> (num_inputs, num_templates, length_out) + + # compute expectations + # how many points in each window? seems necessary to normalize + # for numerical stability. + Nx = conv1d(onesx, weights, padding=padding) # 1,nt,l + empty = Nx == 0 + Nx[empty] = 1 + if centered: + Et = conv1d(onesx, wt, padding=padding) # 1,nt,l + Et /= Nx + Ex = conv1d(wx, weights, padding=padding) # nx,nt,l + Ex /= Nx + + # compute (weighted) covariance + # important: the formula E[XY] - EX EY is well-suited here, + # because the means are naturally subtracted correctly + # patch-wise. you couldn't pre-subtract them! + cov = conv1d(wx, wt, padding=padding) + cov /= Nx + if centered: + cov -= Ex * Et + + # compute variances for denominator, using var X = E[X^2] - (EX)^2 + if normalized: + var_template = conv1d( + onesx, wt * template, padding=padding + ) + var_template /= Nx + var_x = conv1d(wx * x, weights, padding=padding) + var_x /= Nx + if centered: + var_template -= npx.square(Et) + var_x -= npx.square(Ex) + + # fill in zeros to avoid problems when dividing + var_template[var_template <= 0] = 1 + var_x[var_x <= 0] = 1 + + # now find the final normxcorr + corr = cov # renaming for clarity + if normalized: + corr[torch.broadcast_to(empty, corr.shape)] = 0 + corr /= npx.sqrt(var_x) + corr /= npx.sqrt(var_template) + + return corr \ No newline at end of file diff --git a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py index 42fd12383d..1933239619 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py @@ -4,8 +4,8 @@ import numpy as np -from .motion_utils import Motion -from .tools import make_multi_method_doc +from .motion_utils import Motion, get_windows, scipy_conv1d +from spikeinterface.sortingcomponents.tools import make_multi_method_doc try: import torch @@ -32,7 +32,7 @@ def estimate_motion( speed_threshold=30, sigma_smooth_s=None, method="decentralized", - output_extra_check=False, + extra_outputs=False, progress_bar=False, upsample_to_histogram_bin=False, verbose=False, @@ -90,7 +90,7 @@ def estimate_motion( sigma_smooth_s: None or float Optional smooting gaussian kernel when not None - output_extra_check: bool, default: False + extra_outputs: bool, default: False If True then return an extra dict that contains variables to check intermediate steps (motion_histogram, non_rigid_windows, pairwise_displacement) upsample_to_histogram_bin: bool or None, default: False @@ -109,17 +109,17 @@ def estimate_motion( ------- motion: Motion object The motion object. - extra_check: dict - Optional output if `output_extra_check=True` + extra: dict + Optional output if `extra_outputs=True` This dict contain histogram, pairwise_displacement usefull for ploting. """ # TODO handle multi segment one day assert recording.get_num_segments() == 1 - if output_extra_check: - extra_check = {} + if extra_outputs: + extra = {} else: - extra_check = None + extra = None # contact positions probe = recording.get_probe() @@ -131,11 +131,11 @@ def estimate_motion( # get windows non_rigid_windows, non_rigid_window_centers = get_windows( - rigid, bin_um, contact_pos, spatial_bin_edges, margin_um, win_step_um, win_sigma_um, win_shape + rigid, contact_pos, spatial_bin_edges, margin_um, win_step_um, win_sigma_um, win_shape ) - if output_extra_check: - extra_check["non_rigid_windows"] = non_rigid_windows + if extra_outputs: + extra["non_rigid_windows"] = non_rigid_windows # run method method_class = estimate_motion_methods[method] @@ -150,7 +150,7 @@ def estimate_motion( non_rigid_windows, verbose, progress_bar, - extra_check, + extra, **method_kwargs, ) @@ -165,8 +165,8 @@ def estimate_motion( if upsample_to_histogram_bin is None: upsample_to_histogram_bin = not rigid if upsample_to_histogram_bin: - extra_check["motion_array"] = motion_array - extra_check["non_rigid_window_centers"] = non_rigid_window_centers + extra["motion_array"] = motion_array + extra["non_rigid_window_centers"] = non_rigid_window_centers non_rigid_windows = np.array(non_rigid_windows) non_rigid_windows /= non_rigid_windows.sum(axis=0, keepdims=True) non_rigid_window_centers = spatial_bin_edges[:-1] + bin_um / 2 @@ -175,8 +175,8 @@ def estimate_motion( # TODO handle multi segment motion = Motion([motion_array], [temporal_bins], non_rigid_window_centers, direction=direction) - if output_extra_check: - return motion, extra_check + if extra_outputs: + return motion, extra else: return motion @@ -270,7 +270,7 @@ def run( non_rigid_windows, verbose, progress_bar, - extra_check, + extra, histogram_depth_smooth_um=None, histogram_time_smooth_s=None, pairwise_displacement_method="conv", @@ -329,11 +329,11 @@ def run( smooth_kernel /= np.sum(smooth_kernel) motion_histogram = scipy.signal.fftconvolve(motion_histogram, smooth_kernel[:, None], mode="same", axes=0) - if extra_check is not None: - extra_check["motion_histogram"] = motion_histogram - extra_check["pairwise_displacement_list"] = [] - extra_check["temporal_hist_bin_edges"] = temporal_hist_bin_edges - extra_check["spatial_hist_bin_edges"] = spatial_hist_bin_edges + if extra is not None: + extra["motion_histogram"] = motion_histogram + extra["pairwise_displacement_list"] = [] + extra["temporal_hist_bin_edges"] = temporal_hist_bin_edges + extra["spatial_hist_bin_edges"] = spatial_hist_bin_edges # temporal bins are bin center temporal_bins = 0.5 * (temporal_hist_bin_edges[1:] + temporal_hist_bin_edges[:-1]) @@ -378,8 +378,8 @@ def run( all_pairwise_displacements[i] = pairwise_displacement all_pairwise_displacement_weights[i] = pairwise_displacement_weight - if extra_check is not None: - extra_check["pairwise_displacement_list"].append(pairwise_displacement) + if extra is not None: + extra["pairwise_displacement_list"].append(pairwise_displacement) if verbose: print(f"Computing global displacement: {i + 1} / {len(non_rigid_windows)}") @@ -496,7 +496,7 @@ def run( non_rigid_windows, verbose, progress_bar, - extra_check, + extra, num_amp_bins=20, num_shifts_global=15, num_iterations=10, @@ -535,12 +535,12 @@ def run( # convert to um motion = -(shift_indices * bin_um) - if extra_check: - extra_check["motion_histograms"] = motion_histograms - extra_check["target_histogram"] = target_histogram - extra_check["shift_covs_block"] = shift_covs_block - extra_check["temporal_hist_bin_edges"] = temporal_hist_bin_edges - extra_check["spatial_hist_bin_edges"] = spatial_hist_bin_edges + if extra: + extra["motion_histograms"] = motion_histograms + extra["target_histogram"] = target_histogram + extra["shift_covs_block"] = shift_covs_block + extra["temporal_hist_bin_edges"] = temporal_hist_bin_edges + extra["spatial_hist_bin_edges"] = spatial_hist_bin_edges return motion, temporal_bins @@ -564,82 +564,7 @@ def get_spatial_bin_edges(recording, direction, margin_um, bin_um): return spatial_bins -def get_windows(rigid, bin_um, contact_pos, spatial_bin_edges, margin_um, win_step_um, win_sigma_um, win_shape): - """ - Generate spatial windows (taper) for non-rigid motion. - For rigid motion, this is equivalent to have one unique rectangular window that covers the entire probe. - The windowing can be gaussian or rectangular. - Parameters - ---------- - rigid : bool - If True, returns a single rectangular window - bin_um : float - Spatial bin size in um - contact_pos : np.ndarray - Position of electrodes (num_channels, 2) - spatial_bin_edges : np.array - The pre-computed spatial bin edges - margin_um : float - The margin to extend (if positive) or shrink (if negative) the probe dimension to compute windows.= - win_step_um : float - The steps at which windows are defined - win_sigma_um : float - Sigma of gaussian window (if win_shape is gaussian) - win_shape : float - "gaussian" | "rect" - - Returns - ------- - non_rigid_windows : list of 1D arrays - The scaling for each window. Each element has num_spatial_bins values - non_rigid_window_centers: 1D np.array - The center of each window - - Notes - ----- - Note that kilosort2.5 uses overlaping rectangular windows. - Here by default we use gaussian window. - - """ - # TODO remove bin_um - bin_centers = spatial_bin_edges[:-1] + bin_um / 2.0 - # bin_centers = 0.5 * (spatial_bin_edges[1:] + spatial_bin_edges[:-1]) - n = bin_centers.size - - if rigid: - # win_shape = 'rect' is forced - non_rigid_windows = [np.ones(n, dtype="float64")] - middle = (spatial_bin_edges[0] + spatial_bin_edges[-1]) / 2.0 - non_rigid_window_centers = np.array([middle]) - else: - # TODO put a warning - assert win_sigma_um >= win_step_um, f"win_sigma_um too low {win_sigma_um} compared to win_step_um {win_step_um}" - - min_ = np.min(contact_pos) - margin_um - max_ = np.max(contact_pos) + margin_um - num_non_rigid_windows = int((max_ - min_) // win_step_um) - border = ((max_ - min_) % win_step_um) / 2 - non_rigid_window_centers = np.arange(num_non_rigid_windows + 1) * win_step_um + min_ + border - non_rigid_windows = [] - - for win_center in non_rigid_window_centers: - if win_shape == "gaussian": - win = np.exp(-((bin_centers - win_center) ** 2) / (2 * win_sigma_um**2)) - elif win_shape == "rect": - win = np.abs(bin_centers - win_center) < (win_sigma_um / 2.0) - win = win.astype("float64") - elif win_shape == "triangle": - center_dist = np.abs(bin_centers - win_center) - in_window = center_dist <= (win_sigma_um / 2.0) - win = -center_dist - win[~in_window] = 0 - win[in_window] -= win[in_window].min() - win[in_window] /= win[in_window].max() - - non_rigid_windows.append(win) - - return non_rigid_windows, non_rigid_window_centers def make_2d_motion_histogram( @@ -1325,6 +1250,9 @@ def iterative_template_registration( return optimal_shift_indices, target_spikecount_hist, shift_covs_block +# TODO charlie sam : make a unique function for this +# at the moment this is the legacy one ported in spikeinterface +# the one in deredge.py is more recent def normxcorr1d( template, x, @@ -1444,33 +1372,7 @@ def normxcorr1d( return corr -def scipy_conv1d(input, weights, padding="valid"): - """SciPy translation of torch F.conv1d""" - from scipy.signal import correlate - - n, c_in, length = input.shape - c_out, in_by_groups, kernel_size = weights.shape - assert in_by_groups == c_in == 1 - - if padding == "same": - mode = "same" - length_out = length - elif padding == "valid": - mode = "valid" - length_out = length - 2 * (kernel_size // 2) - elif isinstance(padding, int): - mode = "valid" - input = np.pad(input, [*[(0, 0)] * (input.ndim - 1), (padding, padding)]) - length_out = length - (kernel_size - 1) + 2 * padding - else: - raise ValueError(f"Unknown 'padding' value of {padding}, 'padding' must be 'same', 'valid' or an integer") - - output = np.zeros((n, c_out, length_out), dtype=input.dtype) - for m in range(n): - for c in range(c_out): - output[m, c] = correlate(input[m, 0], weights[c, 0], mode=mode) - return output def clean_motion_vector(motion, temporal_bins, bin_duration_s, speed_threshold=30, sigma_smooth_s=None): diff --git a/src/spikeinterface/sortingcomponents/motion/motion_utils.py b/src/spikeinterface/sortingcomponents/motion/motion_utils.py index 26d4b35b1a..30c3deb0ec 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_utils.py @@ -1,3 +1,4 @@ +import warnings import json from pathlib import Path @@ -228,3 +229,125 @@ def copy(self): self.spatial_bins_um.copy(), interpolation_method=self.interpolation_method, ) + + + +def get_windows(rigid, contact_pos, spatial_bin_edges, margin_um, win_step_um, win_sigma_um, win_shape, + zero_threshold=None): + """ + Generate spatial windows (taper) for non-rigid motion. + For rigid motion, this is equivalent to have one unique rectangular window that covers the entire probe. + The windowing can be gaussian or rectangular. + + Parameters + ---------- + rigid : bool + If True, returns a single rectangular window + contact_pos : np.ndarray + Position of electrodes (num_channels, 2) + spatial_bin_edges : np.array + The pre-computed spatial bin edges + margin_um : float + The margin to extend (if positive) or shrink (if negative) the probe dimension to compute windows.= + win_step_um : float + The steps at which windows are defined + win_sigma_um : float + Sigma of gaussian window (if win_shape is gaussian) + win_shape : float + "gaussian" | "rect" + + Returns + ------- + windows : 2D arrays + The scaling for each window. Each element has num_spatial_bins values + shape: (num_window, spatial_bins) + window_centers: 1D np.array + The center of each window + + Notes + ----- + Note that kilosort2.5 uses overlaping rectangular windows. + Here by default we use gaussian window. + + """ + bin_centers = 0.5 * (spatial_bin_edges[1:] + spatial_bin_edges[:-1]) + n = bin_centers.size + + if rigid: + # win_shape = 'rect' is forced + windows = [np.ones(n, dtype="float64")] + middle = (spatial_bin_edges[0] + spatial_bin_edges[-1]) / 2.0 + window_centers = np.array([middle]) + else: + if win_sigma_um <= win_step_um/5.: + warnings.warn( + f"get_windows(): spatial windows are probably not overlaping because {win_sigma_um=} and {win_step_um=}" + ) + + min_ = np.min(contact_pos) - margin_um + max_ = np.max(contact_pos) + margin_um + num_windows = int((max_ - min_) // win_step_um) + border = ((max_ - min_) % win_step_um) / 2 + window_centers = np.arange(num_windows + 1) * win_step_um + min_ + border + windows = [] + + for win_center in window_centers: + if win_shape == "gaussian": + win = np.exp(-((bin_centers - win_center) ** 2) / (2 * win_sigma_um**2)) + elif win_shape == "rect": + win = np.abs(bin_centers - win_center) < (win_sigma_um / 2.0) + win = win.astype("float64") + elif win_shape == "triangle": + center_dist = np.abs(bin_centers - win_center) + in_window = center_dist <= (win_sigma_um / 2.0) + win = -center_dist + win[~in_window] = 0 + win[in_window] -= win[in_window].min() + win[in_window] /= win[in_window].max() + windows.append(win) + + windows = np.array(windows) + + if zero_threshold is not None: + windows[windows < zero_threshold] = 0 + windows /= windows.sum(axis=1, keepdims=True) + + return windows, window_centers + + +def get_window_domains(windows): + """Array of windows -> list of slices where window > 0.""" + slices = [] + for w in windows: + in_window = np.flatnonzero(w) + slices.append(slice(in_window[0], in_window[-1] + 1)) + return slices + + +def scipy_conv1d(input, weights, padding="valid"): + """SciPy translation of torch F.conv1d""" + from scipy.signal import correlate + + n, c_in, length = input.shape + c_out, in_by_groups, kernel_size = weights.shape + assert in_by_groups == c_in == 1 + + if padding == "same": + mode = "same" + length_out = length + elif padding == "valid": + mode = "valid" + length_out = length - 2 * (kernel_size // 2) + elif isinstance(padding, int): + mode = "valid" + input = np.pad(input, [*[(0, 0)] * (input.ndim - 1), (padding, padding)]) + length_out = length - (kernel_size - 1) + 2 * padding + else: + raise ValueError(f"Unknown 'padding' value of {padding}, 'padding' must be 'same', 'valid' or an integer") + + output = np.zeros((n, c_out, length_out), dtype=input.dtype) + for m in range(n): + for c in range(c_out): + output[m, c] = correlate(input[m, 0], weights[c, 0], mode=mode) + + return output \ No newline at end of file diff --git a/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py index af62ba52ec..7cba0ff6fc 100644 --- a/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py @@ -3,7 +3,7 @@ import numpy as np import pytest from spikeinterface.core.node_pipeline import ExtractDenseWaveforms -from spikeinterface.sortingcomponents.motion_estimation import estimate_motion +from spikeinterface.sortingcomponents.motion import estimate_motion from spikeinterface.sortingcomponents.peak_detection import detect_peaks from spikeinterface.sortingcomponents.peak_localization import LocalizeCenterOfMass from spikeinterface.sortingcomponents.tests.common import make_dataset @@ -18,12 +18,10 @@ plt.show() -@pytest.fixture(scope="module") -def setup_module(tmp_path_factory): - recording, sorting = make_dataset() - cache_folder = tmp_path_factory.mktemp("cache_folder") +def setup_module(cache_folder): cache_folder.mkdir(parents=True, exist_ok=True) + recording, sorting = make_dataset() # detect and localize extract_dense_waveforms = ExtractDenseWaveforms(recording, ms_before=0.1, ms_after=0.3, return_output=False) pipeline_nodes = [ @@ -49,6 +47,12 @@ def setup_module(tmp_path_factory): return recording, sorting, cache_folder +@pytest.fixture(scope="module", name="setup_module") +def setup_module_fixture(tmp_path_factory): + cache_folder = tmp_path_factory.mktemp("cache_folder") + return setup_module(cache_folder) + + def test_estimate_motion(setup_module): # recording, sorting = make_dataset() recording, sorting, cache_folder = setup_module @@ -215,5 +219,8 @@ def test_estimate_motion(setup_module): if __name__ == "__main__": - setup_module() - test_estimate_motion() + import tempfile + with tempfile.TemporaryDirectory() as tmpdirname: + cache_folder = Path(tmpdirname) + args = setup_module(cache_folder) + test_estimate_motion(args) From cabbbeb26c1f35077f326df9b39fe4984bb11ebc Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Fri, 21 Jun 2024 15:06:41 +0200 Subject: [PATCH 04/31] dredge test file --- .../sortingcomponents/motion/tests/test_drege.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/spikeinterface/sortingcomponents/motion/tests/test_drege.py diff --git a/src/spikeinterface/sortingcomponents/motion/tests/test_drege.py b/src/spikeinterface/sortingcomponents/motion/tests/test_drege.py new file mode 100644 index 0000000000..218d9036aa --- /dev/null +++ b/src/spikeinterface/sortingcomponents/motion/tests/test_drege.py @@ -0,0 +1,11 @@ +import pytest + + + + +def test_dredge_online_lfp(): + pass + + +if __name__ == "__main__": + pass From 00bef349e5cb6f8dabffd3c323fd44262d402670 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Fri, 21 Jun 2024 16:33:42 +0200 Subject: [PATCH 05/31] fix --- .../sortingcomponents/motion/dredge.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index b77b87d45a..d7825bf4a7 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -127,15 +127,17 @@ def dredge_online_lfp( # get windows windows, window_centers = get_windows( - geom, - win_step_um, - win_scale_um, - spatial_bin_centers=geom[:, 1], + rigid=rigid, + contact_pos=geom, + # TODO check dim and direction and assert unique + spatial_bin_edges=geom[:, 1], margin_um=win_margin_um, + win_step_um=win_step_um, + win_sigma_um=win_scale_um, win_shape=win_shape, zero_threshold=1e-5, - rigid=rigid, ) + B = len(windows) if extra_outputs: From 62c75f870487bdf38808be4712b05e3f7e223b20 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Fri, 21 Jun 2024 20:18:00 +0200 Subject: [PATCH 06/31] Put spatial_bin_centers in get_windows() --- .../sortingcomponents/motion/dredge.py | 23 +++++++++---------- .../motion/motion_estimation.py | 3 ++- .../sortingcomponents/motion/motion_utils.py | 17 +++++++------- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index d7825bf4a7..6bbf3139e1 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -125,12 +125,14 @@ def dredge_online_lfp( bin_s=1 / fs, # only relevant for time_horizon_s ) - # get windows + # in LFP bin center are contact position + # TODO check dim and direction and assert unique + spatial_bin_centers = geom[:, 1] + windows, window_centers = get_windows( rigid=rigid, contact_pos=geom, - # TODO check dim and direction and assert unique - spatial_bin_edges=geom[:, 1], + spatial_bin_centers=spatial_bin_centers, margin_um=win_margin_um, win_step_um=win_step_um, win_sigma_um=win_scale_um, @@ -165,8 +167,9 @@ def dredge_online_lfp( extra["D01"] = [] extra["C01"] = [] extra["S01"] = [] - extra["mincorrs"] = [mincorr0] - extra["max_disp_um"] = max_disp_um + extra["mincorrs"] = [mincorr0] + extra["max_disp_um"] = max_disp_um + P_online[:, t0:t1], _ = thomas_solve(Ds0, Ss0, **thomas_kw) # -- loop through chunks @@ -206,9 +209,10 @@ def dredge_online_lfp( Ss10, _ = threshold_correlation_matrix( Cs10, mincorr=mincorr1, t_offset_bins=T_chunk, **threshold_kw ) - extra["mincorrs"].append(mincorr1) + if extra_outputs: + extra["mincorrs"].append(mincorr1) extra["D"].append(Ds1) extra["C"].append(Cs1) extra["S"].append(Ss1) @@ -232,12 +236,7 @@ def dredge_online_lfp( t0, t1 = t1, t2 traces0 = traces1 - # -- convert to motion estimate and return - motion = Motion( - displacement=[P_online], - temporal_bins_s=[lfp_recording.get_times(0)], - spatial_bin_um=[window_centers] - ) + motion = Motion([P_online.T], [lfp_recording.get_times(0)], window_centers, direction="y") if extra_outputs: return motion, extra diff --git a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py index 1933239619..f0c01a005e 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py @@ -128,10 +128,11 @@ def estimate_motion( # spatial bins spatial_bin_edges = get_spatial_bin_edges(recording, direction, margin_um, bin_um) + spatial_bin_centers = 0.5 * (spatial_bin_edges[1:] + spatial_bin_edges[:-1]) # get windows non_rigid_windows, non_rigid_window_centers = get_windows( - rigid, contact_pos, spatial_bin_edges, margin_um, win_step_um, win_sigma_um, win_shape + rigid, contact_pos, spatial_bin_centers, margin_um, win_step_um, win_sigma_um, win_shape ) if extra_outputs: diff --git a/src/spikeinterface/sortingcomponents/motion/motion_utils.py b/src/spikeinterface/sortingcomponents/motion/motion_utils.py index 30c3deb0ec..5987a47cd2 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_utils.py @@ -232,7 +232,7 @@ def copy(self): -def get_windows(rigid, contact_pos, spatial_bin_edges, margin_um, win_step_um, win_sigma_um, win_shape, +def get_windows(rigid, contact_pos, spatial_bin_centers, margin_um, win_step_um, win_sigma_um, win_shape, zero_threshold=None): """ Generate spatial windows (taper) for non-rigid motion. @@ -245,8 +245,8 @@ def get_windows(rigid, contact_pos, spatial_bin_edges, margin_um, win_step_um, w If True, returns a single rectangular window contact_pos : np.ndarray Position of electrodes (num_channels, 2) - spatial_bin_edges : np.array - The pre-computed spatial bin edges + spatial_bin_centers : np.array + The pre-computed spatial bin centers margin_um : float The margin to extend (if positive) or shrink (if negative) the probe dimension to compute windows.= win_step_um : float @@ -270,13 +270,12 @@ def get_windows(rigid, contact_pos, spatial_bin_edges, margin_um, win_step_um, w Here by default we use gaussian window. """ - bin_centers = 0.5 * (spatial_bin_edges[1:] + spatial_bin_edges[:-1]) - n = bin_centers.size + n = spatial_bin_centers.size if rigid: # win_shape = 'rect' is forced windows = [np.ones(n, dtype="float64")] - middle = (spatial_bin_edges[0] + spatial_bin_edges[-1]) / 2.0 + middle = (spatial_bin_centers[0] + spatial_bin_centers[-1]) / 2.0 window_centers = np.array([middle]) else: if win_sigma_um <= win_step_um/5.: @@ -293,12 +292,12 @@ def get_windows(rigid, contact_pos, spatial_bin_edges, margin_um, win_step_um, w for win_center in window_centers: if win_shape == "gaussian": - win = np.exp(-((bin_centers - win_center) ** 2) / (2 * win_sigma_um**2)) + win = np.exp(-((spatial_bin_centers - win_center) ** 2) / (2 * win_sigma_um**2)) elif win_shape == "rect": - win = np.abs(bin_centers - win_center) < (win_sigma_um / 2.0) + win = np.abs(spatial_bin_centers - win_center) < (win_sigma_um / 2.0) win = win.astype("float64") elif win_shape == "triangle": - center_dist = np.abs(bin_centers - win_center) + center_dist = np.abs(spatial_bin_centers - win_center) in_window = center_dist <= (win_sigma_um / 2.0) win = -center_dist win[~in_window] = 0 From 13c81ac6985a7a5295c7668b6b6053740a4e15e6 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Fri, 21 Jun 2024 21:22:08 +0200 Subject: [PATCH 07/31] small fix --- src/spikeinterface/sortingcomponents/motion/dredge.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index 6bbf3139e1..56770dbf63 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -92,10 +92,8 @@ def dredge_online_lfp( Returns ------- - me : motion_util.MotionEstimate - A motion estimate object. me.displacement is the displacement trace, but this object - includes methods for getting the displacement at different times and depths; see - the documentation in the motion_util.py file. + motion : Motion + A motion object. extra : dict Dict containing extra info for debugging """ @@ -126,7 +124,7 @@ def dredge_online_lfp( ) # in LFP bin center are contact position - # TODO check dim and direction and assert unique + # TODO sam check dim and direction and assert unique spatial_bin_centers = geom[:, 1] windows, window_centers = get_windows( From 1ac451cecb24e7f130dad941837f83079e0481de Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 26 Jun 2024 14:18:43 +0200 Subject: [PATCH 08/31] motion_estimation() : refactoring party. Move method into separated files. Change, refactor (and rename some) kwargs from common to specific methos. Add dredge_lfp class. --- src/spikeinterface/preprocessing/motion.py | 10 +- .../tests/test_benchmark_motion_estimation.py | 2 +- .../sortingcomponents/motion/__init__.py | 1 + .../sortingcomponents/motion/decentralized.py | 814 ++++++++++ .../sortingcomponents/motion/dredge.py | 132 +- .../motion/iterative_template.py | 287 ++++ .../motion/motion_cleaner.py | 73 + .../motion/motion_estimation.py | 1429 +---------------- .../sortingcomponents/motion/motion_utils.py | 195 ++- .../motion/tests/test_motion_estimation.py | 4 +- .../motion/tests/test_motion_utils.py | 2 +- 11 files changed, 1543 insertions(+), 1406 deletions(-) create mode 100644 src/spikeinterface/sortingcomponents/motion/decentralized.py create mode 100644 src/spikeinterface/sortingcomponents/motion/iterative_template.py create mode 100644 src/spikeinterface/sortingcomponents/motion/motion_cleaner.py diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index 1e825f9c73..ed097d19fc 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -40,10 +40,10 @@ margin_um=0.0, # win_shape="gaussian", # win_step_um=50.0, - # win_sigma_um=150.0, + # win_scale_um=150.0, win_shape="gaussian", win_step_um=100.0, - win_sigma_um=200.0, + win_scale_um=200.0, histogram_depth_smooth_um=5.0, histogram_time_smooth_s=None, pairwise_displacement_method="conv", @@ -99,10 +99,10 @@ margin_um=0.0, # win_shape="gaussian", # win_step_um=50.0, - # win_sigma_um=150.0, + # win_scale_um=150.0, win_shape="gaussian", win_step_um=100.0, - win_sigma_um=200.0, + win_scale_um=200.0, histogram_depth_smooth_um=5.0, histogram_time_smooth_s=None, pairwise_displacement_method="conv", @@ -181,7 +181,7 @@ bin_duration_s=2.0, rigid=False, win_step_um=50.0, - win_sigma_um=150.0, + win_scale_um=150.0, margin_um=0, win_shape="rect", ), diff --git a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py index 526cc2e92f..56e1c18d62 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py @@ -45,7 +45,7 @@ def test_benchmark_motion_estimaton(create_cache_folder): bin_um=5.0, rigid=False, win_step_um=50.0, - win_sigma_um=200.0, + win_scale_um=200.0, ), ), ) diff --git a/src/spikeinterface/sortingcomponents/motion/__init__.py b/src/spikeinterface/sortingcomponents/motion/__init__.py index 2a045bd108..15233efc32 100644 --- a/src/spikeinterface/sortingcomponents/motion/__init__.py +++ b/src/spikeinterface/sortingcomponents/motion/__init__.py @@ -2,3 +2,4 @@ from .motion_estimation import estimate_motion from .motion_interpolation import (correct_motion_on_peaks, interpolate_motion_on_traces, InterpolateMotionRecording, interpolate_motion) +from .motion_cleaner import clean_motion_vector diff --git a/src/spikeinterface/sortingcomponents/motion/decentralized.py b/src/spikeinterface/sortingcomponents/motion/decentralized.py new file mode 100644 index 0000000000..3b8f19cc3e --- /dev/null +++ b/src/spikeinterface/sortingcomponents/motion/decentralized.py @@ -0,0 +1,814 @@ +import numpy as np + +from tqdm.auto import tqdm, trange + +try: + import torch + import torch.nn.functional as F + + HAVE_TORCH = True +except ImportError: + HAVE_TORCH = False + +from .motion_utils import Motion, get_windows, get_spatial_bin_edges, make_2d_motion_histogram, scipy_conv1d + + +class DecentralizedRegistration: + """ + Method developed by the Paninski's group from Columbia university: + Charlie Windolf, Julien Boussard, Erdem Varol, Hyun Dong Lee + + This method is also known as DREDGe, but this implemenation does not use LFP signals. + + Original reference: + DECENTRALIZED MOTION INFERENCE AND REGISTRATION OF NEUROPIXEL DATA + https://ieeexplore.ieee.org/document/9414145 + https://proceedings.neurips.cc/paper/2021/hash/b950ea26ca12daae142bd74dba4427c8-Abstract.html + + This code was improved during Spike Sorting NY Hackathon 2022 by Erdem Varol and Charlie Windolf. + An additional major improvement can be found in this paper: + https://www.biorxiv.org/content/biorxiv/early/2022/12/05/2022.12.04.519043.full.pdf + + + Here are some various implementations by the original team: + https://github.com/int-brain-lab/spikes_localization_registration/blob/main/registration_pipeline/image_based_motion_estimate.py#L211 + https://github.com/cwindolf/spike-psvae/tree/main/spike_psvae + https://github.com/evarol/DREDge + """ + + name = "decentralized" + need_peak_location = True + params_doc = """ + bin_um: float, default: 10 + Spatial bin size in micrometers + hist_margin_um: float, default: 0 + Margin in um from histogram estimation. + Positive margin extrapolate out of the probe the motion. + Negative margin crop the motion on the border + bin_duration_s: float, default: 2.0 + Bin duration in second + histogram_depth_smooth_um: None or float + Optional gaussian smoother on histogram on depth axis. + This is given as the sigma of the gaussian in micrometers. + histogram_time_smooth_s: None or float + Optional gaussian smoother on histogram on time axis. + This is given as the sigma of the gaussian in seconds. + pairwise_displacement_method: "conv" or "phase_cross_correlation" + How to estimate the displacement in the pairwise matrix. + max_displacement_um: float + Maximum possible displacement in micrometers. + weight_scale: "linear" or "exp" + For parwaise displacement, how to to rescale the associated weight matrix. + error_sigma: float, default: 0.2 + In case weight_scale="exp" this controls the sigma of the exponential. + conv_engine: "numpy" or "torch" or None, default: None + In case of pairwise_displacement_method="conv", what library to use to compute + the underlying correlation + torch_device=None + In case of conv_engine="torch", you can control which device (cpu or gpu) + batch_size: int + Size of batch for the convolution. Increasing this will speed things up dramatically + on GPUs and sometimes on CPU as well. + corr_threshold: float + Minimum correlation between pair of time bins in order for these to be + considered when optimizing a global displacment vector to align with + the pairwise displacements. + time_horizon_s: None or float + When not None the parwise discplament matrix is computed in a small time horizon. + In short only pair of bins close in time. + So the pariwaise matrix is super sparse and have values only the diagonal. + convergence_method: "lsmr" | "lsqr_robust" | "gradient_descent", default: "lsqr_robust" + Which method to use to compute the global displacement vector from the pairwise matrix. + robust_regression_sigma: float + Use for convergence_method="lsqr_robust" for iterative selection of the regression. + temporal_prior : bool, default: True + Ensures continuity across time, unless there is evidence in the recording for jumps. + spatial_prior : bool, default: False + Ensures continuity across space. Not usually necessary except in recordings with + glitches across space. + force_spatial_median_continuity: bool, default: False + When spatial_prior=False we can optionally apply a median continuity across spatial windows. + reference_displacement : string, one of: "mean", "median", "time", "mode_search" + Strategy for picking what is considered displacement=0. + - "mean" : the mean displacement is subtracted + - "median" : the median displacement is subtracted + - "time" : the displacement at a given time (in seconds) is subtracted + - "mode_search" : an attempt is made to guess the mode. needs work. + lsqr_robust_n_iter: int + Number of iteration for convergence_method="lsqr_robust". + """ + + @classmethod + def run( + cls, + recording, + peaks, + peak_locations, + direction, + rigid, + win_shape, + win_step_um, + win_scale_um, + win_margin_um, + verbose, + progress_bar, + extra, + bin_um=10.0, + hist_margin_um=0.0, + bin_duration_s=2.0, + histogram_depth_smooth_um=None, + histogram_time_smooth_s=None, + pairwise_displacement_method="conv", + max_displacement_um=100.0, + weight_scale="linear", + error_sigma=0.2, + conv_engine=None, + torch_device=None, + batch_size=1, + corr_threshold=0.0, + time_horizon_s=None, + convergence_method="lsqr_robust", + soft_weights=False, + normalized_xcorr=True, + centered_xcorr=True, + temporal_prior=True, + spatial_prior=False, + force_spatial_median_continuity=False, + reference_displacement="median", + reference_displacement_time_s=0, + robust_regression_sigma=2, + lsqr_robust_n_iter=20, + weight_with_amplitude=False, + ): + # use torch if installed + if conv_engine is None: + conv_engine = "torch" if HAVE_TORCH else "numpy" + + dim = ["x", "y", "z"].index(direction) + contact_pos = recording.get_channel_locations()[:, dim] + + # spatial histogram bins + spatial_bin_edges = get_spatial_bin_edges(recording, direction, hist_margin_um, bin_um) + spatial_bin_centers = 0.5 * (spatial_bin_edges[1:] + spatial_bin_edges[:-1]) + + # get spatial windows + non_rigid_windows, non_rigid_window_centers = get_windows( + rigid, contact_pos, spatial_bin_centers, win_margin_um, win_step_um, win_scale_um, win_shape + ) + + # make 2D histogram raster + if verbose: + print("Computing motion histogram") + + motion_histogram, temporal_hist_bin_edges, spatial_hist_bin_edges = make_2d_motion_histogram( + recording, + peaks, + peak_locations, + direction=direction, + bin_duration_s=bin_duration_s, + spatial_bin_edges=spatial_bin_edges, + weight_with_amplitude=weight_with_amplitude, + ) + import scipy.signal + + if histogram_depth_smooth_um is not None: + bins = np.arange(motion_histogram.shape[1]) * bin_um + bins = bins - np.mean(bins) + smooth_kernel = np.exp(-(bins**2) / (2 * histogram_depth_smooth_um**2)) + smooth_kernel /= np.sum(smooth_kernel) + + motion_histogram = scipy.signal.fftconvolve(motion_histogram, smooth_kernel[None, :], mode="same", axes=1) + + if histogram_time_smooth_s is not None: + bins = np.arange(motion_histogram.shape[0]) * bin_duration_s + bins = bins - np.mean(bins) + smooth_kernel = np.exp(-(bins**2) / (2 * histogram_time_smooth_s**2)) + smooth_kernel /= np.sum(smooth_kernel) + motion_histogram = scipy.signal.fftconvolve(motion_histogram, smooth_kernel[:, None], mode="same", axes=0) + + if extra is not None: + extra["motion_histogram"] = motion_histogram + extra["pairwise_displacement_list"] = [] + extra["temporal_hist_bin_edges"] = temporal_hist_bin_edges + extra["spatial_hist_bin_edges"] = spatial_hist_bin_edges + + # temporal bins are bin center + temporal_bins = 0.5 * (temporal_hist_bin_edges[1:] + temporal_hist_bin_edges[:-1]) + + motion_array = np.zeros((temporal_bins.size, len(non_rigid_windows)), dtype=np.float64) + windows_iter = non_rigid_windows + if progress_bar: + windows_iter = tqdm(windows_iter, desc="windows") + if spatial_prior: + all_pairwise_displacements = np.empty( + (len(non_rigid_windows), temporal_bins.size, temporal_bins.size), dtype=np.float64 + ) + all_pairwise_displacement_weights = np.empty( + (len(non_rigid_windows), temporal_bins.size, temporal_bins.size), dtype=np.float64 + ) + for i, win in enumerate(windows_iter): + window_slice = np.flatnonzero(win > 1e-5) + window_slice = slice(window_slice[0], window_slice[-1]) + if verbose: + print(f"Computing pairwise displacement: {i + 1} / {len(non_rigid_windows)}") + + pairwise_displacement, pairwise_displacement_weight = compute_pairwise_displacement( + motion_histogram[:, window_slice], + bin_um, + window=win[window_slice], + method=pairwise_displacement_method, + weight_scale=weight_scale, + error_sigma=error_sigma, + conv_engine=conv_engine, + torch_device=torch_device, + batch_size=batch_size, + max_displacement_um=max_displacement_um, + normalized_xcorr=normalized_xcorr, + centered_xcorr=centered_xcorr, + corr_threshold=corr_threshold, + time_horizon_s=time_horizon_s, + bin_duration_s=bin_duration_s, + progress_bar=False, + ) + + if spatial_prior: + all_pairwise_displacements[i] = pairwise_displacement + all_pairwise_displacement_weights[i] = pairwise_displacement_weight + + if extra is not None: + extra["pairwise_displacement_list"].append(pairwise_displacement) + + if verbose: + print(f"Computing global displacement: {i + 1} / {len(non_rigid_windows)}") + + # TODO: if spatial_prior, do this after the loop + if not spatial_prior: + motion_array[:, i] = compute_global_displacement( + pairwise_displacement, + pairwise_displacement_weight=pairwise_displacement_weight, + convergence_method=convergence_method, + robust_regression_sigma=robust_regression_sigma, + lsqr_robust_n_iter=lsqr_robust_n_iter, + temporal_prior=temporal_prior, + spatial_prior=spatial_prior, + soft_weights=soft_weights, + progress_bar=False, + ) + + if spatial_prior: + motion_array = compute_global_displacement( + all_pairwise_displacements, + pairwise_displacement_weight=all_pairwise_displacement_weights, + convergence_method=convergence_method, + robust_regression_sigma=robust_regression_sigma, + lsqr_robust_n_iter=lsqr_robust_n_iter, + temporal_prior=temporal_prior, + spatial_prior=spatial_prior, + soft_weights=soft_weights, + progress_bar=False, + ) + elif len(non_rigid_windows) > 1: + # if spatial_prior is False, we still want keep the spatial bins + # correctly offset from each other + if force_spatial_median_continuity: + for i in range(len(non_rigid_windows) - 1): + motion_array[:, i + 1] -= np.median(motion_array[:, i + 1] - motion_array[:, i]) + + # try to avoid constant offset + # let the user choose how to do this. here are some ideas. + # (one can also -= their own number on the result of this function.) + if reference_displacement == "mean": + motion_array -= motion_array.mean() + elif reference_displacement == "median": + motion_array -= np.median(motion_array) + elif reference_displacement == "time": + # reference the motion to 0 at a specific time, independently in each window + reference_displacement_bin = np.digitize(reference_displacement_time_s, temporal_hist_bin_edges) - 1 + motion_array -= motion_array[reference_displacement_bin, :] + elif reference_displacement == "mode_search": + # just a sketch of an idea + # things might want to change, should have a configurable bin size, + # should use a call to histogram instead of the loop, ... + step_size = 0.1 + round_mode = np.round # floor? + best_ref = np.median(motion_array) + max_zeros = np.sum(round_mode(motion_array - best_ref) == 0) + for ref in np.arange(np.floor(motion_array.min()), np.ceil(motion_array.max()), step_size): + n_zeros = np.sum(round_mode(motion_array - ref) == 0) + if n_zeros > max_zeros: + max_zeros = n_zeros + best_ref = ref + motion_array -= best_ref + + # replace nan by zeros + np.nan_to_num(motion_array, copy=False) + + motion = Motion([motion_array], [temporal_bins], non_rigid_window_centers, direction=direction) + + return motion + + +def compute_pairwise_displacement( + motion_hist, + bin_um, + method="conv", + weight_scale="linear", + error_sigma=0.2, + conv_engine="numpy", + torch_device=None, + batch_size=1, + max_displacement_um=1500, + corr_threshold=0, + time_horizon_s=None, + normalized_xcorr=True, + centered_xcorr=True, + bin_duration_s=None, + progress_bar=False, + window=None, +): + """ + Compute pairwise displacement + """ + from scipy import linalg + + assert conv_engine in ("torch", "numpy"), f"'conv_engine' must be 'torch' or 'numpy'" + size = motion_hist.shape[0] + pairwise_displacement = np.zeros((size, size), dtype="float32") + + if time_horizon_s is not None: + band_width = int(np.ceil(time_horizon_s / bin_duration_s)) + if band_width >= size: + time_horizon_s = None + + if conv_engine == "torch": + if torch_device is None: + torch_device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + + if method == "conv": + if max_displacement_um is None: + n = motion_hist.shape[1] // 2 + else: + n = min( + motion_hist.shape[1] // 2, + int(np.ceil(max_displacement_um // bin_um)), + ) + possible_displacement = np.arange(-n, n + 1) * bin_um + + xrange = trange if progress_bar else range + + motion_hist_engine = motion_hist + window_engine = window + if conv_engine == "torch": + motion_hist_engine = torch.as_tensor(motion_hist, dtype=torch.float32, device=torch_device) + window_engine = torch.as_tensor(window, dtype=torch.float32, device=torch_device) + + pairwise_displacement = np.empty((size, size), dtype=np.float32) + correlation = np.empty((size, size), dtype=motion_hist.dtype) + + for i in xrange(0, size, batch_size): + corr = normxcorr1d( + motion_hist_engine, + motion_hist_engine[i : i + batch_size], + weights=window_engine, + padding=possible_displacement.size // 2, + conv_engine=conv_engine, + normalized=normalized_xcorr, + centered=centered_xcorr, + ) + if conv_engine == "torch": + max_corr, best_disp_inds = torch.max(corr, dim=2) + best_disp = possible_displacement[best_disp_inds.cpu()] + pairwise_displacement[i : i + batch_size] = best_disp + correlation[i : i + batch_size] = max_corr.cpu() + elif conv_engine == "numpy": + best_disp_inds = np.argmax(corr, axis=2) + max_corr = np.take_along_axis(corr, best_disp_inds[..., None], 2).squeeze() + best_disp = possible_displacement[best_disp_inds] + pairwise_displacement[i : i + batch_size] = best_disp + correlation[i : i + batch_size] = max_corr + + if corr_threshold is not None and corr_threshold > 0: + which = correlation > corr_threshold + correlation *= which + + elif method == "phase_cross_correlation": + # this 'phase_cross_correlation' is an old idea from Julien/Charlie/Erden that is kept for testing + # but this is not very releveant + try: + import skimage.registration + except ImportError: + raise ImportError("To use the 'phase_cross_correlation' method install scikit-image") + + errors = np.zeros((size, size), dtype="float32") + loop = range(size) + if progress_bar: + loop = tqdm(loop) + for i in loop: + for j in range(size): + shift, error, diffphase = skimage.registration.phase_cross_correlation( + motion_hist[i, :], motion_hist[j, :] + ) + pairwise_displacement[i, j] = shift * bin_um + errors[i, j] = error + correlation = 1 - errors + + else: + raise ValueError( + f"method {method} does not exist for compute_pairwise_displacement. Current possible methods are" + f" 'conv' or 'phase_cross_correlation'" + ) + + if weight_scale == "linear": + # between 0 and 1 + pairwise_displacement_weight = correlation + elif weight_scale == "exp": + pairwise_displacement_weight = np.exp((correlation - 1) / error_sigma) + + # handle the time horizon by multiplying the weights by a + # matrix with the time horizon on its diagonal bands. + if method == "conv" and time_horizon_s is not None and time_horizon_s > 0: + horizon_matrix = linalg.toeplitz( + np.r_[np.ones(band_width, dtype=bool), np.zeros(size - band_width, dtype=bool)] + ) + pairwise_displacement_weight *= horizon_matrix + + return pairwise_displacement, pairwise_displacement_weight + + + +_possible_convergence_method = ("lsmr", "gradient_descent", "lsqr_robust") + + +def compute_global_displacement( + pairwise_displacement, + pairwise_displacement_weight=None, + sparse_mask=None, + temporal_prior=True, + spatial_prior=True, + soft_weights=False, + convergence_method="lsmr", + robust_regression_sigma=2, + lsqr_robust_n_iter=20, + progress_bar=False, +): + """ + Compute global displacement + + Arguments + --------- + pairwise_displacement : time x time array + pairwise_displacement_weight : time x time array + sparse_mask : time x time array + convergence_method : str + One of "gradient" + + """ + import scipy + from scipy.optimize import minimize + from scipy.sparse import csr_matrix + from scipy.sparse.linalg import lsqr + from scipy.stats import zscore + + if convergence_method == "gradient_descent": + size = pairwise_displacement.shape[0] + + D = pairwise_displacement + if pairwise_displacement_weight is not None or sparse_mask is not None: + # weighted problem + if pairwise_displacement_weight is None: + pairwise_displacement_weight = np.ones_like(D) + if sparse_mask is None: + sparse_mask = np.ones_like(D) + W = pairwise_displacement_weight * sparse_mask + + I, J = np.nonzero(W > 0) + Wij = W[I, J] + Dij = D[I, J] + W = csr_matrix((Wij, (I, J)), shape=W.shape) + WD = csr_matrix((Wij * Dij, (I, J)), shape=W.shape) + fixed_terms = (W @ WD).diagonal() - (WD @ W).diagonal() + diag_WW = (W @ W).diagonal() + Wsq = W.power(2) + + def obj(p): + return 0.5 * np.square(Wij * (Dij - (p[I] - p[J]))).sum() + + def jac(p): + return fixed_terms - 2 * (Wsq @ p) + 2 * p * diag_WW + + else: + # unweighted problem, it's faster when we have no weights + fixed_terms = -D.sum(axis=1) + D.sum(axis=0) + + def obj(p): + v = np.square((D - (p[:, None] - p[None, :]))).sum() + return 0.5 * v + + def jac(p): + return fixed_terms + 2 * (size * p - p.sum()) + + res = minimize(fun=obj, jac=jac, x0=D.mean(axis=1), method="L-BFGS-B") + if not res.success: + print("Global displacement gradient descent had an error") + displacement = res.x + + elif convergence_method == "lsqr_robust": + + if sparse_mask is not None: + I, J = np.nonzero(sparse_mask > 0) + elif pairwise_displacement_weight is not None: + I, J = pairwise_displacement_weight.nonzero() + else: + I, J = np.nonzero(np.ones_like(pairwise_displacement, dtype=bool)) + + nnz_ones = np.ones(I.shape[0], dtype=pairwise_displacement.dtype) + + if pairwise_displacement_weight is not None: + if isinstance(pairwise_displacement_weight, scipy.sparse.csr_matrix): + W = np.array(pairwise_displacement_weight[I, J]).T + else: + W = pairwise_displacement_weight[I, J][:, None] + else: + W = nnz_ones[:, None] + if isinstance(pairwise_displacement, scipy.sparse.csr_matrix): + V = np.array(pairwise_displacement[I, J])[0] + else: + V = pairwise_displacement[I, J] + M = csr_matrix((nnz_ones, (range(I.shape[0]), I)), shape=(I.shape[0], pairwise_displacement.shape[0])) + N = csr_matrix((nnz_ones, (range(I.shape[0]), J)), shape=(I.shape[0], pairwise_displacement.shape[0])) + A = M - N + idx = np.ones(A.shape[0], dtype=bool) + + # TODO: this is already soft_weights + xrange = trange if progress_bar else range + for i in xrange(lsqr_robust_n_iter): + p = lsqr(A[idx].multiply(W[idx]), V[idx] * W[idx][:, 0])[0] + idx = np.nonzero(np.abs(zscore(A @ p - V)) <= robust_regression_sigma) + displacement = p + + elif convergence_method == "lsmr": + import gc + from scipy import sparse + + D = pairwise_displacement + + # weighted problem + if pairwise_displacement_weight is None: + pairwise_displacement_weight = np.ones_like(D) + if sparse_mask is None: + sparse_mask = np.ones_like(D) + W = pairwise_displacement_weight * sparse_mask + if isinstance(W, scipy.sparse.csr_matrix): + W = W.astype(np.float32).toarray() + D = D.astype(np.float32).toarray() + + assert D.shape == W.shape + + # first dimension is the windows dim, which could be empty in rigid case + # we expand dims so that below we can consider only the nonrigid case + if D.ndim == 2: + W = W[None] + D = D[None] + assert D.ndim == W.ndim == 3 + B, T, T_ = D.shape + assert T == T_ + + # sparsify the problem + # we will make a list of temporal problems and then + # stack over the windows axis to finish. + # each matrix in coefficients will be (sparse_dim, T) + coefficients = [] + # each vector in targets will be (T,) + targets = [] + # we want to solve for a vector of shape BT, which we will reshape + # into a (B, T) matrix. + # after the loop below, we will stack a coefts matrix (sparse_dim, B, T) + # and a target vector of shape (B, T), both to be vectorized on last two axes, + # so that the target p is indexed by i = bT + t (block/window major). + + # calculate coefficients matrices and target vector + # this list stores boolean masks corresponding to whether or not each + # term comes from the prior or the likelihood. we can trim the likelihood terms, + # but not the prior terms, in the trimmed least squares (robust iters) iterations below. + cannot_trim = [] + for Wb, Db in zip(W, D): + # indices of active temporal pairs in this window + I, J = np.nonzero(Wb > 0) + n_sampled = I.size + + # construct Kroneckers and sparse objective in this window + pair_weights = np.ones(n_sampled) + if soft_weights: + pair_weights = Wb[I, J] + Mb = sparse.csr_matrix((pair_weights, (range(n_sampled), I)), shape=(n_sampled, T)) + Nb = sparse.csr_matrix((pair_weights, (range(n_sampled), J)), shape=(n_sampled, T)) + block_sparse_kron = Mb - Nb + block_disp_pairs = pair_weights * Db[I, J] + cannot_trim_block = np.ones_like(block_disp_pairs, dtype=bool) + + # add the temporal smoothness prior in this window + if temporal_prior: + temporal_diff_operator = sparse.diags( + ( + np.full(T - 1, -1, dtype=block_sparse_kron.dtype), + np.full(T - 1, 1, dtype=block_sparse_kron.dtype), + ), + offsets=(0, 1), + shape=(T - 1, T), + ) + block_sparse_kron = sparse.vstack( + (block_sparse_kron, temporal_diff_operator), + format="csr", + ) + block_disp_pairs = np.concatenate( + (block_disp_pairs, np.zeros(T - 1)), + ) + cannot_trim_block = np.concatenate( + (cannot_trim_block, np.zeros(T - 1, dtype=bool)), + ) + + coefficients.append(block_sparse_kron) + targets.append(block_disp_pairs) + cannot_trim.append(cannot_trim_block) + coefficients = sparse.block_diag(coefficients) + targets = np.concatenate(targets, axis=0) + cannot_trim = np.concatenate(cannot_trim, axis=0) + + # spatial smoothness prior: penalize difference of each block's + # displacement with the next. + # only if B > 1, and not in the last window. + # this is a (BT, BT) sparse matrix D such that: + # entry at (i, j) is: + # { 1 if i = j, i.e., i = j = bT + t for b = 0,...,B-2 + # { -1 if i = bT + t and j = (b+1)T + t for b = 0,...,B-2 + # { 0 otherwise. + # put more simply, the first (B-1)T diagonal entries are 1, + # and entries (i, j) such that i = j - T are -1. + if B > 1 and spatial_prior: + spatial_diff_operator = sparse.diags( + ( + np.ones((B - 1) * T, dtype=block_sparse_kron.dtype), + np.full((B - 1) * T, -1, dtype=block_sparse_kron.dtype), + ), + offsets=(0, T), + shape=((B - 1) * T, B * T), + ) + coefficients = sparse.vstack((coefficients, spatial_diff_operator)) + targets = np.concatenate((targets, np.zeros((B - 1) * T, dtype=targets.dtype))) + cannot_trim = np.concatenate((cannot_trim, np.zeros((B - 1) * T, dtype=bool))) + coefficients = coefficients.tocsr() + + # initialize at the column mean of pairwise displacements (in each window) + p0 = D.mean(axis=2).reshape(B * T) + + # use LSMR to solve the whole problem || targets - coefficients @ motion ||^2 + iters = range(max(1, lsqr_robust_n_iter)) + if progress_bar and lsqr_robust_n_iter > 1: + iters = tqdm(iters, desc="robust lsqr") + for it in iters: + # trim active set -- start with no trimming + idx = slice(None) + if it: + idx = np.flatnonzero( + cannot_trim | (np.abs(zscore(coefficients @ displacement - targets)) <= robust_regression_sigma) + ) + + # solve trimmed ols problem + displacement, *_ = sparse.linalg.lsmr(coefficients[idx], targets[idx], x0=p0) + + # warm start next iteration + p0 = displacement + # Cleanup lsmr memory (see https://stackoverflow.com/questions/56147713/memory-leak-in-scipy) + # TODO: check if this gets fixed in scipy + gc.collect() + + displacement = displacement.reshape(B, T).T + else: + raise ValueError( + f"Method {convergence_method} doesn't exist for compute_global_displacement" + f" possible values for 'convergence_method' are {_possible_convergence_method}" + ) + + return np.squeeze(displacement) + + +# TODO charlie sam : make a unique function for this +# at the moment this is the legacy one ported in spikeinterface +# the one in deredge.py is more recent +def normxcorr1d( + template, + x, + weights=None, + centered=True, + normalized=True, + padding="same", + conv_engine="torch", +): + """normxcorr1d: Normalized cross-correlation, optionally weighted + + The API is like torch's F.conv1d, except I have accidentally + changed the position of input/weights -- template acts like weights, + and x acts like input. + + Returns the cross-correlation of `template` and `x` at spatial lags + determined by `mode`. Useful for estimating the location of `template` + within `x`. + + This might not be the most efficient implementation -- ideas welcome. + It uses a direct convolutional translation of the formula + corr = (E[XY] - EX EY) / sqrt(var X * var Y) + + This also supports weights! In that case, the usual adaptation of + the above formula is made to the weighted case -- and all of the + normalizations are done per block in the same way. + + Parameters + ---------- + template : tensor, shape (num_templates, length) + The reference template signal + x : tensor, 1d shape (length,) or 2d shape (num_inputs, length) + The signal in which to find `template` + weights : tensor, shape (length,) + Will use weighted means, variances, covariances if supplied. + centered : bool + If true, means will be subtracted (per weighted patch). + normalized : bool + If true, normalize by the variance (per weighted patch). + padding : str + How far to look? if unset, we'll use half the length + conv_engine : string, one of "torch", "numpy" + What library to use for computing cross-correlations. + If numpy, falls back to the scipy correlate function. + + Returns + ------- + corr : tensor + """ + if conv_engine == "torch": + assert HAVE_TORCH + conv1d = F.conv1d + npx = torch + elif conv_engine == "numpy": + conv1d = scipy_conv1d + npx = np + else: + raise ValueError(f"Unknown conv_engine {conv_engine}. 'conv_engine' must be 'torch' or 'numpy'") + + x = npx.atleast_2d(x) + num_templates, length = template.shape + num_inputs, length_ = template.shape + assert length == length_ + + # generalize over weighted / unweighted case + device_kw = {} if conv_engine == "numpy" else dict(device=x.device) + ones = npx.ones((1, 1, length), dtype=x.dtype, **device_kw) + no_weights = weights is None + if no_weights: + weights = ones + wt = template[:, None, :] + else: + assert weights.shape == (length,) + weights = weights[None, None] + wt = template[:, None, :] * weights + + # conv1d valid rule: + # (B,1,L),(O,1,L)->(B,O,L) + + # compute expectations + # how many points in each window? seems necessary to normalize + # for numerical stability. + N = conv1d(ones, weights, padding=padding) + if centered: + Et = conv1d(ones, wt, padding=padding) + Et /= N + Ex = conv1d(x[:, None, :], weights, padding=padding) + Ex /= N + + # compute (weighted) covariance + # important: the formula E[XY] - EX EY is well-suited here, + # because the means are naturally subtracted correctly + # patch-wise. you couldn't pre-subtract them! + cov = conv1d(x[:, None, :], wt, padding=padding) + cov /= N + if centered: + cov -= Ex * Et + + # compute variances for denominator, using var X = E[X^2] - (EX)^2 + if normalized: + var_template = conv1d(ones, wt * template[:, None, :], padding=padding) + var_template /= N + var_x = conv1d(npx.square(x)[:, None, :], weights, padding=padding) + var_x /= N + if centered: + var_template -= npx.square(Et) + var_x -= npx.square(Ex) + + # now find the final normxcorr + corr = cov # renaming for clarity + if normalized: + corr /= npx.sqrt(var_x) + corr /= npx.sqrt(var_template) + # get rid of NaNs in zero-variance areas + corr[~npx.isfinite(corr)] = 0 + + return corr \ No newline at end of file diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index 56770dbf63..70dfc86107 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -1,5 +1,5 @@ """ -Re-implementation or copy-paste of DREDge +Copy-paste and then refactoring of DREDge https://github.com/evarol/dredge For historical reason, some function from the DREDge package where implemeneted @@ -10,7 +10,7 @@ The main entry for this function are still: * motion = estimate_motion((recording, ..., method='dredge_lfp') - * motion = estimate_motion((recording, ..., method='dredge_ap') + * motion = estimate_motion((recording, ..., method='dredge_ap') < not Done yet but here the original functions from Charlie, Julien and Erdem have been ported for an easier maintenance instead of making DREDge a dependency of spikeinterface. @@ -23,16 +23,77 @@ from .motion_utils import Motion, get_windows, get_window_domains, scipy_conv1d + +# TODO add direction + +# simple class wrapper to be compliant with estimate_motion +class DredgeLfpRegistration: + """ + + """ + name = "dredge_lfp" + need_peak_location = False + params_doc = """ + + """ + @classmethod + def run( + cls, + recording, + peaks, + peak_locations, + direction, + rigid, + win_shape, + win_step_um, + win_scale_um, + win_margin_um, + verbose, + progress_bar, + extra, + + **method_kwargs, + ): + # Note peaks and peak_locations are not used and can be None + + outs = dredge_online_lfp( + recording, + direction=direction, + rigid=rigid, + win_shape=win_shape, + win_step_um=win_step_um, + win_scale_um=win_scale_um, + win_margin_um=win_margin_um, + extra_outputs=(extra is not None), + progress_bar=progress_bar, + **method_kwargs, + ) + + if extra is not None: + motion, extra_ = outs + extra.update(extra_) + + else: + motion = outs + + + + + def dredge_online_lfp( lfp_recording, - rigid=True, - chunk_len_s=10.0, - max_disp_um=500, + direction='y', # nonrigid window construction arguments + rigid=True, win_shape="gaussian", win_step_um=800, win_scale_um=850, win_margin_um=None, + + chunk_len_s=10.0, + max_disp_um=500, + + time_horizon_s=None, # weighting arguments mincorr=0.8, @@ -45,7 +106,7 @@ def dredge_online_lfp( # misc extra_outputs=False, device=None, - pbar=True, + progress_bar=True, ): """Online registration of a preprocessed LFP recording @@ -58,6 +119,11 @@ def dredge_online_lfp( estimating motion at the original frequency (which may be high). rigid : boolean, optional If True, window-related arguments are ignored and we do rigid registration + win_shape, win_step_um, win_scale_um, win_margin_um : float + Nonrigid window-related arguments + The depth domain will be broken up into windows with shape controlled by win_shape, + spaced by win_step_um at a margin of win_margin_um from the boundary, and with + width controlled by win_scale_um. chunk_len_s : float Length of chunks (in seconds) that the recording is broken into for online registration. The computational speed of the method is a function of the @@ -75,11 +141,6 @@ def dredge_online_lfp( chunk. Setting it as small as possible (while following that rule) can speed things up and improve the result by making it impossible to estimate motion which is too big. - win_shape, win_step_um, win_scale_um, win_margin_um : float - Nonrigid window-related arguments - The depth domain will be broken up into windows with shape controlled by win_shape, - spaced by win_step_um at a margin of win_margin_um from the boundary, and with - width controlled by win_scale_um. mincorr : float in [0,1] Minimum correlation between pairs of frames such that they will be included in the optimization of the displacement estimates. @@ -97,7 +158,11 @@ def dredge_online_lfp( extra : dict Dict containing extra info for debugging """ - geom = lfp_recording.get_channel_locations() + dim = ["x", "y", "z"].index(direction) + # contact pos is the only on the direction + contact_pos = lfp_recording.get_channel_locations()[:, dim] + + fs = lfp_recording.get_sampling_frequency() T_total = lfp_recording.get_num_samples() T_chunk = min(int(np.floor(fs * chunk_len_s)), T_total) @@ -108,9 +173,9 @@ def dredge_online_lfp( thomas_kw = thomas_kw if thomas_kw is not None else {} full_xcorr_kw = dict( rigid=rigid, - bin_um=np.median(np.diff(geom[:, 1])), + bin_um=np.median(np.diff(contact_pos)), max_disp_um=max_disp_um, - pbar=False, + progress_bar=False, device=device, **xcorr_kw, ) @@ -123,17 +188,28 @@ def dredge_online_lfp( bin_s=1 / fs, # only relevant for time_horizon_s ) - # in LFP bin center are contact position - # TODO sam check dim and direction and assert unique - spatial_bin_centers = geom[:, 1] + + # here we check that contact positons are unique on the direction + if contact_pos.size != np.unique(contact_pos).size: + raise ValueError( + f"estimate motion with 'dredge_lfp' need channel_positions to be unique in the direction='{direction}'" + ) + if np.any(np.diff(contact_pos) < 0): + raise ValueError( + f"estimate motion with 'dredge_lfp' need channel_positions to be ordered direction='{direction}'" + "please use spikeinterface.preprocessing.depth_order(recording)" + ) + + # Important detail : in LFP bin center are contact position in the direction + spatial_bin_centers = contact_pos windows, window_centers = get_windows( rigid=rigid, - contact_pos=geom, + contact_pos=contact_pos, spatial_bin_centers=spatial_bin_centers, - margin_um=win_margin_um, + win_margin_um=win_margin_um, win_step_um=win_step_um, - win_sigma_um=win_scale_um, + win_scale_um=win_scale_um, win_shape=win_shape, zero_threshold=1e-5, ) @@ -149,7 +225,7 @@ def dredge_online_lfp( t0, t1 = 0, T_chunk traces0 = lfp_recording.get_traces(start_frame=t0, end_frame=t1) Ds0, Cs0, max_disp_um = xcorr_windows( - traces0.T, windows, geom[:, 1], win_scale_um, **full_xcorr_kw + traces0.T, windows, contact_pos, win_scale_um, **full_xcorr_kw ) full_xcorr_kw["max_disp_um"] = max_disp_um Ss0, mincorr0 = threshold_correlation_matrix( @@ -172,7 +248,7 @@ def dredge_online_lfp( # -- loop through chunks chunk_starts = range(T_chunk, T_total, T_chunk) - if pbar: + if progress_bar: chunk_starts = trange( T_chunk, T_total, @@ -188,7 +264,7 @@ def dredge_online_lfp( Ds10, Cs10, _ = xcorr_windows( traces1.T, windows, - geom[:, 1], + contact_pos, win_scale_um, raster_b=traces0.T, **full_xcorr_kw, @@ -196,7 +272,7 @@ def dredge_online_lfp( # cross-correlation in current chunk Ds1, Cs1, _ = xcorr_windows( - traces1.T, windows, geom[:, 1], win_scale_um, **full_xcorr_kw + traces1.T, windows, contact_pos, win_scale_um, **full_xcorr_kw ) Ss1, mincorr1 = threshold_correlation_matrix( Cs1, @@ -368,7 +444,7 @@ def thomas_solve( Us_prevcur=None, Ds_curprev=None, Us_curprev=None, - pbar=False, + progress_bar=False, bandwidth=None, ): """Block tridiagonal algorithm, special cased to our setting @@ -455,7 +531,7 @@ def thomas_solve( ys = [res[:, T]] # forward pass - for b in (trange(1, B, desc="Solve") if pbar else range(1, B)): + for b in (trange(1, B, desc="Solve") if progress_bar else range(1, B)): if b < B - 1: Lambda_s_diagb = laplacian(T, eps=eps, lambd=lambda_s, ridge_mask=had_weights[b]) else: @@ -548,7 +624,7 @@ def xcorr_windows( bin_um=1, max_disp_um=None, max_dt_bins=None, - pbar=True, + progress_bar=True, centered=True, normalized=True, masks=None, @@ -592,7 +668,7 @@ def xcorr_windows( # estimate each window's displacement Ds = np.zeros((B, T0, T1), dtype=np.float32) Cs = np.zeros((B, T0, T1), dtype=np.float32) - block_iter = trange(B, desc="Cross correlation") if pbar else range(B) + block_iter = trange(B, desc="Cross correlation") if progress_bar else range(B) for b in block_iter: window = windows_[b] diff --git a/src/spikeinterface/sortingcomponents/motion/iterative_template.py b/src/spikeinterface/sortingcomponents/motion/iterative_template.py new file mode 100644 index 0000000000..ab6877adc3 --- /dev/null +++ b/src/spikeinterface/sortingcomponents/motion/iterative_template.py @@ -0,0 +1,287 @@ +import numpy as np + +from .motion_utils import Motion, get_windows, get_spatial_bin_edges, make_3d_motion_histograms + + +class IterativeTemplateRegistration: + """ + Alignment function implemented by Kilosort2.5 and ported from pykilosort: + https://github.com/int-brain-lab/pykilosort/blob/ibl_prod/pykilosort/datashift2.py#L166 + + The main difference with respect to the original implementation are: + * scipy is used for gaussian smoothing + * windowing is implemented as gaussian tapering (instead of rectangular blocks) + * the 3d histogram is constructed in less cryptic way + * peak_locations are computed outside and so can either center fo mass or monopolar trianglation + contrary to kilosort2.5 use exclusively center of mass + + See https://www.science.org/doi/abs/10.1126/science.abf4588?cookieSet=1 + + Ported by Alessio Buccino into SpikeInterface + """ + + name = "iterative_template" + need_peak_location = True + params_doc = """ + bin_um: float, default: 10 + Spatial bin size in micrometers + hist_margin_um: float, default: 0 + Margin in um from histogram estimation. + Positive margin extrapolate out of the probe the motion. + Negative margin crop the motion on the border + bin_duration_s: float, default: 2.0 + Bin duration in second + num_amp_bins: int, default: 20 + number ob bins in the histogram on the log amplitues dimension + num_shifts_global: int, default: 15 + Number of spatial bin shifts to consider for global alignment + num_iterations: int, default: 10 + Number of iterations for global alignment procedure + num_shifts_block: int, default: 5 + Number of spatial bin shifts to consider for non-rigid alignment + smoothing_sigma: float, default: 0.5 + Sigma of gaussian for covariance matrices smoothing + kriging_sigma: float, + sigma parameter for kriging_kernel function + kriging_p: foat + p parameter for kriging_kernel function + kriging_d: float + d parameter for kriging_kernel function + """ + + @classmethod + def run( + cls, + recording, + peaks, + peak_locations, + direction, + rigid, + win_shape, + win_step_um, + win_scale_um, + win_margin_um, + verbose, + progress_bar, + extra, + bin_um=10.0, + hist_margin_um=0.0, + bin_duration_s=2.0, + num_amp_bins=20, + num_shifts_global=15, + num_iterations=10, + num_shifts_block=5, + smoothing_sigma=0.5, + kriging_sigma=1, + kriging_p=2, + kriging_d=2, + ): + + dim = ["x", "y", "z"].index(direction) + contact_pos = recording.get_channel_locations()[:, dim] + + # spatial histogram bins + spatial_bin_edges = get_spatial_bin_edges(recording, direction, hist_margin_um, bin_um) + spatial_bin_centers = 0.5 * (spatial_bin_edges[1:] + spatial_bin_edges[:-1]) + + # get spatial windows + non_rigid_windows, non_rigid_window_centers = get_windows( + rigid, contact_pos, spatial_bin_centers, win_margin_um, win_step_um, win_scale_um, win_shape + ) + + + # make a 3D histogram + motion_histograms, temporal_hist_bin_edges, spatial_hist_bin_edges = make_3d_motion_histograms( + recording, + peaks, + peak_locations, + direction=direction, + num_amp_bins=num_amp_bins, + bin_duration_s=bin_duration_s, + spatial_bin_edges=spatial_bin_edges, + ) + # temporal bins are bin center + temporal_bins = temporal_hist_bin_edges[:-1] + bin_duration_s // 2.0 + + # do alignment + shift_indices, target_histogram, shift_covs_block = iterative_template_registration( + motion_histograms, + non_rigid_windows=non_rigid_windows, + num_shifts_global=num_shifts_global, + num_iterations=num_iterations, + num_shifts_block=num_shifts_block, + smoothing_sigma=smoothing_sigma, + kriging_sigma=kriging_sigma, + kriging_p=kriging_p, + kriging_d=kriging_d, + ) + + # convert to um + motion_array = -(shift_indices * bin_um) + + if extra: + extra["non_rigid_windows"] = non_rigid_windows + extra["motion_histograms"] = motion_histograms + extra["target_histogram"] = target_histogram + extra["shift_covs_block"] = shift_covs_block + extra["temporal_hist_bin_edges"] = temporal_hist_bin_edges + extra["spatial_hist_bin_edges"] = spatial_hist_bin_edges + + # replace nan by zeros + np.nan_to_num(motion_array, copy=False) + + motion = Motion([motion_array], [temporal_bins], non_rigid_window_centers, direction=direction) + + return motion + + + +def iterative_template_registration( + spikecounts_hist_images, + non_rigid_windows=None, + num_shifts_global=15, + num_iterations=10, + num_shifts_block=5, + smoothing_sigma=0.5, + kriging_sigma=1, + kriging_p=2, + kriging_d=2, +): + """ + + Parameters + ---------- + + spikecounts_hist_images : np.ndarray + Spike count histogram images (num_temporal_bins, num_spatial_bins, num_amps_bins) + non_rigid_windows : list, default: None + If num_non_rigid_windows > 1, this argument is required and it is a list of + windows to taper spatial bins in different blocks + num_shifts_global : int, default: 15 + Number of spatial bin shifts to consider for global alignment + num_iterations : int, default: 10 + Number of iterations for global alignment procedure + num_shifts_block : int, default: 5 + Number of spatial bin shifts to consider for non-rigid alignment + smoothing_sigma : float, default: 0.5 + Sigma of gaussian for covariance matrices smoothing + kriging_sigma : float, default: 1 + sigma parameter for kriging_kernel function + kriging_p : float, default: 2 + p parameter for kriging_kernel function + kriging_d : float, default: 2 + d parameter for kriging_kernel function + + Returns + ------- + optimal_shift_indices + Optimal shifts for each temporal and spatial bin (num_temporal_bins, num_non_rigid_windows) + target_spikecount_hist + Target histogram used for alignment (num_spatial_bins, num_amps_bins) + """ + from scipy.ndimage import gaussian_filter, gaussian_filter1d + + # F is y bins by amp bins by batches + # ysamp are the coordinates of the y bins in um + spikecounts_hist_images = spikecounts_hist_images.swapaxes(0, 1).swapaxes(1, 2) + num_temporal_bins = spikecounts_hist_images.shape[2] + + # look up and down this many y bins to find best alignment + shift_covs = np.zeros((2 * num_shifts_global + 1, num_temporal_bins)) + shifts = np.arange(-num_shifts_global, num_shifts_global + 1) + + # mean subtraction to compute covariance + F = spikecounts_hist_images + Fg = F - np.mean(F, axis=0) + + # initialize the target "frame" for alignment with a single sample + # here we removed min(299, ...) + F0 = Fg[:, :, np.floor(num_temporal_bins / 2).astype("int") - 1] + F0 = F0[:, :, np.newaxis] + + # first we do rigid registration by integer shifts + # everything is iteratively aligned until most of the shifts become 0. + best_shifts = np.zeros((num_iterations, num_temporal_bins)) + for iteration in range(num_iterations): + for t, shift in enumerate(shifts): + # for each NEW potential shift, estimate covariance + Fs = np.roll(Fg, shift, axis=0) + shift_covs[t, :] = np.mean(Fs * F0, axis=(0, 1)) + if iteration + 1 < num_iterations: + # estimate the best shifts + imax = np.argmax(shift_covs, axis=0) + # align the data by these integer shifts + for t, shift in enumerate(shifts): + ibest = imax == t + Fg[:, :, ibest] = np.roll(Fg[:, :, ibest], shift, axis=0) + best_shifts[iteration, ibest] = shift + # new target frame based on our current best alignment + F0 = np.mean(Fg, axis=2)[:, :, np.newaxis] + target_spikecount_hist = F0[:, :, 0] + + # now we figure out how to split the probe into nblocks pieces + # if len(non_rigid_windows) = 1, then we're doing rigid registration + num_non_rigid_windows = len(non_rigid_windows) + + # for each small block, we only look up and down this many samples to find + # nonrigid shift + shifts_block = np.arange(-num_shifts_block, num_shifts_block + 1) + num_shifts = len(shifts_block) + shift_covs_block = np.zeros((2 * num_shifts_block + 1, num_temporal_bins, num_non_rigid_windows)) + + # this part determines the up/down covariance for each block without + # shifting anything + for window_index in range(num_non_rigid_windows): + win = non_rigid_windows[window_index] + window_slice = np.flatnonzero(win > 1e-5) + window_slice = slice(window_slice[0], window_slice[-1]) + tiled_window = win[window_slice, np.newaxis, np.newaxis] + Ftaper = Fg[window_slice] * np.tile(tiled_window, (1,) + Fg.shape[1:]) + for t, shift in enumerate(shifts_block): + Fs = np.roll(Ftaper, shift, axis=0) + F0taper = F0[window_slice] * np.tile(tiled_window, (1,) + F0.shape[1:]) + shift_covs_block[t, :, window_index] = np.mean(Fs * F0taper, axis=(0, 1)) + + # gaussian smoothing: + # here the original my_conv2_cpu is substituted with scipy gaussian_filters + shift_covs_block_smooth = shift_covs_block.copy() + shifts_block_up = np.linspace(-num_shifts_block, num_shifts_block, (2 * num_shifts_block * 10) + 1) + # 1. 2d smoothing over time and blocks dimensions for each shift + for shift_index in range(num_shifts): + shift_covs_block_smooth[shift_index, :, :] = gaussian_filter( + shift_covs_block_smooth[shift_index, :, :], smoothing_sigma + ) # some additional smoothing for robustness, across all dimensions + # 2. 1d smoothing over shift dimension for each spatial block + for window_index in range(num_non_rigid_windows): + shift_covs_block_smooth[:, :, window_index] = gaussian_filter1d( + shift_covs_block_smooth[:, :, window_index], smoothing_sigma, axis=0 + ) # some additional smoothing for robustness, across all dimensions + upsample_kernel = kriging_kernel( + shifts_block[:, np.newaxis], shifts_block_up[:, np.newaxis], sigma=kriging_sigma, p=kriging_p, d=kriging_d + ) + + optimal_shift_indices = np.zeros((num_temporal_bins, num_non_rigid_windows)) + for window_index in range(num_non_rigid_windows): + # using the upsampling kernel K, get the upsampled cross-correlation + # curves + upsampled_cov = upsample_kernel.T @ shift_covs_block_smooth[:, :, window_index] + + # find the max index of these curves + imax = np.argmax(upsampled_cov, axis=0) + + # add the value of the shift to the last row of the matrix of shifts + # (as if it was the last iteration of the main rigid loop ) + best_shifts[num_iterations - 1, :] = shifts_block_up[imax] + + # the sum of all the shifts equals the final shifts for this block + optimal_shift_indices[:, window_index] = np.sum(best_shifts, axis=0) + + return optimal_shift_indices, target_spikecount_hist, shift_covs_block + + +def kriging_kernel(source_location, target_location, sigma=1, p=2, d=2): + from scipy.spatial.distance import cdist + + dist_xy = cdist(source_location, target_location, metric="euclidean") + K = np.exp(-((dist_xy / sigma) ** p) / d) + return K diff --git a/src/spikeinterface/sortingcomponents/motion/motion_cleaner.py b/src/spikeinterface/sortingcomponents/motion/motion_cleaner.py new file mode 100644 index 0000000000..401210e079 --- /dev/null +++ b/src/spikeinterface/sortingcomponents/motion/motion_cleaner.py @@ -0,0 +1,73 @@ +import numpy as np + + + +def clean_motion_vector(motion, temporal_bins, bin_duration_s, speed_threshold=30, sigma_smooth_s=None): + """ + Simple machinery to remove spurious fast bump in the motion vector. + Also can applyt a smoothing. + + + Arguments + --------- + motion: numpy array 2d + Motion estimate in um. + temporal_bins: numpy.array 1d + temporal bins (bin center) + bin_duration_s: float + bin duration in second + speed_threshold: float (units um/s) + Maximum speed treshold between 2 bins allowed. + Expressed in um/s + sigma_smooth_s: None or float + Optional smooting gaussian kernel. + + Returns + ------- + corr : tensor + + + """ + motion_clean = motion.copy() + + # STEP 1 : + # * detect long plateau or small peak corssing the speed thresh + # * mask the period and interpolate + for i in range(motion.shape[1]): + one_motion = motion_clean[:, i] + speed = np.diff(one_motion, axis=0) / bin_duration_s + (inds,) = np.nonzero(np.abs(speed) > speed_threshold) + inds += 1 + if inds.size % 2 == 1: + # more compicated case: number of of inds is odd must remove first or last + # take the smallest duration sum + inds0 = inds[:-1] + inds1 = inds[1:] + d0 = np.sum(inds0[1::2] - inds0[::2]) + d1 = np.sum(inds1[1::2] - inds1[::2]) + if d0 < d1: + inds = inds0 + mask = np.ones(motion_clean.shape[0], dtype="bool") + for i in range(inds.size // 2): + mask[inds[i * 2] : inds[i * 2 + 1]] = False + import scipy.interpolate + + f = scipy.interpolate.interp1d(temporal_bins[mask], one_motion[mask]) + one_motion[~mask] = f(temporal_bins[~mask]) + + # Step 2 : gaussian smooth + if sigma_smooth_s is not None: + half_size = motion_clean.shape[0] // 2 + if motion_clean.shape[0] % 2 == 0: + # take care of the shift + bins = (np.arange(motion_clean.shape[0]) - half_size + 1) * bin_duration_s + else: + bins = (np.arange(motion_clean.shape[0]) - half_size) * bin_duration_s + smooth_kernel = np.exp(-(bins**2) / (2 * sigma_smooth_s**2)) + smooth_kernel /= np.sum(smooth_kernel) + smooth_kernel = smooth_kernel[:, None] + motion_clean = scipy.signal.fftconvolve(motion_clean, smooth_kernel, mode="same", axes=0) + + return motion_clean + + diff --git a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py index f0c01a005e..7804238024 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py @@ -1,41 +1,36 @@ from __future__ import annotations -from tqdm.auto import tqdm, trange +import warnings import numpy as np -from .motion_utils import Motion, get_windows, scipy_conv1d + from spikeinterface.sortingcomponents.tools import make_multi_method_doc -try: - import torch - import torch.nn.functional as F - HAVE_TORCH = True -except ImportError: - HAVE_TORCH = False +from .motion_utils import Motion, get_windows, get_spatial_bin_edges +from .decentralized import DecentralizedRegistration +from .iterative_template import IterativeTemplateRegistration +from .dredge import DredgeLfpRegistration def estimate_motion( recording, - peaks, - peak_locations, + peaks=None, + peak_locations=None, direction="y", - bin_duration_s=10.0, - bin_um=10.0, - margin_um=0.0, + # bin_um=10.0, + # hist_margin_um=0.0, rigid=False, win_shape="gaussian", win_step_um=50.0, - win_sigma_um=150.0, - post_clean=False, - speed_threshold=30, - sigma_smooth_s=None, + win_scale_um=150.0, + win_margin_um=0., method="decentralized", extra_outputs=False, progress_bar=False, - upsample_to_histogram_bin=False, verbose=False, + margin_um=None, **method_kwargs, ): """ @@ -48,57 +43,44 @@ def estimate_motion( recording: BaseRecording The recording extractor peaks: numpy array - Peak vector (complex dtype) + Peak vector (complex dtype). + Needed for decentralized and iterative_template methods. peak_locations: numpy array Complex dtype with "x", "y", "z" fields + Needed for decentralized and iterative_template methods. + direction: "x" | "y" | "z", default: "y" + Dimension on which the motion is estimated. "y" is depth along the probe. + + {method_doc} **histogram section** - direction: "x" | "y" | "z", default: "y" - Dimension on which the motion is estimated. "y" is depth along the probe. - bin_duration_s: float, default: 10 - Bin duration in second - bin_um: float, default: 10 - Spatial bin size in micrometers - margin_um: float, default: 0 - Margin in um to exclude from histogram estimation and - non-rigid smoothing functions to avoid edge effects. - Positive margin extrapolate out of the probe the motion. - Negative margin crop the motion on the border **non-rigid section** rigid : bool, default: False Compute rigid (one motion for the entire probe) or non rigid motion Rigid computation is equivalent to non-rigid with only one window with rectangular shape. - win_shape: "gaussian" | "rect" | "triangle", default: "gaussian" + win_shape : "gaussian" | "rect" | "triangle", default: "gaussian" The shape of the windows for non rigid. When rigid this is force to "rect" - win_step_um: float, default: 50 - Step deteween window - win_sigma_um: float, default: 150 - Sigma of the gaussian window - - **motion cleaning section** - - post_clean: bool, default: False - Apply some post cleaning to motion matrix or not - speed_threshold: float default: 30. - Detect to fast motion bump and remove then with interpolation - sigma_smooth_s: None or float - Optional smooting gaussian kernel when not None - + Nonrigid window-related arguments + The depth domain will be broken up into windows with shape controlled by win_shape, + spaced by win_step_um at a margin of win_margin_um from the boundary, and with + width controlled by win_scale_um. + win_step_um : float, default: 50 + See win_shape + win_scale_um : float, default: 150 + See win_shape + win_margin_um : float, default: 0. + See win_shape + + extra_outputs: bool, default: False If True then return an extra dict that contains variables to check intermediate steps (motion_histogram, non_rigid_windows, pairwise_displacement) - upsample_to_histogram_bin: bool or None, default: False - If True then upsample the returned motion array to the number of depth bins specified by bin_um. - When None: - * for non rigid case: then automatically True - * for rigid (non_rigid_kwargs=None): automatically False - This feature is in fact a bad idea and the interpolation should be done outside using better methods progress_bar: bool, default: False Display progress bar or not verbose: bool, default: False @@ -113,8 +95,19 @@ def estimate_motion( Optional output if `extra_outputs=True` This dict contain histogram, pairwise_displacement usefull for ploting. """ - # TODO handle multi segment one day - assert recording.get_num_segments() == 1 + + if margin_um is not None: + warnings.warn("estimate_motion() margin_um has been removed used hist_margin_um or win_margin_um") + + + # TODO handle multi segment one day : Charlie this is for you + assert recording.get_num_segments() == 1, "At the moment estimate_motion handle only unique segment" + + method_class = estimate_motion_methods[method] + + if method_class.need_peak_location: + if peaks is None or peak_locations is None: + raise ValueError(f"estimate_motion: the method {method} need peaks and peak_locations") if extra_outputs: extra = {} @@ -122,59 +115,46 @@ def estimate_motion( extra = None # contact positions - probe = recording.get_probe() - dim = ["x", "y", "z"].index(direction) - contact_pos = probe.contact_positions[:, dim] + # probe = recording.get_probe() + # dim = ["x", "y", "z"].index(direction) + # contact_pos = probe.contact_positions[:, dim] - # spatial bins - spatial_bin_edges = get_spatial_bin_edges(recording, direction, margin_um, bin_um) - spatial_bin_centers = 0.5 * (spatial_bin_edges[1:] + spatial_bin_edges[:-1]) + # # spatial histogram bins + # spatial_bin_edges = get_spatial_bin_edges(recording, direction, hist_margin_um, bin_um) + # spatial_bin_centers = 0.5 * (spatial_bin_edges[1:] + spatial_bin_edges[:-1]) - # get windows - non_rigid_windows, non_rigid_window_centers = get_windows( - rigid, contact_pos, spatial_bin_centers, margin_um, win_step_um, win_sigma_um, win_shape - ) + # # get spatial windows + # non_rigid_windows, non_rigid_window_centers = get_windows( + # rigid, contact_pos, spatial_bin_centers, win_margin_um, win_step_um, win_scale_um, win_shape + # ) - if extra_outputs: - extra["non_rigid_windows"] = non_rigid_windows + # if extra_outputs: + # extra["non_rigid_windows"] = non_rigid_windows # run method - method_class = estimate_motion_methods[method] - motion_array, temporal_bins = method_class.run( + + motion = method_class.run( recording, peaks, peak_locations, direction, - bin_duration_s, - bin_um, - spatial_bin_edges, - non_rigid_windows, + + # bin_um, + # spatial_bin_edges, + # non_rigid_windows, + rigid, + win_shape, + win_step_um, + win_scale_um, + win_margin_um, + verbose, progress_bar, extra, **method_kwargs, ) - # replace nan by zeros - np.nan_to_num(motion_array, copy=False) - - if post_clean: - motion_array = clean_motion_vector( - motion_array, temporal_bins, bin_duration_s, speed_threshold=speed_threshold, sigma_smooth_s=sigma_smooth_s - ) - - if upsample_to_histogram_bin is None: - upsample_to_histogram_bin = not rigid - if upsample_to_histogram_bin: - extra["motion_array"] = motion_array - extra["non_rigid_window_centers"] = non_rigid_window_centers - non_rigid_windows = np.array(non_rigid_windows) - non_rigid_windows /= non_rigid_windows.sum(axis=0, keepdims=True) - non_rigid_window_centers = spatial_bin_edges[:-1] + bin_um / 2 - motion_array = motion_array @ non_rigid_windows - - # TODO handle multi segment - motion = Motion([motion_array], [temporal_bins], non_rigid_window_centers, direction=direction) + # motion = Motion([motion_array], [temporal_bins], non_rigid_window_centers, direction=direction) if extra_outputs: return motion, extra @@ -182,1272 +162,7 @@ def estimate_motion( return motion -class DecentralizedRegistration: - """ - Method developed by the Paninski's group from Columbia university: - Charlie Windolf, Julien Boussard, Erdem Varol, Hyun Dong Lee - - This method is also known as DREDGe, but this implemenation does not use LFP signals. - - Original reference: - DECENTRALIZED MOTION INFERENCE AND REGISTRATION OF NEUROPIXEL DATA - https://ieeexplore.ieee.org/document/9414145 - https://proceedings.neurips.cc/paper/2021/hash/b950ea26ca12daae142bd74dba4427c8-Abstract.html - - This code was improved during Spike Sorting NY Hackathon 2022 by Erdem Varol and Charlie Windolf. - An additional major improvement can be found in this paper: - https://www.biorxiv.org/content/biorxiv/early/2022/12/05/2022.12.04.519043.full.pdf - - - Here are some various implementations by the original team: - https://github.com/int-brain-lab/spikes_localization_registration/blob/main/registration_pipeline/image_based_motion_estimate.py#L211 - https://github.com/cwindolf/spike-psvae/tree/main/spike_psvae - https://github.com/evarol/DREDge - """ - - name = "decentralized" - params_doc = """ - histogram_depth_smooth_um: None or float - Optional gaussian smoother on histogram on depth axis. - This is given as the sigma of the gaussian in micrometers. - histogram_time_smooth_s: None or float - Optional gaussian smoother on histogram on time axis. - This is given as the sigma of the gaussian in seconds. - pairwise_displacement_method: "conv" or "phase_cross_correlation" - How to estimate the displacement in the pairwise matrix. - max_displacement_um: float - Maximum possible displacement in micrometers. - weight_scale: "linear" or "exp" - For parwaise displacement, how to to rescale the associated weight matrix. - error_sigma: float, default: 0.2 - In case weight_scale="exp" this controls the sigma of the exponential. - conv_engine: "numpy" or "torch" or None, default: None - In case of pairwise_displacement_method="conv", what library to use to compute - the underlying correlation - torch_device=None - In case of conv_engine="torch", you can control which device (cpu or gpu) - batch_size: int - Size of batch for the convolution. Increasing this will speed things up dramatically - on GPUs and sometimes on CPU as well. - corr_threshold: float - Minimum correlation between pair of time bins in order for these to be - considered when optimizing a global displacment vector to align with - the pairwise displacements. - time_horizon_s: None or float - When not None the parwise discplament matrix is computed in a small time horizon. - In short only pair of bins close in time. - So the pariwaise matrix is super sparse and have values only the diagonal. - convergence_method: "lsmr" | "lsqr_robust" | "gradient_descent", default: "lsqr_robust" - Which method to use to compute the global displacement vector from the pairwise matrix. - robust_regression_sigma: float - Use for convergence_method="lsqr_robust" for iterative selection of the regression. - temporal_prior : bool, default: True - Ensures continuity across time, unless there is evidence in the recording for jumps. - spatial_prior : bool, default: False - Ensures continuity across space. Not usually necessary except in recordings with - glitches across space. - force_spatial_median_continuity: bool, default: False - When spatial_prior=False we can optionally apply a median continuity across spatial windows. - reference_displacement : string, one of: "mean", "median", "time", "mode_search" - Strategy for picking what is considered displacement=0. - - "mean" : the mean displacement is subtracted - - "median" : the median displacement is subtracted - - "time" : the displacement at a given time (in seconds) is subtracted - - "mode_search" : an attempt is made to guess the mode. needs work. - lsqr_robust_n_iter: int - Number of iteration for convergence_method="lsqr_robust". - """ - - @classmethod - def run( - cls, - recording, - peaks, - peak_locations, - direction, - bin_duration_s, - bin_um, - spatial_bin_edges, - non_rigid_windows, - verbose, - progress_bar, - extra, - histogram_depth_smooth_um=None, - histogram_time_smooth_s=None, - pairwise_displacement_method="conv", - max_displacement_um=100.0, - weight_scale="linear", - error_sigma=0.2, - conv_engine=None, - torch_device=None, - batch_size=1, - corr_threshold=0.0, - time_horizon_s=None, - convergence_method="lsqr_robust", - soft_weights=False, - normalized_xcorr=True, - centered_xcorr=True, - temporal_prior=True, - spatial_prior=False, - force_spatial_median_continuity=False, - reference_displacement="median", - reference_displacement_time_s=0, - robust_regression_sigma=2, - lsqr_robust_n_iter=20, - weight_with_amplitude=False, - ): - # use torch if installed - if conv_engine is None: - conv_engine = "torch" if HAVE_TORCH else "numpy" - - # make 2D histogram raster - if verbose: - print("Computing motion histogram") - - motion_histogram, temporal_hist_bin_edges, spatial_hist_bin_edges = make_2d_motion_histogram( - recording, - peaks, - peak_locations, - direction=direction, - bin_duration_s=bin_duration_s, - spatial_bin_edges=spatial_bin_edges, - weight_with_amplitude=weight_with_amplitude, - ) - import scipy.signal - - if histogram_depth_smooth_um is not None: - bins = np.arange(motion_histogram.shape[1]) * bin_um - bins = bins - np.mean(bins) - smooth_kernel = np.exp(-(bins**2) / (2 * histogram_depth_smooth_um**2)) - smooth_kernel /= np.sum(smooth_kernel) - - motion_histogram = scipy.signal.fftconvolve(motion_histogram, smooth_kernel[None, :], mode="same", axes=1) - - if histogram_time_smooth_s is not None: - bins = np.arange(motion_histogram.shape[0]) * bin_duration_s - bins = bins - np.mean(bins) - smooth_kernel = np.exp(-(bins**2) / (2 * histogram_time_smooth_s**2)) - smooth_kernel /= np.sum(smooth_kernel) - motion_histogram = scipy.signal.fftconvolve(motion_histogram, smooth_kernel[:, None], mode="same", axes=0) - - if extra is not None: - extra["motion_histogram"] = motion_histogram - extra["pairwise_displacement_list"] = [] - extra["temporal_hist_bin_edges"] = temporal_hist_bin_edges - extra["spatial_hist_bin_edges"] = spatial_hist_bin_edges - - # temporal bins are bin center - temporal_bins = 0.5 * (temporal_hist_bin_edges[1:] + temporal_hist_bin_edges[:-1]) - - motion = np.zeros((temporal_bins.size, len(non_rigid_windows)), dtype=np.float64) - windows_iter = non_rigid_windows - if progress_bar: - windows_iter = tqdm(windows_iter, desc="windows") - if spatial_prior: - all_pairwise_displacements = np.empty( - (len(non_rigid_windows), temporal_bins.size, temporal_bins.size), dtype=np.float64 - ) - all_pairwise_displacement_weights = np.empty( - (len(non_rigid_windows), temporal_bins.size, temporal_bins.size), dtype=np.float64 - ) - for i, win in enumerate(windows_iter): - window_slice = np.flatnonzero(win > 1e-5) - window_slice = slice(window_slice[0], window_slice[-1]) - if verbose: - print(f"Computing pairwise displacement: {i + 1} / {len(non_rigid_windows)}") - - pairwise_displacement, pairwise_displacement_weight = compute_pairwise_displacement( - motion_histogram[:, window_slice], - bin_um, - window=win[window_slice], - method=pairwise_displacement_method, - weight_scale=weight_scale, - error_sigma=error_sigma, - conv_engine=conv_engine, - torch_device=torch_device, - batch_size=batch_size, - max_displacement_um=max_displacement_um, - normalized_xcorr=normalized_xcorr, - centered_xcorr=centered_xcorr, - corr_threshold=corr_threshold, - time_horizon_s=time_horizon_s, - bin_duration_s=bin_duration_s, - progress_bar=False, - ) - - if spatial_prior: - all_pairwise_displacements[i] = pairwise_displacement - all_pairwise_displacement_weights[i] = pairwise_displacement_weight - - if extra is not None: - extra["pairwise_displacement_list"].append(pairwise_displacement) - - if verbose: - print(f"Computing global displacement: {i + 1} / {len(non_rigid_windows)}") - - # TODO: if spatial_prior, do this after the loop - if not spatial_prior: - motion[:, i] = compute_global_displacement( - pairwise_displacement, - pairwise_displacement_weight=pairwise_displacement_weight, - convergence_method=convergence_method, - robust_regression_sigma=robust_regression_sigma, - lsqr_robust_n_iter=lsqr_robust_n_iter, - temporal_prior=temporal_prior, - spatial_prior=spatial_prior, - soft_weights=soft_weights, - progress_bar=False, - ) - - if spatial_prior: - motion = compute_global_displacement( - all_pairwise_displacements, - pairwise_displacement_weight=all_pairwise_displacement_weights, - convergence_method=convergence_method, - robust_regression_sigma=robust_regression_sigma, - lsqr_robust_n_iter=lsqr_robust_n_iter, - temporal_prior=temporal_prior, - spatial_prior=spatial_prior, - soft_weights=soft_weights, - progress_bar=False, - ) - elif len(non_rigid_windows) > 1: - # if spatial_prior is False, we still want keep the spatial bins - # correctly offset from each other - if force_spatial_median_continuity: - for i in range(len(non_rigid_windows) - 1): - motion[:, i + 1] -= np.median(motion[:, i + 1] - motion[:, i]) - - # try to avoid constant offset - # let the user choose how to do this. here are some ideas. - # (one can also -= their own number on the result of this function.) - if reference_displacement == "mean": - motion -= motion.mean() - elif reference_displacement == "median": - motion -= np.median(motion) - elif reference_displacement == "time": - # reference the motion to 0 at a specific time, independently in each window - reference_displacement_bin = np.digitize(reference_displacement_time_s, temporal_hist_bin_edges) - 1 - motion -= motion[reference_displacement_bin, :] - elif reference_displacement == "mode_search": - # just a sketch of an idea - # things might want to change, should have a configurable bin size, - # should use a call to histogram instead of the loop, ... - step_size = 0.1 - round_mode = np.round # floor? - best_ref = np.median(motion) - max_zeros = np.sum(round_mode(motion - best_ref) == 0) - for ref in np.arange(np.floor(motion.min()), np.ceil(motion.max()), step_size): - n_zeros = np.sum(round_mode(motion - ref) == 0) - if n_zeros > max_zeros: - max_zeros = n_zeros - best_ref = ref - motion -= best_ref - - return motion, temporal_bins - - -class IterativeTemplateRegistration: - """ - Alignment function implemented by Kilosort2.5 and ported from pykilosort: - https://github.com/int-brain-lab/pykilosort/blob/ibl_prod/pykilosort/datashift2.py#L166 - - The main difference with respect to the original implementation are: - * scipy is used for gaussian smoothing - * windowing is implemented as gaussian tapering (instead of rectangular blocks) - * the 3d histogram is constructed in less cryptic way - * peak_locations are computed outside and so can either center fo mass or monopolar trianglation - contrary to kilosort2.5 use exclusively center of mass - - See https://www.science.org/doi/abs/10.1126/science.abf4588?cookieSet=1 - - Ported by Alessio Buccino into SpikeInterface - """ - - name = "iterative_template" - params_doc = """ - num_amp_bins: int, default: 20 - number ob bins in the histogram on the log amplitues dimension - num_shifts_global: int, default: 15 - Number of spatial bin shifts to consider for global alignment - num_iterations: int, default: 10 - Number of iterations for global alignment procedure - num_shifts_block: int, default: 5 - Number of spatial bin shifts to consider for non-rigid alignment - smoothing_sigma: float, default: 0.5 - Sigma of gaussian for covariance matrices smoothing - kriging_sigma: float, - sigma parameter for kriging_kernel function - kriging_p: foat - p parameter for kriging_kernel function - kriging_d: float - d parameter for kriging_kernel function - """ - - @classmethod - def run( - cls, - recording, - peaks, - peak_locations, - direction, - bin_duration_s, - bin_um, - spatial_bin_edges, - non_rigid_windows, - verbose, - progress_bar, - extra, - num_amp_bins=20, - num_shifts_global=15, - num_iterations=10, - num_shifts_block=5, - smoothing_sigma=0.5, - kriging_sigma=1, - kriging_p=2, - kriging_d=2, - ): - # make a 3D histogram - motion_histograms, temporal_hist_bin_edges, spatial_hist_bin_edges = make_3d_motion_histograms( - recording, - peaks, - peak_locations, - direction=direction, - num_amp_bins=num_amp_bins, - bin_duration_s=bin_duration_s, - spatial_bin_edges=spatial_bin_edges, - ) - # temporal bins are bin center - temporal_bins = temporal_hist_bin_edges[:-1] + bin_duration_s // 2.0 - - # do alignment - shift_indices, target_histogram, shift_covs_block = iterative_template_registration( - motion_histograms, - non_rigid_windows=non_rigid_windows, - num_shifts_global=num_shifts_global, - num_iterations=num_iterations, - num_shifts_block=num_shifts_block, - smoothing_sigma=smoothing_sigma, - kriging_sigma=kriging_sigma, - kriging_p=kriging_p, - kriging_d=kriging_d, - ) - - # convert to um - motion = -(shift_indices * bin_um) - - if extra: - extra["motion_histograms"] = motion_histograms - extra["target_histogram"] = target_histogram - extra["shift_covs_block"] = shift_covs_block - extra["temporal_hist_bin_edges"] = temporal_hist_bin_edges - extra["spatial_hist_bin_edges"] = spatial_hist_bin_edges - - return motion, temporal_bins - - -_methods_list = [DecentralizedRegistration, IterativeTemplateRegistration] +_methods_list = [DecentralizedRegistration, IterativeTemplateRegistration, DredgeLfpRegistration] estimate_motion_methods = {m.name: m for m in _methods_list} method_doc = make_multi_method_doc(_methods_list) estimate_motion.__doc__ = estimate_motion.__doc__.format(method_doc=method_doc) - - -def get_spatial_bin_edges(recording, direction, margin_um, bin_um): - # contact along one axis - probe = recording.get_probe() - dim = ["x", "y", "z"].index(direction) - contact_pos = probe.contact_positions[:, dim] - - min_ = np.min(contact_pos) - margin_um - max_ = np.max(contact_pos) + margin_um - spatial_bins = np.arange(min_, max_ + bin_um, bin_um) - - return spatial_bins - - - - - -def make_2d_motion_histogram( - recording, - peaks, - peak_locations, - weight_with_amplitude=False, - direction="y", - bin_duration_s=1.0, - bin_um=2.0, - margin_um=50, - spatial_bin_edges=None, -): - """ - Generate 2d motion histogram in depth and time. - - Parameters - ---------- - recording : BaseRecording - The input recording - peaks : np.array - The peaks array - peak_locations : np.array - Array with peak locations - weight_with_amplitude : bool, default: False - If True, motion histogram is weighted by amplitudes - direction : "x" | "y" | "z", default: "y" - The depth direction - bin_duration_s : float, default: 1.0 - The temporal bin duration in s - bin_um : float, default: 2.0 - The spatial bin size in um. Ignored if spatial_bin_edges is given. - margin_um : float, default: 50 - The margin to add to the minimum and maximum positions before spatial binning. - Ignored if spatial_bin_edges is given. - spatial_bin_edges : np.array, default: None - The pre-computed spatial bin edges - - Returns - ------- - motion_histogram - 2d np.array with motion histogram (num_temporal_bins, num_spatial_bins) - temporal_bin_edges - 1d array with temporal bin edges - spatial_bin_edges - 1d array with spatial bin edges - """ - n_samples = recording.get_num_samples() - mint_s = recording.sample_index_to_time(0) - maxt_s = recording.sample_index_to_time(n_samples) - temporal_bin_edges = np.arange(mint_s, maxt_s + bin_duration_s, bin_duration_s) - if spatial_bin_edges is None: - spatial_bin_edges = get_spatial_bin_edges(recording, direction, margin_um, bin_um) - - arr = np.zeros((peaks.size, 2), dtype="float64") - arr[:, 0] = recording.sample_index_to_time(peaks["sample_index"]) - arr[:, 1] = peak_locations[direction] - - if weight_with_amplitude: - weights = np.abs(peaks["amplitude"]) - else: - weights = None - - motion_histogram, edges = np.histogramdd(arr, bins=(temporal_bin_edges, spatial_bin_edges), weights=weights) - - # average amplitude in each bin - if weight_with_amplitude: - bin_counts, _ = np.histogramdd(arr, bins=(temporal_bin_edges, spatial_bin_edges)) - bin_counts[bin_counts == 0] = 1 - motion_histogram = motion_histogram / bin_counts - - return motion_histogram, temporal_bin_edges, spatial_bin_edges - - -def make_3d_motion_histograms( - recording, - peaks, - peak_locations, - direction="y", - bin_duration_s=1.0, - bin_um=2.0, - margin_um=50, - num_amp_bins=20, - log_transform=True, - spatial_bin_edges=None, -): - """ - Generate 3d motion histograms in depth, amplitude, and time. - This is used by the "iterative_template_registration" (Kilosort2.5) method. - - - Parameters - ---------- - recording : BaseRecording - The input recording - peaks : np.array - The peaks array - peak_locations : np.array - Array with peak locations - direction : "x" | "y" | "z", default: "y" - The depth direction - bin_duration_s : float, default: 1.0 - The temporal bin duration in s. - bin_um : float, default: 2.0 - The spatial bin size in um. Ignored if spatial_bin_edges is given. - margin_um : float, default: 50 - The margin to add to the minimum and maximum positions before spatial binning. - Ignored if spatial_bin_edges is given. - log_transform : bool, default: True - If True, histograms are log-transformed - spatial_bin_edges : np.array, default: None - The pre-computed spatial bin edges - - Returns - ------- - motion_histograms - 3d np.array with motion histogram (num_temporal_bins, num_spatial_bins, num_amp_bins) - temporal_bin_edges - 1d array with temporal bin edges - spatial_bin_edges - 1d array with spatial bin edges - """ - n_samples = recording.get_num_samples() - mint_s = recording.sample_index_to_time(0) - maxt_s = recording.sample_index_to_time(n_samples) - temporal_bin_edges = np.arange(mint_s, maxt_s + bin_duration_s, bin_duration_s) - if spatial_bin_edges is None: - spatial_bin_edges = get_spatial_bin_edges(recording, direction, margin_um, bin_um) - - # pre-compute abs amplitude and ranges for scaling - amplitude_bin_edges = np.linspace(0, 1, num_amp_bins + 1) - abs_peaks = np.abs(peaks["amplitude"]) - max_peak_amp = np.max(abs_peaks) - min_peak_amp = np.min(abs_peaks) - # log amplitudes and scale between 0-1 - abs_peaks_log_norm = (np.log10(abs_peaks) - np.log10(min_peak_amp)) / ( - np.log10(max_peak_amp) - np.log10(min_peak_amp) - ) - - arr = np.zeros((peaks.size, 3), dtype="float64") - arr[:, 0] = recording.sample_index_to_time(peaks["sample_index"]) - arr[:, 1] = peak_locations[direction] - arr[:, 2] = abs_peaks_log_norm - - motion_histograms, edges = np.histogramdd( - arr, - bins=( - temporal_bin_edges, - spatial_bin_edges, - amplitude_bin_edges, - ), - ) - - if log_transform: - motion_histograms = np.log2(1 + motion_histograms) - - return motion_histograms, temporal_bin_edges, spatial_bin_edges - - -def compute_pairwise_displacement( - motion_hist, - bin_um, - method="conv", - weight_scale="linear", - error_sigma=0.2, - conv_engine="numpy", - torch_device=None, - batch_size=1, - max_displacement_um=1500, - corr_threshold=0, - time_horizon_s=None, - normalized_xcorr=True, - centered_xcorr=True, - bin_duration_s=None, - progress_bar=False, - window=None, -): - """ - Compute pairwise displacement - """ - from scipy import linalg - - assert conv_engine in ("torch", "numpy"), f"'conv_engine' must be 'torch' or 'numpy'" - size = motion_hist.shape[0] - pairwise_displacement = np.zeros((size, size), dtype="float32") - - if time_horizon_s is not None: - band_width = int(np.ceil(time_horizon_s / bin_duration_s)) - if band_width >= size: - time_horizon_s = None - - if conv_engine == "torch": - if torch_device is None: - torch_device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - - if method == "conv": - if max_displacement_um is None: - n = motion_hist.shape[1] // 2 - else: - n = min( - motion_hist.shape[1] // 2, - int(np.ceil(max_displacement_um // bin_um)), - ) - possible_displacement = np.arange(-n, n + 1) * bin_um - - xrange = trange if progress_bar else range - - motion_hist_engine = motion_hist - window_engine = window - if conv_engine == "torch": - motion_hist_engine = torch.as_tensor(motion_hist, dtype=torch.float32, device=torch_device) - window_engine = torch.as_tensor(window, dtype=torch.float32, device=torch_device) - - pairwise_displacement = np.empty((size, size), dtype=np.float32) - correlation = np.empty((size, size), dtype=motion_hist.dtype) - - for i in xrange(0, size, batch_size): - corr = normxcorr1d( - motion_hist_engine, - motion_hist_engine[i : i + batch_size], - weights=window_engine, - padding=possible_displacement.size // 2, - conv_engine=conv_engine, - normalized=normalized_xcorr, - centered=centered_xcorr, - ) - if conv_engine == "torch": - max_corr, best_disp_inds = torch.max(corr, dim=2) - best_disp = possible_displacement[best_disp_inds.cpu()] - pairwise_displacement[i : i + batch_size] = best_disp - correlation[i : i + batch_size] = max_corr.cpu() - elif conv_engine == "numpy": - best_disp_inds = np.argmax(corr, axis=2) - max_corr = np.take_along_axis(corr, best_disp_inds[..., None], 2).squeeze() - best_disp = possible_displacement[best_disp_inds] - pairwise_displacement[i : i + batch_size] = best_disp - correlation[i : i + batch_size] = max_corr - - if corr_threshold is not None and corr_threshold > 0: - which = correlation > corr_threshold - correlation *= which - - elif method == "phase_cross_correlation": - # this 'phase_cross_correlation' is an old idea from Julien/Charlie/Erden that is kept for testing - # but this is not very releveant - try: - import skimage.registration - except ImportError: - raise ImportError("To use the 'phase_cross_correlation' method install scikit-image") - - errors = np.zeros((size, size), dtype="float32") - loop = range(size) - if progress_bar: - loop = tqdm(loop) - for i in loop: - for j in range(size): - shift, error, diffphase = skimage.registration.phase_cross_correlation( - motion_hist[i, :], motion_hist[j, :] - ) - pairwise_displacement[i, j] = shift * bin_um - errors[i, j] = error - correlation = 1 - errors - - else: - raise ValueError( - f"method {method} does not exist for compute_pairwise_displacement. Current possible methods are" - f" 'conv' or 'phase_cross_correlation'" - ) - - if weight_scale == "linear": - # between 0 and 1 - pairwise_displacement_weight = correlation - elif weight_scale == "exp": - pairwise_displacement_weight = np.exp((correlation - 1) / error_sigma) - - # handle the time horizon by multiplying the weights by a - # matrix with the time horizon on its diagonal bands. - if method == "conv" and time_horizon_s is not None and time_horizon_s > 0: - horizon_matrix = linalg.toeplitz( - np.r_[np.ones(band_width, dtype=bool), np.zeros(size - band_width, dtype=bool)] - ) - pairwise_displacement_weight *= horizon_matrix - - return pairwise_displacement, pairwise_displacement_weight - - -_possible_convergence_method = ("lsmr", "gradient_descent", "lsqr_robust") - - -def compute_global_displacement( - pairwise_displacement, - pairwise_displacement_weight=None, - sparse_mask=None, - temporal_prior=True, - spatial_prior=True, - soft_weights=False, - convergence_method="lsmr", - robust_regression_sigma=2, - lsqr_robust_n_iter=20, - progress_bar=False, -): - """ - Compute global displacement - - Arguments - --------- - pairwise_displacement : time x time array - pairwise_displacement_weight : time x time array - sparse_mask : time x time array - convergence_method : str - One of "gradient" - - """ - import scipy - from scipy.optimize import minimize - from scipy.sparse import csr_matrix - from scipy.sparse.linalg import lsqr - from scipy.stats import zscore - - if convergence_method == "gradient_descent": - size = pairwise_displacement.shape[0] - - D = pairwise_displacement - if pairwise_displacement_weight is not None or sparse_mask is not None: - # weighted problem - if pairwise_displacement_weight is None: - pairwise_displacement_weight = np.ones_like(D) - if sparse_mask is None: - sparse_mask = np.ones_like(D) - W = pairwise_displacement_weight * sparse_mask - - I, J = np.nonzero(W > 0) - Wij = W[I, J] - Dij = D[I, J] - W = csr_matrix((Wij, (I, J)), shape=W.shape) - WD = csr_matrix((Wij * Dij, (I, J)), shape=W.shape) - fixed_terms = (W @ WD).diagonal() - (WD @ W).diagonal() - diag_WW = (W @ W).diagonal() - Wsq = W.power(2) - - def obj(p): - return 0.5 * np.square(Wij * (Dij - (p[I] - p[J]))).sum() - - def jac(p): - return fixed_terms - 2 * (Wsq @ p) + 2 * p * diag_WW - - else: - # unweighted problem, it's faster when we have no weights - fixed_terms = -D.sum(axis=1) + D.sum(axis=0) - - def obj(p): - v = np.square((D - (p[:, None] - p[None, :]))).sum() - return 0.5 * v - - def jac(p): - return fixed_terms + 2 * (size * p - p.sum()) - - res = minimize(fun=obj, jac=jac, x0=D.mean(axis=1), method="L-BFGS-B") - if not res.success: - print("Global displacement gradient descent had an error") - displacement = res.x - - elif convergence_method == "lsqr_robust": - - if sparse_mask is not None: - I, J = np.nonzero(sparse_mask > 0) - elif pairwise_displacement_weight is not None: - I, J = pairwise_displacement_weight.nonzero() - else: - I, J = np.nonzero(np.ones_like(pairwise_displacement, dtype=bool)) - - nnz_ones = np.ones(I.shape[0], dtype=pairwise_displacement.dtype) - - if pairwise_displacement_weight is not None: - if isinstance(pairwise_displacement_weight, scipy.sparse.csr_matrix): - W = np.array(pairwise_displacement_weight[I, J]).T - else: - W = pairwise_displacement_weight[I, J][:, None] - else: - W = nnz_ones[:, None] - if isinstance(pairwise_displacement, scipy.sparse.csr_matrix): - V = np.array(pairwise_displacement[I, J])[0] - else: - V = pairwise_displacement[I, J] - M = csr_matrix((nnz_ones, (range(I.shape[0]), I)), shape=(I.shape[0], pairwise_displacement.shape[0])) - N = csr_matrix((nnz_ones, (range(I.shape[0]), J)), shape=(I.shape[0], pairwise_displacement.shape[0])) - A = M - N - idx = np.ones(A.shape[0], dtype=bool) - - # TODO: this is already soft_weights - xrange = trange if progress_bar else range - for i in xrange(lsqr_robust_n_iter): - p = lsqr(A[idx].multiply(W[idx]), V[idx] * W[idx][:, 0])[0] - idx = np.nonzero(np.abs(zscore(A @ p - V)) <= robust_regression_sigma) - displacement = p - - elif convergence_method == "lsmr": - import gc - from scipy import sparse - - D = pairwise_displacement - - # weighted problem - if pairwise_displacement_weight is None: - pairwise_displacement_weight = np.ones_like(D) - if sparse_mask is None: - sparse_mask = np.ones_like(D) - W = pairwise_displacement_weight * sparse_mask - if isinstance(W, scipy.sparse.csr_matrix): - W = W.astype(np.float32).toarray() - D = D.astype(np.float32).toarray() - - assert D.shape == W.shape - - # first dimension is the windows dim, which could be empty in rigid case - # we expand dims so that below we can consider only the nonrigid case - if D.ndim == 2: - W = W[None] - D = D[None] - assert D.ndim == W.ndim == 3 - B, T, T_ = D.shape - assert T == T_ - - # sparsify the problem - # we will make a list of temporal problems and then - # stack over the windows axis to finish. - # each matrix in coefficients will be (sparse_dim, T) - coefficients = [] - # each vector in targets will be (T,) - targets = [] - # we want to solve for a vector of shape BT, which we will reshape - # into a (B, T) matrix. - # after the loop below, we will stack a coefts matrix (sparse_dim, B, T) - # and a target vector of shape (B, T), both to be vectorized on last two axes, - # so that the target p is indexed by i = bT + t (block/window major). - - # calculate coefficients matrices and target vector - # this list stores boolean masks corresponding to whether or not each - # term comes from the prior or the likelihood. we can trim the likelihood terms, - # but not the prior terms, in the trimmed least squares (robust iters) iterations below. - cannot_trim = [] - for Wb, Db in zip(W, D): - # indices of active temporal pairs in this window - I, J = np.nonzero(Wb > 0) - n_sampled = I.size - - # construct Kroneckers and sparse objective in this window - pair_weights = np.ones(n_sampled) - if soft_weights: - pair_weights = Wb[I, J] - Mb = sparse.csr_matrix((pair_weights, (range(n_sampled), I)), shape=(n_sampled, T)) - Nb = sparse.csr_matrix((pair_weights, (range(n_sampled), J)), shape=(n_sampled, T)) - block_sparse_kron = Mb - Nb - block_disp_pairs = pair_weights * Db[I, J] - cannot_trim_block = np.ones_like(block_disp_pairs, dtype=bool) - - # add the temporal smoothness prior in this window - if temporal_prior: - temporal_diff_operator = sparse.diags( - ( - np.full(T - 1, -1, dtype=block_sparse_kron.dtype), - np.full(T - 1, 1, dtype=block_sparse_kron.dtype), - ), - offsets=(0, 1), - shape=(T - 1, T), - ) - block_sparse_kron = sparse.vstack( - (block_sparse_kron, temporal_diff_operator), - format="csr", - ) - block_disp_pairs = np.concatenate( - (block_disp_pairs, np.zeros(T - 1)), - ) - cannot_trim_block = np.concatenate( - (cannot_trim_block, np.zeros(T - 1, dtype=bool)), - ) - - coefficients.append(block_sparse_kron) - targets.append(block_disp_pairs) - cannot_trim.append(cannot_trim_block) - coefficients = sparse.block_diag(coefficients) - targets = np.concatenate(targets, axis=0) - cannot_trim = np.concatenate(cannot_trim, axis=0) - - # spatial smoothness prior: penalize difference of each block's - # displacement with the next. - # only if B > 1, and not in the last window. - # this is a (BT, BT) sparse matrix D such that: - # entry at (i, j) is: - # { 1 if i = j, i.e., i = j = bT + t for b = 0,...,B-2 - # { -1 if i = bT + t and j = (b+1)T + t for b = 0,...,B-2 - # { 0 otherwise. - # put more simply, the first (B-1)T diagonal entries are 1, - # and entries (i, j) such that i = j - T are -1. - if B > 1 and spatial_prior: - spatial_diff_operator = sparse.diags( - ( - np.ones((B - 1) * T, dtype=block_sparse_kron.dtype), - np.full((B - 1) * T, -1, dtype=block_sparse_kron.dtype), - ), - offsets=(0, T), - shape=((B - 1) * T, B * T), - ) - coefficients = sparse.vstack((coefficients, spatial_diff_operator)) - targets = np.concatenate((targets, np.zeros((B - 1) * T, dtype=targets.dtype))) - cannot_trim = np.concatenate((cannot_trim, np.zeros((B - 1) * T, dtype=bool))) - coefficients = coefficients.tocsr() - - # initialize at the column mean of pairwise displacements (in each window) - p0 = D.mean(axis=2).reshape(B * T) - - # use LSMR to solve the whole problem || targets - coefficients @ motion ||^2 - iters = range(max(1, lsqr_robust_n_iter)) - if progress_bar and lsqr_robust_n_iter > 1: - iters = tqdm(iters, desc="robust lsqr") - for it in iters: - # trim active set -- start with no trimming - idx = slice(None) - if it: - idx = np.flatnonzero( - cannot_trim | (np.abs(zscore(coefficients @ displacement - targets)) <= robust_regression_sigma) - ) - - # solve trimmed ols problem - displacement, *_ = sparse.linalg.lsmr(coefficients[idx], targets[idx], x0=p0) - - # warm start next iteration - p0 = displacement - # Cleanup lsmr memory (see https://stackoverflow.com/questions/56147713/memory-leak-in-scipy) - # TODO: check if this gets fixed in scipy - gc.collect() - - displacement = displacement.reshape(B, T).T - else: - raise ValueError( - f"Method {convergence_method} doesn't exist for compute_global_displacement" - f" possible values for 'convergence_method' are {_possible_convergence_method}" - ) - - return np.squeeze(displacement) - - -def iterative_template_registration( - spikecounts_hist_images, - non_rigid_windows=None, - num_shifts_global=15, - num_iterations=10, - num_shifts_block=5, - smoothing_sigma=0.5, - kriging_sigma=1, - kriging_p=2, - kriging_d=2, -): - """ - - Parameters - ---------- - - spikecounts_hist_images : np.ndarray - Spike count histogram images (num_temporal_bins, num_spatial_bins, num_amps_bins) - non_rigid_windows : list, default: None - If num_non_rigid_windows > 1, this argument is required and it is a list of - windows to taper spatial bins in different blocks - num_shifts_global : int, default: 15 - Number of spatial bin shifts to consider for global alignment - num_iterations : int, default: 10 - Number of iterations for global alignment procedure - num_shifts_block : int, default: 5 - Number of spatial bin shifts to consider for non-rigid alignment - smoothing_sigma : float, default: 0.5 - Sigma of gaussian for covariance matrices smoothing - kriging_sigma : float, default: 1 - sigma parameter for kriging_kernel function - kriging_p : float, default: 2 - p parameter for kriging_kernel function - kriging_d : float, default: 2 - d parameter for kriging_kernel function - - Returns - ------- - optimal_shift_indices - Optimal shifts for each temporal and spatial bin (num_temporal_bins, num_non_rigid_windows) - target_spikecount_hist - Target histogram used for alignment (num_spatial_bins, num_amps_bins) - """ - from scipy.ndimage import gaussian_filter, gaussian_filter1d - - # F is y bins by amp bins by batches - # ysamp are the coordinates of the y bins in um - spikecounts_hist_images = spikecounts_hist_images.swapaxes(0, 1).swapaxes(1, 2) - num_temporal_bins = spikecounts_hist_images.shape[2] - - # look up and down this many y bins to find best alignment - shift_covs = np.zeros((2 * num_shifts_global + 1, num_temporal_bins)) - shifts = np.arange(-num_shifts_global, num_shifts_global + 1) - - # mean subtraction to compute covariance - F = spikecounts_hist_images - Fg = F - np.mean(F, axis=0) - - # initialize the target "frame" for alignment with a single sample - # here we removed min(299, ...) - F0 = Fg[:, :, np.floor(num_temporal_bins / 2).astype("int") - 1] - F0 = F0[:, :, np.newaxis] - - # first we do rigid registration by integer shifts - # everything is iteratively aligned until most of the shifts become 0. - best_shifts = np.zeros((num_iterations, num_temporal_bins)) - for iteration in range(num_iterations): - for t, shift in enumerate(shifts): - # for each NEW potential shift, estimate covariance - Fs = np.roll(Fg, shift, axis=0) - shift_covs[t, :] = np.mean(Fs * F0, axis=(0, 1)) - if iteration + 1 < num_iterations: - # estimate the best shifts - imax = np.argmax(shift_covs, axis=0) - # align the data by these integer shifts - for t, shift in enumerate(shifts): - ibest = imax == t - Fg[:, :, ibest] = np.roll(Fg[:, :, ibest], shift, axis=0) - best_shifts[iteration, ibest] = shift - # new target frame based on our current best alignment - F0 = np.mean(Fg, axis=2)[:, :, np.newaxis] - target_spikecount_hist = F0[:, :, 0] - - # now we figure out how to split the probe into nblocks pieces - # if len(non_rigid_windows) = 1, then we're doing rigid registration - num_non_rigid_windows = len(non_rigid_windows) - - # for each small block, we only look up and down this many samples to find - # nonrigid shift - shifts_block = np.arange(-num_shifts_block, num_shifts_block + 1) - num_shifts = len(shifts_block) - shift_covs_block = np.zeros((2 * num_shifts_block + 1, num_temporal_bins, num_non_rigid_windows)) - - # this part determines the up/down covariance for each block without - # shifting anything - for window_index in range(num_non_rigid_windows): - win = non_rigid_windows[window_index] - window_slice = np.flatnonzero(win > 1e-5) - window_slice = slice(window_slice[0], window_slice[-1]) - tiled_window = win[window_slice, np.newaxis, np.newaxis] - Ftaper = Fg[window_slice] * np.tile(tiled_window, (1,) + Fg.shape[1:]) - for t, shift in enumerate(shifts_block): - Fs = np.roll(Ftaper, shift, axis=0) - F0taper = F0[window_slice] * np.tile(tiled_window, (1,) + F0.shape[1:]) - shift_covs_block[t, :, window_index] = np.mean(Fs * F0taper, axis=(0, 1)) - - # gaussian smoothing: - # here the original my_conv2_cpu is substituted with scipy gaussian_filters - shift_covs_block_smooth = shift_covs_block.copy() - shifts_block_up = np.linspace(-num_shifts_block, num_shifts_block, (2 * num_shifts_block * 10) + 1) - # 1. 2d smoothing over time and blocks dimensions for each shift - for shift_index in range(num_shifts): - shift_covs_block_smooth[shift_index, :, :] = gaussian_filter( - shift_covs_block_smooth[shift_index, :, :], smoothing_sigma - ) # some additional smoothing for robustness, across all dimensions - # 2. 1d smoothing over shift dimension for each spatial block - for window_index in range(num_non_rigid_windows): - shift_covs_block_smooth[:, :, window_index] = gaussian_filter1d( - shift_covs_block_smooth[:, :, window_index], smoothing_sigma, axis=0 - ) # some additional smoothing for robustness, across all dimensions - upsample_kernel = kriging_kernel( - shifts_block[:, np.newaxis], shifts_block_up[:, np.newaxis], sigma=kriging_sigma, p=kriging_p, d=kriging_d - ) - - optimal_shift_indices = np.zeros((num_temporal_bins, num_non_rigid_windows)) - for window_index in range(num_non_rigid_windows): - # using the upsampling kernel K, get the upsampled cross-correlation - # curves - upsampled_cov = upsample_kernel.T @ shift_covs_block_smooth[:, :, window_index] - - # find the max index of these curves - imax = np.argmax(upsampled_cov, axis=0) - - # add the value of the shift to the last row of the matrix of shifts - # (as if it was the last iteration of the main rigid loop ) - best_shifts[num_iterations - 1, :] = shifts_block_up[imax] - - # the sum of all the shifts equals the final shifts for this block - optimal_shift_indices[:, window_index] = np.sum(best_shifts, axis=0) - - return optimal_shift_indices, target_spikecount_hist, shift_covs_block - - -# TODO charlie sam : make a unique function for this -# at the moment this is the legacy one ported in spikeinterface -# the one in deredge.py is more recent -def normxcorr1d( - template, - x, - weights=None, - centered=True, - normalized=True, - padding="same", - conv_engine="torch", -): - """normxcorr1d: Normalized cross-correlation, optionally weighted - - The API is like torch's F.conv1d, except I have accidentally - changed the position of input/weights -- template acts like weights, - and x acts like input. - - Returns the cross-correlation of `template` and `x` at spatial lags - determined by `mode`. Useful for estimating the location of `template` - within `x`. - - This might not be the most efficient implementation -- ideas welcome. - It uses a direct convolutional translation of the formula - corr = (E[XY] - EX EY) / sqrt(var X * var Y) - - This also supports weights! In that case, the usual adaptation of - the above formula is made to the weighted case -- and all of the - normalizations are done per block in the same way. - - Parameters - ---------- - template : tensor, shape (num_templates, length) - The reference template signal - x : tensor, 1d shape (length,) or 2d shape (num_inputs, length) - The signal in which to find `template` - weights : tensor, shape (length,) - Will use weighted means, variances, covariances if supplied. - centered : bool - If true, means will be subtracted (per weighted patch). - normalized : bool - If true, normalize by the variance (per weighted patch). - padding : str - How far to look? if unset, we'll use half the length - conv_engine : string, one of "torch", "numpy" - What library to use for computing cross-correlations. - If numpy, falls back to the scipy correlate function. - - Returns - ------- - corr : tensor - """ - if conv_engine == "torch": - assert HAVE_TORCH - conv1d = F.conv1d - npx = torch - elif conv_engine == "numpy": - conv1d = scipy_conv1d - npx = np - else: - raise ValueError(f"Unknown conv_engine {conv_engine}. 'conv_engine' must be 'torch' or 'numpy'") - - x = npx.atleast_2d(x) - num_templates, length = template.shape - num_inputs, length_ = template.shape - assert length == length_ - - # generalize over weighted / unweighted case - device_kw = {} if conv_engine == "numpy" else dict(device=x.device) - ones = npx.ones((1, 1, length), dtype=x.dtype, **device_kw) - no_weights = weights is None - if no_weights: - weights = ones - wt = template[:, None, :] - else: - assert weights.shape == (length,) - weights = weights[None, None] - wt = template[:, None, :] * weights - - # conv1d valid rule: - # (B,1,L),(O,1,L)->(B,O,L) - - # compute expectations - # how many points in each window? seems necessary to normalize - # for numerical stability. - N = conv1d(ones, weights, padding=padding) - if centered: - Et = conv1d(ones, wt, padding=padding) - Et /= N - Ex = conv1d(x[:, None, :], weights, padding=padding) - Ex /= N - - # compute (weighted) covariance - # important: the formula E[XY] - EX EY is well-suited here, - # because the means are naturally subtracted correctly - # patch-wise. you couldn't pre-subtract them! - cov = conv1d(x[:, None, :], wt, padding=padding) - cov /= N - if centered: - cov -= Ex * Et - - # compute variances for denominator, using var X = E[X^2] - (EX)^2 - if normalized: - var_template = conv1d(ones, wt * template[:, None, :], padding=padding) - var_template /= N - var_x = conv1d(npx.square(x)[:, None, :], weights, padding=padding) - var_x /= N - if centered: - var_template -= npx.square(Et) - var_x -= npx.square(Ex) - - # now find the final normxcorr - corr = cov # renaming for clarity - if normalized: - corr /= npx.sqrt(var_x) - corr /= npx.sqrt(var_template) - # get rid of NaNs in zero-variance areas - corr[~npx.isfinite(corr)] = 0 - - return corr - - - - - -def clean_motion_vector(motion, temporal_bins, bin_duration_s, speed_threshold=30, sigma_smooth_s=None): - """ - Simple machinery to remove spurious fast bump in the motion vector. - Also can applyt a smoothing. - - - Arguments - --------- - motion: numpy array 2d - Motion estimate in um. - temporal_bins: numpy.array 1d - temporal bins (bin center) - bin_duration_s: float - bin duration in second - speed_threshold: float (units um/s) - Maximum speed treshold between 2 bins allowed. - Expressed in um/s - sigma_smooth_s: None or float - Optional smooting gaussian kernel. - - Returns - ------- - corr : tensor - - - """ - motion_clean = motion.copy() - - # STEP 1 : - # * detect long plateau or small peak corssing the speed thresh - # * mask the period and interpolate - for i in range(motion.shape[1]): - one_motion = motion_clean[:, i] - speed = np.diff(one_motion, axis=0) / bin_duration_s - (inds,) = np.nonzero(np.abs(speed) > speed_threshold) - inds += 1 - if inds.size % 2 == 1: - # more compicated case: number of of inds is odd must remove first or last - # take the smallest duration sum - inds0 = inds[:-1] - inds1 = inds[1:] - d0 = np.sum(inds0[1::2] - inds0[::2]) - d1 = np.sum(inds1[1::2] - inds1[::2]) - if d0 < d1: - inds = inds0 - mask = np.ones(motion_clean.shape[0], dtype="bool") - for i in range(inds.size // 2): - mask[inds[i * 2] : inds[i * 2 + 1]] = False - import scipy.interpolate - - f = scipy.interpolate.interp1d(temporal_bins[mask], one_motion[mask]) - one_motion[~mask] = f(temporal_bins[~mask]) - - # Step 2 : gaussian smooth - if sigma_smooth_s is not None: - half_size = motion_clean.shape[0] // 2 - if motion_clean.shape[0] % 2 == 0: - # take care of the shift - bins = (np.arange(motion_clean.shape[0]) - half_size + 1) * bin_duration_s - else: - bins = (np.arange(motion_clean.shape[0]) - half_size) * bin_duration_s - smooth_kernel = np.exp(-(bins**2) / (2 * sigma_smooth_s**2)) - smooth_kernel /= np.sum(smooth_kernel) - smooth_kernel = smooth_kernel[:, None] - motion_clean = scipy.signal.fftconvolve(motion_clean, smooth_kernel, mode="same", axes=0) - - return motion_clean - - -def kriging_kernel(source_location, target_location, sigma=1, p=2, d=2): - from scipy.spatial.distance import cdist - - dist_xy = cdist(source_location, target_location, metric="euclidean") - K = np.exp(-((dist_xy / sigma) ** p) / d) - return K diff --git a/src/spikeinterface/sortingcomponents/motion/motion_utils.py b/src/spikeinterface/sortingcomponents/motion/motion_utils.py index 5987a47cd2..d9c0bedb24 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_utils.py @@ -232,7 +232,7 @@ def copy(self): -def get_windows(rigid, contact_pos, spatial_bin_centers, margin_um, win_step_um, win_sigma_um, win_shape, +def get_windows(rigid, contact_pos, spatial_bin_centers, win_margin_um, win_step_um, win_scale_um, win_shape, zero_threshold=None): """ Generate spatial windows (taper) for non-rigid motion. @@ -244,14 +244,14 @@ def get_windows(rigid, contact_pos, spatial_bin_centers, margin_um, win_step_um, rigid : bool If True, returns a single rectangular window contact_pos : np.ndarray - Position of electrodes (num_channels, 2) + Position of electrodes of the corection direction shape=(num_channels, ) spatial_bin_centers : np.array The pre-computed spatial bin centers - margin_um : float + win_margin_um : float The margin to extend (if positive) or shrink (if negative) the probe dimension to compute windows.= win_step_um : float The steps at which windows are defined - win_sigma_um : float + win_scale_um : float Sigma of gaussian window (if win_shape is gaussian) win_shape : float "gaussian" | "rect" @@ -278,13 +278,13 @@ def get_windows(rigid, contact_pos, spatial_bin_centers, margin_um, win_step_um, middle = (spatial_bin_centers[0] + spatial_bin_centers[-1]) / 2.0 window_centers = np.array([middle]) else: - if win_sigma_um <= win_step_um/5.: + if win_scale_um <= win_step_um/5.: warnings.warn( - f"get_windows(): spatial windows are probably not overlaping because {win_sigma_um=} and {win_step_um=}" + f"get_windows(): spatial windows are probably not overlaping because {win_scale_um=} and {win_step_um=}" ) - min_ = np.min(contact_pos) - margin_um - max_ = np.max(contact_pos) + margin_um + min_ = np.min(contact_pos) - win_margin_um + max_ = np.max(contact_pos) + win_margin_um num_windows = int((max_ - min_) // win_step_um) border = ((max_ - min_) % win_step_um) / 2 window_centers = np.arange(num_windows + 1) * win_step_um + min_ + border @@ -292,13 +292,13 @@ def get_windows(rigid, contact_pos, spatial_bin_centers, margin_um, win_step_um, for win_center in window_centers: if win_shape == "gaussian": - win = np.exp(-((spatial_bin_centers - win_center) ** 2) / (2 * win_sigma_um**2)) + win = np.exp(-((spatial_bin_centers - win_center) ** 2) / (2 * win_scale_um**2)) elif win_shape == "rect": - win = np.abs(spatial_bin_centers - win_center) < (win_sigma_um / 2.0) + win = np.abs(spatial_bin_centers - win_center) < (win_scale_um / 2.0) win = win.astype("float64") elif win_shape == "triangle": center_dist = np.abs(spatial_bin_centers - win_center) - in_window = center_dist <= (win_sigma_um / 2.0) + in_window = center_dist <= (win_scale_um / 2.0) win = -center_dist win[~in_window] = 0 win[in_window] -= win[in_window].min() @@ -349,4 +349,175 @@ def scipy_conv1d(input, weights, padding="valid"): for c in range(c_out): output[m, c] = correlate(input[m, 0], weights[c, 0], mode=mode) - return output \ No newline at end of file + return output + + +def get_spatial_bin_edges(recording, direction, hist_margin_um, bin_um): + # contact along one axis + probe = recording.get_probe() + dim = ["x", "y", "z"].index(direction) + contact_pos = probe.contact_positions[:, dim] + + min_ = np.min(contact_pos) - hist_margin_um + max_ = np.max(contact_pos) + hist_margin_um + spatial_bins = np.arange(min_, max_ + bin_um, bin_um) + + return spatial_bins + + + +def make_2d_motion_histogram( + recording, + peaks, + peak_locations, + weight_with_amplitude=False, + direction="y", + bin_duration_s=1.0, + bin_um=2.0, + hist_margin_um=50, + spatial_bin_edges=None, +): + """ + Generate 2d motion histogram in depth and time. + + Parameters + ---------- + recording : BaseRecording + The input recording + peaks : np.array + The peaks array + peak_locations : np.array + Array with peak locations + weight_with_amplitude : bool, default: False + If True, motion histogram is weighted by amplitudes + direction : "x" | "y" | "z", default: "y" + The depth direction + bin_duration_s : float, default: 1.0 + The temporal bin duration in s + bin_um : float, default: 2.0 + The spatial bin size in um. Ignored if spatial_bin_edges is given. + hist_margin_um : float, default: 50 + The margin to add to the minimum and maximum positions before spatial binning. + Ignored if spatial_bin_edges is given. + spatial_bin_edges : np.array, default: None + The pre-computed spatial bin edges + + Returns + ------- + motion_histogram + 2d np.array with motion histogram (num_temporal_bins, num_spatial_bins) + temporal_bin_edges + 1d array with temporal bin edges + spatial_bin_edges + 1d array with spatial bin edges + """ + n_samples = recording.get_num_samples() + mint_s = recording.sample_index_to_time(0) + maxt_s = recording.sample_index_to_time(n_samples) + temporal_bin_edges = np.arange(mint_s, maxt_s + bin_duration_s, bin_duration_s) + if spatial_bin_edges is None: + spatial_bin_edges = get_spatial_bin_edges(recording, direction, hist_margin_um, bin_um) + + arr = np.zeros((peaks.size, 2), dtype="float64") + arr[:, 0] = recording.sample_index_to_time(peaks["sample_index"]) + arr[:, 1] = peak_locations[direction] + + if weight_with_amplitude: + weights = np.abs(peaks["amplitude"]) + else: + weights = None + + motion_histogram, edges = np.histogramdd(arr, bins=(temporal_bin_edges, spatial_bin_edges), weights=weights) + + # average amplitude in each bin + if weight_with_amplitude: + bin_counts, _ = np.histogramdd(arr, bins=(temporal_bin_edges, spatial_bin_edges)) + bin_counts[bin_counts == 0] = 1 + motion_histogram = motion_histogram / bin_counts + + return motion_histogram, temporal_bin_edges, spatial_bin_edges + + +def make_3d_motion_histograms( + recording, + peaks, + peak_locations, + direction="y", + bin_duration_s=1.0, + bin_um=2.0, + hist_margin_um=50, + num_amp_bins=20, + log_transform=True, + spatial_bin_edges=None, +): + """ + Generate 3d motion histograms in depth, amplitude, and time. + This is used by the "iterative_template_registration" (Kilosort2.5) method. + + + Parameters + ---------- + recording : BaseRecording + The input recording + peaks : np.array + The peaks array + peak_locations : np.array + Array with peak locations + direction : "x" | "y" | "z", default: "y" + The depth direction + bin_duration_s : float, default: 1.0 + The temporal bin duration in s. + bin_um : float, default: 2.0 + The spatial bin size in um. Ignored if spatial_bin_edges is given. + hist_margin_um : float, default: 50 + The margin to add to the minimum and maximum positions before spatial binning. + Ignored if spatial_bin_edges is given. + log_transform : bool, default: True + If True, histograms are log-transformed + spatial_bin_edges : np.array, default: None + The pre-computed spatial bin edges + + Returns + ------- + motion_histograms + 3d np.array with motion histogram (num_temporal_bins, num_spatial_bins, num_amp_bins) + temporal_bin_edges + 1d array with temporal bin edges + spatial_bin_edges + 1d array with spatial bin edges + """ + n_samples = recording.get_num_samples() + mint_s = recording.sample_index_to_time(0) + maxt_s = recording.sample_index_to_time(n_samples) + temporal_bin_edges = np.arange(mint_s, maxt_s + bin_duration_s, bin_duration_s) + if spatial_bin_edges is None: + spatial_bin_edges = get_spatial_bin_edges(recording, direction, hist_margin_um, bin_um) + + # pre-compute abs amplitude and ranges for scaling + amplitude_bin_edges = np.linspace(0, 1, num_amp_bins + 1) + abs_peaks = np.abs(peaks["amplitude"]) + max_peak_amp = np.max(abs_peaks) + min_peak_amp = np.min(abs_peaks) + # log amplitudes and scale between 0-1 + abs_peaks_log_norm = (np.log10(abs_peaks) - np.log10(min_peak_amp)) / ( + np.log10(max_peak_amp) - np.log10(min_peak_amp) + ) + + arr = np.zeros((peaks.size, 3), dtype="float64") + arr[:, 0] = recording.sample_index_to_time(peaks["sample_index"]) + arr[:, 1] = peak_locations[direction] + arr[:, 2] = abs_peaks_log_norm + + motion_histograms, edges = np.histogramdd( + arr, + bins=( + temporal_bin_edges, + spatial_bin_edges, + amplitude_bin_edges, + ), + ) + + if log_transform: + motion_histograms = np.log2(1 + motion_histograms) + + return motion_histograms, temporal_bin_edges, spatial_bin_edges diff --git a/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py index 7cba0ff6fc..c626de527f 100644 --- a/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py @@ -153,11 +153,11 @@ def test_estimate_motion(setup_module): bin_duration_s=1.0, bin_um=10.0, margin_um=5, - output_extra_check=True, + extra_outputs=True, ) kwargs.update(cases_kwargs) - motion, extra_check = estimate_motion(recording, peaks, peak_locations, **kwargs) + motion, extra = estimate_motion(recording, peaks, peak_locations, **kwargs) motions[name] = motion if cases_kwargs["rigid"]: diff --git a/src/spikeinterface/sortingcomponents/motion/tests/test_motion_utils.py b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_utils.py index 0b67be39c0..73c469c955 100644 --- a/src/spikeinterface/sortingcomponents/motion/tests/test_motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_utils.py @@ -4,7 +4,7 @@ import numpy as np import pytest -from spikeinterface.sortingcomponents.motion_utils import Motion +from spikeinterface.sortingcomponents.motion.motion_utils import Motion from spikeinterface.generation import make_one_displacement_vector if hasattr(pytest, "global_test_folder"): From 532ea488d7bd8bd22058d64e74a8c6c0027fca79 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 26 Jun 2024 22:55:48 +0200 Subject: [PATCH 09/31] start porting dredge_ap --- .../sortingcomponents/motion/decentralized.py | 18 +- .../sortingcomponents/motion/dredge.py | 451 +++++++++++++++++- .../motion/motion_estimation.py | 4 +- .../sortingcomponents/motion/motion_utils.py | 16 + 4 files changed, 466 insertions(+), 23 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion/decentralized.py b/src/spikeinterface/sortingcomponents/motion/decentralized.py index 3b8f19cc3e..9a76b94d20 100644 --- a/src/spikeinterface/sortingcomponents/motion/decentralized.py +++ b/src/spikeinterface/sortingcomponents/motion/decentralized.py @@ -168,23 +168,9 @@ def run( bin_duration_s=bin_duration_s, spatial_bin_edges=spatial_bin_edges, weight_with_amplitude=weight_with_amplitude, + depth_smooth_um=histogram_depth_smooth_um, + time_smooth_s=histogram_time_smooth_s, ) - import scipy.signal - - if histogram_depth_smooth_um is not None: - bins = np.arange(motion_histogram.shape[1]) * bin_um - bins = bins - np.mean(bins) - smooth_kernel = np.exp(-(bins**2) / (2 * histogram_depth_smooth_um**2)) - smooth_kernel /= np.sum(smooth_kernel) - - motion_histogram = scipy.signal.fftconvolve(motion_histogram, smooth_kernel[None, :], mode="same", axes=1) - - if histogram_time_smooth_s is not None: - bins = np.arange(motion_histogram.shape[0]) * bin_duration_s - bins = bins - np.mean(bins) - smooth_kernel = np.exp(-(bins**2) / (2 * histogram_time_smooth_s**2)) - smooth_kernel /= np.sum(smooth_kernel) - motion_histogram = scipy.signal.fftconvolve(motion_histogram, smooth_kernel[:, None], mode="same", axes=0) if extra is not None: extra["motion_histogram"] = motion_histogram diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index 70dfc86107..60d93f355c 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -14,17 +14,318 @@ but here the original functions from Charlie, Julien and Erdem have been ported for an easier maintenance instead of making DREDge a dependency of spikeinterface. + +Some renaming has been done. Small details has been added. +But this code is very similar to the original code. +2 classes has been added : DredgeApRegistration and DredgeLfpRegistration +but the original function dredge_ap() and dredge_online_lfp() can be used directly. + """ import warnings from tqdm.auto import trange import numpy as np +import gc from .motion_utils import Motion, get_windows, get_window_domains, scipy_conv1d -# TODO add direction +# to discuss +# margin +# xcorr new function +# which dataset band usefull for ? +# dredge_ap +# use patient 2 + +# todo use gaussian_filter1d in historgam 2d +# put smotthing inside the histogram function +# put the log for weight inhitstogram + + +# simple class wrapper to be compliant with estimate_motion +class DredgeApRegistration: + """ + + """ + name = "dredge_ap" + need_peak_location = True + params_doc = """ + + """ + @classmethod + def run( + cls, + recording, + peaks, + peak_locations, + direction, + rigid, + win_shape, + win_step_um, + win_scale_um, + win_margin_um, + verbose, + progress_bar, + extra, + **method_kwargs, + ): + dim = ["x", "y", "z"].index(direction) + peak_amplitudes = peaks["amplitude"] + peak_depths = peak_locations[direction] + peak_times = recording.sample_index_to_time(peaks["sample_index"]) + + + outs = dredge_ap( + peak_amplitudes, + peak_depths, + peak_times, + direction=direction, + rigid=rigid, + win_shape=win_shape, + win_step_um=win_step_um, + win_scale_um=win_scale_um, + win_margin_um=win_margin_um, + extra_outputs=(extra is not None), + progress_bar=progress_bar, + **method_kwargs, + ) + + if extra is not None: + motion, extra_ = outs + extra.update(extra_) + else: + motion = outs + return motion + +# @TODO : Charlie I started very small refactoring, I let you continue +def dredge_ap( + amps, + depths_um, + times_s, + direction="y", + rigid=False, + # nonrigid window construction arguments + win_shape="gaussian", + win_step_um=400, + win_scale_um=450, + win_margin_um=None, + bin_um=1.0, + bin_s=1.0, + max_disp_um=None, + time_horizon_s=1000, + mincorr=0.1, + # weights arguments + do_window_weights=True, + weights_threshold_low=0.2, + weights_threshold_high=0.2, + mincorr_percentile=None, + mincorr_percentile_nneighbs=None, + # raster arguments + amp_scale_fn=None, + post_transform=np.log1p, + gaussian_smoothing_sigma_um=1, + gaussian_smoothing_sigma_s=1, + avg_in_bin=False, + count_masked_correlation=False, + count_bins=401, + count_bin_min=2, + # low-level keyword args + thomas_kw=None, + xcorr_kw=None, + # misc + device=None, + progress_bar=True, + extra_outputs=False, + precomputed_D_C_maxdisp=None, +): + """Estimate motion from spikes + + Spikes located at depths specified in `depths` along the probe, occurring at times in + seconds specified in `times` with amplitudes `amps` are used to create a 2d image of + the spiking activity. This image is cross-correlated with itself to produce a displacement + matrix (or several, one for each nonrigid window). This matrix is used to solve for a + motion estimate. + + Arguments + --------- + amps : np.array of shape (n_spikes,) + depths: np.array of shape (n_spikes,) + times : np.array of shape (n_spikes,) + The amplitudes, depths (microns) and times (seconds) of input + spike events. + direction : "x" | "y", default "y" + Dimension on which the motion is estimated. "y" is depth along the probe. + rigid : bool, default=False + If True, ignore the nonrigid window args (win_shape, win_step_um, win_scale_um, + win_margin_um) and do rigid registration (equivalent to one flat window, which + is how it's implemented). + win_shape : str, default="gaussian" + Nonrigid window shape + win_step_um : float + Spacing between nonrigid window centers in microns + win_scale_um : float + Controls the width of nonrigid windows centers + win_margin_um : float + Distance of nonrigid windows centers from the probe boundary (-1000 means there will + be no window center within 1000um of the edge of the probe) + bin_um: float + bin_s : float + The size of the bins along depth in microns and along time in seconds. + The returned object's .displacement array will respect these bins. + Increasing these can lead to more stable estimates and faster runtimes + at the cost of spatial and/or temporal resolution. + max_disp_um : float + Maximum possible displacement in microns. If you can guess a number which is larger + than the largest displacement possible in your recording across a span of `time_horizon_s` + seconds, setting this value to that number can stabilize the result and speed up + the algorithm (since it can do less cross-correlating). + By default, this is set to win-scale_um / 4, or 112.5 microns. Which can be a bit + large! + time_horizon_s : float + "Time horizon" parameter, in seconds. Time bins separated by more seconds than this + will not be cross-correlated. So, if your data has nonstationarities or changes which + could lead to bad cross-correlations at some timescale, it can help to input that + value here. If this is too small, it can make the motion estimation unstable. + mincorr : float, between 0 and 1 + Correlation threshold. Pairs of frames whose maximal cross correlation value is smaller + than this threshold will be ignored when solving for the global displacement estimate. + thomas_kw, xcorr_kw, raster_kw, weights_kw + These dictionaries allow setting parameters for fine control over the registration + device : str or torch.device + What torch device to run on? E.g., "cpu" or "cuda" or "cuda:1". + + Returns + ------- + motion_est : a motion_util.MotionEstimate object + This has a .displacement attribute which is the displacement estimate in a + (num_nonrigid_blocks, num_time_bins) array. It also has properties describing + the time and spatial bins, and methods for getting the displacement at a particular + time and depth. See the documentation of these classes in motion_util.py. + extra : dict + This has extra info about what happened during registration, including the nonrigid + windows if one wants to visualize them. Set `extra_outputs` to also save displacement + and correlation matrices. + """ + thomas_kw = thomas_kw if thomas_kw is not None else {} + xcorr_kw = xcorr_kw if xcorr_kw is not None else {} + if time_horizon_s: + xcorr_kw["max_dt_bins"] = np.ceil(time_horizon_s / bin_s) + raster_kw = dict( + amp_scale_fn=amp_scale_fn, + post_transform=post_transform, + gaussian_smoothing_sigma_um=gaussian_smoothing_sigma_um, + gaussian_smoothing_sigma_s=gaussian_smoothing_sigma_s, + bin_s=bin_s, + bin_um=bin_um, + avg_in_bin=avg_in_bin, + return_counts=count_masked_correlation, + count_bins=count_bins, + count_bin_min=count_bin_min, + ) + weights_kw = dict( + mincorr=mincorr, + time_horizon_s=time_horizon_s, + do_window_weights=do_window_weights, + weights_threshold_low=weights_threshold_low, + weights_threshold_high=weights_threshold_high, + ) + + # this will store return values other than the MotionEstimate + extra = {} + + # TODO charlie switch this to make_2d_motion_histogram after having putting more option + raster_res = spike_raster( + amps, + depths_um, + times_s, + **raster_kw, + ) + if count_masked_correlation: + raster, spatial_bin_edges_um, time_bin_edges_s, counts = raster_res + else: + raster, spatial_bin_edges_um, time_bin_edges_s = raster_res + windows, window_centers = get_windows( + # pseudo geom to fool spikeinterface + np.c_[np.zeros_like(spatial_bin_edges_um), spatial_bin_edges_um], + win_step_um, + win_scale_um, + spatial_bin_edges=spatial_bin_edges_um, + margin_um=-win_scale_um / 2 if win_margin_um is None else win_margin_um, + win_shape=win_shape, + zero_threshold=1e-5, + rigid=rigid, + ) + if extra_outputs and count_masked_correlation: + extra["counts"] = counts + + # cross-correlate to get D and C + if precomputed_D_C_maxdisp is None: + Ds, Cs, max_disp_um = xcorr_windows( + raster, + windows, + spatial_bin_edges_um, + win_scale_um, + rigid=rigid, + bin_um=bin_um, + max_disp_um=max_disp_um, + progress_bar=progress_bar, + device=device, + masks=(counts > 0) if count_masked_correlation else None, + **xcorr_kw, + ) + else: + Ds, Cs, max_disp_um = precomputed_D_C_maxdisp + + # turn Cs into weights + Us, wextra = weight_correlation_matrix( + Ds, + Cs, + windows, + raster, + spatial_bin_edges_um, + time_bin_edges_s, + raster_kw, + lambda_t=thomas_kw.get("lambda_t", DEFAULT_LAMBDA_T), + eps=thomas_kw.get("eps", DEFAULT_EPS), + progress_bar=progress_bar, + in_place=not extra_outputs, + **weights_kw, + ) + extra.update({k: wextra[k] for k in wextra if k not in ("S", "U")}) + if extra_outputs: + extra.update({k: wextra[k] for k in wextra if k in ("S", "U")}) + del wextra + if extra_outputs: + extra["D"] = Ds + extra["C"] = Cs + del Cs + + # @charlie : is this needed ? + gc.collect() + + # solve for P + # now we can do our tridiag solve + displacement, textra = thomas_solve(Ds, Us, progress_bar=progress_bar, **thomas_kw) + if extra_outputs: + extra.update(textra) + del textra + + if extra_outputs: + extra["windows"] = windows + extra["window_centers"] = window_centers + extra["max_disp_um"] = max_disp_um + + time_bin_centers = 0.5 * (time_bin_edges_s[1:] + time_bin_edges_s[:-1]) + spatial_bin_centers_um = 0.5 * (spatial_bin_edges_um[1:] + spatial_bin_edges_um[:-1]) + + motion = Motion([displacement], [time_bin_centers], spatial_bin_centers_um, direction=direction) + + if extra_outputs: + return motion, extra + else: + return motion + # simple class wrapper to be compliant with estimate_motion class DredgeLfpRegistration: @@ -72,10 +373,9 @@ def run( if extra is not None: motion, extra_ = outs extra.update(extra_) - else: motion = outs - + return motion @@ -117,6 +417,8 @@ def dredge_online_lfp( be the target resolution of the registration, so definitely use SpikeInterface to resample your recording to, say, 250Hz (or a value you like) rather than estimating motion at the original frequency (which may be high). + direction : "x" | "y", default "y" + Dimension on which the motion is estimated. "y" is depth along the probe. rigid : boolean, optional If True, window-related arguments are ignored and we do rigid registration win_shape, win_step_um, win_scale_um, win_margin_um : float @@ -310,7 +612,7 @@ def dredge_online_lfp( t0, t1 = t1, t2 traces0 = traces1 - motion = Motion([P_online.T], [lfp_recording.get_times(0)], window_centers, direction="y") + motion = Motion([P_online.T], [lfp_recording.get_times(0)], window_centers, direction=direction) if extra_outputs: return motion, extra @@ -941,4 +1243,143 @@ def normxcorr1d( corr /= npx.sqrt(var_x) corr /= npx.sqrt(var_template) - return corr \ No newline at end of file + return corr + + +def get_weights( + Ds, + Ss, + Sigma0inv_t, + windows, + raster, + dbe, + tbe, + raster_kw, + weights_threshold_low=0.0, + weights_threshold_high=np.inf, + progress_bar=False, +): + """Compute per-time-bin weighting for each nonrigid window""" + # determine window-weighted raster "heat" in each nonrigid window + # as a function of time + assert windows.shape[1] == dbe.size - 1 + weights = [] + p_inds = [] + for b in range((len(Ds))): + ilow, ihigh = np.flatnonzero(windows[b])[[0, -1]] + ihigh += 1 + window_sliced = windows[b, ilow:ihigh] + weights.append(window_sliced @ raster[ilow:ihigh]) + weights_orig = np.array(weights) + + scale_fn = raster_kw["post_transform"] or raster_kw["amp_scale_fn"] + if isinstance(weights_threshold_low, tuple): + nspikes_threshold_low, amp_threshold_low = weights_threshold_low + unif = np.full_like(windows[0], 1 / len(windows[0])) + weights_threshold_low = ( + scale_fn(amp_threshold_low) + * windows + @ (nspikes_threshold_low * unif) + ) + weights_threshold_low = weights_threshold_low[:, None] + if isinstance(weights_threshold_high, tuple): + nspikes_threshold_high, amp_threshold_high = weights_threshold_high + unif = np.full_like(windows[0], 1 / len(windows[0])) + weights_threshold_high = ( + scale_fn(amp_threshold_high) + * windows + @ (nspikes_threshold_high * unif) + ) + weights_threshold_high = weights_threshold_high[:, None] + weights_thresh = weights_orig.copy() + weights_thresh[weights_orig < weights_threshold_low] = 0 + weights_thresh[weights_orig > weights_threshold_high] = np.inf + + return weights, weights_thresh, p_inds + +def weight_correlation_matrix( + Ds, + Cs, + windows, + raster, + depth_bin_edges, + time_bin_edges, + raster_kw, + mincorr=0.0, + mincorr_percentile=None, + mincorr_percentile_nneighbs=20, + max_dt_s=None, + lambda_t=DEFAULT_LAMBDA_T, + eps=DEFAULT_EPS, + do_window_weights=True, + weights_threshold_low=0.0, + weights_threshold_high=np.inf, + progress_bar=True, + in_place=False, +): + """Transform the correlation matrix into the weights used in optimization.""" + extra = {} + + Ds = np.asarray(Ds) + Cs = np.asarray(Cs) + if Ds.ndim == 2: + Ds = Ds[None] + Cs = Cs[None] + B, T, T_ = Ds.shape + assert T == T_ + assert Ds.shape == Cs.shape + extra = {} + + Ss, mincorr = threshold_correlation_matrix( + Cs, + mincorr=mincorr, + mincorr_percentile=mincorr_percentile, + mincorr_percentile_nneighbs=mincorr_percentile_nneighbs, + max_dt_s=max_dt_s, + bin_s=time_bin_edges[1] - time_bin_edges[0], + T=T, + in_place=in_place, + ) + extra["S"] = Ss + extra["mincorr"] = mincorr + + if not do_window_weights: + return Ss, extra + + # get weights + L_t = lambda_t * laplacian(T, eps=max(1e-5, eps)) + weights_orig, weights_thresh, Pind = get_weights( + Ds, + Ss, + L_t, + windows, + raster, + depth_bin_edges, + time_bin_edges, + raster_kw, + weights_threshold_low=weights_threshold_low, + weights_threshold_high=weights_threshold_high, + progress_bar=progress_bar, + ) + extra["weights_orig"] = weights_orig + extra["weights_thresh"] = weights_thresh + extra["Pind"] = Pind + + # update noise model. we deliberately divide by zero and inf here. + Us = Ss if in_place else np.zeros_like(Ss) + with np.errstate(divide="ignore"): + # low mem impl of U = abs(1/(1/weights_thresh+1/weights_thresh'+1/S)) + np.reciprocal(Ss, out=Us) + invW = 1.0 / weights_thresh + Us += invW[:, :, None] + Us += invW[:, None, :] + np.reciprocal(Us, out=Us) + # handles possible -0s that cause issues elsewhere + np.abs(Us, out=Us) + # more readable equivalent: + # for b in range(B): + # invWbtt = invW[b, :, None] + invW[b, None, :] + # Us[b] = np.abs(1.0 / (invWbtt + 1.0 / Ss[b])) + extra["U"] = Us + + return Us, extra diff --git a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py index 7804238024..b6fa344def 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py @@ -11,7 +11,7 @@ from .motion_utils import Motion, get_windows, get_spatial_bin_edges from .decentralized import DecentralizedRegistration from .iterative_template import IterativeTemplateRegistration -from .dredge import DredgeLfpRegistration +from .dredge import DredgeLfpRegistration, DredgeApRegistration def estimate_motion( @@ -162,7 +162,7 @@ def estimate_motion( return motion -_methods_list = [DecentralizedRegistration, IterativeTemplateRegistration, DredgeLfpRegistration] +_methods_list = [DecentralizedRegistration, IterativeTemplateRegistration, DredgeLfpRegistration, DredgeApRegistration] estimate_motion_methods = {m.name: m for m in _methods_list} method_doc = make_multi_method_doc(_methods_list) estimate_motion.__doc__ = estimate_motion.__doc__.format(method_doc=method_doc) diff --git a/src/spikeinterface/sortingcomponents/motion/motion_utils.py b/src/spikeinterface/sortingcomponents/motion/motion_utils.py index d9c0bedb24..44bf3eb3ad 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_utils.py @@ -376,6 +376,8 @@ def make_2d_motion_histogram( bin_um=2.0, hist_margin_um=50, spatial_bin_edges=None, + depth_smooth_um=None, + time_smooth_s=None, ): """ Generate 2d motion histogram in depth and time. @@ -401,6 +403,12 @@ def make_2d_motion_histogram( Ignored if spatial_bin_edges is given. spatial_bin_edges : np.array, default: None The pre-computed spatial bin edges + depth_smooth_um: None or float + Optional gaussian smoother on histogram on depth axis. + This is given as the sigma of the gaussian in micrometers. + time_smooth_s: None or float + Optional gaussian smoother on histogram on time axis. + This is given as the sigma of the gaussian in seconds. Returns ------- @@ -435,6 +443,14 @@ def make_2d_motion_histogram( bin_counts[bin_counts == 0] = 1 motion_histogram = motion_histogram / bin_counts + from scipy.ndimage import gaussian_filter1d + + if depth_smooth_um is not None: + motion_histogram = gaussian_filter1d(motion_histogram, depth_smooth_um / bin_um, axis=1, mode="constant") + + if time_smooth_s is not None: + motion_histogram = gaussian_filter1d(motion_histogram, time_smooth_s / bin_duration_s, axis=0, mode="constant") + return motion_histogram, temporal_bin_edges, spatial_bin_edges From c2f5289efecbdc2128b6c48feaefac028bdff8fd Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 26 Jun 2024 23:03:33 +0200 Subject: [PATCH 10/31] important comments --- src/spikeinterface/sortingcomponents/motion/dredge.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index 60d93f355c..817f34f88f 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -245,6 +245,8 @@ def dredge_ap( raster, spatial_bin_edges_um, time_bin_edges_s, counts = raster_res else: raster, spatial_bin_edges_um, time_bin_edges_s = raster_res + + # TODO Sam I did not yet change parameters here with the get_windows API windows, window_centers = get_windows( # pseudo geom to fool spikeinterface np.c_[np.zeros_like(spatial_bin_edges_um), spatial_bin_edges_um], From cab6646e7edacf091ec1396744a1e122ae8499cf Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Thu, 27 Jun 2024 12:18:31 +0200 Subject: [PATCH 11/31] wip dredge_ap --- .../sortingcomponents/motion/decentralized.py | 4 +- .../sortingcomponents/motion/dredge.py | 139 ++++++++++++------ .../sortingcomponents/motion/motion_utils.py | 14 +- 3 files changed, 102 insertions(+), 55 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion/decentralized.py b/src/spikeinterface/sortingcomponents/motion/decentralized.py index 9a76b94d20..baf0fae1f2 100644 --- a/src/spikeinterface/sortingcomponents/motion/decentralized.py +++ b/src/spikeinterface/sortingcomponents/motion/decentralized.py @@ -145,7 +145,7 @@ def run( conv_engine = "torch" if HAVE_TORCH else "numpy" dim = ["x", "y", "z"].index(direction) - contact_pos = recording.get_channel_locations()[:, dim] + contact_depth = recording.get_channel_locations()[:, dim] # spatial histogram bins spatial_bin_edges = get_spatial_bin_edges(recording, direction, hist_margin_um, bin_um) @@ -153,7 +153,7 @@ def run( # get spatial windows non_rigid_windows, non_rigid_window_centers = get_windows( - rigid, contact_pos, spatial_bin_centers, win_margin_um, win_step_um, win_scale_um, win_shape + rigid, contact_depth, spatial_bin_centers, win_margin_um, win_step_um, win_scale_um, win_shape ) # make 2D histogram raster diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index 817f34f88f..779b236d77 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -28,7 +28,7 @@ import gc -from .motion_utils import Motion, get_windows, get_window_domains, scipy_conv1d +from .motion_utils import Motion, get_windows, get_window_domains, scipy_conv1d, make_2d_motion_histogram # to discuss @@ -70,16 +70,12 @@ def run( extra, **method_kwargs, ): - dim = ["x", "y", "z"].index(direction) - peak_amplitudes = peaks["amplitude"] - peak_depths = peak_locations[direction] - peak_times = recording.sample_index_to_time(peaks["sample_index"]) outs = dredge_ap( - peak_amplitudes, - peak_depths, - peak_times, + recording, + peaks, + peak_locations, direction=direction, rigid=rigid, win_shape=win_shape, @@ -100,9 +96,9 @@ def run( # @TODO : Charlie I started very small refactoring, I let you continue def dredge_ap( - amps, - depths_um, - times_s, + recording, + peaks, + peak_locations, direction="y", rigid=False, # nonrigid window construction arguments @@ -113,7 +109,7 @@ def dredge_ap( bin_um=1.0, bin_s=1.0, max_disp_um=None, - time_horizon_s=1000, + time_horizon_s=1000., mincorr=0.1, # weights arguments do_window_weights=True, @@ -149,6 +145,8 @@ def dredge_ap( Arguments --------- + recording : Recording + The recording amps : np.array of shape (n_spikes,) depths: np.array of shape (n_spikes,) times : np.array of shape (n_spikes,) @@ -207,10 +205,22 @@ def dredge_ap( windows if one wants to visualize them. Set `extra_outputs` to also save displacement and correlation matrices. """ + + dim = ["x", "y", "z"].index(direction) + # @charlie: I removed amps/depths_um/times_s from the signature + # preaks and peak_locations are more SI compatible + # the way to get then + amps = peak_amplitudes = peaks["amplitude"] + depths_um = peak_depths = peak_locations[direction] + times_s = peak_times = recording.sample_index_to_time(peaks["sample_index"]) + + + thomas_kw = thomas_kw if thomas_kw is not None else {} xcorr_kw = xcorr_kw if xcorr_kw is not None else {} if time_horizon_s: xcorr_kw["max_dt_bins"] = np.ceil(time_horizon_s / bin_s) + raster_kw = dict( amp_scale_fn=amp_scale_fn, post_transform=post_transform, @@ -234,33 +244,70 @@ def dredge_ap( # this will store return values other than the MotionEstimate extra = {} - # TODO charlie switch this to make_2d_motion_histogram after having putting more option - raster_res = spike_raster( - amps, - depths_um, - times_s, - **raster_kw, + + + # TODO charlie I switch this to make_2d_motion_histogram + # but we need to add all options from the original spike_raster() + + # raster_res = spike_raster( + # amps, + # depths_um, + # times_s, + # **raster_kw, + # ) + # if count_masked_correlation: + # raster, spatial_bin_edges_um, time_bin_edges_s, counts = raster_res + # else: + # raster, spatial_bin_edges_um, time_bin_edges_s = raster_res + + motion_histogram, time_bin_edges_s, spatial_bin_edges_um = make_2d_motion_histogram( + recording, + peaks, + peak_locations, + weight_with_amplitude=False, + direction="y", + bin_duration_s=1.0, + bin_um=2.0, + hist_margin_um=50, + spatial_bin_edges=None, + depth_smooth_um=None, + time_smooth_s=None, ) - if count_masked_correlation: - raster, spatial_bin_edges_um, time_bin_edges_s, counts = raster_res - else: - raster, spatial_bin_edges_um, time_bin_edges_s = raster_res + raster = motion_histogram.T + + + # TODO @charlie check that we are doing the same thing + # windows, window_centers = get_windows( + # np.c_[np.zeros_like(spatial_bin_edges_um), spatial_bin_edges_um], + # win_step_um, + # win_scale_um, + # spatial_bin_edges=spatial_bin_edges_um, + # margin_um=-win_scale_um / 2 if win_margin_um is None else win_margin_um, + # win_shape=win_shape, + # zero_threshold=1e-5, + # rigid=rigid, + # ) + + dim = ["x", "y", "z"].index(direction) + contact_depth = recording.get_channel_locations()[:, dim] + spatial_bin_centers = 0.5 * (spatial_bin_edges_um[1:] + spatial_bin_edges_um[:-1]) - # TODO Sam I did not yet change parameters here with the get_windows API windows, window_centers = get_windows( - # pseudo geom to fool spikeinterface - np.c_[np.zeros_like(spatial_bin_edges_um), spatial_bin_edges_um], + rigid, + contact_depth, + spatial_bin_centers, + win_margin_um, win_step_um, win_scale_um, - spatial_bin_edges=spatial_bin_edges_um, - margin_um=-win_scale_um / 2 if win_margin_um is None else win_margin_um, - win_shape=win_shape, + win_shape, zero_threshold=1e-5, - rigid=rigid, ) - if extra_outputs and count_masked_correlation: - extra["counts"] = counts + # TODO charlie : put back the count + # if extra_outputs and count_masked_correlation: + # extra["counts"] = counts + + # cross-correlate to get D and C if precomputed_D_C_maxdisp is None: Ds, Cs, max_disp_um = xcorr_windows( @@ -273,7 +320,8 @@ def dredge_ap( max_disp_um=max_disp_um, progress_bar=progress_bar, device=device, - masks=(counts > 0) if count_masked_correlation else None, + # TODO charlie : put back the count for the mask + # masks=(counts > 0) if count_masked_correlation else None, **xcorr_kw, ) else: @@ -319,9 +367,7 @@ def dredge_ap( extra["max_disp_um"] = max_disp_um time_bin_centers = 0.5 * (time_bin_edges_s[1:] + time_bin_edges_s[:-1]) - spatial_bin_centers_um = 0.5 * (spatial_bin_edges_um[1:] + spatial_bin_edges_um[:-1]) - - motion = Motion([displacement], [time_bin_centers], spatial_bin_centers_um, direction=direction) + motion = Motion([displacement.T], [time_bin_centers], window_centers, direction=direction) if extra_outputs: return motion, extra @@ -464,7 +510,7 @@ def dredge_online_lfp( """ dim = ["x", "y", "z"].index(direction) # contact pos is the only on the direction - contact_pos = lfp_recording.get_channel_locations()[:, dim] + contact_depth = lfp_recording.get_channel_locations()[:, dim] fs = lfp_recording.get_sampling_frequency() @@ -477,7 +523,7 @@ def dredge_online_lfp( thomas_kw = thomas_kw if thomas_kw is not None else {} full_xcorr_kw = dict( rigid=rigid, - bin_um=np.median(np.diff(contact_pos)), + bin_um=np.median(np.diff(contact_depth)), max_disp_um=max_disp_um, progress_bar=False, device=device, @@ -494,22 +540,22 @@ def dredge_online_lfp( # here we check that contact positons are unique on the direction - if contact_pos.size != np.unique(contact_pos).size: + if contact_depth.size != np.unique(contact_depth).size: raise ValueError( f"estimate motion with 'dredge_lfp' need channel_positions to be unique in the direction='{direction}'" ) - if np.any(np.diff(contact_pos) < 0): + if np.any(np.diff(contact_depth) < 0): raise ValueError( f"estimate motion with 'dredge_lfp' need channel_positions to be ordered direction='{direction}'" "please use spikeinterface.preprocessing.depth_order(recording)" ) # Important detail : in LFP bin center are contact position in the direction - spatial_bin_centers = contact_pos + spatial_bin_centers = contact_depth windows, window_centers = get_windows( rigid=rigid, - contact_pos=contact_pos, + contact_depth=contact_depth, spatial_bin_centers=spatial_bin_centers, win_margin_um=win_margin_um, win_step_um=win_step_um, @@ -529,7 +575,7 @@ def dredge_online_lfp( t0, t1 = 0, T_chunk traces0 = lfp_recording.get_traces(start_frame=t0, end_frame=t1) Ds0, Cs0, max_disp_um = xcorr_windows( - traces0.T, windows, contact_pos, win_scale_um, **full_xcorr_kw + traces0.T, windows, contact_depth, win_scale_um, **full_xcorr_kw ) full_xcorr_kw["max_disp_um"] = max_disp_um Ss0, mincorr0 = threshold_correlation_matrix( @@ -568,7 +614,7 @@ def dredge_online_lfp( Ds10, Cs10, _ = xcorr_windows( traces1.T, windows, - contact_pos, + contact_depth, win_scale_um, raster_b=traces0.T, **full_xcorr_kw, @@ -576,7 +622,7 @@ def dredge_online_lfp( # cross-correlation in current chunk Ds1, Cs1, _ = xcorr_windows( - traces1.T, windows, contact_pos, win_scale_um, **full_xcorr_kw + traces1.T, windows, contact_depth, win_scale_um, **full_xcorr_kw ) Ss1, mincorr1 = threshold_correlation_matrix( Cs1, @@ -954,6 +1000,7 @@ def xcorr_windows( slices = get_window_domains(windows) B, D = windows.shape D_, T0 = raster_a.shape + assert D == D_ # torch versions on device @@ -1310,7 +1357,7 @@ def weight_correlation_matrix( mincorr=0.0, mincorr_percentile=None, mincorr_percentile_nneighbs=20, - max_dt_s=None, + time_horizon_s=None, lambda_t=DEFAULT_LAMBDA_T, eps=DEFAULT_EPS, do_window_weights=True, @@ -1337,7 +1384,7 @@ def weight_correlation_matrix( mincorr=mincorr, mincorr_percentile=mincorr_percentile, mincorr_percentile_nneighbs=mincorr_percentile_nneighbs, - max_dt_s=max_dt_s, + time_horizon_s=time_horizon_s, bin_s=time_bin_edges[1] - time_bin_edges[0], T=T, in_place=in_place, diff --git a/src/spikeinterface/sortingcomponents/motion/motion_utils.py b/src/spikeinterface/sortingcomponents/motion/motion_utils.py index 44bf3eb3ad..3fb0f8505a 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_utils.py @@ -232,7 +232,7 @@ def copy(self): -def get_windows(rigid, contact_pos, spatial_bin_centers, win_margin_um, win_step_um, win_scale_um, win_shape, +def get_windows(rigid, contact_depth, spatial_bin_centers, win_margin_um, win_step_um, win_scale_um, win_shape, zero_threshold=None): """ Generate spatial windows (taper) for non-rigid motion. @@ -243,7 +243,7 @@ def get_windows(rigid, contact_pos, spatial_bin_centers, win_margin_um, win_step ---------- rigid : bool If True, returns a single rectangular window - contact_pos : np.ndarray + contact_depth : np.ndarray Position of electrodes of the corection direction shape=(num_channels, ) spatial_bin_centers : np.array The pre-computed spatial bin centers @@ -283,8 +283,8 @@ def get_windows(rigid, contact_pos, spatial_bin_centers, win_margin_um, win_step f"get_windows(): spatial windows are probably not overlaping because {win_scale_um=} and {win_step_um=}" ) - min_ = np.min(contact_pos) - win_margin_um - max_ = np.max(contact_pos) + win_margin_um + min_ = np.min(contact_depth) - win_margin_um + max_ = np.max(contact_depth) + win_margin_um num_windows = int((max_ - min_) // win_step_um) border = ((max_ - min_) % win_step_um) / 2 window_centers = np.arange(num_windows + 1) * win_step_um + min_ + border @@ -356,10 +356,10 @@ def get_spatial_bin_edges(recording, direction, hist_margin_um, bin_um): # contact along one axis probe = recording.get_probe() dim = ["x", "y", "z"].index(direction) - contact_pos = probe.contact_positions[:, dim] + contact_depth = probe.contact_positions[:, dim] - min_ = np.min(contact_pos) - hist_margin_um - max_ = np.max(contact_pos) + hist_margin_um + min_ = np.min(contact_depth) - hist_margin_um + max_ = np.max(contact_depth) + hist_margin_um spatial_bins = np.arange(min_, max_ + bin_um, bin_um) return spatial_bins From 37014bbcb41344c1f7be1aeeb999a7ad75311e53 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Thu, 27 Jun 2024 14:15:48 +0200 Subject: [PATCH 12/31] more refactoring and parameters change --- .../benchmark/benchmark_motion_estimation.py | 4 +- .../benchmark/benchmark_tools.py | 2 +- .../sortingcomponents/motion/decentralized.py | 23 ++++++---- .../sortingcomponents/motion/dredge.py | 31 +++++++------ .../motion/iterative_template.py | 16 +++++-- .../motion/motion_cleaner.py | 2 +- .../motion/motion_estimation.py | 35 ++++----------- .../sortingcomponents/motion/motion_utils.py | 43 ++++++++++++++----- 8 files changed, 90 insertions(+), 66 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py index 55ef21de9d..ec7e1e24a8 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_estimation.py @@ -9,13 +9,13 @@ from spikeinterface.core import get_noise_levels from spikeinterface.sortingcomponents.benchmark.benchmark_tools import Benchmark, BenchmarkStudy, _simpleaxis -from spikeinterface.sortingcomponents.motion_estimation import estimate_motion +from spikeinterface.sortingcomponents.motion import estimate_motion from spikeinterface.sortingcomponents.peak_detection import detect_peaks from spikeinterface.sortingcomponents.peak_selection import select_peaks from spikeinterface.sortingcomponents.peak_localization import localize_peaks from spikeinterface.widgets import plot_probe_map -from spikeinterface.sortingcomponents.motion_utils import Motion +from spikeinterface.sortingcomponents.motion import Motion # import MEArec as mr diff --git a/src/spikeinterface/sortingcomponents/benchmark/benchmark_tools.py b/src/spikeinterface/sortingcomponents/benchmark/benchmark_tools.py index e9f128993d..7dc3fad280 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/benchmark_tools.py +++ b/src/spikeinterface/sortingcomponents/benchmark/benchmark_tools.py @@ -443,7 +443,7 @@ def load_folder(cls, folder): result[k] = load_extractor(folder / k) elif format == "Motion": - from spikeinterface.sortingcomponents.motion_utils import Motion + from spikeinterface.sortingcomponents.motion import Motion result[k] = Motion.load(folder / k) elif format == "zarr_templates": diff --git a/src/spikeinterface/sortingcomponents/motion/decentralized.py b/src/spikeinterface/sortingcomponents/motion/decentralized.py index baf0fae1f2..5815f77a48 100644 --- a/src/spikeinterface/sortingcomponents/motion/decentralized.py +++ b/src/spikeinterface/sortingcomponents/motion/decentralized.py @@ -10,7 +10,7 @@ except ImportError: HAVE_TORCH = False -from .motion_utils import Motion, get_windows, get_spatial_bin_edges, make_2d_motion_histogram, scipy_conv1d +from .motion_utils import Motion, get_spatial_windows, get_spatial_bin_edges, make_2d_motion_histogram, scipy_conv1d class DecentralizedRegistration: @@ -113,11 +113,11 @@ def run( verbose, progress_bar, extra, - bin_um=10.0, - hist_margin_um=0.0, - bin_duration_s=2.0, - histogram_depth_smooth_um=None, - histogram_time_smooth_s=None, + bin_um=1.0, + hist_margin_um=20.0, + bin_duration_s=1.0, + histogram_depth_smooth_um=1., + histogram_time_smooth_s=1., pairwise_displacement_method="conv", max_displacement_um=100.0, weight_scale="linear", @@ -152,8 +152,15 @@ def run( spatial_bin_centers = 0.5 * (spatial_bin_edges[1:] + spatial_bin_edges[:-1]) # get spatial windows - non_rigid_windows, non_rigid_window_centers = get_windows( - rigid, contact_depth, spatial_bin_centers, win_margin_um, win_step_um, win_scale_um, win_shape + non_rigid_windows, non_rigid_window_centers = get_spatial_windows( + contact_depth, + spatial_bin_centers, + rigid=rigid, + win_shape=win_shape, + win_step_um=win_step_um, + win_scale_um=win_scale_um, + win_margin_um=win_margin_um, + zero_threshold=None ) # make 2D histogram raster diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index 779b236d77..f2a46bdfcb 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -28,7 +28,7 @@ import gc -from .motion_utils import Motion, get_windows, get_window_domains, scipy_conv1d, make_2d_motion_histogram +from .motion_utils import Motion, get_spatial_windows, get_window_domains, scipy_conv1d, make_2d_motion_histogram # to discuss @@ -42,6 +42,8 @@ # put smotthing inside the histogram function # put the log for weight inhitstogram +# TODO maybe change everywhere bin_duration_s to bin_s + # simple class wrapper to be compliant with estimate_motion class DredgeApRegistration: @@ -221,6 +223,9 @@ def dredge_ap( if time_horizon_s: xcorr_kw["max_dt_bins"] = np.ceil(time_horizon_s / bin_s) + #TODO @charlie I think this is a bad to have the dict which is transported to every function + # this should be used only in histogram function but not in weight_correlation_matrix() + # only important kwargs should be explicitly reported raster_kw = dict( amp_scale_fn=amp_scale_fn, post_transform=post_transform, @@ -277,7 +282,7 @@ def dredge_ap( # TODO @charlie check that we are doing the same thing - # windows, window_centers = get_windows( + # windows, window_centers = get_spatial_windows( # np.c_[np.zeros_like(spatial_bin_edges_um), spatial_bin_edges_um], # win_step_um, # win_scale_um, @@ -292,16 +297,18 @@ def dredge_ap( contact_depth = recording.get_channel_locations()[:, dim] spatial_bin_centers = 0.5 * (spatial_bin_edges_um[1:] + spatial_bin_edges_um[:-1]) - windows, window_centers = get_windows( - rigid, + windows, window_centers = get_spatial_windows( contact_depth, spatial_bin_centers, - win_margin_um, - win_step_um, - win_scale_um, - win_shape, - zero_threshold=1e-5, - ) + rigid=rigid, + win_shape=win_shape, + win_step_um=win_step_um, + win_scale_um=win_scale_um, + win_margin_um=win_margin_um, + zero_threshold=1e-5 + ) + + # TODO charlie : put back the count # if extra_outputs and count_masked_correlation: @@ -553,10 +560,10 @@ def dredge_online_lfp( # Important detail : in LFP bin center are contact position in the direction spatial_bin_centers = contact_depth - windows, window_centers = get_windows( - rigid=rigid, + windows, window_centers = get_spatial_windows( contact_depth=contact_depth, spatial_bin_centers=spatial_bin_centers, + rigid=rigid, win_margin_um=win_margin_um, win_step_um=win_step_um, win_scale_um=win_scale_um, diff --git a/src/spikeinterface/sortingcomponents/motion/iterative_template.py b/src/spikeinterface/sortingcomponents/motion/iterative_template.py index ab6877adc3..e7c978e865 100644 --- a/src/spikeinterface/sortingcomponents/motion/iterative_template.py +++ b/src/spikeinterface/sortingcomponents/motion/iterative_template.py @@ -1,6 +1,6 @@ import numpy as np -from .motion_utils import Motion, get_windows, get_spatial_bin_edges, make_3d_motion_histograms +from .motion_utils import Motion, get_spatial_windows, get_spatial_bin_edges, make_3d_motion_histograms class IterativeTemplateRegistration: @@ -78,18 +78,26 @@ def run( ): dim = ["x", "y", "z"].index(direction) - contact_pos = recording.get_channel_locations()[:, dim] + contact_depth = recording.get_channel_locations()[:, dim] # spatial histogram bins spatial_bin_edges = get_spatial_bin_edges(recording, direction, hist_margin_um, bin_um) spatial_bin_centers = 0.5 * (spatial_bin_edges[1:] + spatial_bin_edges[:-1]) # get spatial windows - non_rigid_windows, non_rigid_window_centers = get_windows( - rigid, contact_pos, spatial_bin_centers, win_margin_um, win_step_um, win_scale_um, win_shape + non_rigid_windows, non_rigid_window_centers = get_spatial_windows( + contact_depth=contact_depth, + spatial_bin_centers=spatial_bin_centers, + rigid=rigid, + win_margin_um=win_margin_um, + win_step_um=win_step_um, + win_scale_um=win_scale_um, + win_shape=win_shape, + zero_threshold=None, ) + # make a 3D histogram motion_histograms, temporal_hist_bin_edges, spatial_hist_bin_edges = make_3d_motion_histograms( recording, diff --git a/src/spikeinterface/sortingcomponents/motion/motion_cleaner.py b/src/spikeinterface/sortingcomponents/motion/motion_cleaner.py index 401210e079..de2c7df4cc 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_cleaner.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_cleaner.py @@ -1,6 +1,6 @@ import numpy as np - +# TODO this need a full rewrite with motion object def clean_motion_vector(motion, temporal_bins, bin_duration_s, speed_threshold=30, sigma_smooth_s=None): """ diff --git a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py index b6fa344def..aab3a1b491 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py @@ -8,7 +8,7 @@ from spikeinterface.sortingcomponents.tools import make_multi_method_doc -from .motion_utils import Motion, get_windows, get_spatial_bin_edges +from .motion_utils import Motion, get_spatial_windows, get_spatial_bin_edges from .decentralized import DecentralizedRegistration from .iterative_template import IterativeTemplateRegistration from .dredge import DredgeLfpRegistration, DredgeApRegistration @@ -19,13 +19,11 @@ def estimate_motion( peaks=None, peak_locations=None, direction="y", - # bin_um=10.0, - # hist_margin_um=0.0, rigid=False, win_shape="gaussian", win_step_um=50.0, win_scale_um=150.0, - win_margin_um=0., + win_margin_um=None, method="decentralized", extra_outputs=False, progress_bar=False, @@ -36,7 +34,8 @@ def estimate_motion( """ Estimate motion for given peaks and after their localization. - Note that the way you detect peak locations (center of mass/monopolar triangulation) have an impact on the result. + Note that the way you detect peak locations (center of mass/monopolar triangulation) + have an impact on the result. Parameters ---------- @@ -70,14 +69,14 @@ def estimate_motion( The depth domain will be broken up into windows with shape controlled by win_shape, spaced by win_step_um at a margin of win_margin_um from the boundary, and with width controlled by win_scale_um. + When win_margin_um is None the margin is automatically set to -win_scale_um/2. + See get_spatial_windows. win_step_um : float, default: 50 See win_shape win_scale_um : float, default: 150 See win_shape - win_margin_um : float, default: 0. - See win_shape - - + win_margin_um : None | float, default: None + See win_shape extra_outputs: bool, default: False If True then return an extra dict that contains variables to check intermediate steps (motion_histogram, non_rigid_windows, pairwise_displacement) @@ -114,25 +113,7 @@ def estimate_motion( else: extra = None - # contact positions - # probe = recording.get_probe() - # dim = ["x", "y", "z"].index(direction) - # contact_pos = probe.contact_positions[:, dim] - - # # spatial histogram bins - # spatial_bin_edges = get_spatial_bin_edges(recording, direction, hist_margin_um, bin_um) - # spatial_bin_centers = 0.5 * (spatial_bin_edges[1:] + spatial_bin_edges[:-1]) - - # # get spatial windows - # non_rigid_windows, non_rigid_window_centers = get_windows( - # rigid, contact_pos, spatial_bin_centers, win_margin_um, win_step_um, win_scale_um, win_shape - # ) - - # if extra_outputs: - # extra["non_rigid_windows"] = non_rigid_windows - # run method - motion = method_class.run( recording, peaks, diff --git a/src/spikeinterface/sortingcomponents/motion/motion_utils.py b/src/spikeinterface/sortingcomponents/motion/motion_utils.py index 3fb0f8505a..f0442996a6 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_utils.py @@ -232,29 +232,45 @@ def copy(self): -def get_windows(rigid, contact_depth, spatial_bin_centers, win_margin_um, win_step_um, win_scale_um, win_shape, - zero_threshold=None): +def get_spatial_windows( + contact_depth, + spatial_bin_centers, + rigid=False, + win_shape="gaussian", + win_step_um=50.0, + win_scale_um=150.0, + win_margin_um=None, + zero_threshold=None + ): """ Generate spatial windows (taper) for non-rigid motion. For rigid motion, this is equivalent to have one unique rectangular window that covers the entire probe. The windowing can be gaussian or rectangular. + Windows are centered between the min/max of contact_depth. + We can ensure window to not be to close from border with win_margin_um. + Parameters ---------- - rigid : bool - If True, returns a single rectangular window contact_depth : np.ndarray Position of electrodes of the corection direction shape=(num_channels, ) spatial_bin_centers : np.array The pre-computed spatial bin centers - win_margin_um : float - The margin to extend (if positive) or shrink (if negative) the probe dimension to compute windows.= + rigid : bool, default False + If True, returns a single rectangular window + win_shape : str, default "gaussian" + Shape of the window + "gaussian" | "rect" | "triangle" win_step_um : float The steps at which windows are defined - win_scale_um : float - Sigma of gaussian window (if win_shape is gaussian) - win_shape : float - "gaussian" | "rect" + win_scale_um : float, default 150. + Sigma of gaussian window if win_shape is gaussian + Width of the rectangle if win_shape is rect + win_margin_um : None | float, default None + The margin to extend (if positive) or shrink (if negative) the probe dimension to compute windows. + When None, then the margin is set to -win_scale_um./2 + zero_threshold: None | float + Lower value for thresholding to set zeros. Returns ------- @@ -280,9 +296,14 @@ def get_windows(rigid, contact_depth, spatial_bin_centers, win_margin_um, win_st else: if win_scale_um <= win_step_um/5.: warnings.warn( - f"get_windows(): spatial windows are probably not overlaping because {win_scale_um=} and {win_step_um=}" + f"get_spatial_windows(): spatial windows are probably not overlaping because {win_scale_um=} and {win_step_um=}" ) + # @charlie: I am pretty sure this is the best option + if win_margin_um is None: + # this ensure that first/last windows do not overflow outside the probe + win_margin_um = -win_scale_um / 2. + min_ = np.min(contact_depth) - win_margin_um max_ = np.max(contact_depth) + win_margin_um num_windows = int((max_ - min_) // win_step_um) From e2e9bff651e6c319e88086f92aabc6fe09c8b470 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Thu, 27 Jun 2024 17:05:17 +0200 Subject: [PATCH 13/31] import fix --- .../benchmark/benchmark_motion_interpolation.py | 2 +- src/spikeinterface/sortingcomponents/motion/dredge.py | 7 ------- .../motion/tests/test_motion_interpolation.py | 4 ++-- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_interpolation.py b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_interpolation.py index a6ff05fc55..38365adfd1 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/benchmark_motion_interpolation.py @@ -6,7 +6,7 @@ from spikeinterface.sorters import run_sorter from spikeinterface.comparison import GroundTruthComparison -from spikeinterface.sortingcomponents.motion_interpolation import InterpolateMotionRecording +from spikeinterface.sortingcomponents.motion.motion_interpolation import InterpolateMotionRecording from spikeinterface.curation import MergeUnitsSorting diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index f2a46bdfcb..6fa2242d8c 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -31,13 +31,6 @@ from .motion_utils import Motion, get_spatial_windows, get_window_domains, scipy_conv1d, make_2d_motion_histogram -# to discuss -# margin -# xcorr new function -# which dataset band usefull for ? -# dredge_ap -# use patient 2 - # todo use gaussian_filter1d in historgam 2d # put smotthing inside the histogram function # put the log for weight inhitstogram diff --git a/src/spikeinterface/sortingcomponents/motion/tests/test_motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_interpolation.py index cb26560272..e022f0cc6c 100644 --- a/src/spikeinterface/sortingcomponents/motion/tests/test_motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_interpolation.py @@ -4,13 +4,13 @@ import pytest import spikeinterface.core as sc from spikeinterface import download_dataset -from spikeinterface.sortingcomponents.motion_interpolation import ( +from spikeinterface.sortingcomponents.motion.motion_interpolation import ( InterpolateMotionRecording, correct_motion_on_peaks, interpolate_motion, interpolate_motion_on_traces, ) -from spikeinterface.sortingcomponents.motion_utils import Motion +from spikeinterface.sortingcomponents.motion import Motion from spikeinterface.sortingcomponents.tests.common import make_dataset From ea017292bcc2bcdfcc4a3e7031cf6b2759ea356b Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Fri, 28 Jun 2024 18:45:04 +0200 Subject: [PATCH 14/31] fixing dredge_ap details --- .../sortingcomponents/motion/dredge.py | 73 +++++++++++-------- .../motion/motion_estimation.py | 4 +- .../sortingcomponents/motion/motion_utils.py | 9 ++- src/spikeinterface/widgets/motion.py | 2 +- 4 files changed, 51 insertions(+), 37 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index 6fa2242d8c..eb10ebe40b 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -113,10 +113,10 @@ def dredge_ap( mincorr_percentile=None, mincorr_percentile_nneighbs=None, # raster arguments - amp_scale_fn=None, - post_transform=np.log1p, - gaussian_smoothing_sigma_um=1, - gaussian_smoothing_sigma_s=1, + amp_scale_fn=None, ## @Charlie this one is not used anymore + post_transform=np.log1p, ###@this one is directly transimited to weight_correlation_matrix() and so get_wieiith() + histogram_depth_smooth_um=1, + histogram_time_smooth_s=1, avg_in_bin=False, count_masked_correlation=False, count_bins=401, @@ -219,18 +219,19 @@ def dredge_ap( #TODO @charlie I think this is a bad to have the dict which is transported to every function # this should be used only in histogram function but not in weight_correlation_matrix() # only important kwargs should be explicitly reported - raster_kw = dict( - amp_scale_fn=amp_scale_fn, - post_transform=post_transform, - gaussian_smoothing_sigma_um=gaussian_smoothing_sigma_um, - gaussian_smoothing_sigma_s=gaussian_smoothing_sigma_s, - bin_s=bin_s, - bin_um=bin_um, - avg_in_bin=avg_in_bin, - return_counts=count_masked_correlation, - count_bins=count_bins, - count_bin_min=count_bin_min, - ) + # raster_kw = dict( + # amp_scale_fn=amp_scale_fn, + # post_transform=post_transform, + # histogram_depth_smooth_um=histogram_depth_smooth_um, + # histogram_time_smooth_s=histogram_time_smooth_s, + # bin_s=bin_s, + # bin_um=bin_um, + # avg_in_bin=avg_in_bin, + # return_counts=count_masked_correlation, + # count_bins=count_bins, + # count_bin_min=count_bin_min, + # ) + weights_kw = dict( mincorr=mincorr, time_horizon_s=time_horizon_s, @@ -246,7 +247,7 @@ def dredge_ap( # TODO charlie I switch this to make_2d_motion_histogram # but we need to add all options from the original spike_raster() - + # but I think this is OK # raster_res = spike_raster( # amps, # depths_um, @@ -258,23 +259,26 @@ def dredge_ap( # else: # raster, spatial_bin_edges_um, time_bin_edges_s = raster_res + + motion_histogram, time_bin_edges_s, spatial_bin_edges_um = make_2d_motion_histogram( recording, peaks, peak_locations, weight_with_amplitude=False, - direction="y", - bin_duration_s=1.0, - bin_um=2.0, - hist_margin_um=50, + avg_in_bin=avg_in_bin, + direction=direction, + bin_duration_s=bin_s, + bin_um=bin_um, + hist_margin_um=0., # @charlie maybe we should expose this and set +20. for instance spatial_bin_edges=None, - depth_smooth_um=None, - time_smooth_s=None, + depth_smooth_um=histogram_depth_smooth_um, + time_smooth_s=histogram_time_smooth_s, ) raster = motion_histogram.T - # TODO @charlie check that we are doing the same thing + # TODO @charlie you should check that we are doing the same thing # windows, window_centers = get_spatial_windows( # np.c_[np.zeros_like(spatial_bin_edges_um), spatial_bin_edges_um], # win_step_um, @@ -303,7 +307,7 @@ def dredge_ap( - # TODO charlie : put back the count + # TODO charlie : the count has disapeared # if extra_outputs and count_masked_correlation: # extra["counts"] = counts @@ -335,7 +339,8 @@ def dredge_ap( raster, spatial_bin_edges_um, time_bin_edges_s, - raster_kw, + # raster_kw, #@charlie this is removed + post_transform=post_transform, # @charlie this isnew lambda_t=thomas_kw.get("lambda_t", DEFAULT_LAMBDA_T), eps=thomas_kw.get("eps", DEFAULT_EPS), progress_bar=progress_bar, @@ -668,7 +673,7 @@ def dredge_online_lfp( return motion - +# -- zone forbiden for sam # -- functions from dredgelib DEFAULT_LAMBDA_T = 1.0 @@ -1303,7 +1308,9 @@ def get_weights( raster, dbe, tbe, - raster_kw, + # @charlie raster_kw is removed in favor of post_transform only is this OK ??? + # raster_kw, + post_transform=np.log1p, weights_threshold_low=0.0, weights_threshold_high=np.inf, progress_bar=False, @@ -1321,7 +1328,8 @@ def get_weights( weights.append(window_sliced @ raster[ilow:ihigh]) weights_orig = np.array(weights) - scale_fn = raster_kw["post_transform"] or raster_kw["amp_scale_fn"] + # scale_fn = raster_kw["post_transform"] or raster_kw["amp_scale_fn"] + scale_fn = post_transform if isinstance(weights_threshold_low, tuple): nspikes_threshold_low, amp_threshold_low = weights_threshold_low unif = np.full_like(windows[0], 1 / len(windows[0])) @@ -1353,7 +1361,9 @@ def weight_correlation_matrix( raster, depth_bin_edges, time_bin_edges, - raster_kw, + # @charlie raster_kw is remove in favor of post_transform only + # raster_kw, + post_transform=np.log1p, mincorr=0.0, mincorr_percentile=None, mincorr_percentile_nneighbs=20, @@ -1405,7 +1415,8 @@ def weight_correlation_matrix( raster, depth_bin_edges, time_bin_edges, - raster_kw, + #raster_kw, + post_transform=post_transform, weights_threshold_low=weights_threshold_low, weights_threshold_high=weights_threshold_high, progress_bar=progress_bar, diff --git a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py index aab3a1b491..bbf2cdfd69 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py @@ -21,8 +21,8 @@ def estimate_motion( direction="y", rigid=False, win_shape="gaussian", - win_step_um=50.0, - win_scale_um=150.0, + win_step_um=50.0, # 400 + win_scale_um=150.0, # 400 win_margin_um=None, method="decentralized", extra_outputs=False, diff --git a/src/spikeinterface/sortingcomponents/motion/motion_utils.py b/src/spikeinterface/sortingcomponents/motion/motion_utils.py index f0442996a6..f2d6e62f16 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_utils.py @@ -299,7 +299,6 @@ def get_spatial_windows( f"get_spatial_windows(): spatial windows are probably not overlaping because {win_scale_um=} and {win_step_um=}" ) - # @charlie: I am pretty sure this is the best option if win_margin_um is None: # this ensure that first/last windows do not overflow outside the probe win_margin_um = -win_scale_um / 2. @@ -392,6 +391,7 @@ def make_2d_motion_histogram( peaks, peak_locations, weight_with_amplitude=False, + avg_in_bin=True, direction="y", bin_duration_s=1.0, bin_um=2.0, @@ -413,6 +413,9 @@ def make_2d_motion_histogram( Array with peak locations weight_with_amplitude : bool, default: False If True, motion histogram is weighted by amplitudes + avg_in_bin : bool, default True + If true, average the amplitudes in each bin. + This is done only if weight_with_amplitude=True. direction : "x" | "y" | "z", default: "y" The depth direction bin_duration_s : float, default: 1.0 @@ -457,9 +460,9 @@ def make_2d_motion_histogram( weights = None motion_histogram, edges = np.histogramdd(arr, bins=(temporal_bin_edges, spatial_bin_edges), weights=weights) - + # average amplitude in each bin - if weight_with_amplitude: + if weight_with_amplitude and avg_in_bin: bin_counts, _ = np.histogramdd(arr, bins=(temporal_bin_edges, spatial_bin_edges)) bin_counts[bin_counts == 0] = 1 motion_histogram = motion_histogram / bin_counts diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 0b79350a62..6f8bdc7a6e 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -5,7 +5,7 @@ from .base import BaseWidget, to_attr from spikeinterface.core import BaseRecording, SortingAnalyzer -from spikeinterface.sortingcomponents.motion_utils import Motion +from spikeinterface.sortingcomponents.motion import Motion class MotionWidget(BaseWidget): From 75decf6ec7937ed91e9bb68e7c7911c986e66c3e Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 3 Jul 2024 14:00:54 +0200 Subject: [PATCH 15/31] still rafactor and fix estimate_motion() for dredge_ap --- doc/modules/sortingcomponents.rst | 2 +- src/spikeinterface/preprocessing/motion.py | 8 +++---- .../tests/test_benchmark_motion_estimation.py | 2 +- .../test_benchmark_motion_interpolation.py | 2 +- .../sortingcomponents/motion/decentralized.py | 12 +++++----- .../sortingcomponents/motion/dredge.py | 10 +++------ .../motion/iterative_template.py | 8 +++---- .../motion/motion_estimation.py | 22 +++++++------------ .../sortingcomponents/motion/motion_utils.py | 16 ++++++++------ .../motion/tests/test_motion_estimation.py | 2 +- 10 files changed, 38 insertions(+), 46 deletions(-) diff --git a/doc/modules/sortingcomponents.rst b/doc/modules/sortingcomponents.rst index f33a0b3cf2..e7e05312bc 100644 --- a/doc/modules/sortingcomponents.rst +++ b/doc/modules/sortingcomponents.rst @@ -193,7 +193,7 @@ Here is an example with non-rigid motion estimation: from spikeinterface.sortingcomponents.motion_estimation import estimate_motion motion, temporal_bins, spatial_bins, extra_check = estimate_motion(recording=recording, peaks=peaks, peak_locations=peak_locations, - direction='y', bin_duration_s=10., bin_um=10., margin_um=0., + direction='y', bin_s=10., bin_um=10., margin_um=0., method='decentralized_registration', rigid=False, win_shape='gaussian', win_step_um=50., win_sigma_um=150., progress_bar=True, verbose=True) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index ed097d19fc..59945b0a06 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -34,7 +34,7 @@ "estimate_motion_kwargs": dict( method="decentralized", direction="y", - bin_duration_s=2.0, + bin_s=2.0, rigid=False, bin_um=5.0, margin_um=0.0, @@ -93,7 +93,7 @@ "estimate_motion_kwargs": dict( method="decentralized", direction="y", - bin_duration_s=2.0, + bin_s=2.0, rigid=False, bin_um=5.0, margin_um=0.0, @@ -148,7 +148,7 @@ ), "estimate_motion_kwargs": dict( method="decentralized", - bin_duration_s=10.0, + bin_s=10.0, rigid=True, ), "interpolate_motion_kwargs": dict( @@ -178,7 +178,7 @@ ), "estimate_motion_kwargs": dict( method="iterative_template", - bin_duration_s=2.0, + bin_s=2.0, rigid=False, win_step_um=50.0, win_scale_um=150.0, diff --git a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py index 56e1c18d62..78a9eb7dbc 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_estimation.py @@ -41,7 +41,7 @@ def test_benchmark_motion_estimaton(create_cache_folder): localize_kwargs=dict(method=loc_method), estimate_motion_kwargs=dict( method=est_method, - bin_duration_s=1.0, + bin_s=1.0, bin_um=5.0, rigid=False, win_step_um=50.0, diff --git a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_interpolation.py b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_interpolation.py index 6d80d027f2..18def37d54 100644 --- a/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/benchmark/tests/test_benchmark_motion_interpolation.py @@ -56,7 +56,7 @@ def test_benchmark_motion_interpolation(create_cache_folder): # plt.show() cases = {} - bin_duration_s = 1.0 + bin_s = 1.0 cases["static_SC2"] = dict( label="No drift - no correction - SC2", diff --git a/src/spikeinterface/sortingcomponents/motion/decentralized.py b/src/spikeinterface/sortingcomponents/motion/decentralized.py index 5815f77a48..53bc29d4b1 100644 --- a/src/spikeinterface/sortingcomponents/motion/decentralized.py +++ b/src/spikeinterface/sortingcomponents/motion/decentralized.py @@ -45,7 +45,7 @@ class DecentralizedRegistration: Margin in um from histogram estimation. Positive margin extrapolate out of the probe the motion. Negative margin crop the motion on the border - bin_duration_s: float, default: 2.0 + bin_s: float, default: 2.0 Bin duration in second histogram_depth_smooth_um: None or float Optional gaussian smoother on histogram on depth axis. @@ -115,7 +115,7 @@ def run( extra, bin_um=1.0, hist_margin_um=20.0, - bin_duration_s=1.0, + bin_s=1.0, histogram_depth_smooth_um=1., histogram_time_smooth_s=1., pairwise_displacement_method="conv", @@ -172,7 +172,7 @@ def run( peaks, peak_locations, direction=direction, - bin_duration_s=bin_duration_s, + bin_s=bin_s, spatial_bin_edges=spatial_bin_edges, weight_with_amplitude=weight_with_amplitude, depth_smooth_um=histogram_depth_smooth_um, @@ -220,7 +220,7 @@ def run( centered_xcorr=centered_xcorr, corr_threshold=corr_threshold, time_horizon_s=time_horizon_s, - bin_duration_s=bin_duration_s, + bin_s=bin_s, progress_bar=False, ) @@ -315,7 +315,7 @@ def compute_pairwise_displacement( time_horizon_s=None, normalized_xcorr=True, centered_xcorr=True, - bin_duration_s=None, + bin_s=None, progress_bar=False, window=None, ): @@ -329,7 +329,7 @@ def compute_pairwise_displacement( pairwise_displacement = np.zeros((size, size), dtype="float32") if time_horizon_s is not None: - band_width = int(np.ceil(time_horizon_s / bin_duration_s)) + band_width = int(np.ceil(time_horizon_s / bin_s)) if band_width >= size: time_horizon_s = None diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index eb10ebe40b..007dc3dd0a 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -28,15 +28,13 @@ import gc -from .motion_utils import Motion, get_spatial_windows, get_window_domains, scipy_conv1d, make_2d_motion_histogram +from .motion_utils import Motion, get_spatial_windows, get_window_domains, scipy_conv1d, make_2d_motion_histogram, get_spatial_bin_edges # todo use gaussian_filter1d in historgam 2d # put smotthing inside the histogram function # put the log for weight inhitstogram -# TODO maybe change everywhere bin_duration_s to bin_s - # simple class wrapper to be compliant with estimate_motion class DredgeApRegistration: @@ -259,16 +257,14 @@ def dredge_ap( # else: # raster, spatial_bin_edges_um, time_bin_edges_s = raster_res - - motion_histogram, time_bin_edges_s, spatial_bin_edges_um = make_2d_motion_histogram( recording, peaks, peak_locations, - weight_with_amplitude=False, + weight_with_amplitude=True, avg_in_bin=avg_in_bin, direction=direction, - bin_duration_s=bin_s, + bin_s=bin_s, bin_um=bin_um, hist_margin_um=0., # @charlie maybe we should expose this and set +20. for instance spatial_bin_edges=None, diff --git a/src/spikeinterface/sortingcomponents/motion/iterative_template.py b/src/spikeinterface/sortingcomponents/motion/iterative_template.py index e7c978e865..a49d5bd639 100644 --- a/src/spikeinterface/sortingcomponents/motion/iterative_template.py +++ b/src/spikeinterface/sortingcomponents/motion/iterative_template.py @@ -29,7 +29,7 @@ class IterativeTemplateRegistration: Margin in um from histogram estimation. Positive margin extrapolate out of the probe the motion. Negative margin crop the motion on the border - bin_duration_s: float, default: 2.0 + bin_s: float, default: 2.0 Bin duration in second num_amp_bins: int, default: 20 number ob bins in the histogram on the log amplitues dimension @@ -66,7 +66,7 @@ def run( extra, bin_um=10.0, hist_margin_um=0.0, - bin_duration_s=2.0, + bin_s=2.0, num_amp_bins=20, num_shifts_global=15, num_iterations=10, @@ -105,11 +105,11 @@ def run( peak_locations, direction=direction, num_amp_bins=num_amp_bins, - bin_duration_s=bin_duration_s, + bin_s=bin_s, spatial_bin_edges=spatial_bin_edges, ) # temporal bins are bin center - temporal_bins = temporal_hist_bin_edges[:-1] + bin_duration_s // 2.0 + temporal_bins = temporal_hist_bin_edges[:-1] + bin_s // 2.0 # do alignment shift_indices, target_histogram, shift_covs_block = iterative_template_registration( diff --git a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py index bbf2cdfd69..6cd935e582 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py @@ -21,8 +21,8 @@ def estimate_motion( direction="y", rigid=False, win_shape="gaussian", - win_step_um=50.0, # 400 - win_scale_um=150.0, # 400 + win_step_um=50.0, # @alessio charlie is proposing here instead 400 + win_scale_um=150.0, # @alessio charlie is proposing here instead 400 win_margin_um=None, method="decentralized", extra_outputs=False, @@ -32,9 +32,13 @@ def estimate_motion( **method_kwargs, ): """ - Estimate motion for given peaks and after their localization. + + + Estimate motion with several possible methods. - Note that the way you detect peak locations (center of mass/monopolar triangulation) + Most of methods except dredge_lfp needs peaks and after their localization. + + Note that the way you detect peak locations (center of mass/monopolar_triangulation/grid_convolution) have an impact on the result. Parameters @@ -50,13 +54,8 @@ def estimate_motion( direction: "x" | "y" | "z", default: "y" Dimension on which the motion is estimated. "y" is depth along the probe. - - {method_doc} - **histogram section** - - **non-rigid section** rigid : bool, default: False @@ -120,9 +119,6 @@ def estimate_motion( peak_locations, direction, - # bin_um, - # spatial_bin_edges, - # non_rigid_windows, rigid, win_shape, win_step_um, @@ -135,8 +131,6 @@ def estimate_motion( **method_kwargs, ) - # motion = Motion([motion_array], [temporal_bins], non_rigid_window_centers, direction=direction) - if extra_outputs: return motion, extra else: diff --git a/src/spikeinterface/sortingcomponents/motion/motion_utils.py b/src/spikeinterface/sortingcomponents/motion/motion_utils.py index f2d6e62f16..de94b7a899 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_utils.py @@ -393,7 +393,7 @@ def make_2d_motion_histogram( weight_with_amplitude=False, avg_in_bin=True, direction="y", - bin_duration_s=1.0, + bin_s=1.0, bin_um=2.0, hist_margin_um=50, spatial_bin_edges=None, @@ -418,7 +418,7 @@ def make_2d_motion_histogram( This is done only if weight_with_amplitude=True. direction : "x" | "y" | "z", default: "y" The depth direction - bin_duration_s : float, default: 1.0 + bin_s : float, default: 1.0 The temporal bin duration in s bin_um : float, default: 2.0 The spatial bin size in um. Ignored if spatial_bin_edges is given. @@ -446,9 +446,11 @@ def make_2d_motion_histogram( n_samples = recording.get_num_samples() mint_s = recording.sample_index_to_time(0) maxt_s = recording.sample_index_to_time(n_samples) - temporal_bin_edges = np.arange(mint_s, maxt_s + bin_duration_s, bin_duration_s) + temporal_bin_edges = np.arange(mint_s, maxt_s + bin_s, bin_s) if spatial_bin_edges is None: spatial_bin_edges = get_spatial_bin_edges(recording, direction, hist_margin_um, bin_um) + else: + bin_um = spatial_bin_edges[1] - spatial_bin_edges[0] arr = np.zeros((peaks.size, 2), dtype="float64") arr[:, 0] = recording.sample_index_to_time(peaks["sample_index"]) @@ -473,7 +475,7 @@ def make_2d_motion_histogram( motion_histogram = gaussian_filter1d(motion_histogram, depth_smooth_um / bin_um, axis=1, mode="constant") if time_smooth_s is not None: - motion_histogram = gaussian_filter1d(motion_histogram, time_smooth_s / bin_duration_s, axis=0, mode="constant") + motion_histogram = gaussian_filter1d(motion_histogram, time_smooth_s / bin_s, axis=0, mode="constant") return motion_histogram, temporal_bin_edges, spatial_bin_edges @@ -483,7 +485,7 @@ def make_3d_motion_histograms( peaks, peak_locations, direction="y", - bin_duration_s=1.0, + bin_s=1.0, bin_um=2.0, hist_margin_um=50, num_amp_bins=20, @@ -505,7 +507,7 @@ def make_3d_motion_histograms( Array with peak locations direction : "x" | "y" | "z", default: "y" The depth direction - bin_duration_s : float, default: 1.0 + bin_s : float, default: 1.0 The temporal bin duration in s. bin_um : float, default: 2.0 The spatial bin size in um. Ignored if spatial_bin_edges is given. @@ -529,7 +531,7 @@ def make_3d_motion_histograms( n_samples = recording.get_num_samples() mint_s = recording.sample_index_to_time(0) maxt_s = recording.sample_index_to_time(n_samples) - temporal_bin_edges = np.arange(mint_s, maxt_s + bin_duration_s, bin_duration_s) + temporal_bin_edges = np.arange(mint_s, maxt_s + bin_s, bin_s) if spatial_bin_edges is None: spatial_bin_edges = get_spatial_bin_edges(recording, direction, hist_margin_um, bin_um) diff --git a/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py index c626de527f..ac1d5aace7 100644 --- a/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py @@ -150,7 +150,7 @@ def test_estimate_motion(setup_module): kwargs = dict( direction="y", - bin_duration_s=1.0, + bin_s=1.0, bin_um=10.0, margin_um=5, extra_outputs=True, From ae18211e536541a4dd0ab15c67116836a04607ca Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Wed, 3 Jul 2024 16:19:33 +0200 Subject: [PATCH 16/31] move doc for dredge classes --- src/spikeinterface/preprocessing/motion.py | 2 +- .../sortingcomponents/motion/decentralized.py | 2 +- .../sortingcomponents/motion/dredge.py | 190 ++++++++++-------- .../motion/tests/test_motion_estimation.py | 18 +- 4 files changed, 121 insertions(+), 91 deletions(-) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index 59945b0a06..c77745a4ff 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -409,7 +409,7 @@ def correct_motion( def load_motion_info(folder): - from spikeinterface.sortingcomponents.motion_utils import Motion + from spikeinterface.sortingcomponents.motion import Motion folder = Path(folder) diff --git a/src/spikeinterface/sortingcomponents/motion/decentralized.py b/src/spikeinterface/sortingcomponents/motion/decentralized.py index 53bc29d4b1..a01fb2ffb3 100644 --- a/src/spikeinterface/sortingcomponents/motion/decentralized.py +++ b/src/spikeinterface/sortingcomponents/motion/decentralized.py @@ -45,7 +45,7 @@ class DecentralizedRegistration: Margin in um from histogram estimation. Positive margin extrapolate out of the probe the motion. Negative margin crop the motion on the border - bin_s: float, default: 2.0 + bin_s: float, default 1.0 Bin duration in second histogram_depth_smooth_um: None or float Optional gaussian smoother on histogram on depth axis. diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index 007dc3dd0a..71d7b0ea8d 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -39,12 +39,52 @@ # simple class wrapper to be compliant with estimate_motion class DredgeApRegistration: """ + Estimate motion from spikes times and depth. + This the certified and official version of the dredge implementation. + + Method developed by the Paninski's group from Columbia university: + Charlie Windolf, Julien Boussard, Erdem Varol + + This method is quite similar to "decentralized" which was the previous implementation in spikeinterface. + + The reference is here https://www.biorxiv.org/content/10.1101/2023.10.24.563768v1 + + The original code were here : https://github.com/evarol/DREDge + But this code which use the same internal function is in line with the Motion object of spikeinterface contrary to the dredge repo. + + This code has been ported in spikeinterface (with simple copy/paste) by Samuel but main author is truely Charlie Windolf. """ + name = "dredge_ap" need_peak_location = True params_doc = """ - + bin_um: float + Bin duration in second + bin_s : float + The size of the bins along depth in microns and along time in seconds. + The returned object's .displacement array will respect these bins. + Increasing these can lead to more stable estimates and faster runtimes + at the cost of spatial and/or temporal resolution. + max_disp_um : float + Maximum possible displacement in microns. If you can guess a number which is larger + than the largest displacement possible in your recording across a span of `time_horizon_s` + seconds, setting this value to that number can stabilize the result and speed up + the algorithm (since it can do less cross-correlating). + By default, this is set to win-scale_um / 4, or 112.5 microns. Which can be a bit + large! + time_horizon_s : float + "Time horizon" parameter, in seconds. Time bins separated by more seconds than this + will not be cross-correlated. So, if your data has nonstationarities or changes which + could lead to bad cross-correlations at some timescale, it can help to input that + value here. If this is too small, it can make the motion estimation unstable. + mincorr : float, between 0 and 1 + Correlation threshold. Pairs of frames whose maximal cross correlation value is smaller + than this threshold will be ignored when solving for the global displacement estimate. + thomas_kw, xcorr_kw, raster_kw, weights_kw + These dictionaries allow setting parameters for fine control over the registration + device : str or torch.device + What torch device to run on? E.g., "cpu" or "cuda" or "cuda:1". """ @classmethod def run( @@ -87,6 +127,7 @@ def run( motion = outs return motion + # @TODO : Charlie I started very small refactoring, I let you continue def dredge_ap( recording, @@ -138,13 +179,14 @@ def dredge_ap( Arguments --------- - recording : Recording - The recording - amps : np.array of shape (n_spikes,) - depths: np.array of shape (n_spikes,) - times : np.array of shape (n_spikes,) - The amplitudes, depths (microns) and times (seconds) of input - spike events. + recording: BaseRecording + The recording extractor + peaks: numpy array + Peak vector (complex dtype). + Needed for decentralized and iterative_template methods. + peak_locations: numpy array + Complex dtype with "x", "y", "z" fields + Needed for decentralized and iterative_template methods. direction : "x" | "y", default "y" Dimension on which the motion is estimated. "y" is depth along the probe. rigid : bool, default=False @@ -160,39 +202,12 @@ def dredge_ap( win_margin_um : float Distance of nonrigid windows centers from the probe boundary (-1000 means there will be no window center within 1000um of the edge of the probe) - bin_um: float - bin_s : float - The size of the bins along depth in microns and along time in seconds. - The returned object's .displacement array will respect these bins. - Increasing these can lead to more stable estimates and faster runtimes - at the cost of spatial and/or temporal resolution. - max_disp_um : float - Maximum possible displacement in microns. If you can guess a number which is larger - than the largest displacement possible in your recording across a span of `time_horizon_s` - seconds, setting this value to that number can stabilize the result and speed up - the algorithm (since it can do less cross-correlating). - By default, this is set to win-scale_um / 4, or 112.5 microns. Which can be a bit - large! - time_horizon_s : float - "Time horizon" parameter, in seconds. Time bins separated by more seconds than this - will not be cross-correlated. So, if your data has nonstationarities or changes which - could lead to bad cross-correlations at some timescale, it can help to input that - value here. If this is too small, it can make the motion estimation unstable. - mincorr : float, between 0 and 1 - Correlation threshold. Pairs of frames whose maximal cross correlation value is smaller - than this threshold will be ignored when solving for the global displacement estimate. - thomas_kw, xcorr_kw, raster_kw, weights_kw - These dictionaries allow setting parameters for fine control over the registration - device : str or torch.device - What torch device to run on? E.g., "cpu" or "cuda" or "cuda:1". + {} Returns ------- - motion_est : a motion_util.MotionEstimate object - This has a .displacement attribute which is the displacement estimate in a - (num_nonrigid_blocks, num_time_bins) array. It also has properties describing - the time and spatial bins, and methods for getting the displacement at a particular - time and depth. See the documentation of these classes in motion_util.py. + motion : Motion + The motion object extra : dict This has extra info about what happened during registration, including the nonrigid windows if one wants to visualize them. Set `extra_outputs` to also save displacement @@ -376,15 +391,64 @@ def dredge_ap( return motion +dredge_ap.__doc__ = dredge_ap.__doc__.format(DredgeApRegistration.params_doc) + + # simple class wrapper to be compliant with estimate_motion class DredgeLfpRegistration: """ + Estimate motion from LFP recording. + This the certified and official version of the dredge implementation. + + Method developed by the Paninski's group from Columbia university: + Charlie Windolf, Julien Boussard, Erdem Varol + + The reference is here https://www.biorxiv.org/content/10.1101/2023.10.24.563768v1 """ name = "dredge_lfp" need_peak_location = False params_doc = """ - + lfp_recording : spikeinterface BaseRecording object + Preprocessed LFP recording. The temporal resolution of this recording will + be the target resolution of the registration, so definitely use SpikeInterface + to resample your recording to, say, 250Hz (or a value you like) rather than + estimating motion at the original frequency (which may be high). + direction : "x" | "y", default "y" + Dimension on which the motion is estimated. "y" is depth along the probe. + rigid : boolean, optional + If True, window-related arguments are ignored and we do rigid registration + win_shape, win_step_um, win_scale_um, win_margin_um : float + Nonrigid window-related arguments + The depth domain will be broken up into windows with shape controlled by win_shape, + spaced by win_step_um at a margin of win_margin_um from the boundary, and with + width controlled by win_scale_um. + chunk_len_s : float + Length of chunks (in seconds) that the recording is broken into for online + registration. The computational speed of the method is a function of the + number of samples this corresponds to, and things can get slow if it is + set high enough that the number of samples per chunk is bigger than ~10,000. + But, it can't be set too low or the algorithm doesn't have enough data + to work with. The default is set assuming sampling rate of 250Hz, leading + to 2500 samples per chunk. + time_horizon_s : float + Time-bins farther apart than this value in seconds will not be cross-correlated. + Set this to at least `chunk_len_s`. + max_disp_um : number, optional + This is the ceiling on the possible displacement estimates. It should be + set to a number which is larger than the allowed displacement in a single + chunk. Setting it as small as possible (while following that rule) can speed + things up and improve the result by making it impossible to estimate motion + which is too big. + mincorr : float in [0,1] + Minimum correlation between pairs of frames such that they will be included + in the optimization of the displacement estimates. + mincorr_percentile, mincorr_percentile_nneighbs + If mincorr_percentile is set to a number in [0, 100], then mincorr will be replaced + by this percentile of the correlations of neighbors within mincorr_percentile_nneighbs + time bins of each other. + device : string or torch.device + Controls torch device """ @classmethod def run( @@ -429,6 +493,8 @@ def run( + + def dredge_online_lfp( lfp_recording, direction='y', @@ -461,46 +527,7 @@ def dredge_online_lfp( Arguments --------- - lfp_recording : spikeinterface BaseRecording object - Preprocessed LFP recording. The temporal resolution of this recording will - be the target resolution of the registration, so definitely use SpikeInterface - to resample your recording to, say, 250Hz (or a value you like) rather than - estimating motion at the original frequency (which may be high). - direction : "x" | "y", default "y" - Dimension on which the motion is estimated. "y" is depth along the probe. - rigid : boolean, optional - If True, window-related arguments are ignored and we do rigid registration - win_shape, win_step_um, win_scale_um, win_margin_um : float - Nonrigid window-related arguments - The depth domain will be broken up into windows with shape controlled by win_shape, - spaced by win_step_um at a margin of win_margin_um from the boundary, and with - width controlled by win_scale_um. - chunk_len_s : float - Length of chunks (in seconds) that the recording is broken into for online - registration. The computational speed of the method is a function of the - number of samples this corresponds to, and things can get slow if it is - set high enough that the number of samples per chunk is bigger than ~10,000. - But, it can't be set too low or the algorithm doesn't have enough data - to work with. The default is set assuming sampling rate of 250Hz, leading - to 2500 samples per chunk. - time_horizon_s : float - Time-bins farther apart than this value in seconds will not be cross-correlated. - Set this to at least `chunk_len_s`. - max_disp_um : number, optional - This is the ceiling on the possible displacement estimates. It should be - set to a number which is larger than the allowed displacement in a single - chunk. Setting it as small as possible (while following that rule) can speed - things up and improve the result by making it impossible to estimate motion - which is too big. - mincorr : float in [0,1] - Minimum correlation between pairs of frames such that they will be included - in the optimization of the displacement estimates. - mincorr_percentile, mincorr_percentile_nneighbs - If mincorr_percentile is set to a number in [0, 100], then mincorr will be replaced - by this percentile of the correlations of neighbors within mincorr_percentile_nneighbs - time bins of each other. - device : string or torch.device - Controls torch device + {} Returns ------- @@ -668,9 +695,10 @@ def dredge_online_lfp( else: return motion +dredge_online_lfp.__doc__ = dredge_online_lfp.__doc__.format(DredgeLfpRegistration.params_doc) + -# -- zone forbiden for sam -# -- functions from dredgelib +# -- functions from dredgelib (zone forbiden for sam) DEFAULT_LAMBDA_T = 1.0 DEFAULT_EPS = 1e-3 diff --git a/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py index ac1d5aace7..1168b65c79 100644 --- a/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py @@ -18,7 +18,8 @@ plt.show() -def setup_module(cache_folder): +def setup_dataset_and_peaks(cache_folder): + print(cache_folder, type(cache_folder)) cache_folder.mkdir(parents=True, exist_ok=True) recording, sorting = make_dataset() @@ -47,15 +48,16 @@ def setup_module(cache_folder): return recording, sorting, cache_folder -@pytest.fixture(scope="module", name="setup_module") -def setup_module_fixture(tmp_path_factory): - cache_folder = tmp_path_factory.mktemp("cache_folder") - return setup_module(cache_folder) +@pytest.fixture(scope="module", name="dataset") +def dataset_fixture(create_cache_folder): + cache_folder = create_cache_folder / "motion_estimation" + return setup_dataset_and_peaks(cache_folder) -def test_estimate_motion(setup_module): +def test_estimate_motion(dataset): # recording, sorting = make_dataset() - recording, sorting, cache_folder = setup_module + recording, sorting, cache_folder = dataset + peaks = np.load(cache_folder / "dataset_peaks.npy") peak_locations = np.load(cache_folder / "dataset_peak_locations.npy") @@ -222,5 +224,5 @@ def test_estimate_motion(setup_module): import tempfile with tempfile.TemporaryDirectory() as tmpdirname: cache_folder = Path(tmpdirname) - args = setup_module(cache_folder) + args = setup_dataset_and_peaks(cache_folder) test_estimate_motion(args) From 64f317722885f8997ef591052239478c33bbbdcd Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Fri, 5 Jul 2024 10:36:06 +0200 Subject: [PATCH 17/31] wip --- src/spikeinterface/generation/hybrid_tools.py | 2 +- src/spikeinterface/preprocessing/motion.py | 30 ++++++++----------- .../motion/motion_interpolation.py | 3 ++ src/spikeinterface/widgets/motion.py | 12 ++++---- 4 files changed, 22 insertions(+), 25 deletions(-) diff --git a/src/spikeinterface/generation/hybrid_tools.py b/src/spikeinterface/generation/hybrid_tools.py index a57e090f5f..8f2ef0ec21 100644 --- a/src/spikeinterface/generation/hybrid_tools.py +++ b/src/spikeinterface/generation/hybrid_tools.py @@ -15,7 +15,7 @@ ) from spikeinterface.core.template_tools import get_template_extremum_channel -from spikeinterface.sortingcomponents.motion_utils import Motion +from spikeinterface.sortingcomponents.motion import Motion from spikeinterface.generation.drift_tools import ( InjectDriftingTemplatesRecording, diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index cb3fde20a7..82a89220bd 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -19,8 +19,8 @@ method="locally_exclusive", peak_sign="neg", detect_threshold=8.0, - exclude_sweep_ms=0.1, - radius_um=50, + exclude_sweep_ms=0.8, + radius_um=80., ), "select_kwargs": dict(), "localize_peaks_kwargs": dict( @@ -35,16 +35,13 @@ "estimate_motion_kwargs": dict( method="decentralized", direction="y", - bin_s=2.0, + bin_s=1.0, rigid=False, bin_um=5.0, - margin_um=0.0, - # win_shape="gaussian", - # win_step_um=50.0, - # win_scale_um=150.0, + hist_margin_um=20.0, win_shape="gaussian", - win_step_um=100.0, - win_scale_um=200.0, + win_step_um=200.0, + win_scale_um=300.0, histogram_depth_smooth_um=5.0, histogram_time_smooth_s=None, pairwise_displacement_method="conv", @@ -78,13 +75,14 @@ method="locally_exclusive", peak_sign="neg", detect_threshold=8.0, - exclude_sweep_ms=0.5, - radius_um=50, + exclude_sweep_ms=0.8, + radius_um=80., ), "select_kwargs": dict(), "localize_peaks_kwargs": dict( method="grid_convolution", - radius_um=40.0, + # radius_um=40.0, + radius_um=80.0, upsampling_um=5.0, sigma_ms=0.25, margin_um=30.0, @@ -97,10 +95,7 @@ bin_s=2.0, rigid=False, bin_um=5.0, - margin_um=0.0, - # win_shape="gaussian", - # win_step_um=50.0, - # win_scale_um=150.0, + hist_margin_um=0.0, win_shape="gaussian", win_step_um=100.0, win_scale_um=200.0, @@ -183,7 +178,7 @@ rigid=False, win_step_um=50.0, win_scale_um=150.0, - margin_um=0, + hist_margin_um=0, win_shape="rect", ), "interpolate_motion_kwargs": dict( @@ -201,6 +196,7 @@ } + def correct_motion( recording, preset="nonrigid_accurate", diff --git a/src/spikeinterface/sortingcomponents/motion/motion_interpolation.py b/src/spikeinterface/sortingcomponents/motion/motion_interpolation.py index 32bb7634e9..11ce11e1aa 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_interpolation.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_interpolation.py @@ -27,6 +27,9 @@ def correct_motion_on_peaks(peaks, peak_locations, motion, recording): corrected_peak_locations: np.array Motion-corrected peak locations """ + if recording is None: + raise ValueError("correct_motion_on_peaks need recording to be not None") + corrected_peak_locations = peak_locations.copy() for segment_index in range(motion.num_segments): diff --git a/src/spikeinterface/widgets/motion.py b/src/spikeinterface/widgets/motion.py index 6f8bdc7a6e..81cda212b2 100644 --- a/src/spikeinterface/widgets/motion.py +++ b/src/spikeinterface/widgets/motion.py @@ -230,7 +230,7 @@ def plot_matplotlib(self, data_plot, **backend_kwargs): from matplotlib.colors import Normalize from .utils_matplotlib import make_mpl_figure - from spikeinterface.sortingcomponents.motion_interpolation import correct_motion_on_peaks + from spikeinterface.sortingcomponents.motion import correct_motion_on_peaks dp = to_attr(data_plot) @@ -291,12 +291,10 @@ class MotionInfoWidget(BaseWidget): ---------- motion_info : dict The motion info returned by correct_motion() or loaded back with load_motion_info(). + recording : RecordingExtractor + The recording extractor object segment_index : int, default: None The segment index to display. - recording : RecordingExtractor, default: None - The recording extractor object (only used to get "real" times). - segment_index : int, default: 0 - The segment index to display. sampling_frequency : float, default: None The sampling frequency (needed if recording is None). depth_lim : tuple or None, default: None @@ -320,8 +318,8 @@ class MotionInfoWidget(BaseWidget): def __init__( self, motion_info: dict, + recording: BaseRecording, segment_index: int | None = None, - recording: BaseRecording | None = None, depth_lim: tuple[float, float] | None = None, motion_lim: tuple[float, float] | None = None, color_amplitude: bool = False, @@ -366,7 +364,7 @@ def __init__( def plot_matplotlib(self, data_plot, **backend_kwargs): from .utils_matplotlib import make_mpl_figure - from spikeinterface.sortingcomponents.motion_interpolation import correct_motion_on_peaks + from spikeinterface.sortingcomponents.motion import correct_motion_on_peaks dp = to_attr(data_plot) From 41e6eda8e68d8da51f63ec026a00236c62fcb821 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Mon, 8 Jul 2024 10:54:03 +0200 Subject: [PATCH 18/31] dredge_lfp doc --- doc/how_to/drift_with_lfp.rst | 189 +++++++++++++ .../drift_with_lfp_12_1.png | Bin 0 -> 48141 bytes .../drift_with_lfp_8_1.png | Bin 0 -> 195184 bytes examples/how_to/drift_with_lfp.py | 113 ++++++++ .../sortingcomponents/motion/decentralized.py | 265 +++++++++--------- .../sortingcomponents/motion/dredge.py | 15 +- .../motion/motion_estimation.py | 1 + 7 files changed, 444 insertions(+), 139 deletions(-) create mode 100644 doc/how_to/drift_with_lfp.rst create mode 100644 doc/how_to/drift_with_lfp_files/drift_with_lfp_12_1.png create mode 100644 doc/how_to/drift_with_lfp_files/drift_with_lfp_8_1.png create mode 100644 examples/how_to/drift_with_lfp.py diff --git a/doc/how_to/drift_with_lfp.rst b/doc/how_to/drift_with_lfp.rst new file mode 100644 index 0000000000..e8d48301a0 --- /dev/null +++ b/doc/how_to/drift_with_lfp.rst @@ -0,0 +1,189 @@ +Estimate drift using the LFP traces +=================================== + +Charlie Windolf and colleagues have developed a method to estimate the +motion using the LFP signal : **dredge**. + +You can see more detail in this preprint `DREDge: robust motion +correction for high-density extracellular recordings across +species `__ + +This method is particularly adapated for the open dataset recorded at +Massachusetts General Hospital by Angelique Paulk and colleagues. The +dataset can be dowloaed `on +datadryad `__. +This challenging dataset contain recording on patient with neuropixel +probe! But a very high and very fast motion on the probe prevent doing +spike sorting. + +The **dredge** method has two sides **dredge_lfp** and **dredge_ap**. +They both haave been ported inside spikeinterface. Here we will use the +**dredge_lfp**. + +Here we demonstrate how to use this method to estimate the fast and high +drift on this recording. + +For each patient, the dataset contains two recording : a high pass (AP - +30kHz) and a low pass (FP - 2.5kHz). We will use the low pass here. + +.. code:: ipython3 + + %matplotlib inline + %load_ext autoreload + %autoreload 2 + +.. code:: ipython3 + + from pathlib import Path + import matplotlib.pyplot as plt + + import spikeinterface.full as si + from spikeinterface.sortingcomponents.motion import estimate_motion + +.. code:: ipython3 + + # the dataset has been locally downloaded + base_folder = Path("/mnt/data/sam/DataSpikeSorting/") + np_data_drift = base_folder / 'human_neuropixel/Pt02/' + +read the spikeglx file +~~~~~~~~~~~~~~~~~~~~~~ + +.. code:: ipython3 + + raw_rec = si.read_spikeglx(np_data_drift) + print(raw_rec) + + +.. parsed-literal:: + + SpikeGLXRecordingExtractor: 384 channels - 2.5kHz - 1 segments - 2,183,292 samples + 873.32s (14.56 minutes) - int16 dtype - 1.56 GiB + + +preprocessing +~~~~~~~~~~~~~ + +Contrary to **dredge_ap** which need peak and peak location, the +**dredge_lfp** is estimating the motion directly on traces but the +method need an important preprocessing: \* low pass filter : this focus +the signal on a particular band \* phase_shift : this is needed to +conpensate the digitalization unalignement \* resample : the sample +fequency of the signal will be the sample frequency of the estimated +motion. Here we choose 250Hz to have 4ms precission. \* +directional_derivative : this optional step apply a derivative at second +order to enhance edges on the traces. This is not a general rules and +need to be tested case by case. \* average_across_direction : neuropixel +1 probe has several contact per depth. They are average to get a unique +virtual signal along the probe depth (“y” in probeinterface and +spikeinterface). + +When appying this preprocessing the motion can be estimated almost by +eyes ont the traces plotted with the map mode. + +.. code:: ipython3 + + lfprec = si.bandpass_filter( + raw_rec, + freq_min=0.5, + freq_max=250, + + margin_ms=1500., + filter_order=3, + dtype="float32", + add_reflect_padding=True, + ) + lfprec = si.phase_shift(lfprec) + lfprec = si.resample(lfprec, resample_rate=250, margin_ms=1000) + + lfprec = si.directional_derivative(lfprec, order=2, edge_order=1) + lfprec = si.average_across_direction(lfprec) + + print(lfprec) + + +.. parsed-literal:: + + AverageAcrossDirectionRecording: 192 channels - 0.2kHz - 1 segments - 218,329 samples + 873.32s (14.56 minutes) - float32 dtype - 159.91 MiB + + +.. code:: ipython3 + + %matplotlib inline + si.plot_traces(lfprec, backend="matplotlib", mode="map", clim=(-0.05, 0.05), time_range=(400, 420)) + + + + +.. parsed-literal:: + + + + + + +.. image:: drift_with_lfp_files/drift_with_lfp_8_1.png + + +Run the method +~~~~~~~~~~~~~~ + +``estimate_motion()`` is the generic funciton with multi method in +spikeinterface. + +This return a ``Motion`` object, you can note that the interval is +exactly the same as downsampled signal. + +Here we use ``rigid=True``, this means that we have one unqiue signal to +describe the motion for the entire probe. + +.. code:: ipython3 + + motion = estimate_motion(lfprec, method='dredge_lfp', rigid=True, progress_bar=True) + motion + + + +.. parsed-literal:: + + Online chunks [10.0s each]: 0%| | 0/87 [00:00?BO2I*8<;GK)V|NFnU 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
I89rcnZqJ0g?nb-r?dN*ob}iZe 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 O!5+(~NS8_)2K+At*QCn; literal 0 HcmV?d00001 diff --git a/doc/how_to/drift_with_lfp_files/drift_with_lfp_8_1.png b/doc/how_to/drift_with_lfp_files/drift_with_lfp_8_1.png new file mode 100644 index 0000000000000000000000000000000000000000..c38c2eb6c564d8a76567d122e63c4359aaeae7ba GIT binary patch literal 195184 zcmY&=bzI!sx;3S^yF+m=QrxAuyF10*T}p9xDDG0+9g5B1?phe!>EQa!d+xdC-0zPh zza*2LWM@BnueF}FCrU+08Wo8M2?`1dRaQnq4GIdH9}4P25ds|K$VHmH8)U=hE~(?L z?quoiW$J1HrD*EzZ13c5Z(~O8Y2oT-iU^q=KJg-E##)CVXiSqU)>@0_z&uV(vIv2FA~Pu@q3#TtE{9xvmQ zoQ0Yh%I11elh}G#5e{==z?}?q6WamMBw;_vVU?h~0TH>ogq?t}9F}8=fQ@QZM4uZW z&;$mHzFewse*7}r>+62X-4+-q+-AhXDYY4QgRK^@>|6D`rN5J-ewefN+|?(>dq^VB z+%Saoua$9V;=8od@Bi8P=g1+_`1h&*-MyBK*%!5nqDj{=gRCBkhFuGq{(Ygm|Ew9` zjP5_j|2f%f-9U2m-%^`((A@v$BFITrJ!W|R|J)M)bNa{Jf0wAU8-w@%-|moe=5aLt zMV8F>h@x|oWz#W3n#?Ek{cihhefLs8K%o1srOv|6E{1vBdv)9h!1^-4%KR31y(`%Z z7U)Y^ckGIzmTd>c3p_jry5C$Ga1V(mUbRr*;h`i2=OygjqlJP-QvP3@W3kX`hIay^S)PH$leH-p`ehu7Cv*>u( z+jvjux~s{xH8nLoY%0I)DR(-yJ;yHp_oFvoB}qeh4>K9{Prsc_|IJ$N4o^$N4ZJuEj4z`1UaT^@eeRud=Kbd3;c*T2+kQ6c zeX8oqVnf02^SiAwoCgH-B=>12`V_SK6&7~w!Ylw%f3&+`Oh8sp!MGKbpjMP>l)el z)V99Y^9`TF-03I11d%u1f4(z#xDnSociN$C^DY*ci(=yG>6zzI0I4K!tk~3L;M8Tr zr91>PX&F{m7P_V3S@D_r znCo}Z5s~1(M}-pc^$isClF1MalRgL7>pi*O^r;W~&@FJ~fv1+FUeO<>>l3oQ$&!sGnCQ^V8y z(`n8#@;2bY!8i_wt>$x@ORW44t*f~w*}&(nt)ap9BUU%(_x!zjwhgyeo(|Wyv>~~x zDrHiavJ7W)17|lamriWAX*~d|zga|I?>q7fK;%K$e~`5&?ezUJX!v1{?7sWD4qWw@ zrTT}+-RS#obCLV1>W$thr`OfK=@fVlEw#yU#|?h9y>6c%n>F%7p+hJJ1Y_3s|-6(56BqQ8t(#NJN36qJ&4aJ+kwI@>T3 z;G!0$$I5->QG<~y2ra;t`>T?4{)%>R;8A}bX8+mtRFGSL__+DiotZZ#<-@=dIrR#h zt^(U5|NE`n@U4s3BCk>NqAAyGHSLEeEE$TCud-}iQ8@{TizviHoK=Se+*xbkmPaW#`;gtQz$b^LQN>2CE0(mx?M z+3qg<{jiv*SRW>cV?4-V-6g{4r6S7!H%iyWWS?A@$xHE>TGis{%Mt7_ZegJKKAGOt zQG&_#A6Y>>b@3g-jO{h~jp=aK$J1Rhh;yPvWjw0z8SKShhwg+jFKE-Q>|MVh=fqaH#$&|J{YH1|?$bB3{s*^tcWNcL^Oi`9xT6>=H!rchs!3%Fbs?n%lEXAf;Tqa9Fd_EPU zYjz6q{aSb!c*Xk5*nhr3iBl>>A)8rJnyKkV16)~b6Y)ikcuMDiv`m?44pM3RYwf$0 z#eCqk{A}aEGN6yp@XKA0)DWRK;orz68R$N>ygAjbaJ)X@e*p_DTErRC8Gan+#6F-C;R^Qejv@Vn~iWNAi=Al!m&! zu!)FHd1Jg$iA<=Nj+2&+hQh4Q-65*BG>ugk`=J2R>yVSG&?CLfVjvk-OJlgO8_(nk zUtXqM;6YEqb;|Y|@YtPy`tkX&Nm5gDf~`B=VF2GGwYL6cVv6h6FFiE(H}X)^b`xu| zzz5T<_qTg+dRuo`-y_dEt;iclWa^)>ip=}Z2PmVLU3~C`S6_DiLsb5s8mEBMU9MW= zzNzKF^X0$M+T9`fPnG$;fKw+2hlZ}M=h*Gsm+qF9+v@dZNdNW>>B$doNP_93Ba4pi zUxX8&JAD8p^O6XeG9oh!9*Wh}xx7O%VmxuOjcBt5i5{JMrlLH`KRvo+J{0}^NJ`8N zBR5mS@ulY2l0mag9+#lgN3MLHW+{Z)Y2)@@YoQJYS~gQrz^exDUI_dSxuSZ5-UnNim)$A}JD zgjmU36!#JlwAoQn=|z()&w>;c&#pan1olg49W8kvI(6tVX6|24~w| zZmd%EktLC;oXAY&i(1nH9*2CAkJRdU@I)dk?XlHw^hUahmqN2NZNp$4i^F@T^x~eDW@2! zgnuRGWpPNWW<3ttOxb5f>rKPa_)#ili52l||H^!Zq-OtFC`k620ez>V7j1&+idm@%w-@ zx?bLbWb@%3%{>J0Wf}G7=Me$K&06t8LQNtOw}OmQp6Y=R_`?V|R-jD1Z zf?2M--Y>RGYwbeFq5c?N`r_YjxJ@^)=;sO^=D(x_4Qy?Z5fc-~9J+UH4mYm3_5bX< z*(CpLL%x#D1*6uuiP%m@;0iW=BY(vg`F&&8`-7q0#U|2&FsxzYt}F;adU+Ij^rG4H zv!=UbnEUhD>uC_6R!#7)o5-;0C3Nexac!f33DdkFS2aTFHy!R+`L7k!JXTzvx`p>X z*&FV?irn;p=>iX&RGc=aD=(h|TgjfGJh=}}TY==%e=Us1KN^FBObPCEyVy;aVi<_W zEy-qh%yW>A?$vFH|L#uWoD(g0O4@BH1Y- z@!GM7ER(np3>Rvj(z8l2T(!{QyfIU(eii-AKeGs9YAeAfrlp=k(AeuKupIj*WQ+niD6sPutW$~EDu!pi;rxkL{6t7%t@Jp*SdB0VivW3W z4uag8vS6x9Pj5~rY~6ooZ~LqT$#UUp;L@Wm;*^^w5@8?6keb)|Cwm`aR|CAR-k5+B zwu2o;1Fxrqg;>;jl=h&$vl4Oouy!B9&D1lmi{E@JKl$eh%$UZHIbnHY7{g77f{dWl zA`DVKreX*N^3tK=yh@~4%hILNGYE2&GP`*D3d4s)ke~sR1vSi(oTYl3ROKwA#AAS{jvl= zAdM$e)?#7F`4i*F7vM=J+5G&wrVgJ7SA8)(SN5wp>1i%<^%}dY@9$3jyJx{)&Yg?F za&dB;rI;j>#B-vvIzX{wf{jN6({~&*-_RyNlapAqtS>JG-!iJt>)?9#BVW*^=yOoEnb7e|!LnO#K7ILV%ocF!9Jsvo{S<;IUdieh7;k(thBM7+?emz!Z4b+mm{)d+D{EJ&csOv~$ z>~i1R?MrdcO+wzX7~$JkP!ZUw_XiMN9-uimRk9bbj3Q~?aX%@NDF0&^uq5^5!4A_qznPZKK1rl7qIo_>r%}mGOHgN0}|0d-gn(8p)Mw z)FUs+@={U4-4kQh=$Pf9vY)c=`jlP?&hbuk@mR+2gRi5kxSn$Du z5}f8mfX{VtKJO_J6HJ5Jd6&;L4WdQs=JY3Ux+qCaR2%N02<8g+IMA6IsB%a-ZaK#= zBaaB|B2rrOcYF33lOd^PLE)yRKe^C8la`eklBG}+NvM;T!^40n9J$4hFp6;IS54|L zCWwDv1gYa7YLNj}hiK5B?6-3sfeGdzVQfR;R!e7MRmk98(mL zm^vB^Xu(n{VICR}S*%-l8<^!#B_^loG+;5QRVK}UCAcsF0yBS!I&Dv>&X|AUI8$Vz z)%@YsV5LnW5sZwz_ig)AJiWOYwYHSo(p(}u_ZLQx%tzU3*d;LsQVtmYlOzR4@;g=v zsKSp1>QqfE+!v-G&tpafRlv@gTQRnruj+&xf~zmo)R#+pa4itM!%L|AlXSK8R1;A! zJzbtAGcc1_nW8Av(peybb?PS$*AHe1B({qstf(AR2n7=0|4nj^xpAweV3jiVV0S$g z80!%)ij;$ZsTgK$o1;Ole@AhwI!($I4`yOda|xAGX&cv!^U#3@>(%GgVH^4P8Mi_Q zktl7($9##6ci|rhV}Oft3p~CwST<&bbU8H=SbSTO37#(?%_G8TdY~j>DXxO;AkeeN z(uNCdW#sD;mSUTrC>gqbtHjO#PWIhTI84p*K{ieW#;@K64O#SXgLF2+x9gE?Ql5~O ziaL4&`N>JmHpH5^X!dg zSk8sXu0y=y$m|M+%-0GM;z~WXfg~h$y+$IYn9zuTe~PMKsr&luP0D0*Qyg&P^-k` zB?EB@7^LK5&Ww8}^}B}3B!YF9nTJ0_aHJ09ai(nwz)o+IeUJHXueDtlzEW6!F9B^q z&u!rI?YHxwKhJ_UGhhFaSpt>n+}>V%wjQUv-oDrM{6`Hv{sU|0-b>K%w`Dt4S16*x z<}vpb5j-{%_mQQVTF{?S+VHe^7ek*5#xMW-q#vfD*xShRe03hxO5NkaB)f(A@o-ig z^PVIz1BphpkURWsbHU4y6Xw<<#zC)k5-E@WH>)8jJT>A|t64o)2MraX@%w;9*ga7G zPW*Crs@Z$u;xh|I&);bkfDqY6bw%88*x_;SZUS~qAclv3SczY>il@(UC0c$bpM?S8 z1yM=0MD%xN%(AkSTlzR-o_uVKTb3C}OnYuj0if(NO+hThW3AA7@Cc%Xq}HcDaaHjn z*X-PLt|(`Xs5`7aQ!N=;CBttOQvK%m!Z=iIPenD2lsmB(i{xT@U^PWYREI5;VbiVC zpcUw2WaDUz`Y4}}Q{Eacpp7lQFWDRA9{iQF)EGRyjA`@f1go5i@pQ#n@|P~T4apJx z*EPU&_xfs`wbf2hB?cwsl94qcI;+8|d1n|_1-ZDuTM^|T*)ZoEhc2XRX@l6NOmhpC zqcit6>{1Of2mUb29CN8n?b`nkFlA3IYc#T@$l2DKR}#)mY;XTjaS)1ip+9{rIrb^2 zaEAXT_WK>Q5%$nXQyy%y=~&Nc*14ks-JC=XH(lZ`o;*Dl<#~ZQ>p_dE)CN+vvd+>P zf9TwZDAn%NXDMuFe%eG-@drY$ikCiYz{Q>%Vl;&UvHg3qNV!J4H)%OEtEd)5TB*f% z5`fq$Z);<)j1Qii21n4OJGUcgA31>h6&$>%bDcctU-(HKdQMdQe3h4ux&kBnhQ6l2 zx*OY>OVwTc_)kCk4kTQVX9tHMc){KRX0vk=R}i-W#xuBHXekZhCr-GGECh-- zol1$|^Xi89ZcV=ruBVE){x_kpi%c?JT6LJPFM6n};;=a`R@lK!(yG0}4@R2$W*EQq=oXrr+GKSZoY9-L*klO7fJ}`ey97Rqr`aJj zT`IEZm@sG4M6|dl7VyPxklxkukhfAy4h?y*q)|eT95ctj2_g(lMFhevXhfFXa2w#7 zHD&5LjEc)zkW3AfwBDFs3Sy0|FYTX=*g5KP17+jR-JRvWjzgIcUpCuN{Q7C7t1*%q z30Q&|6EM6h=dvkSTkVoC@7E9}F29S%FE<~nmtW?$J$n8j&l14gBZQ%#H7@=VFCnksN0J&k#-DEo!YOOLQTQllJ}+E|yI1&k$(uu3k{uYfW1^*M z@!?emQwG*Fnhm#+4vC%yP{OdOF0K4`(-WR>lH^#%ZWb2BHr$9qz@X-tmP~71Khc- z2S`8#Hi9YOuAR@z*zZJ3!4BP}2C%%OW{If)AHzf_2AD9H6+nJ@j|^7E*PAPTC4`{B zFhWX&3d521MhM(MJLKx&{WBmZ(MDDnUne>*N&qU6N?&SnEG3WXT=)*2MXk|7Q8(3^ za&AFd-L6B_7?i8lm51K7jIM|nqI>Ebv_sC_*s9-a_(Av`$9@mP{)<@hO^euZ${#K?5owjUU^j8sBoe{=?jW8n{=h&-mVi1-)`4wF8XqMF@@ND& z<{@$OH=RNT>gxKyO*;m=D5+Ks8B*De)mB)R}0tvdhr6+=4yV zy%m*--ws2T#IUGw7_WT`q*S!SRS;8Wh9J7BDh?hUbrXQSlG*F_YskTu0d57Ji(%e~ zA@~(Sipa(fMOi!c_~le5vOyYLY+Nr#?dQgXT??4}z3q-hLx~pBF}4a_4q2l_EXh1+6Ib zw3gV=JsBa-3#;1NXOf4g=ZVStD#VsBqE@v}hW`}=K>~|tbf|smnV^eL;H5KU| z$rfjYQGLxUk@=-9#K*xUPPgP6)8S8?P211k_>A`G6szG#SXalo$-X`WV2LTLcD}OV z4(Jl$x)~6KdgNdOM>gp&WG~!`{?3z3p{nuP5+~MdNUhbefBB|E^vyz-Sorvs0+c;M zU#QyyH5c*Cs0PH1!hWq_>#*7k6i(Pb!7f>496f=RDm66{NOk8WO_bfQi|Y4z|5+7G zd-A20*~1i1A=XMk$aQ~Jja@LDTOYy)44~xZE7}*Ybz*O;4as4n!Ug;~Cr54Yh}wLe zv$t7GT^LJTvF3IDsuWoag{5UmAoCZ6_=FuhJa;qBH)ekxXUL(dphc-iFvp9nJwkLG+cK6q!!wtoc=40voE65+`|*QR{Y z)wj?gx{>ciq=EUN?$w|bZw=R`u91$+#D3UGvGWaob)vyE1epWiGiGY)%`7;5S9L-C z>!pCU8s@Z;wo0nvm5$A>jUpQJ7fEp$)s)vVZ`h_PAWO=cub%c0C!_eEzR=yZ z#xK3%;3Q+3c4t=8RE!%X4oy25oE8PdW^#rEOpoIqDD9FuX2)UYLm%>ry_&JTo8Jl>g5v2-@cbY4u$WLB#WMQ^HU@J2Q4+Kd925TB_0Kl zZxVG4u#Nc?62$FnrJQW|eEg#jwN%yA`XHPW zKXPhd%gvn$jcloyQunQ9B?c)iFCqIcW-wsKAUZu{)n(sdVhz&q&-p*Sj`)X}oC=z~ zD%GcV#HwPE2{6|$(Q)RVhL4O2E5RR~3$O`fAZU!No$xiIRK|i1HdF}3MB*JiG_gDi87r-bDBF?Z;z5jf(49p-K2Q^oMmTLNC zEyFGH6ym(L>nv7qbLdFFeu`NAvdO029F!uAyBT-Uv@;gbZH;zka&9{y9Pj5vHjPK^ zsqc1Su4QK}x*N~wfKyFHuwq{g9vr}+?}NdI6Ru7uyGdhT++fAeB1)-apC{Btw<`Hw zXOVc`!tX?aHb}0J2!cC7f)-(09w5e1N)A(ot6%y+DKSj(z0&!XBpbCtLZ;+uTxnx1 zBIQuqniBV+Kh;6hGgDWxBF9(`773KlHC^iBPAR*E6ImAgOEi={9gW-g(ApLc^Mjfw zt6Cr=i;X1p#zg zqh+GQ&H)B49(yBSCtq{p!c;I!5;uyr*l`=Jojt^Qvp6b0`$tWdr zy$}>FM4a=T4kwzTG*PgO$+Val@H zS3AA|ez+(jX6XWP)}>}6XPcb5_$ztaUR%+P?%1u~k6f8g)5wu&CTnx^#bYj^j zO(f&>O=hKDmrLo8Z$z~h%Cm;HpVQrphCWaQES&jNwq!nR`bXzZkJG>kBxy5f+CpP zc}BzYS`lG^f{mg9;(pz*OviSH@L+!EjAOo7&79hO0h_N zi|Qymqbt(pfs3MWK*rB>hhTFH5=l^Gi)d9So6IbuXa$DpYvVU;4+f?_!G)L}Owm6X z=ZbcIUUH_s<%RHJQx!E-E{PL&iIDi*k>b#N&jSht*!toLNEggX)_vaSDK(#Nu|B+&5%|?#|kwdW8cfFTm-n3e0BQlcaKQ_9K zW$XvAbm7_}D^I}E_GVY$RRaPY^y2OUsQobfh@0IiW>J+&1GGS)+r8WX{< zgeO3W85d$bnpX1_^g`OiCljkAH)Hdp8l!@EbqJ$gmVyO{vCi!kww)D=A32)nmr3aS z5N;nCJK<9(N~HXzM%3Bx*WQqVy=Q)f{vO)iE@>D8-%B+jL)3ktm+&ZbZTJfPE_%L{@X{{66&fb%H*k2>8t+WQe26- zCL%Cg&KNt_9tHK><5PU&?@e#4MdE_L9*yJ$Y-r!DP{-EnxRq?-&j6&E{-mook)0Bx zhJPvY)tZ(B;wCvl&T*S@P_R(3$h_=^^i(v^Xm;HDr2YS_1{<9r3psEXH1=M6Tj~?l zP;NiQ(t`gO0^I&#$GE#i#C?%;H=PWR#OD9^Ak_#bQ99bF#J@#MKTB?W&list283)@ zHSDoZI9cXOZ@RwnAquA;mntdQBrr6Gl)@5d#F6UmA8gSpB^hpmESwgs(KNH7f+xJ8 zCS#DQJ`~_t&Xt@Zs_;pe!8sL8BlBql%F4whL)Srd&X~25)2NMF@VUag4%DTq%ZLri zDQ2|gFQiqU3dO_MEmEUg?bW5=yoPi*GaFm!~fwT zG~KP)R^0hq?_j+(Rbr{bopTJ>Vqjmrw|k~*YHE)WVEAp%NDBjrs9Kb+d883*IDGmz zDuG@Ycx@G3eC_xo{=25gYj)dvM1zGweE2OS7D#Z9tQ#2$*tvRZ_p7Rc_hw_Ir|vp} z+R^z7h~*9{58mQ()^wJdf{my#W@o)>l^j)N)=HpCNx^Sn64^_5Z$0kc1SL{D1ik8h}ct0g0Kp&Xj2d~N-ANiph4mB1cc9&7lCNn37f~oAe|C}tixOr`T(dNrm z#Ru<6C(4Fz^bIT>ce#ub8gKq#j79Dc%)z!9p6!-GLeQM@%zViF4%~{E=sc>9abyz6 z<7FHjh?KquY;ehR_?@yWOvZ5Hi$o!qhuj8eP4~vCS{zA!5Yxv_vKCW$$cjzz#V!|z zp~!?IE;Arr-pJvqF7s72#$*ty-sixBlCjcy*+)^#Q|2P1f~#Y zB6v+N^lCYumq^UZTy=)&065Yb_*I%O&1EA~19^!Zy*pkx&XN3ZP*SuQW`O!e6$`+A zD%_Ow&ir}QLO(rUR!q8O+W$dDS-)bzx8TL~6-sK#oGSCGg!l8X%m#EHD~W7Z;aH&||1K-f1|dxOs;F}!;PZ!?=P+0NRSs zQE=o|R@ZR@)_iWC$~z!CV~g||IpyIpX%jg{2Jmd3J+7#z)EsP*w{J6JX=o5xub zl3zWmqta4#+vrOHqK|tnu_C5^`;59WCf+&(;{!^`++>1u(v;_rRF=mQ-$foMh37Dq z>1#FhEA<}@vU)7p1&CGT50a;M@EU(YwsJw+aCOjk`N3%A3*`|iMLLb@p%15(mMv@2 zq=p~uDp>sq<9<9GfP$^Q*G|EJ#;}?#@@&WnN3a6GE?GdPTYT5yLyP>^JpLR^h_iK0(A6^x zy!qQM*U>-uV9Xpxn-vpn3BmZzV}aNU{GC|%)LMsAhJklJu44x5qq#%(?v;qelTTR$ z;|I(D_Ze13rS1K#Ctpg=aJuK6M?50+Cps|CdbMj`CHPrq^6Aa;G8H?d;%;Ii%JH=1 zWRIHdZ^cz@pR9#UGbMUYHaqg(z$$}6PkRs_scOYH)y-L*wIa>wqSCR=&Ul45bz$!r zhytst6d;wPZhg$*Cc$-kR+ZRJZs{2C{j}OMMs%UM=I0T&`NA7^yZbOcP_JvI@I&UJ zn*nPNi!x$=t-l3pqu%wFQa2+DN4K`BDMsr^AH~PDh#s=kJLuaG< zr>8C809G0^u9H-?|lcP=fsa4jsDd_o*}(bn5+kvInd4 z2!6Z!ZE3fE(?6p%nnPrzznPU9*^f#c#pfE~66E>8;aF?eWYS_9)!p%1xF|{z!$E+M zeX2R>goIS5?^GQa8POYFG6Z5HgBiw%j<$5}pRUjGVn;a?9=)3gFpkUp^!Sp-Ew|kj z!1#^tmZ5YJhHpc%CTAd=u>5bRQOV=YB*S3ifd(BdB20Fr6qFxF{+qy;^g+Ioet!I9 zirP%mQ#kcS4ItIPuiqc^b0Y(>65$%-4DV|(7ITAR*i&uWoh(H+8rDG(dG-uk;bn#^|H)o=y!>S%p`2anqWO z;gmx-8)MLDjb0B(K3StVxO}%ru0ufpdrS}D(zG@yQBcyhb0XXKiyV8C~z;JWH#$&PEmEFIzL4HSmBG&YZYMM9*aJl=7=9&ITMTzVGHM z!53augfHnnaCcp6dVvE5lz)xkmX}!o5BnjhL(GETNb@D^=Y=8k>ULDT#iWGkRIpgM z^SjU%XaeA{;=jr9RSN@T_&-ydxG%?@}z6gYo(uMqoHECyIRo8LlJTNIetd{2L5%icjE>SW3? z%1#(tZ%}u&((O7@I)`1=W}juD`}K+t<+d@)&{@R|Rdf9al}WVy9{`a}J)td}R~?jU z^||oYAbGw;Z*(u`w5Q#21eT4Mo~tjl*{Pj|hxx4mhBo9;X;(Hi!z=g>7X}gzdBwV2 zxm96Cyvl#5Q})th%GP65i-{-RfC7m&axabAv-TU4X&t*)$FE8cc_o6WHiS5?~9(%UVllSLbI;Y(6Z7y2I2JOe>rkC-h%nspMrXUeX_ z-wX)T764fEmkY-dSrG5TAyI&H(t&!PWRu&^y@Xd*g{6y~r;=+v`UYDoP}?2rZ0`!! zCnLDh%FcC?iAl43bB^)_25sP?c++egwGX)v^8M&`f&Z@R%?ZD40C7hE5Bw%F3BK^E zE)s7p{2o{cVrVsKjN$JLXDS=1mhTC~=W!THk%sN_IgX4IG)si@ANI^h``u-|a0AtF zkF%JPPtNN`ckosRx;CE<&AEK!yj_ zwiq%zaHz8Vwek6$hRw5g*-lGe%S>{WR*O!LIdhsbQDPFsAz(2qYGn=AA8 zF2kO@naeTYB76q8O4`D^usjE}48`ZXxQ1TzDaoH+U%A7kTd=T*?|EI>{hGozWn~Mx zSs3O*(q!08eC0W)1VOqx^R?puRf4)5d3~N@{_e9|KDDQXKU#KF_CMnMCfubgNQ4$4 zek1M+Hb)a30wfULeWYW>$SLN@5!NJEzI4fQTjg|bW{;XnfB`$R9W=uz)v!<%yBt#U z6`$nAtT9j%wm+A(Y^y!{bxeRlxmo%P_RKk)sUzuiVBEuiV38o2mv*j$o8Y^k)8oBBQ*$WQ7UF5S{x3~)TNy7x?&@9}f z7s5eL;G$U{w@amD{DRq^0)y`X4-Xk75O0&us^fp=22uhcc_iJ$Y=)jjDLixgjl$J- z;y4;aYDm7DG#1_qQ|>#9bl?H5#5b{6W~q^DoONAz@!~Oh`2V zP1;mH=O$mhbxDj%JKk_)Bs1&kUCX8&UPDEFC&exNMKmo)^^o$>>`||USZR4z#O~lm zZktdn>54Rryw5Z2;wO@1NeW;3 zY7}L$_L{Sm$YEP<8LA7h>@Bi~#3^Ig8=D%B@a3A9g{+S^>F7`=0Z#^~#1=9Xh|}9) zCr&0HGNlvlYh6FokSh)pikDOC@5_2M)hYW-74u#y5e!LT9=OeQmt5!opVqNvAZ7P+ zY0d>|qDwer7$!MI)3ku#%^oI&Xi4R1S&D9K=pG-mMPif2{*Xnv=HJkb0R|4Fy5#sQ zg!o?l-1Hx+qQsH&%_*~z$rT#V?#gi^BquHLT>V5W-F$-^ghhZ;fDyBkCc_*rnEsk` z@i5M%!XGe2IDX9$M>T$mL-m$Aoc^1n5QDWl<#c$I?NS*6Ph_>c^MTUP!TCg-`RCaf zqe=jO^%W7s($d4tn8;Axr5`DuP~x4=3%bi(5T+5XmGubCj1vPo6#Ea}x=1=O z=c>%daP=Ta2n|lsE@X&+L{8q|DiUQ4*{e{Kw1!TWx}U!#NoFDq5V$q>@k+f}svTFk!f-+Wyb^!v^zlnn@FwzKICrj~0#2ZdnxQ%^{fdhwk8F zQa)zhIiPwd+L34P(&%L8xwpJqj6&=|P)TAgRP4Me_F`j+%vq36{qt0G$u(E-N=r5X1lFczzm!-p} zP((LqK&jU_&6*XXuAm>bY@5tI?6%RxE=#Ek4#0`ndsAutqAfY3faG%yD@)`nc)&f$ z3agp_OYkoTZ@5c>R62Xfsq5;fs{n1#g$u)d>ffNEzlhR|lQ@_{Z$A85yizOC$7UmoJ zj#E+uhx-(peO;@ph^A)WtAu;cwuIT0EH$iPJ6_!y16F7`l&+=$_%5-{CObFAke!>1 z^L>%C_AO_BWUN#CXU;Xa3xA6;=>2 zhLG9nCoJh**<=VL+ajGPUk6l`&akc(gbVII-Pd>pL;CR_9esV)h|qPpd^WP5j)(%l zvZ0kWo%%Sdv}p}{jbd@00bT}~Du>xM$P-kBQk=LRnXD_r8Xd5pK5Wrl6olrhNRkslKD_+Ym1P_tRN(<7A>-n&SWc#{%O&D&4;KALC6I@%| z-L<$waW8-R{bTLD_DPO1Mo#k1`N(};FJ{wG4!YQx{1&x3Qavt7|K2H=cfdEhyp|1k zJdJQMTZ;V~<`1`hTi*b_n^2~Np(txOW>eF6h_&5z#JfZra&u9|Qy&2@&iCZm>gp85 zwoGC5c(l$+Nlsgb2CRPZMM;x|wFA;^UaIMXQ@;--F~}o#SvcE~V#0kKRx(l)M#DSc z@as{W1!SmPXONgj=}l_$jMg8c80ZC7KVjl|;cgNH*oA zc9f5;+J1agz;b{kK4+s#sb2N)DX-ESU$jG~EFp8vA4_!l;+UKvT?@Bu|4N|BnV|fe-coVySKu&*j?eZE5Nnm;Z$T3{6jxKfQ24vcu7S? zae?;G%+CheSo{Toi&iRzjAsfneQ>pe?L1`Io9UxwMN$)x?7mp6Q)pRot5OZ1@vq2^ z%jR>}0L2ySZSrKiTKPG-7SWHfO{v+zgJtu!F$9}p>YEljIw_&W;1Q&}b>T{+M36h> zfMqHAO}!QK6{o3=k}%~l6QEs*j!E_gjv8idW7=+Rl$}Km{vr@>bVcH&DZ}Qu-fQqI zmhm!DCu*4Ta=jv3>i33wyTuv8uC;k&6B?Cpwt6m<+TZMjW{Pao0LYNEfYeLXBtqw$ zaPTlrG%&caI$ZW29d1cPIm`dPuK#TJE5)=RASF7j`CGCI-LoZ*`HMd=ssWX9|q>W#K|8RI8ks(%VzA z^!)J!*jn?pl6*LJS7i`mU_+}yj`y2Y+2bi>7r8BzOv*WspGN0)PWzoVz zJ%Kp66ZmnTyIrdIq}_ZQ=;O@;jn3NsLRbUn&Xeuyw=K7ulJKcSlc|BW9gx zPRVM6Ffs|Yh4fMeD!CR<(KRYP5L5Dtt|k{s#j10jC7&jr?8%IN&!+lAO}!NX_y3PY zr2FSp$iT2s`))QQzq>+qKsnwSJ_*-5-|rb2$1xT8Axq(LAv^@U&3^}{kHqx2&sV}SRk3|&U z9%ZK=0;wUrZZZjS5`!)^c9Rk5R!`(Kl-_AeeSO#DxcPdvpOt1gOp^;2WS}sPGIwu( zjAj;))#lNS{33r0F&%|u7j-Xy zY=vHW&FMm1u){)a_y7s*5XGGLGAT*&qg6PN`~&S?GCYdR-V<7ZnCa8=Qa-GEo0>IHxvd8v)Z z$3gC*lZMY5aZQp(G^C_nd%rY?J`uaVDx&wBi}qXO@J-a;%RwtK{&ByoRbHCEhMCTH zxa+6JLNWz_9x(Wx!kIJ8TQn6BbF%^n5|TFHILZ@;pV%LCc2H+q9v5abAylyXE^tz) zd`q5c#a`(9@n?YDWS4(~GK@&q#_3pUK)lI$$3752(!xyLbx-;qwioGh0ODuIpr4#n8OrWc zk|V-)^n={!q$ABl~@P=md;7+oqa2ycOon4M zUeJU>at6I?!>O`RNq}3sdTZ;L3--?QgR5zcJZwxhqmH7B1Vw0Z^6hJ&d>jl*JVT?x z2qMH-K_>ap0YX*vmfoJVputEqkd&R_0tq2G8Q#w&`E#=;4S1A4@EPUj;spNwrIv&U z0|a4*=ZwS;dD^DurTLoig0Gb{$|MOYGVnj;a7sbaDilFbTU=lKyX(eh{zvb^>YUwL zV>sM%`29cVzc&oA-(_6Oe?aR`_e0lZ(DP&7`%~WGrr#ws(2T_YkNAH`On3YnW6*vr zS?KQxr@)(=)#fRjFEWQZ)As9O26qYo=No{10$E5T!bDXwnP8{+m|Ae^RrSFtHzXKq z(1H2Q_??_Neon(&z+2r9v4K7(i{RIR2ki3%6 z>`pYDFt^?J)^^thpE)Mw&Jb zf9%!SaS%M=n2LmKsI3l4szBl&BR4bQR;k=oP40j zRvBJwqsjbOKv^8AT0^dG_=%^Q*`!!Sd+HCeS3=gI7z1Pu2n!!~hfT+i-b$+{8~vp^{kOsT-?QW}Z`hojhSw5YytUqa)kFBOHP7iRK>QfS+ba z*u2<&KktpI;HMnC7>5 zyO8MPchO{JY~=qg zcY8j)pXI%A{wJrpd<0(p{`+!y2yuNd+GJiky`T34EDrVTGdpb)_FNJA>=JGb2Rv*H ziT)jWPfzPQvv_@se}Cj8@j74S?!J8Kjt_djd>0l+^PhZx{03x81i;76tX9|+`I~jb z>6ITtUf43BT@BF)ZK$AsO7bzo!wom&HO|hl{Hnu4A^CKSikXd%jyzfTY_-fBpyxA# zr?g+-bO+0V1C@xAz1b6P$8h`fBJ|yaA>?gHCpmNSc_Vui8s#8-Lf?e=Od)I0Vuw>I z_MTV|pIYZ=12I0{#A(&}WfcE*R43%P+iQLtSmyz?!HIX6t`3wud?{;Ii)nwtT8Ib= zn_T4YU&Yr?*$zoDm>J+Z-=O)LPlPmz-qozwg&Ssm3ZLo{!?pTm9mDVkNuEG%QF8Z= zqM5vkCL2VMX1<)7-LCi!>#X`|X|ni(D4`cftxOVyY9OGpQVk zd`i%kYc95A{I~>g{)h&Gn@}ZAkgmi#i?qX1On1|N-rLuG z)TwpwK>e|ltXD(BlH>`#TL_h>;yZLRur)0e z=r7(FaBC1NJb-8t3OGrVN&@U#zS|r!jXtA*Gw$9D3a){CW+v3PZQ_-sg|!m)n>Q#$ z2$ljjHtRN(^kATmg=%k-wP=3rw`2d?YKIod$fhD?a68_Q^?HRf6;sNF6nDQnvHLcYTy-kS z0b0OZIEsF4lZ{yzk++696}`%n2X`o7Q#+4+9;jr?QN*1m+OG0i{~R>}2kEo0GU*g?&m2hPW=HPs^+oeBNp2dHa64A479ahf9?vLq z?w#t>7FoFb(4A>8&*%n<8_HFZ7yt~md+w1aoY!KzI}ny1(%=Cs>bG$!Yu}*U`L9`5 zQX)%>wSW*%fR>77v|J;$9)-};Wcp4aDuIt&C(4BLHa5#n=a-E2ySK46fvrF}gsTDs zp}OXu?tsA4I#On;ciH0U=(On#!^y=BO$oo6XQLAO{53?ykNHaB1C$({(0cAth>csp@ zU}&=Os?DI(8lV0=!*J~zc-T}FOi=9q7y|u+B@wPd-l4LE{XmAbEsqc1A?MH3b0DwNWJZic^`GResOjK851j)QfU1D;#K(qTDUEaR`ZsLP)- zU+X{&z7u#ZYm#zyY=GK@Vy@v`!qyq6dWZ6s3)d|Ar%g z)dJH=!t}vnikfeAct3=v7I%O>P24ed>?{R@N|}U%9Yg??Yy3Wne~49-s6z|#n_Ct^ zsm6)aA-0&#aYYS*nxHSDV8Wt+kM?N62%Vt(YrCgqIQP)F4)wSqLU{_4@@cUwi|@sl z9*%9ft6W>7y`3KT`{G%>(ea-k{SQiSGMBxbvtk&cDZ%lE&A?m|NO5d6R6H64TbS)z z1_Z9^c*uoK`4%X;foL=9S|d=Br7z-wgr`jLj@=|ejb~<=?V*E9UA*t}GoNVrW8z%% zu-0hc5!KMk>n1$WL*<(V@NkCu=oMQs$cN0b6PE48ieYaCgMhyVp{08B2RkBNqstMu zV#2K4dU=AP@0Z~}tWCpnBgD*NQ0tF<;5TJ<5$L1)Ogt}eLy*aK$LTglb{2~lS%~!~ zPrA5x$tu<>G3t^CdZe*gOs?B^QqUzjacLoxo*(M?>z0%`PlR}@}Lh%vuY zq)Gx)&bo@MxIkPLe@LHC-770UmwdE-$}p4YCqXe_O7>G$G^q2sG2a1g(g#$%;GQb5 zZ*5puyhN@sYvLYhpA#b@+(w?9#062RnW^iDxM^b?rz@2o(GVwHFIz!nSh^4&sei>} zqgPz>D3OB}ryH&BDHhE|bZLIY6u)Y%<#wd{sk0?ph6Xxw-Iga~Z@3Op#vnsIzc1f$_PZxG)B}46 zqCjY$x+9pe7t8vx2o%DY!i|}QOCUo(zc8;fC59NX{5)iTI5oA++2cljbH&}G&}i}J zHY3lczQI&Rt=z=eS9|-(Lj=;--=F@;H+myPZpmv6UWH8lkp|;-r{6zRi&Nn{I^(Q< zoG8zpJvI2C?SHkNJ|9C?Q@u=&jQEwbc5^<`w4V%u=FKlb%e{*buRIbm|1pxS zEU#z1if!DRe}*gjt~{&%gmmH@vB3FIkKwT@9tzRSk;m6ih9r2uv8Y7Fca;^mW6bGx zi#GF`edXyqk%+G&^81zN5b1P*>CUrExohl*mxe4a1v5g!vuVz~H30dq`Tdpga7U+w z0i^hmTc<$SEek&aj~xShtLh4`cq$OGz3m_947i$=(GB-t@o>1?TqT6S} zR=$T+EY%1z|M4CVrV})!dqpnnnRV1Zqm%RsyA{s-q#+0^%x^Ay?g->uXtPu8?t@zB zOQLZ^*}tUu3zDRE35SoN8d?<7?^m3Vc1Jnf>q!-Crz6rJA|Sw`ii7n^AxNCzvzlcS zsk%T~L+V3h-ZOKkdipPZb*p3>MiBR6ny==dDp!pG+%50IpPiTbWV5|zos&DkJXKg# zuI}~%4#R96yr3n1^9!jk3KD!abuxmxT#>0|TnP`*XLq2{ zVTqB^W#{~7FPW8W`EF%*I|Z?K!LfZL!KJAKWV?yPNJ^LwLj=}%?m;O+iZx+vUFXsHG(PkA@J|F(Pd3%=GPBwC+PB>&id;V z2dP+l1;rdE_}DZ&7wZH^q7D_b@M6;B5a<_DAQRe5xeP2OmxxcsBUN@c2JW1em ziJxF9afC2opbUAwbZ+&v4IV<8rDdCy3c4kl?FM?6JIb?jG03VJ(9iU2wuwkh3#K-H~TU zo(63H>MDUey{DH~-qp*-hCy?4bM76#o}S+O+)xk1AYtj`6z_I8$^6$bcH8MSuIE?Q z)~h9pVdta1=P`KD_1N}*l)693Q@ww^=OXs9?fnygX$9_U;q-tPuNix;8HKKgDW{&Z zA#&V3{P<7aOC6g{-mi8>FS;W4_3>AU@&4vlm$%1romX*Ad;bJHdAsOOXZ7apxv&7T z48n-fCAORi5dAcSD!X@1!_Gg}&*uP|=&14F(8CaBw5VmeW6z1k(9sT?Mq1mNbHkEH zb>K(1^-bsu)LF|Z?^k*9;_FRl9T>*qYEaI*le!26y8;6eMmDY zoAQAaAfbvf6#{x8=-O2s84Kce@OGL*NV+~iio5T)9GNINeYCK|=strNSRMpd0!=rV z-#aiXr~D+=h`DwK|HULUT&$?E>>~AB_YbsvB3#R5Hfk`k#JBpwraj-Q#`7x5)WIKU zsuTXp+G-}(9?AgGbE3~{`-P+MQr+QmY6Mm1gw27}K!Y>E65~0XAr&OZ?Mz(F9f*hN& ze5Yx+nIXUrsuM0UJ|>K4KN1FY5q872UEY}*=+R2aJ+NI*(h3x;`b39xu2s0HV+~8`{4myKCG#qp^gAk zNC0viurDp9ltDsUo7GA4#UH2whw|*?%yVEZUxvD;Nj>P?GuSBeU$#Le9Esoyf!ydI zZl^psUTSy~7`0G_xAUjrQ!l{UPQI#$1BVjUWL>g}y3@5mSvNN7np+K-l~IeeJro| zXk+s@;g>4^C?^Y|@9N2!ZASODUjz(h`}ohp_DX++LsX@zSm9z(()HzP!J-eD(t0qd z9_5pzMy~O$5@M5Ofh$b`#2=W6E$l+*`cjsW-Z03}WcXb;rN`~`S06kG7foPNILJu51Xl&X60OhScPc)ppeE$VIdW<)9<_5AYs;^r?IDN)_2#< z*8^UN?f40J`H1r2pR=mItE+4J>9wine(5o@_KDy>rPe>Y^lQZXAJWHvDrn`md1WXz zJS2!$c*;AT?DId9bFSaTQ}+4)=IM`AFvyL5Qp9>+ROfEpJ8TU?^jo5TphbU9PNJ~- zjC^^#e1GdCdwF>In$y!~;I%Oz2r1pSk_&F0!kuXY zH>vUBWNv+hQAF=gA#TjO!Hv%I8EA+C2C{WRd<*s4gVKMas>v?mnVN;VEP5pEJSI?apOjRX?+`OeZt~i?ycHV%0c-&#B1GN_Bev7UxsM|QQX+? zqx&57-7o>wT*LjZp<@oCe|}0T@K4B?tH-^A>t^`5cREfuG~pvjA_x2;Ngy{^Vj$I4 z7L1D<(fhpod__{2%f9ZNkaZLOWeBzs@Y8k&wjNS#1??F86$6BwAlIozFplv5SOgI_ zL50bCXMvf@B4bEpjy_l+H*}27Jjd{{APsJ0W-Bp{>HfggJdy)QXl)?;vD3*-cAXl0 z=Jwnr(+nvV8hl+0qVPVd?81Q(v#pu)f$MO-ehl2WyEitA z#iVS(K z10b+TVAb%Lr1$Gvt7-Ijd(7dY70J$Qzl(#qX?79r%k)pgu7thO2r~JKrOR6=`voNK zRVXW)MMnsG(R!q*B2kxWQ#iC4C+xw?a-woFA2Fu*ARgv#j`5XeF=A&xMBAxjH-Fw5rwBoLX{voO+gOCBl;2Ym%)mdmX=|JMwdBZXNiLEyROJ1qml@ zXCicXOi2_`AQ~aE^8)R5Gqb>%hQ;_)nQ_bLXMv*;%+2Fy@{(!=5lc|s6kXr*vEdk@ z^nAqlR_@kFiXrp|vEg88fad~(B4c`dqgC&m1amXk5wO<4Wl zw?$*L9ryk?np*{Xzqc?4zH+pLOn%$O@9D(54H_rEAn31Lit$%HAYxFh;EV}?{74#u zdsBKc!ccEnbWGJmWp-@=`WQc}icgAIzxSTU#&<22!D!vLaO`7Gd;!K^-_19qzFkT< zqo)a^h`uTLhiiZ5>#keG7E!6v+#VWhTcn_ak@x_mmODi3FEall*N3dd{M~t`yhV)G z$L!xZnecPUw$1lCy{_12?;L7?cHtJ^n;;X$q=@M9i~^QGG%MsEb7c@O0o^T1d`-r* zlmX;BEU1W+wf&GZVO40$8i~;jNalE{Qqd|Iu-o@;*<|AM^(xg$U~g5B;$#sAe->@i zdp>P9j9=*!Uj<}PdnxcCX$Os8(DqJ%AnSHHz8Mb$$^u8P^6^|JIF@TGV(C8OgL`Ki zGu@dTrGKOeK%4^-)JI{}V@Fg!ryeuftP$*a@-+kBx~-Yw?%H#N{_Ll_cE-oNoo)Af zir96o9Z+=}wkV-f!xY#vLLErfn()5eBOAvyIEYabVTy_i-dxxYgx9iaRTQs8i$O+=lY`Ite!+9(9<*QA? z{_SG-=NXw%zxzVhB!C5uZ;r|n0TREUuGH}w+tuX^T#LqF6ALSjy?8Oqud|=0g&}yj zR57Ll$@q>oof8|{q8*da05(Bw`*(m3bE7WGw!?I2LqSmT65jJvBNUp@~lkgv`8J*OXg z*!5(E51XY?$Tcw3+V+y>i!Yu(m$=Dm+ixOwiMHh+AJ-hjC`52ruDzu$jYhQenN(pw zMNMY&VGD|rMoPB7$X8Cz5Sw!kb=oCq6`#RXSacr3>2hzEOZJOJVQX$p7-q)f;&zQT zj8d*)X{k{AT|Nqv^fM!?w`M<4*4W9lOW!L*OZUIZ@d57tYaVUnjdmSo35}G5HrC3q7u*}h7O&aLQ=!^C0agu3?Jsl^iQ>VY$Nn{#vtCDE>R7byBm#is zhXhWZAIgPaW(hAL87TWZPZktLk#^2P(ofkZQdMP8l>i0TTfx75b#T4IQwcX7QU{eV zHOd6V-vqT@nVd7Lpc3RhP5v&X$=cHI@3nG?7^`X!{K3BgKeB;FXc?;x#QC_Q6h~R{ zOUD;%U_ZYtx2I32bxSe8?-HvPNaKTn%zD!MDNDmmO1Q|+J?GDB+Es3)nLq&qtx7RN`4z*O8Bq2Kh(l4`XI4b0XaPQAR;yL0G22_ zoSEmEYS<$r)|b4ZY(S%YsFl~ebwr~V0j)%+Lu8e7dZy6vRmJxZBj23_x2|+IM)(TN zDjM`Q{(B1$7`<0_Iq+NIL^!?%sKJ7)5u<95+{G=l{4fyb(tpH?WAjdl5mI*$sfU=X zKzO>vO8QdpNNX^#`sndDVS)(X6_qi2|MgKIKuGR$GRfR((pYn26N_V*O!FBK6JrL) zIlBF)jEI-?XYnHj^{gAJGTsDng=L79i^`}c8TEYJ`=r&FGalk%pw@JEk4z`_q0z9B zS~ruBpE)?_*Tg72HT+`bpHadzINbdDwdJ`mV*;CCbi6{=hD%NoDnl5RXMU)W*_-!Gf^SBm~(qq4}DIjSAHuU za?B+4u`rU)z&M{l6RIe{X@AKl(4&7;n(6w0V3j z_`-RiuL!w4g6W-CYtLom$%Z<}nzXK4bmL4qm1g)Ls3l~WT~L-eKycmZQmp&9a%3J< ze-Mb4x3dZn&DbttLICubm7?;1VB;13rgRMpPav|IOsWB&z)!)D#t-}}4+0qEU5gU7 zo;No{*x_?Nzne&_1)(dtb}24YbRjmgnNR6Fb`-WtgC%IivyW#e^iFLBz4LaAN)7z0 z5X&F~#CBL1vD8^A%`nB$$PNFSksJllNF@_Ci7P>*cJT2K(W)!(z=qTIPesXxf;zm{ z%pQ@>P-kFi5FF(#XErmf-CL6jRqzrt{^16}$x-xHJkREvlwLvTPyQa3JZk`*Fy`-X zb$HJn{#F4R-w@vHdxX=%9X90)!?q$80q$S%$*n7yS9{S*P4%p=qC71O)0BB~SPzLS9UQgw=9{@_I&f=knZVIz{LYj?HowUy| zcRLxm9-_Mc&}%r8m_Z)u$-qmzX-;p^{vIUS7O;NzFx&A-gcKu zbfAERDC~@C=(@hsr-Ym>iyBU#1Pm&?6aBmItUnZ73smWx-$W_7YZqp2jFt;ja9B^3 zjkZd!>__Q+fzK^)5SZD*JM@s3m-s=wJwx5|rg~6|PuxP#%@5WP@FdL&{nL{5PA<5E$HSDttFqbs)W=qqAfITY<$d zCkw_ZD!Jh`!M!DN8iKULAyU#e!@Z?^*xV@9uWTs+4NXwQ$iFtNAoRViqfzp zP$T$P@=9DbG5Tqwz<9vHiFN9W30K+!eW@tgQ7#G zi=CmwGRFtb7P>O|PoIyIgNZ?ECVdK0RHI-;k99N0E-;-6f!+#Fm<}0#2DR^l>^@iW zXdMCL3F<2a$A}8+UjeD~8JHaD#Dh+klsL%wY4!$dgg5q7*Z(@{L~(=d?j#m)KY)kM zQQP%>oUg?h10NA*$hQ!ZKyaM+`5h~{T4LRx`2NIm&1+{JA~0hfD$%K<)zkSV)ENziBgdA4FT^YO9=X6* zTEB_S!-QOaeU^bXjZEF(5r*$r4?vWu^SC*-49CGxU07~ZCbm9^D=B*2l|OY_YgzuN z(wm2ilP5c;E&K2X!SkQDIK!8L_9XY0c2G9MZmSSe-DF}!X0?zM$Sj*3tt3~#9h3k^ z>#uXxc@?#udga^HNsre23vNVWDfC9zfp*Q4%?alsR|P(gSIxI2*?J20=ff$snG`>H zUAXhPr%1ct5Ff;a=lurCD4^kmq@h%7?dQ$D;?p!)1nGi7aM{Osv~=Oj9lAeX@ic%g z;?W2cA;t0PQOa~YAQy`$^>D0^Vfe30`M_Grla<~?x?~cbb53xyss$Zk1V(rq6AjMa z&L+U6%~^r6zh*C|XayD?@=yts39hf$0>{i(xhb*ZWlj%o$h8bqK|m_-+Jl!nkfidy zciA4lja9Vq*Ku+X?+BN$`am`JF6tLVVz&a?dN9f3K5L5L+;EF%_o&>Qul>YBKdY!p zAuZobO&E(k94rPC0z)2!JXH4%K!TR|z#nZc5Jc7R8;{Kxo+pJYh(Ck~Hah=v15s3A zRd9+_19g&cUD=XT&Guro-zEM-cmXAb<%c$G->3SceOsdt{?7TN&=^i3o^3`w#d#uJJ;FiHjpSSbf{=Krj zh3L;aDG`Y~3g7Q<(2$P~qEGDCvP0YAhmv)WRBBivO6LC9?3(M9PT^v?J z_FAGK;L{G&{ZrOaC9k*kxHnr?Y;{)~FkziNxhfygSyPwCz<5K_=kmsU0wFrX{2LyC z^8A`VAQCH()j;zcC1r4-1;<`wpN05UGyL|4@ZER}G>{1Fr z^QY>c)33xgA2pm*bYi9u0Nf~vB2==OEqLcEHeuYHkl0j}WfHs{=U)3dJ7Z!#R`rs( zfNLS)SrKn!ZBP%Fk{7bP-mv8k7rfUh?zxY`#{@QA8eG8YUQF!`>wf4yziK z(v72Pi&E?RA^z)7vLc9ug;;kn#Uu(``} z!#m#;L?~a9aaJou%XWmJ53p~^XLfKI|AB3_-u+$2@GFeTj*`cB0HAv&Rf-wBD3n*H zhQ9>q`&WM+wry`x6_wY*FJQclK42(@do7el(8cyzW*X4Gm?Z&FRJBFK47y<#vRm6F z><|TGat+XSa^UthJhCvpg{@>*!m6R=C>@m$P8<|%BJM6%5+Ro>pnn=N_KOR=Mpow^ zQ2+9}L*5c~eiJ-X0rAsZ%xS;gd~&SwJgFvaSj1&j&}n_q5zk_ zv|TRoV`lK%G|arRIT4JQ2UM4U~y4M~9`h2c$nFo#vOQNR+;nJa98vZl3ix1dr>a%i$& za-9Src6yrSt#qe}{8&{YW6!g^`@A5BYaAI|$`EKFre0vj|%G&o#3nF_@Ut1`nvOxyI81fz0%rk zr~m88a3$gp6&C3o&rN0cS5k17kiRRbJ4#LEzX$5Sr;3*uC3Z3*pDVhk6`F}RXV<3X zH+YOw9sJ9%M>+7#tFHEqXFAiE=#0YKziz3xb#1)8*3^cAc=i zt+FG+!if=<9`Rd}9{=H>2hYA;kG|4l9ZmeL)=}^C@;$$)pl>IH@6e^`Xh0)~$S2tm zwbE*~X`bh?a;Oy?@i?c<&YV`Y_A@Q_A_EsT@0dEkJ(={zd+q8V89S@=FjacwZ6y|5 zL>>Nfl6GQRzbGp}p2M@S%lnyS`^n5E*-#D$yO4w?jau*MKZ=iFE~BOs<`oX^#sQT% zNmn*@3@S{r%QSLS$1CIrIX#GHHbMF0(s9PkfzrFpjN#3B;AN0cUzS2o%jmKSF1l#i zLu+VQ1yfwkidqBjN8rEmKh^98QrEPNU>IkUXJtL4vl^9LC32`09h4$oIWFcXM@1#7 z7L;+qlK1{NA8srh?XTA;&PVO?4cXKeKghR(pTo3q1I~U3Xgh7I_CsJqsc5?HG?lK! z(9)XKO1;HkXB>&a&uG@J=BuzDz5in`IABUcY+d9=;{ouoaQVE(sP$n!0&lR(+Z@{YcLl%jA#42s^_^oE4(8PlkP6RR`n(s z#vD#?7-DhG;cV?$NpI!97bQ-sF(}3i*c=rlac#aahms*BB(A3$;Hn@z7l)llQnie% zEZVD_hyVTiAos=Vb9uM;r4xPQw#CBL@7vJTL!m7{l09K}?x_YfeV4)Z)G3nL32f)) zAdV9uQ^Ud&!rna?Y~S37H0kf^wdG)ZA0+J(dd>;Qr5F@C-js~iso0NQ-6`zWH#7Lf zr_aEvtZZjJL`hZMxETU7t5`<7kenBJoyW9iRwmfI)Ee@7`%?N=YDj6vvA)CMy$EdUHrsS znqlp?ip-ve8H3*vSEl^jJR5ysDQ7I=iGFvx*4y9yQO$uZne$$x;gm}CsY^rh9GcUL zuYv=(06dbvy&&lNg3>szs7c%-zrnbArAsm7Y^rPGW*@w|k#7*idSFhVJC)8As8679 zLJ>1+swl>Nnz$il(_WP{Cl62goD;%B@W$RhED-4G6W8~B66zC?^G~Qx{%Ml>TBK&j z41um`H7NW7tj6}N?@#Z!D=V-C=~w68<5N97JDdG3`j(vy&P=OzXA>P#m+&!e85V2) z3NuK_n1L5OMaR%qm_tlU`bWe+*7}y-$?Kiitx#3>Q(6rQEQf@2{6Rf`Qp;X<)_C_c zIhmPcQ+L5-n1O5G`B_i7uW;Yt-WDiRJkpb2j#kjuU$EF2r_Te&{r|*#g3(fa8E%I+ zI~ejf7p>FGDBC*#l#U!rMNFSZJ=rTPoC7j2?g)%nK1+H^+qRN4$b>7ElnG|6Fe7jt z_&g54TKaJ}6GV)_&!korna#o88<;QoxLBHJf=81g=7H-iv#!;Rd`z(~Vi77f+&qox z6Vz`zrrBNn~~21Y@GA>B{yrt^c?A&WmNIP04w8iA4v)##R z<|ka31nS__lwI;u+LA4XEsP)3r*uqtsIxa*BV29QUhVOy<|*d=Rryijilc|=0?g;L z-O>woGa8*gCqIPx_YT_4m~r=N6Jv+_r4BsnZa(xreB;ZKsLxk&%yPg@H`jHQG@TlZ z={9qQ(+E`N(iCX_f_+Y9sK7#+a##e)j5)t|Z+&$%flq z7rk#;ge3J@EZO_C?-Pf8ILKWhD2*EPnca$>AZ^{ZA#YG`2T>uMigByHQZjsII?h8M zZ~dhwrBP*o*e_)nw3?!S;oDBO<)R61hHGLECiNX>bJY(BJ!~pFrVcuyOUq^vb~nVn zNnuZof3e?yl+*LK_Em}IzQ_%8t@|+O+AHt?Go$EcWWV8m?yqlKw|q@`(q$Uv8(h@W21w zIK*1~6L+Y0q3qTwy6?|3)OD+qY4k|nZ1iR!u?BvGekZAXV)TES2@iTorS5t6qcZT> zxEEUU&_CDh_HElQ`GkLeM@rVr?Hd^Te2ZG2v3__}8u1}dU_|8{#qSR8>hLdzrEmZ1 zD|YFANZ?h%8jT8G+)M%?_31Rd)_(idW4?=JFTY>In3G)e*{g_WIy>Wl;3*J~8ne$< z+NO+Ym9|hV3QD84wY*l99Ya#aUCq&w_2&I;kh^Rl)GyggBnX6PH`_Aap$E*Y=4xXF znYlVQ`%rKSlvo^C^gm(5Nj~8qE{jlunv=-h3_U0F-d`>`SUCcx$|f`T``#|-ZM~3X z@Dk^IneF44BiqdFUL^v?vv_+8H8Kt&SS*P=l-ZPfCYfYRmoPl-#&<%0)AalI`rXF{ z-BuGehVtk%1pXXR-Lr(~?^mmmbT3)iQk$H=>*>zSankKk{0V-M1QLF(mjtee_0 zH$^P%-Z$Ke^sB11xeOvT^6f0S3C;jRPO>TD49#pSYU>!E8{BCgNkyP{!y}VX_d4XP zoDX9&te)aG=p1yRwg0XoOcC@j*7*aORrFD)KzEigCXPe$;`(V>1SLVbPA6Cm0S%6v zH#%wyxI%lm>IOuh)KQzTRT!m#i?&o;aoiJ~WB(fzbhk1*$PyiErF->!mdoYe^G3bw zlwlw|$fU$}$hvFvoaWtdJREz_I9lGtSHk+BlXQ4FNr+}?T|r(g1OIkKjPZ+j6jrJ*(NIi+>hxPZTwdZ0b4g#+9T;qSNLkw?6oRt}$u2$c<2oiPl4d2|F4Lr7* z22(cd>t&N{lo@qPaVc4TQ*95^r36b?N;}QC0`qc;){5ylK$BQPsNq@dC3QO-M-o&r z6Yjp@0EfNy6?p+$!zj&iedn$VNY!f&(qpJx_6(Np!hoaOS1Y&t9tBn8pevYGl`kH{ zd4yC<%0QikGfT2^qLI`<^w-3Q;m@*1sXlC3&&o&l9J7W1hxB8`x~7`(NlN0rNp-MU z_LD!ZtkqrFqd289K9=bo)7~Vd2)5RfocE`Cs%u9VPq68ZJTl^@-(ikiUNILWZQ)$Q z#}iHjE~u^+qc+S6F0e)HGp0{)0xmo-3mI34dnViOI|J3_{8}*MP3COT}4OSWNR=Zr7rw2_DyTA_W#oA6U{=CfQKAQIp#`WFnu39_+_@i{8a9*`uuf{ zwEghSyu*UcbY_{sV6x&^Gh&M7P2G=TC}Ep)LvzO7y-=qu&)1_bvi5pYD^zAlHbA2C z0NF;^gIn^enU)zarHPy2lej0tVXOT#1G}S)n&s@&KBeT zp*i_M0@Jv(kq0v1yO!t;XgnRY@c}DSwGpk0i5S6j&2z-%7(A7nxHnlr#awy1# zW93?f-fVZ!c$W9nrVnkel&A3+Stn<}Y!9Bz#!524G@~Mrjr?Q2HWnni;`C#qXf@!u zTNC3~Oj|33*>Be^urlz6N2T+iv1xziN3Ri_(00>V;OgIz3ZvDyqtuaoovW+fIFUOZWXOF-_^%X&*+u%d#r{seMC> zx1CVjxOqw@;!gWp7C`FLeCc!AykD+;Z9H2f`BMS-7?x_XQ`(MCtrUHAHxU#G#Yx4%sX@bfww;Vk8%T`Xmd!7)> zO+$1xDRIYMYe~#l4Jq}J6}+YR>rN}?O&XL7rfM#+hj~f z!uW;gU5RBk@5VsB6kn4aL{NsqYihxV5|SmXI<^0>&!e&f0I7?96pte4hoLH;e|2L?>EKUim41(S8T*-2Opphw zK!VB>-=n^NNbV9eei1~vk|ipeo%<)yjGf`nn{35exgnsibt-Cw2(~lFfNhuxp~&VT zcj*jh-H@_a3GV`6!&Y8P!XwY?>TT$t3EaO-5Prlea2iA@SsQd~dcb3DPwllXolH1% zqcE17>dUH)6r{@o__~XrA5JG6a^9LJCM~8G!}A6ddo%=j7$)=`k$gR8p;n1tmxTNI zNm>@Ba?s^NMNE|wHFf(6n+Nv7UMP=Pn-2`34N8!q;UC0!o~n~Wg`90u(9MbIXlK`; zemB%=0<0)a;ucZYq!ugWMvS$vu?hHzi>UAVH{%ZTRrIfo>6WQ7i^|qRZG+>NDkUAE zIMR;B*zn|{i2jxyh4ld9M$qZ#)2R>&>}9R=Sfd{aUp0rJ0a1Or?T@vVnHRn5mY@s< zn=6SnGE3^F9OqNcDth{=)=jaU7#tq4yPS?Nh9XhJ3aiIGTX&JRp4a~dIgpP>#dt~J zBqH85!r$$sEpc2-$a^}aN74xdshq=Ie_2o}-#^?ME^ZhMy!ejndJHil4%uh}emIl(y~xUmm>PDe5oT#yA_adeWTT#ho!n%J4ZTzGgY4xkHOc zITAi~ek+azY2K1ZLRydgt!$Y_^bshG)KU!~-oT89?rQyr4&!h*u*gx=vZ((Ju5>8W z79hxPwiZ^;2uMtF3?7f%q~b-8%#X_yk>a-R|EPM)pf-K{`z4X(l6 z-7RR);ze8Bp}4!dyS2Ckcc%miUR(=bp8a-r-u;tbnMo#@>psszFSNlORwj$DX{Z9R zH6`PWw|=Gz;pBVt_CrDmKUIFUb(#+wZD~jCwP4*stGF7rK!n_=z(5+`&#$z&ochc$ z7!fmAw9G}?R)u)ll==Z4aYuH~*U?7oCq7>f_RS(Vcom07F<@sBC5EJjvEuHu9^ zDsIIm9>pOaKZ#|O9~*#x!z!`h^{fMTl{py%o^JLGshUG^ixU0$7wsk!$4C+HU&?z7 z8~RO*g`CoI@3_My6u3zQcr6#c5mU6&zq@@jk{i^@NK3wdctC83(A-kbqEQ%rTJ zi)~ksiAI@={C+9SS%i>=pKb0|+FufW>r16g)JGticQLMCxZ*Ru>k}|vADfnLy{Yc> zT?_^kKnfU30kd)kTXnT}lp4!~K?(N%VT|-?@=w|LAIpkP$3Von}6+;)E-t88& z=R&*p>(CVk6#IvlDxT;m(Z)OmPerKy*7KQSEwa<;6k zDopb5_$U}Sr>J=0`iPN&%{Hc9oV%pKbb4Tna}y*#v^*%$w{4|*80A&9VY!VzhEaF$ zqw|BPV~KlFli119jJeY$(P=lKLYnNx?-q?JgZ>%~MMD|0re!y$&3H`i-B57t(g1U5 zS<3FvWs~*?s*o876uo%`d;DW2Yn@H&-!q*Vp2FAb^IvwuHL00Vtr&d#K6>&l)Ky10 zicGY{pDYpG#K@m*UDi)Xqp5K@7mdyFAX?)tPiKVa2aTLv4SABXuU)q%9i&@skgRyGE=n(#qlG;}sFl_qu*7xVWu+R72YX8d$_ zZ{|&2@;pbG^dyByXPEI;L5_c>i?^ibEfKrAO*=+BZ)Vou4(FRDA3fHwYol!{S^mQk zv=g7SWV)n@(3eV)s2p;$Npd)(0^-bki&j_Ki5{9MT=ign7Iw-VpSTKMLX8C%%*SAR2am1RabTrWU}BmLVZV{9C`>DD9FpGR!h z3&wxf(Zg#wYtoa>iR6ysimvJcv6%`xnZ3cCoRkI2d03k|`Mw#xI?LWOdC0>Cf+ z`@ow&e!$RRtd`D)%;s|n6Yr{Isxz(GdV9=%TzY-=gxyejZiN%yWS>wlzveQ^o5QPH z-6qnk$f-m?i1NieSg&S=-o-~|aH`V)!pX2=?$6k0T|78se+A$XE+JBel&T{{LL~hf zDxT;$UyCb>>0Pg)%g`95I}(@PV2zq7`ZCP@N195I;!5$t|ATtXFbklT{?w1|)*v--C-b&@=B0X0E5VjX%xx%N@Y(5PG zz1vGrAzCBn;!Lq6FZbk2X*q&;h(Bt(2E#SG{xFc3D_Q~YHXY2gwZ%dv8hIKQ>?@8l zLBAOLiR4WKb4BO{GPEJ+$hM$zsFbW)#^77np zscnE1Fq26a|HgM46d%JfFH%|~qKa6N)51CQf!1+Voo+lJKmoS^jyP;+oHF2z29}5& zP3~G)r)}W6&85F*>v}?PXT{nU%ff0F39_SYoVvQ%&%;jk3A2@*Hg>oqdhlcPtOB{= zU3X^+O;3%L9c_}f8BeOG_~wA>Jfrp2aJ-rqmc+NBnLgu;uR1K^s4x#Eg@}a&+Om2~ z99eA}GdH*Hl%JiC9=L2_wsMeCwlq}9G+-U49-V@4P`2Pc4jc=mPJHXREB~UE5@kse zvf_NhA|SVN26X=JdODzR#e& zVD;#bUKwR%gJD-t-Ll;{kXWB$*m96C|2AN5cIU`|VMs|K4VvK5aaK_y{SaFtV74)K z>2$tlNLnK`r?L*W0H*M7Y3 zdCEhB#nrnNi~Nok2}8`r2;htiIjrA$p}K$tvh$l&Gl_Dda4`An1K*gIphg)E!1;{2 zkxcTdbG7 z$sN8tAAvGBC(ufVW)>SdvPWg0h0iCBwg z0a|_<4%MlYr++^HU z>O7=-YBMX04_0lvUb$YMWjw3QNj~pGCdDJQvXl+dnj^o&CK2H~_ zC8c9y9q)jctsyzj$cr0DZvEHM?x`OxGwI5D_E>y!bN#O}`fQaMhcVU#cZAkuW%nio zv*rb3<5;IBh+l^~VNpvDdapkxWj4E33h|y>5K(}gRbRya7`&>UJg>?sO7KIYG9x2g zpR-$YT=XsBRLgJ{0om;e$@C)Z;GE;*rH(&SccMZKn&&FP##w~CoKdhkh;j+Pw)pxs zf^GWk`cRy`Vmz`JB0%tM0aFM(RD4G8ito+Q-}BgQuKC+xV=hEAIX(WL;5eE0cZJte zHt5pQav}=EPDIoYVxti<&=X}L&_L4uJ&qHnM20g1l6{cGD*QKDyXjF{ak_JsAb_Wdq1=gY+clwcJFZVk4I%OJ&yB=YzF&1%(UA`$+v0|#x3YDsZ? zfLshz%ysW(L{D)4yjlAv`ydys81Kk@7v`IF_aZ$kR_F@X?EXu*doCFan0dfdBl!APUVZBLZF8iS2MwByPpnED+|PPf^~e!BLn- zDL^QC&DQfDJW;U*r}>swKECn$+@88!RyV-KsRJ-gN)ct!DX=>DkT}!H5Go3&%C?I- z@__OB=N$RGSmq!GEz~iylqMG|rtB@sbYQNWNnyBooV%K*XLE^hZHv;R$9UD?=2l3UxHyj8|?{)PcF`nME0%y zRk<$DCrpK`Usm6V2wQF`=z)%@CqIP?G7dlDiXuDJ_;^p9bHLNkchK5e{%g$gSqfaR}b?My_Dt7;0;?O|M6yikf9) zx^2CVlYE*95Qjx7BLpX?ts)CIv|@YaG}eY$o}Z&|*`2%)uTdP@RM6_WY3$A%#W{9F zF4ZdCYG4*-i{xq8XM=Ow=t^E~u-UZ=VA@r`vq_AfNQG43G=O3^4Y7M}F%ItxG>uJ< z(Actfn}99?&fr`-55F%{2W?SP^2|X_d!+PM%mq?R-yHN3z=IVxADFM^&dE+^Z!a?~ z&R?)w1c;k$gJLL(BlK8{o8={Rf+}r->IRavKAdHV02d1AlM9emv)fV<94p|o>`V5m zD+p+bXJIdZhGB(XOn5A8Z!`16ov&XY@>-bY$Trz^^dZ@P*)rwYKu&Oy zS^i^p*XDU^@I(x*k~ZgK;!;}=esyqM{3MEoC5UY`XRY+UWynNWd`g8~O3EnPx%p7H z)t}k>BN9sV58KuUQrUrB{?%i9XAeZE1448WS-F8u%xL~GrJyKRasnutzat~ z?+ftd*!$!-?s@Jl7Uc;s^vq8X-|V9Gb#77JvRHXxTpiuj>vi>Y^0K6^@pP-W>RT!{ zWXKVEd4`_*r_*?svx{5=v(V z_!Vz0^U;Gxk(}0Bp7&~jd(D=E|MVt%-JFI#1atotKH6t^85P9-_xecCHlgzEyql!w z`~|hJNc_VWpCQSaz~H-Hxz}BXI-1EFi|YaXM=a|l)vg7}#@fg{&kKVs5ZdtD1b`T6wPLIjz>^c? z-96Z}%HV>QLIdzz6bZVdUo$hR)lIk!sZqV@R@lrx?UP!#4GePXV=?rHj;_puE>~j{ zIzDx4{^n?2ujgwICL*{~;=E?&+&=>R7X}j71#TPpOt#kAHECz5kcK2T?0MDjTZVbP zGLfDy_VsnV+rMiw?iv#Lc9&vw@Nn%s*gYED*q$_Tbc!eSPpM%~p{lU33E}v7Mvo-h z!WpfKqZzJ_{B5ci+R8Ku5}bV7yU8a6`>*K;fAQo8dMqsre>-<_gDVig5BUPp&Dhlx zbi)ju`M*ljpM>trIT%FCsmFL57Q1^$?LHy=dn=!o?u8z*FkfErn=v71SG(YhZdl0C zf}I#IT_8gGe4nk}PDrElVP5U`JP~|F+cbuwieS*tg<-)nuY>8Fyxv~B)DdbV@}n__ zWmRHT!nDMHd`q6(w*QiCHfG?kCN-oY`U8$Kv+*} zIr4=}lndS6jiS4aj@bZcnE+N+2+|&Gd*xs`9KibYfa-e5u`^Jq4nX^u>JceV%BXfXbxIb`MfR@wK$V(i>a1y28Cw^#S*()2Kf+0&c(?4mh6xv&O!?ntmTfjr1}Zas{x%mZ_G^1DwMD+OuU8LuWbI z2w)yj-LH|p9%{w51cLhpXvuWuJnF%Tvo14TY!4#xao|#KlR)xHS1h#4=?y!Lj%ZL&G&o#E=e}`S^K6>F?yY};-b9ZR6n(8 zy0Xa5o|j-1HWQAw!LuDI|48FS@huLbV4Mx&hk+8 z*vS@@gz^R-2s6TH%qMgZp>@P-ZLJ~5ZM8>9iZkHs-j0QE?1?wU z{zS5>^Bu)l?ydEPi~t@uigTY$1x|1IHd|CPkvsVS{Vcx5^++~-oY}vBkXRJ1Dr1k* zp((X?5jSCNJK}WI$!Q$>O3wR}))laaXtb1*x#{BeD4=H&)N=pu(V>3(qNEVA-Nr>7 zG*c`IW0l08$sD#c2JZ%x~9g1E;VsLuj{5zaW!);snk_lSK`=s+^DJ_(g5g&X#lYQ!-9y z(Up(K-FyVV?~gu!uS&lbmpyyr?-py>g$2xCwmE}shgv#@9iUzv$19dN%KNUQzi2zHdq7oC}{<6IWFKX2cV$yZLFqF+5b=n9je_ye@D6WqlpBz0(+!D9_$T-L?; z7v2zN^$qgoL~Qv44K?f>pDL{Gr5cnIb?pf-7{2t|CdzFk5tn5 zp2j)4fEVPeqhUTtzI}z%Oc0Y9=fY|eyr-!qFy+=wEA(A_xkhq{GdW@Vr`xyVZ>gB4 zNd;WUem+udUh-|-;!`QGM8s>l+6$Yt#&$=wqgW!6$DZ}u$hpi`;HuI-wPr3j(G!jS@RfG`;k#N*XGQs#ha_5A#8{ee zP9}tOy_;s(kBZ`|!cxNoC4x5jY#xQHk)QBQqK$!*&23B{&KQH_%t7X8rUj z<_gAG!*5i__Lfk=L2K^y5gv(tpif-K4~?xVqK?p$(u`Dvl=~H_cq8Si=c>4H-yZ&8 z7lY%bZuz(klOW^5x=63(;v!u%F)o}LrSPFyMi~kVD`Kkk`97W`ZPzKTvYvQH3Wvlq zYjLN@qyy@;RyjWjwh2)C=m3!x7 zwha?FkQ<)_6HTRv2T~D!a(8N@pY|lVFR@tgLB4yzhIO26GjK^{<|2MjGVk}ZD$Q+W zN`2O4QU3i?+r~pui{a(Bxd>W0CizO;U8{B83gbdSv&iS`q>byhPPGEsN^Bdt((wip z^GFKZ`M=k@;%s?$?imS!L1O16<&`Y48`>Nt)FBIP)zJVEeyr^Lmf7@T?&dKWrNk4R zQDNduoUxCmCxJ>wXxEet$3?F8)5{SCc2RyWw9c+5JVA#5xcMzRyY9TN%_%wI*u;#R z=eDYrwf*j>w3pgQ$K?6WKQMj#<-zH`)O5q+dHVH+jjW^Qf4!aOK=>Fm(#bUiT>dBa zgUKlaVca}{Q)&}-`yrO`Yx9|)_Eh&YIQD$PJ35M|zg{B--t|j5d@E#l*rRI|$|BHA zH{0PF{s;-Y7Fk&^e|{t`MtfP<8e3Z%f$zDYVU2-c?SvJg_8GZ%*j`sHd%wBNkq6jb z9d_&^^f%vE!)4te8QSiqOJ#S{84>Z1Q`Y0r2Z>?6H!C?NzFq%ikxWfl$DJiqvJKu(VRm}(w|jy@1$$PR@koMNWe%Uq*pJIP92B*qGg$-LKwky;G$m3yE7=P9DKrtA!m=27A!YBzA!xO=K8o`?3wL^j4l5 zhH8I;>QJRR8{5G2sv$VdL02KC|RivP$OY5O!j5)i#UP!Q} zEZz8c>&MnB5d~GG(H^O$5Zj8xmjV}@f12YgsJwr4kJ^ci;E;E2@YZBm5Q7bjk{w4o+&$* z#{&oOk^_lUoX5fUe-kd?_cK0u%W06$c*LhK3WYDFjq|=qgG>%=WMG%vIZncV&EtPb z>ys;|LY!Nd?IA-w4_ELQ4fB)<97W%+2!nuoB}hVOIJ9^EKSsMlO;O-|KVAFl4i#_TI&qG5<5;fF_Ut^a|yaAewQzu zWusVAcR5jD=}iYo&pfmk1%5JyiDop8<5pv!3^!w{PVvEt{)XV|(Wi@ZNuuH7D zfR~%nT$BWJilhinYZx}9Ek?g&Mo;2_j6rM0u`GR*xtNXW0w$*OzW4M59E%LYVYT6+ z4(V$i@avnh9vZ8UoupTawM^4 zzTFi%1DD~+*)xdJ*|D5E^hDf}Md;33v0L3JwBVrNY7Y4pr3a7aoHXxYgiw1J<}8C9 z9o19mQ7h~be_;#!Q@mZ&{VD!m<Utr~Rt3R^7MSKwN0EX6nV@Z{W+Rqxjx(27%%cFso)8--h`6RUKh;^dsJ}Cz z5?FZQJYOIe)c!>tqoeQQ@#kdj%^vkx_wCAgxby+RYwP!)dT0MVahiPpsJx0`5NH2y z9Ig{1%wjh#79PdINA1%TPu^#S2m5Ur{La~x)%S=KYB(I&ENLqKlCtE`>AOHL@Z;ixYos*>Wc=HAr+E)tWc03W6~ z8?*iv&WW$Z`K=h1xipMAp1tEMa__KluW+oGNH3;Q(rlBiM>fnA<~_+6wI)Pqp`aQI zwCx&OF&Lhaj2fzNiWsUt4=Pf9I)GWlu$|*Hzo%x_J*Ljo1vP{IG@Z!MwlQ`30y|!!wV7Q)IQ5&fXG$(qr(xoR&{3qZR#M)j0bi;o zp<>)hdPg~N!WC+~_xbwpK|cS0F;bppsF*pV@f6!fUlp(ET8dXu9dfu@`&TD!SHRh& zk5NFMqFp5U{0iLp^yYY|(NRcV>t45^8%cNLs+>CPX?3f>4?1qj>BbOou)k@ixQ`E& z&r(~T&jBTvaoKlSb+n1{t~vX9E+9FCHyK%M-4(dcVVW0Rd_57ea;14}OVU?C&0~48 zA8M?wQ|67dxd6QDd&II|fRY$T!(3zgNPHF+2$duc46O zmRJkLEY$8*ALJrc-)wN-Qun+j!Y{*S6Zs!W+RWKgj=0TAzEUNA9ylDN&t>Y1}P6gM$a+p({_#J*qu=?So# zkjE0|JuKLY=1!5uKfdxs0o;IAu`v$OHh8@6Q{)%^oq6DdGZwO&U_n!#HhwH-)vwk> zb?5;_#%kY%1Vrz#Eq%323E53;*6J>1jtWjN7KmeS<^LZ3jES|R_SBDFOzbj zoUB#W(=(^kRt^i8t>Tt6`(PaGb1@?n3P7=(`?xRG@U6v)%3)SNA4^+2psm|j*Y2+9 zIq?Pu1f*_@^kZY0*Jd(H+Eyk>bR+XS67a4!x3O_)(f^YK6Z_$eUjrh%L14>q3G$b` z)yRICiP>sCejS~gqnE6lZQZG`g^#|C__mtI;ujIfOd5lT5q_`b&PrnNG=OxL{~+Kcki&_ZMVEt-x18 z2@kThSWS4ra2W3T($q8O-yTUWe552k=Ek70KUpSHu>`YH(2OiyP#9&Q6QHl2Z=2E- z@UL*%TTR*fV6cs&!I*6jNj+>dG@l0^b20mvsBO1Znsl}+o$(L@LpA?t_a?d=+P{2# zQL|<@GJvdhEk9Xyu}*YjCR&Tos!v*rBpt!yaxh9w^E`{yO8fqo=9$l62# z{`7E{bHi!ruxRddjf6+Zf(gC$x_&(3zYd9%d_OnHL%V&pJQ>d&oMQXTBo_5(!iL=A z(Ol?4J5lnEah!ok&U(>LY2PO&G(jmdqNJ3Ic@ijdaj$Sc*k5D@K1?RV`JRkSdYEGW zeY|R8ylR3^yg8{V(+_bp1ZNJyt&f;rTIZ1kKI<5X(P{dvW|j31d@`$NGKL~B6cu3l{Uq19{vDT$|OwZf(w8&ljVQ~?-Jq@!QN;Q zKPBDxDtO57=wYo-eJ|(xSF~`s;+e53ES$2> znW5+vuVsdJC~RE|Wm#DWaJcS->vQMGtdSQpM%oJqOf+~m zm3$uYPtuNWCMif42O$e4YbcZYdxtvmWa&=nt&gVxJS@zToDQ1{soyi1yDfbzk#^(r zGd)ufcBNi8PtFl}awJcV(O-?(V^-l|f1*a)`Yov$~45;gqvk{CK5X z_?#mv?O;iu3P>t2WIZhp-ACIw6ztz)crGV!EZi*%a8 zSg*Y?Y6thgI-Is^BUdw)JZP#{-?jGbf60zp_3mpUZ&)fe zwh1r}3+-DSJ!MO76~9K`~FWp7KR3|*56g+ar_iB zT20FU7O%AsH9>Z(@_DB8^o(d#)*@axgO1OuxG`VY&Y#$CeU&(r7OU7RhZ{N*@l9cs z;s&b&s!hg2GZ1h(c(aG@pZ1_H>GvR@*$AE*G8b|s4c>}}AmsF^Avcl;kc(AKvIeyr zVt(o?0$Aw8sm2uP$UwsfqbG6u3X1JX3JKi>TJ-$bRfhP4GS-IX5Tz%iqd9swtAne- zo&^qnPx;P5;kQXtp1JtTW?Kc3(_4}r>T{@yH-A|UWG%M9Sc939UU`gpgy;zunDq2% zr<$kCOwV!4JqSqDByAf1zPft@6Zt1-Gcx2`>|S>wNTi@q%8+m`cgJ0^F_)qMcg9p* zFF17k#CFdc!_{Y)DQxcC{k0l(#sm%x-K48MDPxE@gy(HI9h@w3ZKxls37VDqx8&DN z@5vW<8&ouGJNOQF%I50zBNkaU_wZsKMT@08Q$aomchEeJ<$tiL^ znTB6IXb9b5kg~ONXlioUkoi?yX7S4$tSF!OS_8nejA+)SDPV%^T1u_nW9@g7)~NFR z)l;(Yis?1%mAi4r+{W!FW$HEe1I~gyaBu?%ZU$Ab(bhRv^=MK>5@};ABo@kIML< z)7hCaL~KAHNs7GqbLZ*Gfmg%IRcf!tcsDp!t!{*Kl9t$dH`G3fY7S`h=64c`A}Y+} z+3wop(QDp5((4|4tpJxSdc+~fLf%cr)iF=qyF+sQjRWeiT2 zDcb*G75{rOIus_gaH6IUI5GC5g&AI z9`J{1$uLP4sgkEb=l^JcZ0kE!z$`vW zdl?g!!&r>*`=c|n4K2{#P5f|Xj&-MSX)^R>J^?JXp`cdez+0v~BtcJbIK+&B;jK$G z!B|j#`8Vkufse+vghj6ST7zTQ5eYmq(%KqtMW$A<*VM%9!ZKV|I~);FvjhiG+23=3 zeT_7i^6XiCzzKu*%5&3MTENo z8Ju$b6T@s4X(}+rk1Ld)R)5E##xxsTJAPoVZWPIhTnP>VFj$-ojTy#GkX zzbS*!?oc$)(v(qQ5QQm}`*nk_gD)RaO2~3UgH;AnD)ug@EMgrVcOSAD|Ke)SaPf9) zovU zHWb%(mngJMh$SI?Z9itH-ml`aQ1O>aL7zn>qcQV1-HrY^#t=qR0&lU{)s$6(ffTg- zTnShY3~m>MUtWCdN+uhaS)$gz8WOs7;;eNhY}MNe0XbOtr3^;YC0bL{4Ur>AQb>aL zwav>*cWS=4Vy2+4%@ht>hLa1kwqGCe2GcJd%-W{=IkIXH2Ih>`)V=s`$bXUfW zFa|PtdC~>0L1%x`0U8xFoC@b{26ehMZ6tNzAY8@HtdWCSaiFehKe|skVu!BY?tOm~ zO(}|Vl^DA`b60<~ThsT_@IYONOw=jndrWL7hh~t4{>Dmnr+y0eEb96&E^c_26QQ*> zN;k&(gsR4=lw0yV-!HZA!o{z68t){+*jvBE@_tkAkJil30KR}86hX?l;gXR(r zvoY7{NG?o+bZ(IMI_syK;x03U7C=pEV~Pru!?Hij7%EWW8F|WTD0gx6nWX`-vo|L-#G&yalTCe8gSCv(dXe+mgiJaOnZRv$ z)52$be`_wS68c#uRY2Y>hc<-n`U>T?+<36`hXjM7Qo*Tmjl_E&B3e4A%Ly$@Kl<>8 z`!{RVnJg7+Gi)aL3gai4+RH7VaJuc9P>ZIxC&wx%+;}&>rWde;&Aw7f9J66HVQu2| zmrSqYap?78>vdT{C->i|h|m9Zf;T;;o$lothAsa`&QR4IbOtlz_)0O4&vCxd-1>P; zHj~uVoiKR3Ucz@XK+5c*^s>o@?Sh3xt+2`*Nwgg3z;j@`goBy#r>P0z5f}0M;`C_F zJl!yPR9{FIB`_`?SP?&(K0l>)ir(ELNhHzh8}d^{mmytAC5ByC%Km<7lImP$7!0^nyu7t zRQiG|{UlNbvZdw1p=v4evVOnpb2cLQ9nc?=h86#I{@`@B@Mr9`-k})+TG5PoQXe}v zev2uIyT=^5Tz*R#s1qyoPg1vK+BGwGy4al1FyhF-1EBEuvAm9uU4v!+@iaae$9&O*6neqxFK0mWPHdiU;Na#S(gK==_9OtI4Ozhg{ z0=swQgIX*ZT&2{mbQuEO-Qxer7psWowQ$PCiqQ{zEjoD>IvS`0E)KhIG} z^S-0107b28$|#_qNHg*s%V6(64+|#JXt4m~Ww$=~Bau2zebKK9*NQKW8z2! z<2GxD_2bg^-~jX6X2hoaP2{rF7{oQ9kK*Pf#o%?+@7sM$@c&zj1m}kyU~qu_U+e#7 z;c(WjsnZsnMRC>dQo>hNX5I3@V=kr->G0Y-_P77=8~qstkg8=+Y(}iz90fv`&{DSN z+TznINB<7jHYhJDSDsg3+fsNnoR(MZym0j=Smlj!$e~`B=H`zx9s$>-%1iYFR+ZU< z94XiJq_k$UQF)eNv|TH^aC8oF9j`dl!%gW6_c@e2m1<0M6ZoBMA1GC6p|@8vBr5;6 zNLKya3CR(@Ji*ILCQv+7hM~tzidZuMMZF==0SX|~hDCu(2ty)D||+} zBwb}VB!Q@5`tURv&A!I!o0!z)AHQe#qM=niG#Sy^43?1A%o!dP;sbc_Mro-fKN6I2 zNTA!f^PZTc-6k@`uhiw%2-D%uOG?@v^i>3gs?{)zaiEz+Ec`Rzl9I#ID(k|I#;$kp412?VSnua$ zHAEitc~=89VNZ>U8S{s}lXQ1qjS3N(yp>aX^2(rEuVp>G^xcGNAYt?1)S%RiCEl8H zgF47XhLj+MIal4NSAu!J%rRBG&o~WFAF&*w?Ct|BZt3#SysRF&ygJ`<(dQ?hL@^d& z3@@&l!^n5CYBbYkhHcL*K6XD@5%QbkL;t>HdN48p) zN)pT?r#CEv9IFwzySspcs5>2AM==aV>08cD#_w(?4w{lwnZsNoOZ1gDVD)N9hJ22p z`m9_GrCK28@<;vcx(d2NQi8M{X}aWj{W5hMq-ug$3EFZM@FcM9$nRWd9qYRZ>yP$) zvf<*7B1P_%Ic+W5EqF`6yfgMur<(X!BnO(#KhYqYkws{+nA>;w#*LbYBdRO!BED~I zGEOg`W(l^zO_>{$|C6W5LLgCHuJWri&;X=Gr7xw))6Z9)`^F5$g=rrWC7cX*`gnAy z_wC=OoMf8}SnhmjpsTGPib0i5bTQDbrjwkAI1NuUkED3u{Ze}nlJ2`%^UY6Rb(P3z zgU3Su!gRk)r2en42r>`B8N5R7YAO!0a||vKNwU{o?YH(P{PB}8Jx`KXy?V14f;@?D zEz}U{<)j*ZTz!+!+1%O~|MtTQqJ${*sF=IDNCIGgtkb-ZYjN}Cqj$I~dKXW5<5PV2(mEqg@oZJQE+s|yj$6z{JPN;q{Q3wrQ*t;WPF zo^u?Sz4lQQe;QUWYq`O(_?$4~;o_G!%^;K>K+&ya;2Z94cSZ6WUa4t% zXYV!p_$~5q*#=r=97cR6`bO>DQyXaD>{PmfBl#A#I%TPS5@bY*WIE~=A$%{;hP1Zx zSMS9k*IEMy)V`2*ytE))P5@i>1|Qj16Z+^=GWZtf66xsoo$QOkqod9ib~4peX_XHH z3#!^}n8IU$Jk5m6MnN)yIiQ8G(N2I(Ke&9xJoaZr$@E=t)4qu0ItdMHi{Dn``8&%J znzLqBOd^|Ls zG(8y2%a0b*7R=pv=xgT4enY*7C!gKrJsddhl-oJwe;( zWkDvv70|XU@!0N~;U$ivDEOmPLs5`MFxLv-*6#8|Q?Xus?ueBK0{ zupZ_c_!rD!7|E5hB=F`aTU?T)n+QEv%{Afr%jS*p=GFn`hk#HoQIEVq*3G5j4SAb# z#d#9JAYGB^V^xpb(`ActBCa=U7<3N4Tgf8HQH}o@PsZY%v*C5n69?1PX7B@>I*)=G zi^Vw3@a{8*-{A%aC^H%-?KTytx@)~T&3@i)6)0UwdOpU@Y5Bo}Dm@LY+sU(-wRS1H z!7iC07n9K_GueYtywP;Lawk9?#-hqoK*nd{c*476AE=f1A47HbxQnxa?|%#K%WGg0 zDK7TqRein@_7>BlZqUo&|HkzxOL1T9AfR7Ski_nX%E=1hh0c$sy>EL zk7p5B#FdZ<+YA@%2jA388j`b&oa~i9j^3Bq*Y%FTQfzQe=WI*h6xpFd9^A>j1bXNZ zmAy#=(oqoS3VTN12rWN;u+Tj>E+mi@aP(S^MiA#XG>RRuCgLqNR&*D+1EGtP@*_g? z^>drG5?0R-JnGyN?9^&p9H%-eLb;qC>7xK3d3Yz~b2Pa^vnD(MW zQ2eyA5Rd>_IfEj=@}0RpT2MCL40~T7V5B`-{3!M8F$Px}b{RB(Re^Asz^>VBGMNzhw%52)8xwa<~JtptiU1^LhlD0Ur-1v{Yr1r`Ur zK_%d-)tL8KQ0%i-cd=?$X;Uc#X+~xmxPE#hR3)HgDj*cVJEqHw-Ba2OV?_4Ry3)Po zFbj~8ccJB#$kOx=OMraAAU1#FRB#AP&YQvUYl`xZ(QY|B(xjo_6f#2VNbss$9&er7 zLRBLjXY_m;>*N9j#VStBKrGF<=YN#@6L)YeT}hp}h1e@nL6nK_skvq68;E4Z)l_Yc z(j?}MiZ~=^g@DElHWdyHo+JFG;bH0G=%hJI3csI%^##Fy42=?!;nf9?KoF`caj8Jk zhr<9FdObj)i!_jr=21{}Fa@+w@*ssV!*6hvm+Uqx35Es2tzX){dvWcym>$0$}4Wym#OeTh}elh}! zn$0XccuM^#Lf5`l`b*g|#c9C;ettxUPs8H20Q`m~)8MTaAV7%_lfUM74E(s9S`e4j zGSO#F%w7mixX5}%iB3RH<}RT4A;G)TMnEGa&vPjOAV}VLq!!3$vQ~`LjvXoD-}Rqt zas0ghdHcV$9$W$-^sx})P_#{V250RE2cAy<;^d>Fq>a8U9riHzkV&KI5@fl2^oK-R zjm6OV<*>(BuK)=Exzl3}7&bCcq;CF~r7nbB;i_7t!A-O+4uEU!;63;KLu^n{^;J z_jk$4jBGQ@6GRBI_(sKM9hOPBkwKCpF^nvk;)tK#OF#J=Yu-}$3FS?e}J9t_Jr6YlXH zkQo2gCDGUmCd;S(q*`+#?8Uk_ocdan!oynLPho#y!3>Dn;M;qSsU}a7Yp>h|dF&T{ z0W{S$Z-Dd0JvUDL>d@YjJu|fEqXGM_{kO=)X{OW_v|8V*`=fmm?Hq+{OiE~#psqy z=$7nje(5Wu=NkJN-G5i<`Y^-kc4gWxMd+dA6Qk$q=|d2#y?QV9>qcnG=%d0vnQQY` z(4c8=?ALSbUCw987Y*_c{%7g6C#FD%|Lu?7I>1S(7M#j4V)$J9dh9ufeUBIFQ~KOj zx*cTli)ZrLfq&oJdi(A5dVdnu{b}WQX!YFh|BQTh_1W>o_;pP5LFTth1=3tSN91hF z8NH%`b!@~i`Q1dlM+n_eJ?~=oj2OK~`N!AzUf8{VZhgH8Eq1>g?Zg4z!9yX(JU9L2 z1yMa`9mwcM;>>mV{nfk^D5;DINOC~?{18jWik;$=G_z!z%fC%6vyoV2W%S1gbM$DV>=ut1P!MQigm<~x zC?<$&z$13{^3M<9O^D0ey{Bw`ou7OP+?e34kY)sp%t}r7aT_I4ha_Tp6|Est7j}9C#k~O|682qjhZq(Zx)ng>#}2ovl3m;ulis zOhff$Y??R`_tc~$fpwXHkM*yf)hGTH_x&=qY1}~<6KYRaH`SAOP$eUP`^MPh;`Vph zFOzO!@^?aMW{k`P)on}0jJetmCq7-K)%pfenM;6by~5}TS0g};aXhi;U1K--{hja@?0Lp|>6@L(H#tuJ<`XE3Lcht*^wMa)%GXo;}a& zexdjN_YPs0>(d_upy(Cq({t(?q^s$?MfF<|B7&CuU)7)>iFTDH(eoTTQTO{| zGegfce9yoCL~ZO>ans?j|NHREO3ud$vhVct=lQhX_5)bY)6?gs|LNH`M<*v0Sr>Qr z`-(4?ufYH4Tp+z#iW=QEI~`tz7GlcS_%n|hH=VEcInEonW|X?2dd(tXU0mfysLTfu ztXwzsJbmt7VzjWJK=|cJcB+QosSj%NuV^5raN{J%A=}U}U9SqI#7PLMfv;CpMnML~ zfL$Z$U_SZz)wKMt&Y!mb9i=f`9kV3e7m-ujRre>6eu?(DQl}v zAV@4kWT~UVC!mrsntztQkeLW-6b|acwdv4gny7rW2C2I|j~6h(=JvFNZ60?R6H5n0 zac=!`VN>e0rI(bA3V5UDur%n0vWwOzV`!#HBwqyD&CwpshH=8XC3$RqU5d%;2;Fu8 zTQF03gTj0~n*JByWs)Vc?`XRC{HYa?y3y7NpDyiPaXc zP|-O4sH$}yO-sOp_fizwZ*Ah^8Vt+aaaN}x(4KHyeBQwGmv355 zB9>LcHV5ml6n@uqELwtY`Db5@X4=mC<5}?8u(eihWaP?W3imkDGXbe|Z*AEW9=O0>Irct=ODoH_$OyZZS{&OxyUSe~Ql`7U4 zAa671w9_(&9w~Fr3+J5cH;=JNJRFg@t40Y^0&*K&n$pa?+Q>c_vt)b;dI}A0Y6kfP zAPz=Ia?%lRbO`HQsU?H`#QW~$yOu6a#`b6jVogdJ1he~0qj(YMAqJ7Z&cJ(vPVyT2 zcBjSFp!3w_=6?R`=pVu zp&0AMlQH;eNw(k)6tDi z_HOouiC$laV~PBa&(7KpV?Sqw#)v*a>29gX|Lp&!8z`^l-VaMlDqi^>WqAr-E5{!Z zby45%<(~0=ZWFx+4dA@gd{%s&E7}z8>m1-KR~d_0h9^u{CEs zxjCnnetfAaRuBaFwKZXXuCPHzZAQsxhdT#E&C1Wa&fcc2j0G`Y-TYHnJT2#wArzh_CAde%{N z*cgix3JF$AP_Mb#YrhfVNfPDNN*#cRzm9G7HD~gNo30n7GEqN()d@}xJ3v>Dy(lcTF)z8D(J)r;&GW+ zp(`!+YQ<`9t_`TZVy0E5rm3!GD^D)-jv&Nn;bvCn8lXYW*k0#JCa3ADKh?1+sdeO5 zxqwSa<-(AFwd{ruFN&%_F=7e0&$yZ^Q%v*zqpJ%qqxjoOY~FC3HFwbPZ=JAl_G+W*DMFLl@COj{XqR;Zn63 z-IXjRUxbRnOTc74{AHer-IK;eTaVMXuwT_WS6{S^zylpj<2-f9?jGN0cq#={<;gQi zYFceUXI~unA8ESi>sqco9X$2iLiq@yIz)bMQ#f&Jj*GJ7iCj3r7M%y>F=XM08x@it z==#Oi+VEtN-`VmVvTPI?4bRguzBwO3XwW|49UfSPh9p?Y_d7GeT7^+q#?s2q73fg9CF~v_CTH4}rI9pX?VrlC1nU7Mk zM1mku>F9@2PvUMGTlqdz{_>Pu(p3?wvt*6~oWhnN9&B5^)fGl`6GwOcB#-;|_ZVgf zO4E=>40Ag~6kG^L*jLZ(1W`@i|3ix!N+S)T#idz=OmcqNt>u{5GX|(H%%QM$O z(Eifz#=#%__Gs`gEa|cmvwSULF~3G-RaJd%wv7}Ib(%CsE%>bCerxBh0}qp|6w8iq z2&MYL2N5Yw^VDcx^E@i+C>2_d`2f{y2}kPF2Lrm7!-TX9K#!Ywlo&Y{{G+d| zz$L|jkFA80bv*xD-j4nf=t4{Za!bdj0J^EbF`R{&{ z=*`R@Fzxr)@Ltq&|M&Ux>dOyo^Y@j&=dID_IM{!FVH>*pti=Ddc<@^p9*l49*8_Vuxar|j%B_z+;MmzKQ7 zC{DvFCb2j{cr#f3=Jt30f;N3VG1;So1!UyEnaH%P^G|3*Q^U2Y#%oAv$G(kNuQduy z+MJm?N^nxs72sVG)}CLd9*sD)Q{O_>=XvDxVu(KJVD!l?@;PPKX%X`Pahh=agQ}N2 zCsKipW3$h3I78Huv_H(_9N#Mt+C5i+NHB&=PG6#J`YX^@{@sXs8 zao?6g1GYMyDBJ`r{?w@H<@RaIeN@qY>18Qn z_js*S;N6^(HjIy|V%9QXtMBgjTQ9b|@@v?=+sY%z8vcs?x*MLFZTsqZHtVTgd)*qr+iAxw_<4ENe~?jL=J8ccdUDr z%R{ygGx`zN$~JKX|M>mZz0Pef;(v?xn{|;-$Z+{P{DX5{gyga9hCR)jUEeu@Mh(mh zWX*tP#z}&$;i#B2La8_hQ=I%B5oOJ{jNxWAZAe;nh!QsyO_q2tKh*NXepKol01j>JTX92ROxl zJzWbxx}$pVf5@hr?jifP%6v3QWQ$yh&#uy~oVDN?bq4D>WUhZmjZI@WhK?iVcvgh%8{_@T-c@zA$f|{QN%MjGk`blNH6kS}X1{5*Py>9K zzGZkIN4;0K0%?cBY;n;eQ!A(2Vvav_e`rI~d26nRY~1v|2BDDHQstx#9bNz7T0b8S z3#`O}8d6{acb1kb0hs~N-j7PG_FT_euT1}ylE5%WJiKY1TA2Pj`4h`JL`EY@`kl4v zBnT2gw`#^W3a*9PM5^*9H{rm*?9#DdNv%XApY+l(M%{K0|F(Qp zyxn7M%CL^vDXa@j#l5-`Ae0X}%^G>0AGfCq-{5uE*?gE3h_o_uw#G zkD}v)!U}frWyy`@2iKqN?|R@hYzcMdCx4~BLR)0C*Pi4m8ECcl~<6p z0V)`=&o+>u;Cm2|l^&rVND4`=izauBn}BxS+Vyso^rH`0*&Ltbmb|eZ_?-r$u9#Nnp%O*(c{HnB8<;Y; ztByHhD_TuSLq!vmbdgt9*@vQV3MIc2k}XV($!VWkXcbeN0_;67iEtCl7g7cG2w4fR zsUWzBh-%lduo9UjP_B`&W!)E{gv;32rjbPPXE0V;;h}+HA z295mAhLzRxV{Z~p+Y3ysiZ5$6FCYd zSBp6`3J1{XR~h9`q7igo9nz$u=z6_J=xX*lQ#GNo>*f|70Sf8cr%BR{cY2wojbZLF z=_ukVLF>HUAxsGf$pfRdeDnK$U9vJdX=p=Ox49=PI6BFin!7d^V>I}jX)fcoQOeQ* z<>x0&veXPiA^t?@9zji|#FTMpWQ2V$Z&g^*=9jpQH}533r=Fq7O&i)_rR5Zqfgi$f zOsk(n#F<%T?Vh;pQQ)G|zFTTG5nqmR>MI{Y{!!a>2MNxJwu}i?G$=^R z8SH}1k(>jV0<$uYgujH@aMbg*dTq>Q99og*qm z647a9X`w;Wi)5fw*{FsnG1MOIzLItj+d<>`j7*4gOdK%mo@Yct+Q~i+=Cz0-qZKdf zL0(z#n&oE&y008KHhA}yS3ISL&uU?F!dUU%|pk2JYdpDZ%hkX z7!9L?2xb&BBcY~dc{Y{!*Bg9gh3ar{r~5tp_*8A`5Gq)>&mV%z=Q~3z?y~TFd);A@ zkfmT_S6N)md}{@PYa$87*YsHPeBW9MQ~th_I6s#IU(7|+B8Hm&^vVhu0tjOuR>w!e zI=6Ql7bfVbfe9c`VAmI8a{lq;tP-k_4k_zwLPZ7tppKIh@{L37`OT?z4_BT<l4zAm#zDNd;wcO0^0=hf4;7fk=P`UWSD6gFj9ce5sa#)x<|s8STMha zg*BvU##r`hMLg|~F+-(g(p4`=fjdn{? zL`m8#cIZ!1{AOHI6d^zY8^~LSRJXuR4$W5@CJ0#b7hsg`q&td$N=}~ib_IZq{u=I) zTpw6mRnuBnhGX=A2Xvt+mlWmU+PI%M^qJ06WN3%pEH3iv?9N|?t6=39TJSh1m>q<0 zjQ9x4$-rS2IK)s3oUzum*q!Krsp!~Jp{HIA*K!xZ78;xBySB5DE+*Jvrwg)IU#Ga~ zAfUXUtgH=4aJUajmNiaBY-C=T^p?)ElSs8Hi^&E&G!D>#_$ZgQ1f zO&o~is@(N1&@7i;OKKY3C+gd=2LEcElbY*Up##;&%YtP0A;c}RvbX`ZB7Ms^jL34} zC-6iK!U<(#9!ba~;m(v#6Cw`{b!Z51XM}?FKq1%&`nPWF=ANL2@3!3ipPQ;m=*s-=6HWsyRa320ihQ?Nx@3@w1smQ9)TW z8OP-jJx1D+Lk+Qd{R#Z)jk^HmE3G9D0IgKx={QD{#B{B?3S!$Fw-&?Cyje8+Ulf`q z_E24>`9kQ{G`=5y;JSRT!_C0Zm!@pnZ$4KoG=0MJ^EQbiK! z3Cv^6NV4p~xOn5saJRH*!tf&6ADs}YCOYBWe@^JxmKN0hUm1aA?wH@>zw$xivNz@{ zc%RA615Teh53TH8AKo^h;*^ekl>v(|xP}0a)G6!=o#?Z#Wm(Pn4ZMs%s` <2~F@ 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

ax|dwCe54H8p4If`33 zHmygYtPIS)TmPCgQI7G1;mu^irz#8t4rb&V@qnef=zYXPaJ1T&Z8PR0ib%VOC);tA zqR{~xHc6Pq6;e$1ae|LbZGWN#kylmMQ>8^}xl*b>Pl|kIx}tw!tmnQh{-Z|GB+6qfSOeCI%#+y)oRtxlD* zNZOIW>oW2wutKrFfpXPkld;zUusQI|3k5d*_jEr-WJX%78HIpbhH47Gkuy_MG`NH=0kOYXd+d2#_k`y^c6|$^Y7+Htr@=0T68tn_1AJv4 zm?bMDj3H+$&@|w$XzpY26;j3cq>qnvBFR4MdCFCV57l5l-m(4OG3+=e71l8AIxnLi z>v*@!E@s0UM7pLm+@c0Q@RI~UK*A$kY{?CmF|`p?y`ky8^~L1cCv0AR#m4@$b%w4a3k6)p;3G(8*FNyeXHp<`v3! zbqiwX;d0>&&V4`dP&nd9zpQaaI|cSD;il2GGnVt>vFWxD80<8lye_6&S8ve-rv!+Y zEy<0PZxkCtF)LEjIY<4)MIFonK!o0HN?+S}n;rhI7N_{ua;br2di9lHi_Mr!Uy#iN zf@sPi$1Nvugf))k>+Ow#Xr$kHzd~SqSH4cGm4alO=Jhuwm$NQk-HW|*DVH(N7(#m? z?K8s0k+DROh1Pca;+7zyUIZek8!;6M_l}wud>2hfhSY<_&5?_r$QZmrO_gyiyH~D~Y*}X%?m5|O=9nhzfdEW!d&s~w z*fP1nX|2J*DsKPfaCD`~aX*NxLfG=j4amJDo;a7wXc;ZHTB|COjyW<2~*j!x! zfR->0mJi|c*~%er1;-$ODYZngrr!;xOep-xsKg?fC2=fP*pY#3Z4aq*T~t>+2%BDF zv6X<8H5QMSh5-+-gDj3N3x#FE1NLqIWO5e%Vh1KyTYrDXH@SFg94RH7i)&x~B<%7q zdqN8SUw$wkW#{()hI6I0gqJh@VwoHHnq+q`TP&u+Js_fp>-l{FoR~W33%ltlHb{)^ zdo5>C8s7y7x*Ql4cuggF93PS};Thxq>>gVxB0!(`0||Kgr|hELSuQb!6>gUX8hm!u z@Wd3m#eCNzK^n-&VW@!p3k5mFm~-?Ch2am5e79vb&@OLCkrQrQi$uU-6Ac#-dmSR9 zC{twl#A0;g#hPid71ds5W!^S2Np)NmDi5Vn$jQ{*i_<*w2NP|9nyP9>%aXe42f(lV zB{0SR#$kC^GL|iARbc%UP<_{a<@JGyspss+*A%_Ta#LIh6hJ~3PU4Y@ojQ#j<=9ORZEw;KPSItcB$EY& zkBo&IXk^4LH;p`3mMMZPwlWPXrBcaVd~1-1*O9ON$i7Jv5eAnZwXUFY5%uspS-T-ydq`M7X5P=9zLvbL!Tj z?>~@sZ8-2f9)%f`6lI_4t5C(IpUvaj(jN` z`XoHF(#*?T!!H}2QZp+wSDPS*R9&>7VF@v!u0}}`hP=@0^%s$P8l0OIqcGV!w?Ta# zv1ccKKIwLDLsn2WJQZH^+}cC{T`|q9iu}-4msT`!%gWp~_fCSY*-34wmUb{|VoO1q z>%y?XT)Vq|#Nz}kWSR4J!%(z}TBeqkGFv~%Jrjl;LeG+w&Tfi!RKneqPNq4wZD+Xn zTlvNrejjE`FCTmF&Vi+Q)*(4aVgeLPgjrnfJ zEr?8PJ$-y7`K6q${|{4N8P?Vpyx9VUV!_?r-GjSJa3~sFO7Y_EZo%CNuEmSHI}|8d z+`Y7P^V|RKKKnI!?v31Y=FBTI_mRGF_(;PpBqB|2Z~=6oU_aTfYx%wPH^dAx!1v_>yb*kLYrS`6l=S6@~vYnt-i zAYP5Mhu=>NXBOja&z$|McnsuoFKPbE=B>oAhr4Idn#T8F1#WrD2eGdX+g3#|*Dx?K zWY}~m|B^vko3URc2+9q`7BH!%fo2ok^z}`HX7*GSh&&nd4w8i^`C_~ePjqphUO_jI zV`=wD-nkSnQjbazRo?;E8I=n63p9!+_Md_ejTyc4I!iR)ddE+c&O@e3W#o#^prjNY zndu3=%o^)qOVejbWn|MYyb~Z``KX;}QmQx`n7cQ}Lr9RK(wmA3&()yS)bP_*BgJ(9 zV62Oy*rq-cNcx3Z&Etz1AqhUjLR7c-M>LQzX+2=cPCvPW$Gyc$+fFyvt0V2ievn-a z#pS?6k=Ek#3HgP`-1FEor(M(Jqp~qWr0FAR(>9N@n_s5JGP$&}=P2Xcu+e=OXv5N) zjai*RqZnuLJp?8DsNYuBmS;SlgcJ<=-mL3ioBC2L-{3{;lw2eaWmSii1V?8SeTf2R-5J*OxU7&LrsyMF=3bzC9mIN8G%JJ~M*AVJaU(N~mz*CnE*o^nEG zOk(2v$hCH(+l%TYSri=~6F5n*80XvFAr>23QeTHBEhZUn?-6h2KMH38O{+iB4gVky zU?1G@;MYvxO?D*Hu~xA(cFC_jbt_|Y&18F!5*0tBiAlgDCMRbK0>#v<*1MN8XAd46aE$XX4}8LpMDLc**E=khfT#jGQVDCbcd`*qd67%wIN@r zF6nEEc0t*hK8uOS@^9qpgoMc~9WH6DT~#SwvCxyxJA;)saWQe_I3ZG47h4Ry5_vk~ zZP`YB9s+drDzU4rrEx5ZDFsfc_2d<_xCL~DhQbM1PN=CJgyx~WEjlcIOGTmO?&Pfx zXlQ#M_?TtSu-R%!o;+xLKG zCmZqMH6koTV}=XU6LkwJHg7TyN5CcLIHKCm`wUqC|4teGp zAksA1Y=_KJ%-VVO6h!7b_`tH}fROM9?Uj<6KLz<0QHawrVH-OGiIolvYW;`O0M0HM6S1)Q>-_&tV3|l6HTOb_9@6{XfK|Lu8X4cGqGIw!5Fy*L zs$jp_RV+`=La|Wu~*tC z>A0B`r_G^KGz}o7rc%>G8g^jOjw`j-A-kS36dasoWP5KAw0Skg|C(iGB$7Nc_6G9a z_2Y_7{uHnFKWEeY9#d@ky!Xf4EMGX&Ib^;_-(rk)*mw{{D~(dVn>VI$w{>B7bD37p z5F)N0`zdOKO+_g9>}DoD)cejRu1N`L1lL8{v0i0@&2wnr>B3x-75{b=W2?1YtVg%V zru>J$T`pT5+7J+Wg0rV#iVpWATB$1*@&^OGWiDtPJ z>YKeHb?BGX&_`U%fDn`E8@;v2+`cpnn-6u-bw4w8;L{JG^S?|fG|wZiX(0(hZS_f% zMo*40BaOm!-YfDAo&nrgrnEGy(r(^;2#dwC1Iip~sWpv&&|?SOyaFiLPxeE_gbfq~ zR9v>SGpigo8|0y^f``8y1&_Ogto?M|Da@xBw+^P3QV>vbj7BAC)K)5d1sagiZpr3_ z&=Yq)2j^ckg1U4H^64IWzUa5VN5cB6(*2df^DwFbtF5qtfOQWKT%v!Um~=pM&GS1? z2YeS~R52WNa!q)*fF&#&WrI&{79YcXK9^(i4re}Ivl8XreMs$z(P2G`GpR(RM*Bezhvv1kkwWp5qOf2}Z0Zp&R!xu6=c9#+$(Xcf+7(3&!2 zQq-uswn1%TBhc*VE`yUQJ5X5n8n-iGjQO=8PbcL?u_^SMrl7h#ZmOmhl>AJs1;k#r z4ja-0Y;GkPuaU3d?FG)is4Ikc+dvq}QiRPzA{sdzDnCM{l#Wz_bd#3$Qb6qE$BPRB zH9QjBU6fO$Y^PUI(UlEAGhYyv{cv|nnp$S3zVFQwvT*S?kfHvvtgIz1=`{1K{vEY5yZlH(;H`XDGNBS{Q3FFD%2o<>VgPnd#CPDC*EwNFb-$YQquKc(S`d zUOk*CsHCVleyc{4O`6=iKVg)a)O$oSYnt z4Nn`JNE`9j^;{vhh@Ku%U0q$V`a6f=F^oRVdyjuvZZ&miIVq;b--EgrGHNG66AEpPMQVHfk#(XfY-Kq+}PoCM7U~tmHU>EXg!=1CIg*g2OZ2;A#S~8Kj6!es=T(*OCh#S2E}mn*PvP4KGRsxU(Fctd#vxp0~HdE_PfB z4KV}lbgOpa!goDh`rDZG~M}TvU9UkCFPDMrDJ_BtQrjI z$~lC|pTwV{vW#r=<-t0G6mR!_9;yJ=XI06rE(~RB4pmeVzqgmn+cQ6| z8d&+73ndX7ngNFvyvQz(y%hi8;s5{>TsB^aFGKPAdo}o&5Lz|#cG0^ zwP9nn!uJH%Xm!Q7q0fXK1|L7qhh-s5=vJ@tU0LfC zcFNYPz)7B1|D1aCQu4c3_^Lbg=fA%{j|c*92s$p>ems8)Jo|E!D*lq%eV2E2aK7G* z(J^bwyZ8BvFNydqjrf3G;Dg>>_mODgx9@*1y084N_Gkb7nXO-Yv=P5O8m?|!}iTU^90i#|3;9`1RMt9eN# zGoZPaVtK{>u1(LW-zO220MT#8`2yx0GFjwGINOq~0fsewfFvJgEpMrVoVF**xXbqW zPzyBO6Ox%#_i*K#c-9U+HX6Fkpeg*ve=A!_G*@J?Alzh%m)bIjdYXW2sV-GCDnY%pg5$_W0Lq=4@yT&F3wpW$u4&hqLxsmTK&4DA$i$YQ{QZGRK(4@C#5wFqk{`k`1(d8*ywJ~aaIuGoj zPN9wr=HB4fiL{cxm^?rFBbj>g)XjZKa{10`=wJ_w`<`0+XJ*03h`glT%myGCsp%aAly+qbkzpM?3X%ks9i@!wX&#BHSm)1%qfqa&Uj^o3n2(!|$M{aHz*<<( zDD~*VVr8hC`ndxqVwq7b6Z+%y=aB+q3)u;TWswT-(@whcY-C8PYL~zVH)2fUV^wxD z7z?a~1)7Z--(%B-AjFdA<`o-?Q(Z0eXvM=u0wCtP`gfWqHU-ioCr72hueeQp-q#;y znRa#+7+%TXWG5A6^CU{H*Jv9+z+zX*ih!S(z_B+aNFoH|n2ApLj_NnljE&TEp6vg1 zTj~?SN)U(s4K9&SG`V>p`S~o@`|tJpKj`R&tSedoj|e$Pe1!cs=>ec80lw{0Jm(FC%6*fl%h9DY>q3Z91;ycTn|hhBgF3%Kk! z%C8n1^(#1td3Nq%;6;(tBxo~br@VBWq>|K@zY*63Myr*F}W!x_gX+rqwbOOJO`211LO zr18qQXWZL=r-G6N8Qn;I(W!YV?Lkjt)38ztWN)5Z5;6)5RT;K~*kCf1t0iYpmrgT} zwqFx2)2RNw_m{Kr%T7SP<{zjt8KqLS_)``GM?p^;On;5{S0c5Hp<$*ZEzKC<5c)l? z_gJ!#%6&i-QBX;T;TRsX)|-dI78bk7KlP4t@GhgCJSpZM8e_Dag@W7mg-4Vep0(cRMByG1nO0!Gp6bT;F@6ls=q!Lxod5|cVnC=iVd^;@w2Dwl)~ z@L4ALhuhl$(Hk(ZwG#x^e9KzXWIRDuD@J$p&guWOU6)zRj?J|U(2%&pZK>&O_S z4f&ZVxk&@wh>h01CN7qgQ?=~)+AOQ)*1|PI#<@x#W0=HL=3+(XE9m|jn$m&|zN zu5E2uIXIkAJ^k3&C}u=?e0r)k^I$h>pF6*c41A9CJGY?N7YMu)IQ(6* z;`<>|zz&kwQCkaqUL-6^hutkA&{Lm&-htSooxgjmltv(ELyqC~&{1+eAy?TPrjx#R zL}*K~$LPz6GEirZ`BgRu)UTDpPLC=lh|#qI2{AM1XxJz3FHjK}W$?nh6*g?TmEZ`2 zINBvnGAWz9Eve<#7`vb7H*kld)MDPp9_zCWnOT*T*G4B zlnd>k{4mm#^|+*kT3ZtcO`#g^NYrxLj)?_YuHZ0Lfiocq(>=mW-+IF?!uK__cU8kM za5~q;>Y68dXlAWf-Re}Bj0XAz3y;XEBAj2@{nq+rU8i-jiY7mFevektdztFhrrg5j zAk^_6>9Bw`YRPlO;ps*OwGEvYlnZZFbl!wdu~cPeK@tX!#Dhd1N$JJ*_F5?Uu?F>d z^d0v*&uWZ+BODglI0u*%fh<>C7N2p_`s1G|_t2z^HLw_B@?B((-VCMS{{2?Z49Kcc zJX*0J+syqr*&T>ln3>@bp5yXNiJjMf`FCyC+|y{v9-MiQ5c>2T!*?)4TI?{Nl^z{0 zbZoA5;6S$d^RF#77;rJMJPrfzw7KloGuHd!hsUffSC=N+TKP2J9XDzWR0DU6O$kI1@-4L?LfJm2Nt?=e)Zs9&E{S@WHMHE>tlIPvHg%B1hz zbNF^r50i5AXzu+I)zVzev9O6$Imj`+xGt6DD>QTx$Q+A4kL&Uy;xv>1T>c3%pr_k6 z1?8M7_9g-me4qAJhu%(A-QlPGoQ*%{XmGl{|3;6h&4stJfB<8UF3BJZHp$j?x5)Z}Z{nj(%tgx{yy_(@;`XIw47ntj^Ak*dGcK>xR{AZ-Og(c- z>U`LR^~G$s<&`=dXPzx>*u;)&*RJJaG1sKMr;#RR^a1_t=6L*g`TOHtj;V}MGt+LF zuZF`YVWc!OQuT|~rv%xK zoRg4{6nJ#~`0_pP?_Ynh`yz}R7}CHF|KFd*2VYL|J|#1+>DTj*Z&tW{8MR@Z6ju!JO2?M|9vtE)$LExDWgvJDj}1zk~_w^FrWO6f_^iJBI+A3 zC>zA|0hA|UVMK*!sI(6uJj1KwFO~k|NPVa;xcuBpXc}!jfdAYOs)s`H)DRWX&lrt* zEh*(Bi=(NZI6s$&=E7IMS|3WUk7c=Ht3&z5suPSsU4uew&B%o^ddMkJQYvM2au@;_ z`d%CX?eY9&ZVILpsR>g{*o~R=yjGs2)Op<#9NjD>EE}sW`I7rQ&MJrfNHp}!S)`G% z+ENiN%OWd|t!rjT5>Gbgjk8nf7%m`*b6(YrQ$P*J%zzf(U(_6JtE?iEg*NY@v%c^S zeMnrI>IQd25A1%#oW?ekDuaa11JT32*3%cNk5>xM0@dE6IwMAcmeq%oOec>1g5!;VHaOQ zzQ!?cA*jH5WP$cZOL(D}rsS&xgU#M#Mc8&7_~nll<2^<0`Mve83{mW7TBommyJ2)D zzrlPu4z1#lhhiF32MR)oeCbcoS6Wv=n-y(l^Wq^7`CH+`z8qp84}F)}F%NW4^}S;D zP9qKS_FySlO+G?95zRb5uQu6FDJ^XDsIqt9mInK_m2|tZ zW7{8_M`BTQA9;^8^vx~P$#s-d#bfDnO>DQ}#ZLK0v(pE{kPO$zj7h$h2h$s#7u_;> zFk5^9D&_&uiEpg6A}>PJ# zEPzruBbT%FTeDZS^BqhdE*M&q)R^kvz=&3nwGs^mp;<_Ot2B0QwRS#9Wd&?cNZ=ix zkbD2bthh~Eo{M6L&~xOr&x{UTe67;0w-6Axr3?me@tO`e;M3^8qKbOS9hi9{Ya|rb zFc?5&j!YB*-Y?Ken6T3g39;oG@K`;$%)@=`w{P+qlOa(Gt8y`cDxsXYY;#Sux#Z~p zRA!rS$Z*o_dn2I_MuRdnPZ@EtCuRcGf;C~SFs9I5tORBT-3l|~JT zF)y~{TRn~R`?H7je-j#DfcU{XWIsInB1i@|L7ppFEa;$t;xva|o#>TnXOt^ZyWYh7 ztKH5sOoz)=k{Wb$CaXbOdo%IsWBF{0L!t9)OzVGWZEmAe^EONmh zX23JD35oY(T!Xj4936iQcMZI)=s7EEj!y*Cb^iN#9{8JY*aQ=bgV56a@eO%f9pdfw z_+2c59l%=H66uRon(&^)dlVHVkOHG^lW&UsAtx#UH#=D}h*ol7N{BGmtaa@@8E4Gu ziMKg05pr}umWswWNcGw=Y%47(#pOp#Qnd@VHkgknb|r1UcR7!-?eI%WvBUwsp#3o3 z2XEJ2u`ytg?(=9@%Xh4G)XlS`^L2Umv2&V7&RM(r$&x=DpXZgr)}a$R2q?3vQ)+^Q zYemL<%FK+1IsH{3R><@4WAL+456~_(v+(7wAS9ljCE`DJjjnv+ zxX)dLFz;_haM59a6n%1_0k~Jvy-sSc`@029y6A-=3)+mU(d%ucRcgt9#Zo@?$WBnwz*ID7q~j( zamILVew$sn=DKI4=^gU%`J=*qX~ihxQP4RR)EoN7){)AF{MI4NWg%dmeMD8BpALUG zV}H)ajC&#ml7ol8Tui0JEd1HD8l-a_MqQ3!%x*#gG&}(mp87KI_^k zL&AQWWi|fs)@7+@MmEYsWrQQyGtVszp>2JURoWrpOy0a^n))$7EjDD_k|}JYY?IQH zUwd)Y1SOLjOgOu$VW!)|w-OL%mG-yx^ zoREty649*0u7gmTb9UCn1%4pF^h{S5TN=vh(?&x~*}kXmHJ^K^>)N$)T0^(b64JA{ zc1b+p+l>rUH$YTJC0G}c$6^PW)}3ru??1C2sWDBropMdEH75Ih6{yx%4l5JgM5RTV z)j?nok5U3Ydj(z8@pXzCcfI4dTP!c5`Ii4MG{$2h{GP+0w`X$!l~1t{*tI6wB z4x_2i;$cfzVM~c8D`gbsZ|N1LC0T80Sf5NTZJ`V|Ju0k5Z;731(59rEOD+T?8Bq-nEo-YTu_^GgPM+X7 zeQh+hQF$;&V8>B3zHhwPdmKzJ!dbG_2WgI${U^pkrn+dJ(A%DoWZ3*{_bb096F}}r(tI(a|)Nk`{;^cc#96CIl*DPCt zM|?b&!g=OCd6xSM8;}+I>uC`9Oc~}HGjKAn9hP%RXx&Ei=^ZR$9y}2%Bf@kTb$2G~ zLRH?wlVqwHl4CaEJzE#@TaunW@+w32yflYVT*W6J{FXl8aI)mlcF!sUi;5^n5-_A( z&lJx?+;oO?zl2^dnMz_xl)H>4`ET`mtAa&hmGoOIkH{zB1vsmC^ICC+x){oQm@kVR z$2pZczR=@I^eAZQuM2R;NMi1*A{q{^t7G5mWl;q1vo|y1?r&lv#Z=VN4P@fKYS=mg zho+tyRz*<>JQ(w|pN1F**7?O$K$1hwN8~dAC;FwBczc3Y7DI^Ahcw;%RefIo4;4@K zrZ~IcnqmVWqGtns82>o&AJ*HiBh)i|8Ae?B`?wnM1NBQ17qCv#x;s5uC6OuSk%zr# zVRe`y3b?pZ8cdKmZ(sz(rQo;{B%|0hlV^r&)wJw@7nxE1u%#+pRkO%vp-I*rY<5&C zf~}K~*yY8hGF4-pDepbp4DdWfkz6zJ0(h=k6;KY)Nt;`r*|*x6cZlWghjjWl_l`O> zOhB2Od(g}A2+s`C^7CY6Ng3K5Wx03mPWZ%9bed&(Hix+QHIMt zgT-APR*iN2jgl5^_2oA$w`#)GSD2)f?0TEa#MYT*m{DZ^lQzk|R&n?Atd209rb&kT zs0N2#$NMGU>KEkL-v`$ONQP?OAGCQJWKUsxj5feNy?MtQ}A!zbtB4$ercFssTOl{i<)pO$e zD_w-PE;+IFMEVptxkuDBZ`h6-QL^gpl=;{j3eKpYL^PV0;LK&^Ljp92DPlbB$JN+> z=5z?0AEql_3NB!r9CoD~wOS5XRQcR0l}wVDA_ z$meV|GIRCzgF`7^?UTpy|L8s~UK7Q}ojxDkrBr2xo$NRbp)t&P#b~8UvQ3t-F*gl$OiT zX3t>1d#bQDW5|+%mQZj02BLwwy}LjKWnTN*^hSz=CqMDoZ%y(;%CDaL3r-YIo=?JD zvH0d7Zy#h<*3z`5t+%QTbt$7To&oE&x}dEA*Y306sK0Q!@!CdJj?~SwGt3!AaaGm& zkKB{idagO$YM{jrvG?x)(hQgTgJDY1k!bJKfqEIJMY zJe#axmN?gst#s8Qs~#e@gpdSCWvnZ{&NNJD+jCl+B^TszEA2o0Q*5H``%UGWVo9zx zk{1J>sKEtZi^?xCAptatlc#8;jMpADU?nn$ZVk$d zjz|^+>|Aeo7;UD$Nvv^Nz$SuO0LQ*ErfFZ6)}?5l-FU;~5bk(E0X;Mv`9eCofN{rC zMx#x-ynJc4zf~%XJ}LTZbsaqCzwrFg@0!4bBv8wE_s;3yvdb!`g0INz$wb#DBDClh z)b&x_A%!e>mY~RhMw-_!rkO#19CLFTnq^S|v=GCyrg$FBmozyt^X-=82~Hd?HnNhb z7?N+g2q?~Yc@noJ8rgQPg`OWG+daisB@E*ftK+l|feuyX&Nuaqq0$R)%O$w2_zKGj zyXBtrKd-ggbQ0tDsN1>{Oybr|JA$3^zE>31F}IWkgGFx#=HRUT670UlI)M+fvP}je zxh7=VXMhzp`6jfEKXf@PMRntp)QM>y{2ST)OL-@drfR^3jBycb&k>?AaWE4$vZtDf z=e{Q%0y3j4IeI3(rXqhFU(XmP2bp5h4kQKI%sITQ;{pvqP`4(V-8#o4s*=%J*N6T* zMPvIy?PA9NzGMKTKN`=1wLegl-}jKD8|U|5^7o%#AI4+yC3qIPJ=pRAG5TED++Vl5 z2$K| za7L?3p>_R%U&@BCtW|NOzHTC0E1-RY!a55bK^1Y`#5xCBe-@HmYw!{NSL*vUxC}re zkh&pDTaRhYSRYmb(cGhvI>S-iJt%9O_O#|cRy@PXTvI_{@5vLZ%blv`5T8bFiaEk| zz5}ct7+5mQSER~L1krP3kHpgd`0UPsG4*3W*yYD^zT$^{quUyA3Gv?`D8(m&uUW!0 zPV>ug+^2kKWMe`RhROIAQ*TTi6&a4QOIF3D6f>&T<!z@~kyerD(dSHTs;LSwkHidOKn5p_n;{_L110X!l z>!Ce97DI1u2-LNZHdI(4(OKCg8lXe~m7uzuV49vK zz#I%9WWWY zVP;c3(2HYLm>W7?ZD_p_k1`riD_{_fZm3uef%@kWZ)s+7KA8{Nha1jV849joFCMt; z=9PZ!f{qt8UCTu zI_u7OXc*R`c^nxj*u)!V@-Q8HbrR1&Y30TJ_V9LTbI{YZ)WCK(5fZgPJEb(*VjV7^ zNh79DFQM}RRzop1dN3cf2?Pe$u^jMrWpRG(S@-T(EmljtIPxJmPjz-~E!61#p5;S9|i%nh`(8l4{!*0IeC$tOW3j7(c=O=?VuE-r~vJ~0y|5N)bj`Ur_= z^1-+nUK8=94)zJ*8UlQ!qdma+V5vFfqeo)*wLV8L3+RHueePg7TLi#};=;#=<;@$U z!JM0WXM2^EqwS8@7hD+DgjrAoPKd?eQyeoKuUWKhP&04okMLi3A&1Ut|_Ar((Y@5C>ST8inztL#?esmGsMfM45KEF-ah)QNB>*%zB6}wrau3*7W@)};|=g!3Se4bu1yjX|DH;o2BOb!+tn%W`cPs{6; zhRs?EKXF$Y8Hd&BxsHz$a@Z*z_a4Q;wT3mS~Ds!8j9(Y%)jjxe;1k6Cd2$z8-=c%)xO%m=iqU@A_Ha5em7j|~Cg)_Nh=KWr3 z>$~7=3NuY0&~ScBH2Q)f?cM`N(xic;Zc#lP-EM7Dg0%g;5`*Km5A##VGL~`9u76(x zlx+F)`x#iK`s0V~wuixC7DSV(9I|J|K#5)&=)slYTgyz9W@9g~P0PJd|EWqF)yd<~ zKpUdnGwxf5nXd9Qx577brM$JN3!Pt#^&&Hyzyioz;h}bMWu?Om5a+9o$4-|-LlJY< zUe!#4fbd6mNU_u+`mTP!Bj|0z@A7(kfRFgpyS9Yv`2W_j2zI+AIu$PdK5hv@vUL}` zbNPMtX>Y5^$h5x4y5>pGQjLw-<(f2tH$YJNbBtGo0wnPc(~kHGvp#f$r+lHP?o$aJ8^wq5J9HLYmk%#V;)1 z$+TBZDR#SPh48R-qz|Z$tLedv$LCaS$S%U!-xV%{QNLlJ$x0~>1PeE>BsH?Uzgg)hsHe#&DMwKkaHZ* zYb6*3J*ARiw@XNn&WoAX&y56i%};1cndbdDIPCAo-ACuIrp~6sl;DoZPffJ9M^0eR z&r(BHrZ?GR?J4FGT)L!Q3NAMkov2b&B(?=)8?C*^jOf!zkuHZvsn2ofanvd-`cl#4 zf00!h`@w|dwt?ZPIiu9RY{<@!jvoe~Qn>US6#OtHlgt*We6zIu$WCoxztSiWo`BYI zmMu>M8(QLsphJ(xXFlLWm8ChVg{ngZ6vWX$OK^s$MgcV!9BU!H)ewsl+I zX6fKPc_wSzZA(M6S%#I4v%hA|*z8$<9>y4>lePw087gS{RN@)CJ{r^Z8W|$<92?)E zF@M@w(5b@J$_BCP=)886Q7D!h5i0<68{DwP*JU4cHQI(AF*NTnU`*muB8pF=IUm=a zVI5xe)FEM=l=`W&D|b(Ci4jz8pq6u3RrEEn6Y_<2B}!fLzu05 zgz3YVQt%`5ZguWF#b1r;dAt9^bsPC!UxL_;{jv0nTVY0(K3)?B6d>(OG;q*L2=>WN*?h$$XmQ3Ylek$XC3j=weQreUD<`~aP*+p#QYfr zO*zHu^IW|#SHgen;H)R4^p*%WF6UPsa5+G|cMroDkV85c47(#TW8q(bQDlNi*&{vr zZ1~(bDB$^-;~>zSO|o}fZ*E#oIw6dKMK2wQJKSi(m;Y4;kYO>;ModorT#eSm<^RsK zYh^^#MZFef!mICcAcZ-rHkgw~uHUeIY0EMN--O42{gT_`{f5sYHw&Ihk#-v6Cq6)3 zaSl@@;wLj|&*>xy$pbo}ZUVd9NXb$_>zdFPM2}fG*1>LIVR#ov~P>G@%&OQ z^c7VufN|P}~-Wr=0g?0tBa57%P;M>{ouyz1yRX=b4F{GP#7#2SM(q#VtBM)--jgWHIF6GYz`j z+hgw#pzU*h>3#_sVvj0$m#5CP_}S4o_^6njz@MPjjNdKD_S@~mVj^GFW2I51DCR}{ z#pfsh+D8bs-d*-*1$bH6FeMq1Z#CMJr<`+s`RtCYG)9V z9+3YbPW0!X_iR{{6Jx^MMEb)W!A@}+>#-*AX7d)m?T8L$eZO|zR|>nqdsc$7x>fO8 zJ#eyr%z6U2*_jjRCMfO-8g*ffE_c^0+*Q`#>e8QU)c$<8&BZ0^_yOW}Pt)-o^{9SM zZv1Y0_(zQ5KcID4$=Pu5UuW?&du~$Zf$LbN###YW90_ZFjs zCR^{!<-_;k%*~U^n#x^&5BZg}mbehw@So)jhlZ6uy_F@ccyrca#;m1FeI4DJEKBzw zI@fF`whxDv5J=2u;oSk#8b*t6TQl*}sxA9Ku>fOWsgyuysl=c?xYW;s3tRbtasU9} zVzOdlUWkEuUbCEOYD40wqDl|&hThGe0?{<#Xh(zp*xHsIcgk96l zd$`5cg0f52?K5hYgas5_jKxBmm39k#Z*b6SzYBV?gyX^YS`m41EUDs3r`I&)=y+ks z|H(J1&mIu}B$qbRtcyXIF$t@ZpBS3FJ+$xm8Xn%Q{M+#edv`98?^9kO(cE+>d#t7u zVD-H6MbcHVAQ?UT{Q+kZrrHWusSmU z3mWOjfGm}%S09r0(!?yIzDNEwwo|LHF-!uBse|HsmxYX!J0R48=%z5atlFtE8Zzua zM0*T#90g5DGg)29W^X_9!UV@frOUFyUW&yXSGz}%n#gL|fy@1Wk-5gq((NGE81?TK!8OPxAik(AeUm z-`f)}MUUIocNSEfKR5yz5{vVfVVJvhszT~Ev5StqQHvod224ygJzh$sBWKL2W7`t3 zvVZ0dX>Sx{OrSYQ>xyjwu%$=;h+*&3J)ei!ZWEqxcZbz?fz>KqpQg^h2`=Ua%5W(!Z6XC5JBT^C zEO0(W)>iJ=s|JT5#Jq5CCY5yC)}lAq7!Ff8r#4te}&L(gt32ZBHK zo+BG{p8mU76+11f9xr%P=yLzRtXuoz9J{fxsNsJMbV4o%th7Id>Bc2vLBZB!b;feZ zH1YZAZ0EaG!@&R()`K+;Ok}Rg9AanB+4fe?LUSPTFbBh-hZbA&El@qFhWoD{kDbdy zP|ZnH`icW(YJNC0g28EX8*R`GcWh>kvW?*Kw*Z5cknD}=ib8m3gR}_;4ja}A2x<3T z3P)fkG;^v{kg{H6P(MV)|F1t@_Pf>G5)AC?gmA3`$f)<^53(RLY6d=_+wzR)QRof$#gd9b=NrF}VAWov|y#Ms=^x~IbQ z%eSjyIamjgn#+QQw0EOre1RuPS>9~tCL5O=%GU&t7OE=C^?5q@iB6ez*mCZN6TlKG}R7gO6zDbg*r2wR%piH>7AYxJF6j6+-?kx`4=LL?B_Uz@x~*yxH)aE z49^n@w2#%f20jvCmKIJ#e+FxNWnC)NPF3%w{EG&K6+!~AwQXKn`93oW9^cfEEApzx z>hQ;Ad^{w>=tirp?^vzRD`C?q9ELo4E`vt&|Esn4j%upg;ybHnwK|( zdHm<}E*6<*ZB~tlf*fR8D&O2YI+!1Ks5CqN>hK95ijLPQtG~Ik$r~19EAa8tGDu0J z(9G#s@a4i$uhRzz0e<%UCb8%$1GiQJUh<)5j@Qzy1n*y4vH8)L>}>fQ$bAmXisAKcVCqJ8(tF=?}l%cA1(rAM58yM+5?ZP@spsaCim*9`7g zu77i!v-XI#xymg_z^4lyk6MG93N}s>bz{>#`(n6hhthF16$1AL4w^Zgo`n}2UY@V} zQiFjjUMG}2%_uC7KHwXvxLin9Hnfo%`@_B7P}`|mp}+ZDS!;3}wQ@&Qt415X9RGH} z>xQo5f7db=_FqkH`6FAe4S>`C@sr8#z9vP72dlK>_a~PN;tuc4c%O(bPg87l7 zHV3cD@Sfbgp(HH?P!KkGf4w+(j`O(J>G`$Ni?V3G@XWGmuCScTmSyevJHWNMI^)=c zvlGpZZ8hIia?Sal5NlmUi`z_8$__{X=_MJ1W96H?+Ji;w7kZDH@_;!VPNdUdyFxZ%*J#Q4<8`4FKxkX;@T+4p=WZM+T`>MiE zh!iEfT^Yru*K8^}7O7FK&NKw}SSI(!AD8<)^1(yUMe`qFn8x%JBkJ;Dd*a6+ZyJ4) zw<7EQ&9Nx=p~I?fXRY74{mP~RH<9(lLNOl6=OSWxGt+@OP*Hb+3sL^!;WKGFjHfH6 zwaT9HQxZahT9|2T#J~N6QPv3Y~Nj=>B-YRQe{WMiuNx{zihM$A6<=8>p z@s{&G2`ADL2b#>YpI0j2cVPVE_X(KzKode;sKUb%UV@C2%$IoOx|?CJSKJ2M;@wKD1GXt%^<4X98HA~3mB;7`AFdn{P7??@Th zcgOX6n8cp-+_0*mc$SR53E6a{yEUU~xV?QG*~+C70)0jEi0di~EGZCs_Zs?QuQz51 z7s4^!*7#4i=1btpF_B^&rYc$DbX~?${HRDq4f3WJdXJuld`cdtIoRK?$IV!Apt}zQSMmtvsDU4z<(pq9r<1jnj(PX-B=c#i zEgq|l>w1JSm*a#m{4ea*&WyP#A9jy+)Tl!SdO5$gO{&yszK(^4ES$}@VA3Inku-q} zr}LxQTyn0@H62(2Q;a1EzXrb-D>24fxy)_nmW>IcpZ2{EBt~%4Q_K56n))+WWo8c< z?Ul)-Jz@!+3gEwF{+eAju~sK^=-I+mt@D{{c_Fv=zg}?TlXgrdRbtU-z`sFlqm>p4 zKmY#~MZ=L{LL3s~_wIk2-gH>RbTSh#iQt#Het|7zcQmoVLx)tl;UL2R_~ej9du@L~Z5=&&Yz^7Equ!NTrkmw(?n(cgKz(}RDZ#qJJMW{5 z8Xd<30%xqulJ$@^gK@@pNM#%UCv#qYP8>-N%!^BI=$?NgEC(Uv^P*J$_VlJ}Fva zF!k5^i_c|!m^_*B1U`L01HNe*P;=__>O}OK!~@CQ!tAjVCB6rcOrAJGW7On}hf*rp zxk4KJTz50M%CYX8EA=a_mwvZ=BMcp3*bVlPsXfN6Z?3f&x{oOU8tO^R#lRrv^vJAR zu7)PnvbQz|=AU%u_2j=j^0iAgzb>P0Y+dWzsVawc7emjC$GWB$W1ZS{R;@HGe9wF2 zi!671RUNJ4nwKZ@Ha~ZEul4b~RwArkA$^x|4^!QA^oa0rTUSewRb)rjYZ~pxZ*0+5 z)_PNdV zn7iV8|43)w!-mK0rEuzMR45kHSX3KXUI(D9qKk zOxr=x)2*FPmM)46qD2esb_TU%H4KDRzeivB@+ z@fP+=Rjuun+|kQ3+fkz#kdbz18OnIZy`ur_eeYiKfy*|!*D_qWR4`Utr+awjbXgYI#ECl(cR_(F2Lp6*-G z)mH3Kly53YCJUT+pMJOd9p{k)nJOMFubo8)CaITY_@=IVaJ16&WVJG)!M|SE54Cwy zG^Cb|>FCz7s&C)hPmgy%)E8q;s%k}Jq^;3tr;K>)Z9iSK`zsALF$TccD zNn4WTLJ~Ft&U7I>&`>et1TX$sbo5F|Syjk*%Sp%VbB!Cud)2*-!72p~bxAxny^|uv z&kf&ia@(j^hz+-ctN;5H)FZ3MM1$J}yEs7bwbS4~6vT}XSRYoS`N>XDEJBfBO- zs zdAd0of1!-9m*{v)Y3jw=(agD|k2Swe2s_3PmIM;2Vw$rJ?!VLE*5)-!8q-$by{>ih z(=C{-bu!$$YN|%Ja`DA``=!`KaqvUIpwSOmykehiI0;imPuKM(f{+^vo;8yQEs>)9 z6;bIk(RMXyWpTy7Ta=_CaFqkpr!J0s1^=1bl(|%M|UTMNkBmAZ4 zrHgsE@wVyy_&aFji6$j1KG0U)<~QD|LDNuf_zRD9^o^3!*AJ=GXK>Sm3nz;CXG>~7 za6#5>p=||Ts8pY4pYUF0>6pA2=LBV1@lBDmio4~K58pZ(mOYu{Af9HvRjJqh=@@@s z`xgSWA}}19bkwSqLwDE;9d!2#;d&CXF@EMj;)50Y^EEt+)aOs}(zlaL4DGvRl{B@? z?4F-okq^O(wV`&9XU}pA%{KxU{9W2hc@ZGX9v}4yL!C^J7&&n&KV( zo8Fx_hAyZgyV*KK-D{qI{!>+=1K+^tE$f5JYCW!Kp_nTujC#bNI%)Vv^E*H@lnA0# zgYfr>VIpj$kS26vI{G^TN1N)jh0oh=E~uk2Q8Wlk5w+`s>fmSe^OMpM)N~!Z7?zTq zROiGq$=C@0N7);vhgibk%jrwl%Mv7@!&}?pAsR zZhPq+pZr-K*5LBc@SVrw06yLNtHBi0jN>&W;z#Ukc{=LRjDo0g=Ps(3@kwvLPnftH zK9HOTG{4nf!DiO2r*UtB8S7g1I>74V{hF(xX9?d#TSTaA1&Z;yip{e)JOBP_`@<(5 z>Ozo_ig(|_$H%7umBZ0_Rn@v%!PA^axR&M;M!Wd=uJH9;JQX+qsdlz2vC+P!uq*XX zUV|cEWqicCT1*}Mf#t)O@mkrLsj`K3rbpRT0QORlxZYpnUeiv8Zva-N)am!o4KIB6 ze*VL9!6$-QMj8>@{>GMs#<@%`Z2>vUPU(`1Za9Oo#RK0&uf6W_-DpNF^p=Br@}Fub zP2KG571G6>1EV61XMigE(;z%v(t{`z276-~gB-DT6D{1BGTrH;G*(4woy~Usxue#V zldoD2(w%gH&D8<5#SSfDCtoMs{*c|i%m1_@ZuAyM6fD7%^SQQ+3qfc|0gkrE^RdfP&D}7nk>p^Yk71z7r z1`pp6ZYdo@N}cA{dL)(f=hK&r0nMxVr#y0^&3r6!M_<7^<%S+fYXO=@M&JaBDbFWW zQO6>D+al*bw7^o^Z|N3`XBm`{0v38dDZ!TOr`7D)<6&;CC9NO8+zb=hH$oBtmdjwzSVxi zYslt`!@Jnoe4&x?V==4lr<*O?c}MQcK;qVb`XR;riG2KyuL>ZZ(RUQFp1wjZ(vp|; zmJj6B9M`)wI?MqSHOYR~m`K*hmrg)`s692(Y-w;4%pMH&!}#C%42v#rvL7I+O{Tu-3UL~XDSx*%457_ z5Ybo6A908vb5|FVCrDadt@!h)uID2{!hYT^UgzGSn+S#^JyB`eD%7MzcqAs!TC=k~ zqId4BcC_HCVjEZ2bXU&VKJtJkac=Mw7;}19ytgpl9~Jn@eyz0ey{X39-IX-ac3Fro zr@*Yk(kmX-P^$QnrD~a5$Dk&cU&NGP{s1?UVJ^ERds*vy9=%K3ArbTKIf(iOG&58Q zzk=ITz#aI>7XHD>5JWn7uMj*Njv|MHzA}dxIL9yXeXbZ+~ z3$|a`Ig|W`pc$HT=%Dh+#C@U`o}(mD)yzu)ZYC>Y*kbFJ=dnFYW<{w>qAM<6O5V@s zl3>387MhGz(LhFZZkOr6OS6mSug&`h#fG502^nRQi_b5*<;xYNM8)!CVoZEX#B8mc zW}c{>&oHYFYqb`(H0vJ|@;vv&4*sYI7M0mslFvPO_Gh>#4e$tWVk*QIdykl|>%7~l zy=ujI(uYvu9T}{W@}m9aW@hH?Px&e@qMvGg+Im{gFS=-!0gZ3B5=*SMvQ}#sk@UQ= zeLc;w$fH;ETt~>AcK3@MIeC{yVNsuX>jVd@FZIit7(I}IO5~S@M4vbVR=?3+edr*w=RTgcsOXlK5}nz z>xE==3+d*lh$Uz0osRz%j@9T=PPt&Ckrs*ZjS5t$v$9UP5T@p)BdOZ$o+p^LfBq(? z_i(Y9?=2_JgVckzS2Z%PM^u{0Th9FpO*xy^pieF zzwh06;gR1d)Dww;MO9Yb@Dh5Xn6c0z%#l-_qxT@M05JJHwD+Us^Inyd3szS;I9~?j zNP_JwJapYeUfq#+&1Iw7f8kxbSy3?fWpC+o`x#jd1Ha1#dY`zogzMC;14Bck2QvI- z`l&*mXpg)U8@{uN_Ga#RsyQ>pnK#`!Fv>Y|t_K!xbc9CcT2y=Ye!RRmdQ3<)YKd3m zcE)pV$yYfOTZ8s3H`_~ox_IoU-oGfeXjWvlRtxl?hm>H7>aL*WW?0Ta$x6AXZXppH z4Uzb~A9-qrvuAv7tqY|Xytu%txKH(s=O?j6Yn2qITnpgqKZPllS`Gw3nB}EA%6$qo zy_e3omlc^EFc{zsI+4z$HP36~Zk8~SrT6>!;00A(!wa$1c6>qEZ&s2NHm=#=Y!9i5 zkh40u6x2a3%r44Ak=w?`$L``op^B%aqRR2>&5jKjP52^7rZMT5Z|}`(-fjvh&JH0mp`w z{*G1Syn_D>ow}t%(8SKjz-MJ@0an#MC@CJ?8!IS7nFllHJ!p`omHRLpIRYeOkvq4L z<-fp&NKmyA1fq_yC+>eW$X^1?`Ttj)e?md>Xoc~=jLH0Vx5$6@l-SkE|JK1MJ1xK<>&=7K6MMXWJNc zyBOUP%@xX60bAJEBty$4C+{=6^{FzvoE!l5j>1-z7Y#0@3@0VS=FT-P+f>dj4itR! z`Zb7T2Epl_2zrg=N?WX;Y+(guD_WltZDDfi8COpH-8e||Ru0gfPq`AD3S_Ql#y_Oa z&bG(%hnLBGQDR#=R|G|A2M&{Zca=!ge}wv6SAjGI>AzXi0OYy2y>i^Ioe{9b`=SXb zK9Sug^s?`JN^nv<=(h-txrSqY{KiqS&V)AZ;l2NC06(OYpXLOc<^VxQ-E`kXR5vvA zKoDfe*4JX)#LMQIA*-U5p?yB@rwLLO-*5>h_&HcYXds6NQv_vhQ#^H7?3v-sgz)!R z6k{AU3DOPwjRKVXJ0SWr7lQ{(xy}*>lZ5+Nwl?!fQi3Oi4HsgfLCh*v6ON{fnEtt+ z1^9uPJr7|>;#TFT}GO@4@ z)~`x>oW@%?wll%Lc%l6{lil9A?r?Co?1D27ang+h5rPgX8x#I*opXd;4=;@ z@l+VRor=_kU{HXM*-{D{h?gWmE!@HM#bjm)0vPYBIQoKm_)p^6n(iaqzt)A48*pnV zY8GmY)dHeNvU)xB@Ce!u#5hO?+K*ZXn22rorbPp#%}V&r=gP(Sfk)dPzoBWL8}ucU znYDk^=0cfygjqIHalI173?pWTkKzG)ld6*=dRb@$g`YvBV~CUnV)OW43##K_HHdjR z@YpJ(oZ;~m3e_p()Jo}Y!@;)tn7YK!WW+$c^Y4kTG#X8h_Xc}G^?7U;qfOj?h612X zjpQlT6p@iT;y~LXVAaRzt5{|kh?i3jyA`7|C8I~;=BMxjz24_Zgp(VZ?*2k*1}+$T zb>5iKZpLfVZ6k(kb!SzQLHg@#vG;ZvA>^vt#4u*c)xO2FG6RWWJ#F;Fg?gG zSL5?#=^Y&}5dc?pWFmlTANv6dTTrdMbrmtz~Rf0C5?04kk z0@0viy_n#UF* z)mJxJ=`6CP2JkKyzhvWV&FRB-j*bAcQ<4}$H~&la8$mEesFOIxq>;|LkMq{a=GC6& zgm2%j0HK3S0%l@lAOS-<2VemKe>AmSq)r+FGlqh+B$y+tJus{j0^=H7N*!rjxWXUW ztMt4XIzB$tp$qF=I43Vl0Ku@Xkz_T^UNs? zI9(%#AGA;aV#u-A_>GqBc{ijfm{1vE8YQ42`OO_8;SX3RZPhxQY8VP2pM+GXiR^2jY8?v$FLhl zsyCAoVSOX8zH@#6?4$WRl~$9xBcCrj3#P{-TC#vCkCp43ul7c>HxDYPv_K@y$)iTh zKeWeDk_{%qSFguwuh&Kcl%y0e^&Oa6Hty^bL2}1e`*pIhBGeu*MxWWq%CJT44JFsE zaUe!>slA?07yHjRZ@x|6036oKQ~r&lEv} zjRSXhQjts!ZI^RyNr!c(Z!iFoBXT6b!RZv#ME-sRFzVT{05+B$A$IN9v4i_r1Qs0x z#7=nB>BFNF6CY*cxIVFzoNC!frhSf%j{K$9?U>P{H)+veKL26U&LgEZFGg~SFo z{n3N#jL$TYi!G45Z1)ZL9#$CGW{40W2^@t@)0Er*QM-(CzXCG@_MHZJz~s&^M=_dg z|A-j=^&-N#sm1>S^(l|F>(2^7td6R% z*y#{68br?nvb}M}+7H|mo%O|f!>4({ht56;Dw_mK1{gfHsYjYw3?kVg{LdhLX|Om8 z>&)CtrxEnXbxZ~kfnwF@(^L^#ed_u}g;7XKF5}aTZzCe6?9r5O@pkSEm|_tE&ITeff`SI^qBeL?)B9O6h)tQlnF4NX%%EzSqr3IM0*YC$yzOKy@3G)6=_xr zKfE6YWai8tK(J;2w6bZ`rW|V1*r${56rnS~2)IH{1!5(U#X#v!0|Yb-R|FyDZzte@ z?MfyvZHrJqW0kewwCFjX9Uve@lMGb*?d4A}|3=E?prsY1_S1S)Q-PoHgX-()*@*)3!ObRTEI%g)O99xnfM3eg@mho8 zPvI6Lxfq&oItBe5e*?7l8U^IU^d}nI#&0Rb`ivze+rv<*YTkA>9A(FCMquuiIBsPJ z_G5ep3*W6_us1*a%OghBpoq1dc7Y!_cmwODKjmc%9V64AB)JX`rH$jJe7D9bR+`9D zEBjeMY&YujM6P@b&HqE%1!m(J7Ax}OF3z_TSOyWj{J5J z(B,O,L) - - # compute expectations - # how many points in each window? seems necessary to normalize - # for numerical stability. - N = conv1d(ones, weights, padding=padding) - if centered: - Et = conv1d(ones, wt, padding=padding) - Et /= N - Ex = conv1d(x[:, None, :], weights, padding=padding) - Ex /= N - - # compute (weighted) covariance - # important: the formula E[XY] - EX EY is well-suited here, - # because the means are naturally subtracted correctly - # patch-wise. you couldn't pre-subtract them! - cov = conv1d(x[:, None, :], wt, padding=padding) - cov /= N - if centered: - cov -= Ex * Et - - # compute variances for denominator, using var X = E[X^2] - (EX)^2 - if normalized: - var_template = conv1d(ones, wt * template[:, None, :], padding=padding) - var_template /= N - var_x = conv1d(npx.square(x)[:, None, :], weights, padding=padding) - var_x /= N - if centered: - var_template -= npx.square(Et) - var_x -= npx.square(Ex) - - # now find the final normxcorr - corr = cov # renaming for clarity - if normalized: - corr /= npx.sqrt(var_x) - corr /= npx.sqrt(var_template) - # get rid of NaNs in zero-variance areas - corr[~npx.isfinite(corr)] = 0 - - return corr \ No newline at end of file + + + +# normxcorr1d is now implemented in dredge +# we keep the old version here but this will be removed soon + +# def normxcorr1d( +# template, +# x, +# weights=None, +# centered=True, +# normalized=True, +# padding="same", +# conv_engine="torch", +# ): +# """normxcorr1d: Normalized cross-correlation, optionally weighted + +# The API is like torch's F.conv1d, except I have accidentally +# changed the position of input/weights -- template acts like weights, +# and x acts like input. + +# Returns the cross-correlation of `template` and `x` at spatial lags +# determined by `mode`. Useful for estimating the location of `template` +# within `x`. + +# This might not be the most efficient implementation -- ideas welcome. +# It uses a direct convolutional translation of the formula +# corr = (E[XY] - EX EY) / sqrt(var X * var Y) + +# This also supports weights! In that case, the usual adaptation of +# the above formula is made to the weighted case -- and all of the +# normalizations are done per block in the same way. + +# Parameters +# ---------- +# template : tensor, shape (num_templates, length) +# The reference template signal +# x : tensor, 1d shape (length,) or 2d shape (num_inputs, length) +# The signal in which to find `template` +# weights : tensor, shape (length,) +# Will use weighted means, variances, covariances if supplied. +# centered : bool +# If true, means will be subtracted (per weighted patch). +# normalized : bool +# If true, normalize by the variance (per weighted patch). +# padding : str +# How far to look? if unset, we'll use half the length +# conv_engine : string, one of "torch", "numpy" +# What library to use for computing cross-correlations. +# If numpy, falls back to the scipy correlate function. + +# Returns +# ------- +# corr : tensor +# """ +# if conv_engine == "torch": +# assert HAVE_TORCH +# conv1d = F.conv1d +# npx = torch +# elif conv_engine == "numpy": +# conv1d = scipy_conv1d +# npx = np +# else: +# raise ValueError(f"Unknown conv_engine {conv_engine}. 'conv_engine' must be 'torch' or 'numpy'") + +# x = npx.atleast_2d(x) +# num_templates, length = template.shape +# num_inputs, length_ = template.shape +# assert length == length_ + +# # generalize over weighted / unweighted case +# device_kw = {} if conv_engine == "numpy" else dict(device=x.device) +# ones = npx.ones((1, 1, length), dtype=x.dtype, **device_kw) +# no_weights = weights is None +# if no_weights: +# weights = ones +# wt = template[:, None, :] +# else: +# assert weights.shape == (length,) +# weights = weights[None, None] +# wt = template[:, None, :] * weights + +# # conv1d valid rule: +# # (B,1,L),(O,1,L)->(B,O,L) + +# # compute expectations +# # how many points in each window? seems necessary to normalize +# # for numerical stability. +# N = conv1d(ones, weights, padding=padding) +# if centered: +# Et = conv1d(ones, wt, padding=padding) +# Et /= N +# Ex = conv1d(x[:, None, :], weights, padding=padding) +# Ex /= N + +# # compute (weighted) covariance +# # important: the formula E[XY] - EX EY is well-suited here, +# # because the means are naturally subtracted correctly +# # patch-wise. you couldn't pre-subtract them! +# cov = conv1d(x[:, None, :], wt, padding=padding) +# cov /= N +# if centered: +# cov -= Ex * Et + +# # compute variances for denominator, using var X = E[X^2] - (EX)^2 +# if normalized: +# var_template = conv1d(ones, wt * template[:, None, :], padding=padding) +# var_template /= N +# var_x = conv1d(npx.square(x)[:, None, :], weights, padding=padding) +# var_x /= N +# if centered: +# var_template -= npx.square(Et) +# var_x -= npx.square(Ex) + +# # now find the final normxcorr +# corr = cov # renaming for clarity +# if normalized: +# corr /= npx.sqrt(var_x) +# corr /= npx.sqrt(var_template) +# # get rid of NaNs in zero-variance areas +# corr[~npx.isfinite(corr)] = 0 + +# return corr \ No newline at end of file diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index 71d7b0ea8d..9aa15852cd 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -31,9 +31,7 @@ from .motion_utils import Motion, get_spatial_windows, get_window_domains, scipy_conv1d, make_2d_motion_histogram, get_spatial_bin_edges -# todo use gaussian_filter1d in historgam 2d -# put smotthing inside the histogram function -# put the log for weight inhitstogram + # simple class wrapper to be compliant with estimate_motion @@ -157,9 +155,6 @@ def dredge_ap( histogram_depth_smooth_um=1, histogram_time_smooth_s=1, avg_in_bin=False, - count_masked_correlation=False, - count_bins=401, - count_bin_min=2, # low-level keyword args thomas_kw=None, xcorr_kw=None, @@ -288,6 +283,8 @@ def dredge_ap( ) raster = motion_histogram.T + # TODO charlie : put the log for hitstogram + # TODO @charlie you should check that we are doing the same thing # windows, window_centers = get_spatial_windows( @@ -1180,7 +1177,6 @@ def calc_corr_decent_pair( return D, C -# TODO charlie sam : at the moment this is a duplicate with small differences see motion_estimate.py same function def normxcorr1d( template, x, @@ -1190,6 +1186,7 @@ def normxcorr1d( normalized=True, padding="same", conv_engine="torch", + ): """normxcorr1d: Normalized cross-correlation, optionally weighted @@ -1223,10 +1220,10 @@ def normxcorr1d( If true, normalize by the variance (per weighted patch). padding : int, optional How far to look? if unset, we'll use half the length - conv_engine : string, one of "torch", "numpy" + conv_engine : "torch" | "numpy" What library to use for computing cross-correlations. If numpy, falls back to the scipy correlate function. - +conv_engine Returns ------- corr : tensor diff --git a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py index 6cd935e582..f7f4f4ad66 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py @@ -14,6 +14,7 @@ from .dredge import DredgeLfpRegistration, DredgeApRegistration +# estimate_motion > infer_motion def estimate_motion( recording, peaks=None, From 5c250e13e5b160691c49635a05e9187bbd5d4a69 Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Mon, 8 Jul 2024 10:55:11 +0200 Subject: [PATCH 19/31] dredge_lfp doc --- doc/how_to/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/how_to/index.rst b/doc/how_to/index.rst index 54fd404848..8fa14690f8 100644 --- a/doc/how_to/index.rst +++ b/doc/how_to/index.rst @@ -13,3 +13,4 @@ Guides on how to solve specific, short problems in SpikeInterface. Learn how to. combine_recordings process_by_channel_group load_your_data_into_sorting + drift_with_lfp From 8ef01d90b2444dc3e018adc2447b59a48657dabf Mon Sep 17 00:00:00 2001 From: Samuel Garcia Date: Mon, 8 Jul 2024 11:13:24 +0200 Subject: [PATCH 20/31] Add tutorial motion. --- .../plot_1_estimate_motion.py | 103 ++++++++++++++++++ src/spikeinterface/preprocessing/motion.py | 20 +++- 2 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 examples/tutorials/sortingcomponents/plot_1_estimate_motion.py diff --git a/examples/tutorials/sortingcomponents/plot_1_estimate_motion.py b/examples/tutorials/sortingcomponents/plot_1_estimate_motion.py new file mode 100644 index 0000000000..0ce60ac7d3 --- /dev/null +++ b/examples/tutorials/sortingcomponents/plot_1_estimate_motion.py @@ -0,0 +1,103 @@ +""" +Motion estimation +================= + +SpikeInterface offers a very flexible framework to handle drift as a +preprocessing step. If you want to know more, please read the +:ref:`motion_correction` section of the documentation. + +Here a short example with a simulated drifting recording. + +""" + +# %% +import matplotlib.pyplot as plt + + +from spikeinterface.generation import generate_drifting_recording +from spikeinterface.preprocessing import correct_motion +from spikeinterface.widgets import plot_motion, plot_motion_info, plot_probe_map + +# %% +# First, let's simulate a drifting recording using the +# :code:`spikeinterface.generation module`. +# +# Here the simulated recording has a small zigzag motion along the 'y' axis of the probe. + +static_recording, drifting_recording, sorting = generate_drifting_recording( + num_units=200, + duration=300., + probe_name='Neuropixel-128', + generate_displacement_vector_kwargs=dict( + displacement_sampling_frequency=5.0, + drift_start_um=[0, 20], + drift_stop_um=[0, -20], + drift_step_um=1, + motion_list=[ + dict( + drift_mode="zigzag", + non_rigid_gradient=None, + t_start_drift=60.0, + t_end_drift=None, + period_s=200, + ), + ], + ), + seed=2205, +) + +plot_probe_map(drifting_recording) + +# %% +# Here we will use the high level function :code:`correct_motion()` +# +# Internally, this function is doing all steps of the motion detection: +# 1. **activity profile** : detect peaks and localize them along time and depth +# 2. **motion inference**: estimate the drift motion +# 3. **motion interpolation**: interpolate traces using the estimated motion +# +# All steps have an use several methods with many parameters. This is why we can use +# 'preset' which combine methods and related parameters. +# +# This function can take a while peak detection and localization is a slow process +# that need to go trought the entire traces + +recording_corrected, motion, motion_info = correct_motion( + drifting_recording, preset="nonrigid_fast_and_accurate", + output_motion=True, output_motion_info=True, + n_jobs=-1, progress_bar=True, +) + +# %% +# The function return a recording 'corrected' +# +# A new recording is return, this recording will interpolate motion corrected traces +# when calling get_traces() + +print(recording_corrected) + +# %% +# Optionally the function also return the `Motion` object itself +# + +print(motion) + +# %% +# This motion can be plotted, in our case the motion has been estimated as non-rigid +# so we can use the use the `mode='map'` to check the motion across depth. +# + +plot_motion(motion, mode='line') +plot_motion(motion, mode='map') + + +# %% +# The dict `motion_info` can be used for more plotting. +# Here we can appreciate of the two top axes the raster of peaks depth vs times before and +# after correction. + +fig = plt.figure() +plot_motion_info(motion_info, drifting_recording, amplitude_cmap="inferno", color_amplitude=True, figure=fig) +fig.axes[0].set_ylim(520, 620) +plt.show() +# %% diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index 82a89220bd..59b5a590c2 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -201,6 +201,7 @@ def correct_motion( recording, preset="nonrigid_accurate", folder=None, + output_motion=False, output_motion_info=False, overwrite=False, detect_kwargs={}, @@ -251,6 +252,8 @@ def correct_motion( The preset name folder : Path str or None, default: None If not None then intermediate motion info are saved into a folder + output_motion : bool, default: False + It True, the function returns a `motion` object. output_motion_info : bool, default: False If True, then the function returns a `motion_info` dictionary that contains variables to check intermediate steps (motion_histogram, non_rigid_windows, pairwise_displacement) @@ -275,8 +278,11 @@ def correct_motion( ------- recording_corrected : Recording The motion corrected recording + motion : Motion + Optional output if `output_motion=True`. motion_info : dict - Optional output if `output_motion_info=True`. The key "motion" holds the Motion object. + Optional output if `output_motion_info=True`. This dict contains several variable for + for plotting. See `plot_motion_info()` """ # local import are important because "sortingcomponents" is not important by default from spikeinterface.sortingcomponents.peak_detection import detect_peaks, detect_peak_methods @@ -390,10 +396,16 @@ def correct_motion( if folder is not None: save_motion_info(motion_info, folder, overwrite=overwrite) - if output_motion_info: - return recording_corrected, motion_info - else: + if not output_motion and not output_motion_info: return recording_corrected + + out = (recording_corrected, ) + if output_motion: + out += (motion, ) + if output_motion_info: + out += (motion_info, ) + return out + _doc_presets = "\n" From 18b7cfebfcd52c08513a521f96da6975bb0d89aa Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 11 Jul 2024 08:53:59 +0000 Subject: [PATCH 21/31] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- doc/how_to/drift_with_lfp.rst | 14 +- doc/how_to/index.rst | 2 +- examples/how_to/drift_with_lfp.py | 3 - .../plot_1_estimate_motion.py | 18 +- src/spikeinterface/preprocessing/motion.py | 14 +- .../sortingcomponents/motion/__init__.py | 8 +- .../sortingcomponents/motion/decentralized.py | 14 +- .../sortingcomponents/motion/dredge.py | 222 +++++++----------- .../motion/iterative_template.py | 3 - .../motion/motion_cleaner.py | 3 +- .../motion/motion_estimation.py | 12 +- .../sortingcomponents/motion/motion_utils.py | 28 +-- .../motion/tests/test_drege.py | 2 - .../motion/tests/test_motion_estimation.py | 3 +- 14 files changed, 134 insertions(+), 212 deletions(-) diff --git a/doc/how_to/drift_with_lfp.rst b/doc/how_to/drift_with_lfp.rst index e8d48301a0..a215f0920f 100644 --- a/doc/how_to/drift_with_lfp.rst +++ b/doc/how_to/drift_with_lfp.rst @@ -36,7 +36,7 @@ For each patient, the dataset contains two recording : a high pass (AP - from pathlib import Path import matplotlib.pyplot as plt - + import spikeinterface.full as si from spikeinterface.sortingcomponents.motion import estimate_motion @@ -57,7 +57,7 @@ read the spikeglx file .. parsed-literal:: - SpikeGLXRecordingExtractor: 384 channels - 2.5kHz - 1 segments - 2,183,292 samples + SpikeGLXRecordingExtractor: 384 channels - 2.5kHz - 1 segments - 2,183,292 samples 873.32s (14.56 minutes) - int16 dtype - 1.56 GiB @@ -87,7 +87,7 @@ eyes ont the traces plotted with the map mode. raw_rec, freq_min=0.5, freq_max=250, - + margin_ms=1500., filter_order=3, dtype="float32", @@ -95,16 +95,16 @@ eyes ont the traces plotted with the map mode. ) lfprec = si.phase_shift(lfprec) lfprec = si.resample(lfprec, resample_rate=250, margin_ms=1000) - + lfprec = si.directional_derivative(lfprec, order=2, edge_order=1) lfprec = si.average_across_direction(lfprec) - + print(lfprec) .. parsed-literal:: - AverageAcrossDirectionRecording: 192 channels - 0.2kHz - 1 segments - 218,329 samples + AverageAcrossDirectionRecording: 192 channels - 0.2kHz - 1 segments - 218,329 samples 873.32s (14.56 minutes) - float32 dtype - 159.91 MiB @@ -185,5 +185,3 @@ This motion match the LFP signal above. .. image:: drift_with_lfp_files/drift_with_lfp_12_1.png - - diff --git a/doc/how_to/index.rst b/doc/how_to/index.rst index cf9cadcfc3..5d7eae9003 100644 --- a/doc/how_to/index.rst +++ b/doc/how_to/index.rst @@ -14,4 +14,4 @@ Guides on how to solve specific, short problems in SpikeInterface. Learn how to. process_by_channel_group load_your_data_into_sorting benchmark_with_hybrid_recordings - drift_with_lfp \ No newline at end of file + drift_with_lfp diff --git a/examples/how_to/drift_with_lfp.py b/examples/how_to/drift_with_lfp.py index fe84b2ab48..df656bc4ae 100644 --- a/examples/how_to/drift_with_lfp.py +++ b/examples/how_to/drift_with_lfp.py @@ -108,6 +108,3 @@ si.plot_motion(motion, mode='line', ax=ax) ax.set_xlim(400, 420) ax.set_ylim(800, 1300) - - - diff --git a/examples/tutorials/sortingcomponents/plot_1_estimate_motion.py b/examples/tutorials/sortingcomponents/plot_1_estimate_motion.py index 0ce60ac7d3..87eaa4c51a 100644 --- a/examples/tutorials/sortingcomponents/plot_1_estimate_motion.py +++ b/examples/tutorials/sortingcomponents/plot_1_estimate_motion.py @@ -19,15 +19,15 @@ from spikeinterface.widgets import plot_motion, plot_motion_info, plot_probe_map # %% -# First, let's simulate a drifting recording using the +# First, let's simulate a drifting recording using the # :code:`spikeinterface.generation module`. -# +# # Here the simulated recording has a small zigzag motion along the 'y' axis of the probe. static_recording, drifting_recording, sorting = generate_drifting_recording( num_units=200, duration=300., - probe_name='Neuropixel-128', + probe_name='Neuropixel-128', generate_displacement_vector_kwargs=dict( displacement_sampling_frequency=5.0, drift_start_um=[0, 20], @@ -50,12 +50,12 @@ # %% # Here we will use the high level function :code:`correct_motion()` -# +# # Internally, this function is doing all steps of the motion detection: # 1. **activity profile** : detect peaks and localize them along time and depth # 2. **motion inference**: estimate the drift motion # 3. **motion interpolation**: interpolate traces using the estimated motion -# +# # All steps have an use several methods with many parameters. This is why we can use # 'preset' which combine methods and related parameters. # @@ -70,7 +70,7 @@ # %% # The function return a recording 'corrected' -# +# # A new recording is return, this recording will interpolate motion corrected traces # when calling get_traces() @@ -78,14 +78,14 @@ # %% # Optionally the function also return the `Motion` object itself -# +# print(motion) # %% # This motion can be plotted, in our case the motion has been estimated as non-rigid # so we can use the use the `mode='map'` to check the motion across depth. -# +# plot_motion(motion, mode='line') plot_motion(motion, mode='map') @@ -93,7 +93,7 @@ # %% # The dict `motion_info` can be used for more plotting. -# Here we can appreciate of the two top axes the raster of peaks depth vs times before and +# Here we can appreciate of the two top axes the raster of peaks depth vs times before and # after correction. fig = plt.figure() diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index 59b5a590c2..0568650316 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -20,7 +20,7 @@ peak_sign="neg", detect_threshold=8.0, exclude_sweep_ms=0.8, - radius_um=80., + radius_um=80.0, ), "select_kwargs": dict(), "localize_peaks_kwargs": dict( @@ -76,7 +76,7 @@ peak_sign="neg", detect_threshold=8.0, exclude_sweep_ms=0.8, - radius_um=80., + radius_um=80.0, ), "select_kwargs": dict(), "localize_peaks_kwargs": dict( @@ -196,7 +196,6 @@ } - def correct_motion( recording, preset="nonrigid_accurate", @@ -398,16 +397,15 @@ def correct_motion( if not output_motion and not output_motion_info: return recording_corrected - - out = (recording_corrected, ) + + out = (recording_corrected,) if output_motion: - out += (motion, ) + out += (motion,) if output_motion_info: - out += (motion_info, ) + out += (motion_info,) return out - _doc_presets = "\n" for k, v in motion_options_preset.items(): if k == "": diff --git a/src/spikeinterface/sortingcomponents/motion/__init__.py b/src/spikeinterface/sortingcomponents/motion/__init__.py index 15233efc32..d2e6a8a3d9 100644 --- a/src/spikeinterface/sortingcomponents/motion/__init__.py +++ b/src/spikeinterface/sortingcomponents/motion/__init__.py @@ -1,5 +1,9 @@ from .motion_utils import Motion from .motion_estimation import estimate_motion -from .motion_interpolation import (correct_motion_on_peaks, interpolate_motion_on_traces, - InterpolateMotionRecording, interpolate_motion) +from .motion_interpolation import ( + correct_motion_on_peaks, + interpolate_motion_on_traces, + InterpolateMotionRecording, + interpolate_motion, +) from .motion_cleaner import clean_motion_vector diff --git a/src/spikeinterface/sortingcomponents/motion/decentralized.py b/src/spikeinterface/sortingcomponents/motion/decentralized.py index 396a18bba2..d054995839 100644 --- a/src/spikeinterface/sortingcomponents/motion/decentralized.py +++ b/src/spikeinterface/sortingcomponents/motion/decentralized.py @@ -111,8 +111,8 @@ def run( bin_um=1.0, hist_margin_um=20.0, bin_s=1.0, - histogram_depth_smooth_um=1., - histogram_time_smooth_s=1., + histogram_depth_smooth_um=1.0, + histogram_time_smooth_s=1.0, pairwise_displacement_method="conv", max_displacement_um=100.0, weight_scale="linear", @@ -135,7 +135,6 @@ def run( lsqr_robust_n_iter=20, weight_with_amplitude=False, ): - dim = ["x", "y", "z"].index(direction) contact_depth = recording.get_channel_locations()[:, dim] @@ -153,7 +152,7 @@ def run( win_step_um=win_step_um, win_scale_um=win_scale_um, win_margin_um=win_margin_um, - zero_threshold=None + zero_threshold=None, ) # make 2D histogram raster @@ -322,6 +321,7 @@ def compute_pairwise_displacement( try: import torch import torch.nn.functional as F + conv_engine = "torch" except ImportError: conv_engine = "numpy" @@ -430,7 +430,6 @@ def compute_pairwise_displacement( return pairwise_displacement, pairwise_displacement_weight - _possible_convergence_method = ("lsmr", "gradient_descent", "lsqr_robust") @@ -687,9 +686,6 @@ def jac(p): return np.squeeze(displacement) - - - # normxcorr1d is now implemented in dredge # we keep the old version here but this will be removed soon @@ -809,4 +805,4 @@ def jac(p): # # get rid of NaNs in zero-variance areas # corr[~npx.isfinite(corr)] = 0 -# return corr \ No newline at end of file +# return corr diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index 9aa15852cd..3eb83dfbaa 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -2,7 +2,7 @@ Copy-paste and then refactoring of DREDge https://github.com/evarol/dredge -For historical reason, some function from the DREDge package where implemeneted +For historical reason, some function from the DREDge package where implemeneted in spikeinterface in the motion_estimation.py before the DREDge package itself! Here a copy/paste (and small rewriting) of some functions from DREDge. @@ -21,6 +21,7 @@ but the original function dredge_ap() and dredge_online_lfp() can be used directly. """ + import warnings from tqdm.auto import trange @@ -28,10 +29,14 @@ import gc -from .motion_utils import Motion, get_spatial_windows, get_window_domains, scipy_conv1d, make_2d_motion_histogram, get_spatial_bin_edges - - - +from .motion_utils import ( + Motion, + get_spatial_windows, + get_window_domains, + scipy_conv1d, + make_2d_motion_histogram, + get_spatial_bin_edges, +) # simple class wrapper to be compliant with estimate_motion @@ -84,6 +89,7 @@ class DredgeApRegistration: device : str or torch.device What torch device to run on? E.g., "cpu" or "cuda" or "cuda:1". """ + @classmethod def run( cls, @@ -102,7 +108,6 @@ def run( **method_kwargs, ): - outs = dredge_ap( recording, peaks, @@ -141,7 +146,7 @@ def dredge_ap( bin_um=1.0, bin_s=1.0, max_disp_um=None, - time_horizon_s=1000., + time_horizon_s=1000.0, mincorr=0.1, # weights arguments do_window_weights=True, @@ -217,14 +222,12 @@ def dredge_ap( depths_um = peak_depths = peak_locations[direction] times_s = peak_times = recording.sample_index_to_time(peaks["sample_index"]) - - thomas_kw = thomas_kw if thomas_kw is not None else {} xcorr_kw = xcorr_kw if xcorr_kw is not None else {} if time_horizon_s: xcorr_kw["max_dt_bins"] = np.ceil(time_horizon_s / bin_s) - - #TODO @charlie I think this is a bad to have the dict which is transported to every function + + # TODO @charlie I think this is a bad to have the dict which is transported to every function # this should be used only in histogram function but not in weight_correlation_matrix() # only important kwargs should be explicitly reported # raster_kw = dict( @@ -251,8 +254,6 @@ def dredge_ap( # this will store return values other than the MotionEstimate extra = {} - - # TODO charlie I switch this to make_2d_motion_histogram # but we need to add all options from the original spike_raster() # but I think this is OK @@ -266,7 +267,7 @@ def dredge_ap( # raster, spatial_bin_edges_um, time_bin_edges_s, counts = raster_res # else: # raster, spatial_bin_edges_um, time_bin_edges_s = raster_res - + motion_histogram, time_bin_edges_s, spatial_bin_edges_um = make_2d_motion_histogram( recording, peaks, @@ -276,7 +277,7 @@ def dredge_ap( direction=direction, bin_s=bin_s, bin_um=bin_um, - hist_margin_um=0., # @charlie maybe we should expose this and set +20. for instance + hist_margin_um=0.0, # @charlie maybe we should expose this and set +20. for instance spatial_bin_edges=None, depth_smooth_um=histogram_depth_smooth_um, time_smooth_s=histogram_time_smooth_s, @@ -285,7 +286,6 @@ def dredge_ap( # TODO charlie : put the log for hitstogram - # TODO @charlie you should check that we are doing the same thing # windows, window_centers = get_spatial_windows( # np.c_[np.zeros_like(spatial_bin_edges_um), spatial_bin_edges_um], @@ -301,7 +301,7 @@ def dredge_ap( dim = ["x", "y", "z"].index(direction) contact_depth = recording.get_channel_locations()[:, dim] spatial_bin_centers = 0.5 * (spatial_bin_edges_um[1:] + spatial_bin_edges_um[:-1]) - + windows, window_centers = get_spatial_windows( contact_depth, spatial_bin_centers, @@ -310,16 +310,13 @@ def dredge_ap( win_step_um=win_step_um, win_scale_um=win_scale_um, win_margin_um=win_margin_um, - zero_threshold=1e-5 - ) - - + zero_threshold=1e-5, + ) # TODO charlie : the count has disapeared # if extra_outputs and count_masked_correlation: # extra["counts"] = counts - # cross-correlate to get D and C if precomputed_D_C_maxdisp is None: Ds, Cs, max_disp_um = xcorr_windows( @@ -348,7 +345,7 @@ def dredge_ap( spatial_bin_edges_um, time_bin_edges_s, # raster_kw, #@charlie this is removed - post_transform=post_transform, # @charlie this isnew + post_transform=post_transform, # @charlie this isnew lambda_t=thomas_kw.get("lambda_t", DEFAULT_LAMBDA_T), eps=thomas_kw.get("eps", DEFAULT_EPS), progress_bar=progress_bar, @@ -403,6 +400,7 @@ class DredgeLfpRegistration: The reference is here https://www.biorxiv.org/content/10.1101/2023.10.24.563768v1 """ + name = "dredge_lfp" need_peak_location = False params_doc = """ @@ -447,6 +445,7 @@ class DredgeLfpRegistration: device : string or torch.device Controls torch device """ + @classmethod def run( cls, @@ -462,7 +461,6 @@ def run( verbose, progress_bar, extra, - **method_kwargs, ): # Note peaks and peak_locations are not used and can be None @@ -488,24 +486,17 @@ def run( return motion - - - - def dredge_online_lfp( lfp_recording, - direction='y', + direction="y", # nonrigid window construction arguments rigid=True, win_shape="gaussian", win_step_um=800, win_scale_um=850, win_margin_um=None, - chunk_len_s=10.0, max_disp_um=500, - - time_horizon_s=None, # weighting arguments mincorr=0.8, @@ -537,7 +528,6 @@ def dredge_online_lfp( # contact pos is the only on the direction contact_depth = lfp_recording.get_channel_locations()[:, dim] - fs = lfp_recording.get_sampling_frequency() T_total = lfp_recording.get_num_samples() T_chunk = min(int(np.floor(fs * chunk_len_s)), T_total) @@ -563,7 +553,6 @@ def dredge_online_lfp( bin_s=1 / fs, # only relevant for time_horizon_s ) - # here we check that contact positons are unique on the direction if contact_depth.size != np.unique(contact_depth).size: raise ValueError( @@ -599,9 +588,7 @@ def dredge_online_lfp( # below, t0 is start of prev chunk, t1 start of cur chunk, t2 end of cur t0, t1 = 0, T_chunk traces0 = lfp_recording.get_traces(start_frame=t0, end_frame=t1) - Ds0, Cs0, max_disp_um = xcorr_windows( - traces0.T, windows, contact_depth, win_scale_um, **full_xcorr_kw - ) + Ds0, Cs0, max_disp_um = xcorr_windows(traces0.T, windows, contact_depth, win_scale_um, **full_xcorr_kw) full_xcorr_kw["max_disp_um"] = max_disp_um Ss0, mincorr0 = threshold_correlation_matrix( Cs0, @@ -646,19 +633,14 @@ def dredge_online_lfp( ) # cross-correlation in current chunk - Ds1, Cs1, _ = xcorr_windows( - traces1.T, windows, contact_depth, win_scale_um, **full_xcorr_kw - ) + Ds1, Cs1, _ = xcorr_windows(traces1.T, windows, contact_depth, win_scale_um, **full_xcorr_kw) Ss1, mincorr1 = threshold_correlation_matrix( Cs1, mincorr_percentile=mincorr_percentile, mincorr=mincorr, **threshold_kw, ) - Ss10, _ = threshold_correlation_matrix( - Cs10, mincorr=mincorr1, t_offset_bins=T_chunk, **threshold_kw - ) - + Ss10, _ = threshold_correlation_matrix(Cs10, mincorr=mincorr1, t_offset_bins=T_chunk, **threshold_kw) if extra_outputs: extra["mincorrs"].append(mincorr1) @@ -692,7 +674,8 @@ def dredge_online_lfp( else: return motion -dredge_online_lfp.__doc__ = dredge_online_lfp.__doc__.format(DredgeLfpRegistration.params_doc) + +dredge_online_lfp.__doc__ = dredge_online_lfp.__doc__.format(DredgeLfpRegistration.params_doc) # -- functions from dredgelib (zone forbiden for sam) @@ -721,7 +704,6 @@ def laplacian(n, wink=True, eps=DEFAULT_EPS, lambd=1.0, ridge_mask=None): return lap - def neg_hessian_likelihood_term(Ub, Ub_prevcur=None, Ub_curprev=None): """Newton step coefficients @@ -761,12 +743,7 @@ def newton_rhs( # online case align_term = (Ub_prevcur.T + Ub_curprev) @ Pb_prev - rhs = ( - align_term - + grad_at_0 - + (Ub_curprev * Db_curprev).sum(1) - - (Ub_prevcur * Db_prevcur).sum(0) - ) + rhs = align_term + grad_at_0 + (Ub_curprev * Db_curprev).sum(1) - (Ub_prevcur * Db_prevcur).sum(0) return rhs @@ -881,9 +858,7 @@ def thomas_solve( P = np.zeros((B, T)) extra["HU"] = np.zeros((B, T, T)) for b in range(B): - P[b], extra["HU"][b] = newton_solve_rigid( - Ds[b], Us[b], L_t[b], **online_kw_rhs(b) - ) + P[b], extra["HU"][b] = newton_solve_rigid(Ds[b], Us[b], L_t[b], **online_kw_rhs(b)) return P, extra # spatial prior is a sparse, block tridiagonal kronecker product @@ -893,31 +868,21 @@ def thomas_solve( Lambda_s_offdiag = laplacian(T, eps=0, lambd=-lambda_s / 2) # initialize block-LU stuff and forward variable - alpha_hat_b = ( - L_t[0] - + Lambda_s_diagb - + neg_hessian_likelihood_term(Us[0], **online_kw_hess(0)) - ) - targets = np.c_[ - Lambda_s_offdiag, newton_rhs(Us[0], Ds[0], **online_kw_rhs(0)) - ] + alpha_hat_b = L_t[0] + Lambda_s_diagb + neg_hessian_likelihood_term(Us[0], **online_kw_hess(0)) + targets = np.c_[Lambda_s_offdiag, newton_rhs(Us[0], Ds[0], **online_kw_rhs(0))] res = solve(alpha_hat_b, targets, assume_a="pos") assert res.shape == (T, T + 1) gamma_hats = [res[:, :T]] ys = [res[:, T]] # forward pass - for b in (trange(1, B, desc="Solve") if progress_bar else range(1, B)): + for b in trange(1, B, desc="Solve") if progress_bar else range(1, B): if b < B - 1: Lambda_s_diagb = laplacian(T, eps=eps, lambd=lambda_s, ridge_mask=had_weights[b]) else: Lambda_s_diagb = laplacian(T, eps=eps, lambd=lambda_s / 2, ridge_mask=had_weights[b]) - Ab = ( - L_t[b] - + Lambda_s_diagb - + neg_hessian_likelihood_term(Us[b], **online_kw_hess(b)) - ) + Ab = L_t[b] + Lambda_s_diagb + neg_hessian_likelihood_term(Us[b], **online_kw_hess(b)) alpha_hat_b = Ab - Lambda_s_offdiag @ gamma_hats[b - 1] targets[:, T] = newton_rhs(Us[b], Ds[b], **online_kw_rhs(b)) targets[:, T] -= Lambda_s_offdiag @ ys[b - 1] @@ -938,7 +903,6 @@ def thomas_solve( return P, extra - def threshold_correlation_matrix( Cs, mincorr=0.0, @@ -952,10 +916,7 @@ def threshold_correlation_matrix( soft=True, ): if mincorr_percentile is not None: - diags = [ - np.diagonal(Cs, offset=j, axis1=1, axis2=2).ravel() - for j in range(1, mincorr_percentile_nneighbs) - ] + diags = [np.diagonal(Cs, offset=j, axis1=1, axis2=2).ravel() for j in range(1, mincorr_percentile_nneighbs)] mincorr = np.percentile( np.concatenate(diags), mincorr_percentile, @@ -974,12 +935,7 @@ def threshold_correlation_matrix( Ss = np.square((Cs >= mincorr) * Cs) else: Ss = (Cs >= mincorr).astype(Cs.dtype) - if ( - time_horizon_s is not None - and time_horizon_s > 0 - and T is not None - and time_horizon_s < T - ): + if time_horizon_s is not None and time_horizon_s > 0 and T is not None and time_horizon_s < T: tt0 = bin_s * np.arange(T) tt1 = tt0 if t_offset_bins: @@ -1136,11 +1092,7 @@ def calc_corr_decent_pair( # pick torch device if unset if device is None: - device = ( - torch.device("cuda") - if torch.cuda.is_available() - else torch.device("cpu") - ) + device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu") # process rasters into the tensors we need for conv2ds below # convert to TxD device floats @@ -1152,9 +1104,7 @@ def calc_corr_decent_pair( C = np.zeros((Ta, Tb), dtype=np.float32) for i in range(0, Ta, batch_size): for j in range(0, Tb, batch_size): - dt_bins = min( - abs(i - j), abs(i + batch_size - j), abs(i - j - batch_size) - ) + dt_bins = min(abs(i - j), abs(i + batch_size - j), abs(i - j - batch_size)) if max_dt_bins and dt_bins > max_dt_bins: continue weights_ = weights @@ -1186,53 +1136,52 @@ def normxcorr1d( normalized=True, padding="same", conv_engine="torch", - ): """normxcorr1d: Normalized cross-correlation, optionally weighted - The API is like torch's F.conv1d, except I have accidentally - changed the position of input/weights -- template acts like weights, - and x acts like input. - - Returns the cross-correlation of `template` and `x` at spatial lags - determined by `mode`. Useful for estimating the location of `template` - within `x`. - - This might not be the most efficient implementation -- ideas welcome. - It uses a direct convolutional translation of the formula - corr = (E[XY] - EX EY) / sqrt(var X * var Y) - - This also supports weights! In that case, the usual adaptation of - the above formula is made to the weighted case -- and all of the - normalizations are done per block in the same way. - - Arguments - --------- - template : tensor, shape (num_templates, length) - The reference template signal - x : tensor, 1d shape (length,) or 2d shape (num_inputs, length) - The signal in which to find `template` - weights : tensor, shape (length,) - Will use weighted means, variances, covariances if supplied. - centered : bool - If true, means will be subtracted (per weighted patch). - normalized : bool - If true, normalize by the variance (per weighted patch). - padding : int, optional - How far to look? if unset, we'll use half the length - conv_engine : "torch" | "numpy" - What library to use for computing cross-correlations. - If numpy, falls back to the scipy correlate function. -conv_engine - Returns - ------- - corr : tensor + The API is like torch's F.conv1d, except I have accidentally + changed the position of input/weights -- template acts like weights, + and x acts like input. + + Returns the cross-correlation of `template` and `x` at spatial lags + determined by `mode`. Useful for estimating the location of `template` + within `x`. + + This might not be the most efficient implementation -- ideas welcome. + It uses a direct convolutional translation of the formula + corr = (E[XY] - EX EY) / sqrt(var X * var Y) + + This also supports weights! In that case, the usual adaptation of + the above formula is made to the weighted case -- and all of the + normalizations are done per block in the same way. + + Arguments + --------- + template : tensor, shape (num_templates, length) + The reference template signal + x : tensor, 1d shape (length,) or 2d shape (num_inputs, length) + The signal in which to find `template` + weights : tensor, shape (length,) + Will use weighted means, variances, covariances if supplied. + centered : bool + If true, means will be subtracted (per weighted patch). + normalized : bool + If true, normalize by the variance (per weighted patch). + padding : int, optional + How far to look? if unset, we'll use half the length + conv_engine : "torch" | "numpy" + What library to use for computing cross-correlations. + If numpy, falls back to the scipy correlate function. + conv_engine + Returns + ------- + corr : tensor """ - if conv_engine == "torch": import torch import torch.nn.functional as F + conv1d = F.conv1d npx = torch elif conv_engine == "numpy": @@ -1297,9 +1246,7 @@ def normxcorr1d( # compute variances for denominator, using var X = E[X^2] - (EX)^2 if normalized: - var_template = conv1d( - onesx, wt * template, padding=padding - ) + var_template = conv1d(onesx, wt * template, padding=padding) var_template /= Nx var_x = conv1d(wx * x, weights, padding=padding) var_x /= Nx @@ -1354,20 +1301,12 @@ def get_weights( if isinstance(weights_threshold_low, tuple): nspikes_threshold_low, amp_threshold_low = weights_threshold_low unif = np.full_like(windows[0], 1 / len(windows[0])) - weights_threshold_low = ( - scale_fn(amp_threshold_low) - * windows - @ (nspikes_threshold_low * unif) - ) + weights_threshold_low = scale_fn(amp_threshold_low) * windows @ (nspikes_threshold_low * unif) weights_threshold_low = weights_threshold_low[:, None] if isinstance(weights_threshold_high, tuple): nspikes_threshold_high, amp_threshold_high = weights_threshold_high unif = np.full_like(windows[0], 1 / len(windows[0])) - weights_threshold_high = ( - scale_fn(amp_threshold_high) - * windows - @ (nspikes_threshold_high * unif) - ) + weights_threshold_high = scale_fn(amp_threshold_high) * windows @ (nspikes_threshold_high * unif) weights_threshold_high = weights_threshold_high[:, None] weights_thresh = weights_orig.copy() weights_thresh[weights_orig < weights_threshold_low] = 0 @@ -1375,6 +1314,7 @@ def get_weights( return weights, weights_thresh, p_inds + def weight_correlation_matrix( Ds, Cs, @@ -1436,7 +1376,7 @@ def weight_correlation_matrix( raster, depth_bin_edges, time_bin_edges, - #raster_kw, + # raster_kw, post_transform=post_transform, weights_threshold_low=weights_threshold_low, weights_threshold_high=weights_threshold_high, diff --git a/src/spikeinterface/sortingcomponents/motion/iterative_template.py b/src/spikeinterface/sortingcomponents/motion/iterative_template.py index a49d5bd639..f5e2e30d4a 100644 --- a/src/spikeinterface/sortingcomponents/motion/iterative_template.py +++ b/src/spikeinterface/sortingcomponents/motion/iterative_template.py @@ -96,8 +96,6 @@ def run( zero_threshold=None, ) - - # make a 3D histogram motion_histograms, temporal_hist_bin_edges, spatial_hist_bin_edges = make_3d_motion_histograms( recording, @@ -143,7 +141,6 @@ def run( return motion - def iterative_template_registration( spikecounts_hist_images, non_rigid_windows=None, diff --git a/src/spikeinterface/sortingcomponents/motion/motion_cleaner.py b/src/spikeinterface/sortingcomponents/motion/motion_cleaner.py index de2c7df4cc..2ac20ad46d 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_cleaner.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_cleaner.py @@ -2,6 +2,7 @@ # TODO this need a full rewrite with motion object + def clean_motion_vector(motion, temporal_bins, bin_duration_s, speed_threshold=30, sigma_smooth_s=None): """ Simple machinery to remove spurious fast bump in the motion vector. @@ -69,5 +70,3 @@ def clean_motion_vector(motion, temporal_bins, bin_duration_s, speed_threshold=3 motion_clean = scipy.signal.fftconvolve(motion_clean, smooth_kernel, mode="same", axes=0) return motion_clean - - diff --git a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py index f7f4f4ad66..2d8564fc54 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_estimation.py @@ -4,7 +4,6 @@ import numpy as np - from spikeinterface.sortingcomponents.tools import make_multi_method_doc @@ -22,8 +21,8 @@ def estimate_motion( direction="y", rigid=False, win_shape="gaussian", - win_step_um=50.0, # @alessio charlie is proposing here instead 400 - win_scale_um=150.0, # @alessio charlie is proposing here instead 400 + win_step_um=50.0, # @alessio charlie is proposing here instead 400 + win_scale_um=150.0, # @alessio charlie is proposing here instead 400 win_margin_um=None, method="decentralized", extra_outputs=False, @@ -33,8 +32,8 @@ def estimate_motion( **method_kwargs, ): """ - - + + Estimate motion with several possible methods. Most of methods except dredge_lfp needs peaks and after their localization. @@ -98,7 +97,6 @@ def estimate_motion( if margin_um is not None: warnings.warn("estimate_motion() margin_um has been removed used hist_margin_um or win_margin_um") - # TODO handle multi segment one day : Charlie this is for you assert recording.get_num_segments() == 1, "At the moment estimate_motion handle only unique segment" @@ -119,13 +117,11 @@ def estimate_motion( peaks, peak_locations, direction, - rigid, win_shape, win_step_um, win_scale_um, win_margin_um, - verbose, progress_bar, extra, diff --git a/src/spikeinterface/sortingcomponents/motion/motion_utils.py b/src/spikeinterface/sortingcomponents/motion/motion_utils.py index 228110b7ec..a848ca1746 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_utils.py @@ -229,22 +229,21 @@ def copy(self): [d.copy() for d in self.displacement], [t.copy() for t in self.temporal_bins_s], self.spatial_bins_um.copy(), - direction=self.direction, + direction=self.direction, interpolation_method=self.interpolation_method, ) - def get_spatial_windows( - contact_depth, - spatial_bin_centers, - rigid=False, - win_shape="gaussian", - win_step_um=50.0, - win_scale_um=150.0, - win_margin_um=None, - zero_threshold=None - ): + contact_depth, + spatial_bin_centers, + rigid=False, + win_shape="gaussian", + win_step_um=50.0, + win_scale_um=150.0, + win_margin_um=None, + zero_threshold=None, +): """ Generate spatial windows (taper) for non-rigid motion. For rigid motion, this is equivalent to have one unique rectangular window that covers the entire probe. @@ -297,14 +296,14 @@ def get_spatial_windows( middle = (spatial_bin_centers[0] + spatial_bin_centers[-1]) / 2.0 window_centers = np.array([middle]) else: - if win_scale_um <= win_step_um/5.: + if win_scale_um <= win_step_um / 5.0: warnings.warn( f"get_spatial_windows(): spatial windows are probably not overlaping because {win_scale_um=} and {win_step_um=}" ) if win_margin_um is None: # this ensure that first/last windows do not overflow outside the probe - win_margin_um = -win_scale_um / 2. + win_margin_um = -win_scale_um / 2.0 min_ = np.min(contact_depth) - win_margin_um max_ = np.max(contact_depth) + win_margin_um @@ -388,7 +387,6 @@ def get_spatial_bin_edges(recording, direction, hist_margin_um, bin_um): return spatial_bins - def make_2d_motion_histogram( recording, peaks, @@ -465,7 +463,7 @@ def make_2d_motion_histogram( weights = None motion_histogram, edges = np.histogramdd(arr, bins=(temporal_bin_edges, spatial_bin_edges), weights=weights) - + # average amplitude in each bin if weight_with_amplitude and avg_in_bin: bin_counts, _ = np.histogramdd(arr, bins=(temporal_bin_edges, spatial_bin_edges)) diff --git a/src/spikeinterface/sortingcomponents/motion/tests/test_drege.py b/src/spikeinterface/sortingcomponents/motion/tests/test_drege.py index 218d9036aa..8133c1fa6b 100644 --- a/src/spikeinterface/sortingcomponents/motion/tests/test_drege.py +++ b/src/spikeinterface/sortingcomponents/motion/tests/test_drege.py @@ -1,8 +1,6 @@ import pytest - - def test_dredge_online_lfp(): pass diff --git a/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py index 1168b65c79..3c83a56b9d 100644 --- a/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py +++ b/src/spikeinterface/sortingcomponents/motion/tests/test_motion_estimation.py @@ -57,7 +57,7 @@ def dataset_fixture(create_cache_folder): def test_estimate_motion(dataset): # recording, sorting = make_dataset() recording, sorting, cache_folder = dataset - + peaks = np.load(cache_folder / "dataset_peaks.npy") peak_locations = np.load(cache_folder / "dataset_peak_locations.npy") @@ -222,6 +222,7 @@ def test_estimate_motion(dataset): if __name__ == "__main__": import tempfile + with tempfile.TemporaryDirectory() as tmpdirname: cache_folder = Path(tmpdirname) args = setup_dataset_and_peaks(cache_folder) From de7df4b26c0847d4e7f5d1e70a48ed64fdd24c05 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 12 Jul 2024 10:21:46 +0200 Subject: [PATCH 22/31] Fix tests --- src/spikeinterface/core/core_tools.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/core/core_tools.py b/src/spikeinterface/core/core_tools.py index 996718dc42..42d5561547 100644 --- a/src/spikeinterface/core/core_tools.py +++ b/src/spikeinterface/core/core_tools.py @@ -75,7 +75,7 @@ class SIJsonEncoder(json.JSONEncoder): def default(self, obj): from spikeinterface.core.base import BaseExtractor - from spikeinterface.sortingcomponents.motion_utils import Motion + from spikeinterface.sortingcomponents.motion.motion_utils import Motion # Over-write behaviors for datetime object if isinstance(obj, datetime.datetime): From 261b021bee08d8228754cfe0aee2347e8fa4f2e3 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 12 Jul 2024 11:36:02 +0200 Subject: [PATCH 23/31] Cleanup how to --- doc/how_to/drift_with_lfp.rst | 102 ++++++++++++------------------ examples/how_to/drift_with_lfp.py | 56 ++++++++-------- 2 files changed, 68 insertions(+), 90 deletions(-) diff --git a/doc/how_to/drift_with_lfp.rst b/doc/how_to/drift_with_lfp.rst index a215f0920f..0decc1058a 100644 --- a/doc/how_to/drift_with_lfp.rst +++ b/doc/how_to/drift_with_lfp.rst @@ -1,30 +1,27 @@ Estimate drift using the LFP traces =================================== -Charlie Windolf and colleagues have developed a method to estimate the -motion using the LFP signal : **dredge**. +Drift is a well known issue for long shank probes. Some datasets, especially from primates and humans, +can experience very fast motion due to breathing and heart beats. In these cases, the standard motion +estimation methods that use detected spikes as a basis for motion inference will fail, because there +are not enough spikes to "follow" such fast drifts. -You can see more detail in this preprint `DREDge: robust motion -correction for high-density extracellular recordings across -species `__ +Charlie Windolf and colleagues from the Paninski Lab at Columbia have developed a method to estimate +the motion using the LFP signal: **DREDge**. (more details about the method in the paper +`DREDge: robust motion correction for high-density extracellular recordings across species `_). -This method is particularly adapated for the open dataset recorded at -Massachusetts General Hospital by Angelique Paulk and colleagues. The -dataset can be dowloaed `on -datadryad `__. -This challenging dataset contain recording on patient with neuropixel -probe! But a very high and very fast motion on the probe prevent doing -spike sorting. +This method is particularly suited for the open dataset recorded at Massachusetts General Hospital by Angelique Paulk and colleagues in humans (more details in the [paper](https://doi.org/10.1038/s41593-021-00997-0)). The dataset can be dowloaed from [datadryad](https://datadryad.org/stash/dataset/doi:10.5061/dryad.d2547d840) and it contains recordings on human patients with a Neuropixels probe, some of which with very high and fast motion on the probe, which prevents accurate spike sorting without a proper and adequate motion correction -The **dredge** method has two sides **dredge_lfp** and **dredge_ap**. -They both haave been ported inside spikeinterface. Here we will use the -**dredge_lfp**. +The **DREDge** method has two options: **dredge_lfp** and **dredge_ap**, which have both been ported inside `SpikeInterface`. -Here we demonstrate how to use this method to estimate the fast and high -drift on this recording. +Here we will demonstrate the **dredge_lfp** method to estimate the fast and high drift on this recording. -For each patient, the dataset contains two recording : a high pass (AP - -30kHz) and a low pass (FP - 2.5kHz). We will use the low pass here. +For each patient, the dataset contains two streams: + +* a highpass "action potential" (AP), sampled at 30kHz +* a lowpass "local field" (LF) sampled at 2.5kHz + +For this demonstration, we will use the LF stream. .. code:: ipython3 @@ -46,7 +43,7 @@ For each patient, the dataset contains two recording : a high pass (AP - base_folder = Path("/mnt/data/sam/DataSpikeSorting/") np_data_drift = base_folder / 'human_neuropixel/Pt02/' -read the spikeglx file +Read the spikeglx file ~~~~~~~~~~~~~~~~~~~~~~ .. code:: ipython3 @@ -61,25 +58,20 @@ read the spikeglx file 873.32s (14.56 minutes) - int16 dtype - 1.56 GiB -preprocessing +Preprocessing ~~~~~~~~~~~~~ -Contrary to **dredge_ap** which need peak and peak location, the -**dredge_lfp** is estimating the motion directly on traces but the -method need an important preprocessing: \* low pass filter : this focus -the signal on a particular band \* phase_shift : this is needed to -conpensate the digitalization unalignement \* resample : the sample -fequency of the signal will be the sample frequency of the estimated -motion. Here we choose 250Hz to have 4ms precission. \* -directional_derivative : this optional step apply a derivative at second -order to enhance edges on the traces. This is not a general rules and -need to be tested case by case. \* average_across_direction : neuropixel -1 probe has several contact per depth. They are average to get a unique -virtual signal along the probe depth (“y” in probeinterface and -spikeinterface). - -When appying this preprocessing the motion can be estimated almost by -eyes ont the traces plotted with the map mode. +Contrary to the **dredge_ap** approach, which needs detected peaks and peak locations, the **dredge_lfp** +method is estimating the motion directly on traces. +Importantly, the method requires some additional pre-processing steps: + * ``bandpass_filter``: to "focus" the signal on a particular band + * ``phase_shift``: to compensate for the sampling misalignement + * ``resample``: to further reduce the sampling fequency of the signal and speed up the computation. The sampling frequency of the estimated motion will be the same as the resampling frequency. Here we choose 250Hz, which corresponds to a sampling interval of 4ms. + * ``directional_derivative``: this optional step applies a second order derivative in the spatial dimension to enhance edges on the traces. + This is not a general rules and need to be tested case by case. + * ``average_across_direction``: Neuropixels 1.0 probes have two contacts per depth. This steps averages them to obtain a unique virtual signal along the probe depth ("y" in ``spikeinterface``). + +After appying this preprocessing chain, the motion can be estimated almost by eyes ont the traces plotted with the map mode. .. code:: ipython3 @@ -115,28 +107,20 @@ eyes ont the traces plotted with the map mode. - -.. parsed-literal:: - - - - - - .. image:: drift_with_lfp_files/drift_with_lfp_8_1.png Run the method ~~~~~~~~~~~~~~ -``estimate_motion()`` is the generic funciton with multi method in -spikeinterface. +``estimate_motion()`` is the generic function to estimate motion with multiple +methods in ``spikeinterface``. -This return a ``Motion`` object, you can note that the interval is -exactly the same as downsampled signal. +This function returns a ``Motion`` object and we can notice that the interval is exactly +the same as downsampled signal. -Here we use ``rigid=True``, this means that we have one unqiue signal to -describe the motion for the entire probe. +Here we use ``rigid=True``, which means that we have one unqiue signal to +describe the motion across the entire probe depth. .. code:: ipython3 @@ -144,27 +128,24 @@ describe the motion for the entire probe. motion - .. parsed-literal:: Online chunks [10.0s each]: 0%| | 0/87 [00:00 Date: Fri, 12 Jul 2024 12:00:37 +0200 Subject: [PATCH 24/31] Handle negative/0 windows, contact_depth->contact_depths, add verbosity to iterative_template --- src/spikeinterface/preprocessing/motion.py | 1 + .../preprocessing/tests/test_motion.py | 2 +- .../sortingcomponents/motion/decentralized.py | 4 +- .../sortingcomponents/motion/dredge.py | 22 +++--- .../motion/iterative_template.py | 8 +- .../sortingcomponents/motion/motion_utils.py | 79 ++++++++++++------- 6 files changed, 70 insertions(+), 46 deletions(-) diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index 0568650316..5e91623257 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -99,6 +99,7 @@ win_shape="gaussian", win_step_um=100.0, win_scale_um=200.0, + win_margin_um=None, histogram_depth_smooth_um=5.0, histogram_time_smooth_s=None, pairwise_displacement_method="conv", diff --git a/src/spikeinterface/preprocessing/tests/test_motion.py b/src/spikeinterface/preprocessing/tests/test_motion.py index baa7235263..02efef1e3b 100644 --- a/src/spikeinterface/preprocessing/tests/test_motion.py +++ b/src/spikeinterface/preprocessing/tests/test_motion.py @@ -13,7 +13,7 @@ def test_estimate_and_correct_motion(create_cache_folder): if folder.exists(): shutil.rmtree(folder) - rec_corrected = correct_motion(rec, folder=folder) + rec_corrected = correct_motion(rec, folder=folder, estimate_motion_kwargs={"win_step_um": 20}) print(rec_corrected) # test reloading motion info diff --git a/src/spikeinterface/sortingcomponents/motion/decentralized.py b/src/spikeinterface/sortingcomponents/motion/decentralized.py index d054995839..32f60f568f 100644 --- a/src/spikeinterface/sortingcomponents/motion/decentralized.py +++ b/src/spikeinterface/sortingcomponents/motion/decentralized.py @@ -137,7 +137,7 @@ def run( ): dim = ["x", "y", "z"].index(direction) - contact_depth = recording.get_channel_locations()[:, dim] + contact_depths = recording.get_channel_locations()[:, dim] # spatial histogram bins spatial_bin_edges = get_spatial_bin_edges(recording, direction, hist_margin_um, bin_um) @@ -145,7 +145,7 @@ def run( # get spatial windows non_rigid_windows, non_rigid_window_centers = get_spatial_windows( - contact_depth, + contact_depths, spatial_bin_centers, rigid=rigid, win_shape=win_shape, diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index 3eb83dfbaa..7c3b2e4c5a 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -299,11 +299,11 @@ def dredge_ap( # ) dim = ["x", "y", "z"].index(direction) - contact_depth = recording.get_channel_locations()[:, dim] + contact_depths = recording.get_channel_locations()[:, dim] spatial_bin_centers = 0.5 * (spatial_bin_edges_um[1:] + spatial_bin_edges_um[:-1]) windows, window_centers = get_spatial_windows( - contact_depth, + contact_depths, spatial_bin_centers, rigid=rigid, win_shape=win_shape, @@ -526,7 +526,7 @@ def dredge_online_lfp( """ dim = ["x", "y", "z"].index(direction) # contact pos is the only on the direction - contact_depth = lfp_recording.get_channel_locations()[:, dim] + contact_depths = lfp_recording.get_channel_locations()[:, dim] fs = lfp_recording.get_sampling_frequency() T_total = lfp_recording.get_num_samples() @@ -538,7 +538,7 @@ def dredge_online_lfp( thomas_kw = thomas_kw if thomas_kw is not None else {} full_xcorr_kw = dict( rigid=rigid, - bin_um=np.median(np.diff(contact_depth)), + bin_um=np.median(np.diff(contact_depths)), max_disp_um=max_disp_um, progress_bar=False, device=device, @@ -554,21 +554,21 @@ def dredge_online_lfp( ) # here we check that contact positons are unique on the direction - if contact_depth.size != np.unique(contact_depth).size: + if contact_depths.size != np.unique(contact_depths).size: raise ValueError( f"estimate motion with 'dredge_lfp' need channel_positions to be unique in the direction='{direction}'" ) - if np.any(np.diff(contact_depth) < 0): + if np.any(np.diff(contact_depths) < 0): raise ValueError( f"estimate motion with 'dredge_lfp' need channel_positions to be ordered direction='{direction}'" "please use spikeinterface.preprocessing.depth_order(recording)" ) # Important detail : in LFP bin center are contact position in the direction - spatial_bin_centers = contact_depth + spatial_bin_centers = contact_depths windows, window_centers = get_spatial_windows( - contact_depth=contact_depth, + contact_depths=contact_depths, spatial_bin_centers=spatial_bin_centers, rigid=rigid, win_margin_um=win_margin_um, @@ -588,7 +588,7 @@ def dredge_online_lfp( # below, t0 is start of prev chunk, t1 start of cur chunk, t2 end of cur t0, t1 = 0, T_chunk traces0 = lfp_recording.get_traces(start_frame=t0, end_frame=t1) - Ds0, Cs0, max_disp_um = xcorr_windows(traces0.T, windows, contact_depth, win_scale_um, **full_xcorr_kw) + Ds0, Cs0, max_disp_um = xcorr_windows(traces0.T, windows, contact_depths, win_scale_um, **full_xcorr_kw) full_xcorr_kw["max_disp_um"] = max_disp_um Ss0, mincorr0 = threshold_correlation_matrix( Cs0, @@ -626,14 +626,14 @@ def dredge_online_lfp( Ds10, Cs10, _ = xcorr_windows( traces1.T, windows, - contact_depth, + contact_depths, win_scale_um, raster_b=traces0.T, **full_xcorr_kw, ) # cross-correlation in current chunk - Ds1, Cs1, _ = xcorr_windows(traces1.T, windows, contact_depth, win_scale_um, **full_xcorr_kw) + Ds1, Cs1, _ = xcorr_windows(traces1.T, windows, contact_depths, win_scale_um, **full_xcorr_kw) Ss1, mincorr1 = threshold_correlation_matrix( Cs1, mincorr_percentile=mincorr_percentile, diff --git a/src/spikeinterface/sortingcomponents/motion/iterative_template.py b/src/spikeinterface/sortingcomponents/motion/iterative_template.py index f5e2e30d4a..1b5eb75508 100644 --- a/src/spikeinterface/sortingcomponents/motion/iterative_template.py +++ b/src/spikeinterface/sortingcomponents/motion/iterative_template.py @@ -78,7 +78,7 @@ def run( ): dim = ["x", "y", "z"].index(direction) - contact_depth = recording.get_channel_locations()[:, dim] + contact_depths = recording.get_channel_locations()[:, dim] # spatial histogram bins spatial_bin_edges = get_spatial_bin_edges(recording, direction, hist_margin_um, bin_um) @@ -86,7 +86,7 @@ def run( # get spatial windows non_rigid_windows, non_rigid_window_centers = get_spatial_windows( - contact_depth=contact_depth, + contact_depths=contact_depths, spatial_bin_centers=spatial_bin_centers, rigid=rigid, win_margin_um=win_margin_um, @@ -97,6 +97,8 @@ def run( ) # make a 3D histogram + if verbose: + print("Making 3D motion histograms") motion_histograms, temporal_hist_bin_edges, spatial_hist_bin_edges = make_3d_motion_histograms( recording, peaks, @@ -110,6 +112,8 @@ def run( temporal_bins = temporal_hist_bin_edges[:-1] + bin_s // 2.0 # do alignment + if verbose: + print("Estimating alignment shifts") shift_indices, target_histogram, shift_covs_block = iterative_template_registration( motion_histograms, non_rigid_windows=non_rigid_windows, diff --git a/src/spikeinterface/sortingcomponents/motion/motion_utils.py b/src/spikeinterface/sortingcomponents/motion/motion_utils.py index a848ca1746..d98501d257 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_utils.py @@ -235,7 +235,7 @@ def copy(self): def get_spatial_windows( - contact_depth, + contact_depths, spatial_bin_centers, rigid=False, win_shape="gaussian", @@ -248,13 +248,13 @@ def get_spatial_windows( Generate spatial windows (taper) for non-rigid motion. For rigid motion, this is equivalent to have one unique rectangular window that covers the entire probe. The windowing can be gaussian or rectangular. - Windows are centered between the min/max of contact_depth. + Windows are centered between the min/max of contact_depths. We can ensure window to not be to close from border with win_margin_um. Parameters ---------- - contact_depth : np.ndarray + contact_depths : np.ndarray Position of electrodes of the corection direction shape=(num_channels, ) spatial_bin_centers : np.array The pre-computed spatial bin centers @@ -292,9 +292,7 @@ def get_spatial_windows( if rigid: # win_shape = 'rect' is forced - windows = [np.ones(n, dtype="float64")] - middle = (spatial_bin_centers[0] + spatial_bin_centers[-1]) / 2.0 - window_centers = np.array([middle]) + windows, window_centers = get_rigid_windows(spatial_bin_centers) else: if win_scale_um <= win_step_um / 5.0: warnings.warn( @@ -305,27 +303,41 @@ def get_spatial_windows( # this ensure that first/last windows do not overflow outside the probe win_margin_um = -win_scale_um / 2.0 - min_ = np.min(contact_depth) - win_margin_um - max_ = np.max(contact_depth) + win_margin_um - num_windows = int((max_ - min_) // win_step_um) - border = ((max_ - min_) % win_step_um) / 2 - window_centers = np.arange(num_windows + 1) * win_step_um + min_ + border - windows = [] - - for win_center in window_centers: - if win_shape == "gaussian": - win = np.exp(-((spatial_bin_centers - win_center) ** 2) / (2 * win_scale_um**2)) - elif win_shape == "rect": - win = np.abs(spatial_bin_centers - win_center) < (win_scale_um / 2.0) - win = win.astype("float64") - elif win_shape == "triangle": - center_dist = np.abs(spatial_bin_centers - win_center) - in_window = center_dist <= (win_scale_um / 2.0) - win = -center_dist - win[~in_window] = 0 - win[in_window] -= win[in_window].min() - win[in_window] /= win[in_window].max() - windows.append(win) + min_ = np.min(contact_depths) - win_margin_um + max_ = np.max(contact_depths) + win_margin_um + if min_ >= max_: + warnings.warn( + f"get_spatial_windows(): win_margin_um is too large for the probe size. " "Using rigid motion." + ) + # if the probe is too small, we use a single window + windows, window_centers = get_rigid_windows(spatial_bin_centers) + else: + num_windows = int((max_ - min_) // win_step_um) + if num_windows == 0: + warnings.warn( + f"get_spatial_windows(): win_step_um and win_margin_um are too large for the probe size. " + "Using rigid motion." + ) + windows, window_centers = get_rigid_windows(spatial_bin_centers) + else: + border = ((max_ - min_) % win_step_um) / 2 + window_centers = np.arange(num_windows + 1) * win_step_um + min_ + border + windows = [] + + for win_center in window_centers: + if win_shape == "gaussian": + win = np.exp(-((spatial_bin_centers - win_center) ** 2) / (2 * win_scale_um**2)) + elif win_shape == "rect": + win = np.abs(spatial_bin_centers - win_center) < (win_scale_um / 2.0) + win = win.astype("float64") + elif win_shape == "triangle": + center_dist = np.abs(spatial_bin_centers - win_center) + in_window = center_dist <= (win_scale_um / 2.0) + win = -center_dist + win[~in_window] = 0 + win[in_window] -= win[in_window].min() + win[in_window] /= win[in_window].max() + windows.append(win) windows = np.array(windows) @@ -336,6 +348,13 @@ def get_spatial_windows( return windows, window_centers +def get_rigid_windows(spatial_bin_centers): + """Generate a single rectangular window for rigid motion.""" + windows = np.ones((1, spatial_bin_centers.size), dtype="float64") + window_centers = np.array([(spatial_bin_centers[0] + spatial_bin_centers[-1]) / 2.0]) + return windows, window_centers + + def get_window_domains(windows): """Array of windows -> list of slices where window > 0.""" slices = [] @@ -378,10 +397,10 @@ def get_spatial_bin_edges(recording, direction, hist_margin_um, bin_um): # contact along one axis probe = recording.get_probe() dim = ["x", "y", "z"].index(direction) - contact_depth = probe.contact_positions[:, dim] + contact_depths = probe.contact_positions[:, dim] - min_ = np.min(contact_depth) - hist_margin_um - max_ = np.max(contact_depth) + hist_margin_um + min_ = np.min(contact_depths) - hist_margin_um + max_ = np.max(contact_depths) + hist_margin_um spatial_bins = np.arange(min_, max_ + bin_um, bin_um) return spatial_bins From 27a7f12cbdff199a06128e08510c1155da0200b0 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 12 Jul 2024 12:01:29 +0200 Subject: [PATCH 25/31] typo --- src/spikeinterface/sortingcomponents/motion/motion_cleaner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spikeinterface/sortingcomponents/motion/motion_cleaner.py b/src/spikeinterface/sortingcomponents/motion/motion_cleaner.py index 2ac20ad46d..6fe36a6193 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_cleaner.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_cleaner.py @@ -6,7 +6,7 @@ def clean_motion_vector(motion, temporal_bins, bin_duration_s, speed_threshold=30, sigma_smooth_s=None): """ Simple machinery to remove spurious fast bump in the motion vector. - Also can applyt a smoothing. + Also can apply a smoothing. Arguments From 997cdba0170483d8bfac64859ef123a007e6530d Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 12 Jul 2024 12:20:58 +0200 Subject: [PATCH 26/31] Use numpy.broadcast_to for conv_engine numpy in normxcorr1d --- .../preprocessing/tests/test_motion.py | 4 +- .../sortingcomponents/motion/dredge.py | 81 ++++++++++--------- 2 files changed, 44 insertions(+), 41 deletions(-) diff --git a/src/spikeinterface/preprocessing/tests/test_motion.py b/src/spikeinterface/preprocessing/tests/test_motion.py index 02efef1e3b..b7d740cfd6 100644 --- a/src/spikeinterface/preprocessing/tests/test_motion.py +++ b/src/spikeinterface/preprocessing/tests/test_motion.py @@ -13,7 +13,9 @@ def test_estimate_and_correct_motion(create_cache_folder): if folder.exists(): shutil.rmtree(folder) - rec_corrected = correct_motion(rec, folder=folder, estimate_motion_kwargs={"win_step_um": 20}) + rec_corrected = correct_motion( + rec, folder=folder, estimate_motion_kwargs={"win_step_um": 20, "conv_engine": "numpy"} + ) print(rec_corrected) # test reloading motion info diff --git a/src/spikeinterface/sortingcomponents/motion/dredge.py b/src/spikeinterface/sortingcomponents/motion/dredge.py index 7c3b2e4c5a..a0dde6d52b 100644 --- a/src/spikeinterface/sortingcomponents/motion/dredge.py +++ b/src/spikeinterface/sortingcomponents/motion/dredge.py @@ -1137,45 +1137,46 @@ def normxcorr1d( padding="same", conv_engine="torch", ): - """normxcorr1d: Normalized cross-correlation, optionally weighted - - The API is like torch's F.conv1d, except I have accidentally - changed the position of input/weights -- template acts like weights, - and x acts like input. - - Returns the cross-correlation of `template` and `x` at spatial lags - determined by `mode`. Useful for estimating the location of `template` - within `x`. - - This might not be the most efficient implementation -- ideas welcome. - It uses a direct convolutional translation of the formula - corr = (E[XY] - EX EY) / sqrt(var X * var Y) - - This also supports weights! In that case, the usual adaptation of - the above formula is made to the weighted case -- and all of the - normalizations are done per block in the same way. - - Arguments - --------- - template : tensor, shape (num_templates, length) - The reference template signal - x : tensor, 1d shape (length,) or 2d shape (num_inputs, length) - The signal in which to find `template` - weights : tensor, shape (length,) - Will use weighted means, variances, covariances if supplied. - centered : bool - If true, means will be subtracted (per weighted patch). - normalized : bool - If true, normalize by the variance (per weighted patch). - padding : int, optional - How far to look? if unset, we'll use half the length - conv_engine : "torch" | "numpy" - What library to use for computing cross-correlations. - If numpy, falls back to the scipy correlate function. - conv_engine - Returns - ------- - corr : tensor + """ + normxcorr1d: Normalized cross-correlation, optionally weighted + + The API is like torch's F.conv1d, except I have accidentally + changed the position of input/weights -- template acts like weights, + and x acts like input. + + Returns the cross-correlation of `template` and `x` at spatial lags + determined by `mode`. Useful for estimating the location of `template` + within `x`. + + This might not be the most efficient implementation -- ideas welcome. + It uses a direct convolutional translation of the formula + corr = (E[XY] - EX EY) / sqrt(var X * var Y) + + This also supports weights! In that case, the usual adaptation of + the above formula is made to the weighted case -- and all of the + normalizations are done per block in the same way. + + Parameters + ---------- + template : tensor, shape (num_templates, length) + The reference template signal + x : tensor, 1d shape (length,) or 2d shape (num_inputs, length) + The signal in which to find `template` + weights : tensor, shape (length,) + Will use weighted means, variances, covariances if supplied. + centered : bool + If true, means will be subtracted (per weighted patch). + normalized : bool + If true, normalize by the variance (per weighted patch). + padding : int, optional + How far to look? if unset, we'll use half the length + conv_engine : "torch" | "numpy" + What library to use for computing cross-correlations. + If numpy, falls back to the scipy correlate function. + + Returns + ------- + corr : tensor """ if conv_engine == "torch": @@ -1261,7 +1262,7 @@ def normxcorr1d( # now find the final normxcorr corr = cov # renaming for clarity if normalized: - corr[torch.broadcast_to(empty, corr.shape)] = 0 + corr[npx.broadcast_to(empty, corr.shape)] = 0 corr /= npx.sqrt(var_x) corr /= npx.sqrt(var_template) From e8f4e7659a03fcdaddb84f5e58f254685d8f5114 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 12 Jul 2024 12:25:10 +0200 Subject: [PATCH 27/31] formatting --- src/spikeinterface/sortingcomponents/motion/motion_utils.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/spikeinterface/sortingcomponents/motion/motion_utils.py b/src/spikeinterface/sortingcomponents/motion/motion_utils.py index d98501d257..22c64ef8fb 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_utils.py @@ -306,9 +306,7 @@ def get_spatial_windows( min_ = np.min(contact_depths) - win_margin_um max_ = np.max(contact_depths) + win_margin_um if min_ >= max_: - warnings.warn( - f"get_spatial_windows(): win_margin_um is too large for the probe size. " "Using rigid motion." - ) + warnings.warn(f"get_spatial_windows(): win_margin_um is too large for the probe size. Using rigid motion.") # if the probe is too small, we use a single window windows, window_centers = get_rigid_windows(spatial_bin_centers) else: From 756826074a5c5fd23168a3cb31ed80dcb32bb473 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 12 Jul 2024 13:20:51 +0200 Subject: [PATCH 28/31] Fix tdc2 and propagate to API --- doc/api.rst | 18 +++++++---- .../benchmark_with_hybrid_recordings.rst | 32 +++++++++---------- doc/how_to/handle_drift.rst | 2 +- doc/modules/motion_correction.rst | 3 +- doc/modules/sortingcomponents.rst | 6 ++-- .../benchmark_with_hybrid_recordings.py | 2 +- examples/how_to/handle_drift.py | 2 +- src/spikeinterface/preprocessing/motion.py | 4 +-- .../sorters/internal/tridesclous2.py | 2 +- 9 files changed, 38 insertions(+), 33 deletions(-) diff --git a/doc/api.rst b/doc/api.rst index 3e825084e7..ac221ac602 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -408,12 +408,6 @@ Peak Detection .. autofunction:: detect_peaks -Motion Correction -~~~~~~~~~~~~~~~~~ -.. automodule:: spikeinterface.sortingcomponents.motion_interpolation - - .. autoclass:: InterpolateMotionRecording - Clustering ~~~~~~~~~~ .. automodule:: spikeinterface.sortingcomponents.clustering @@ -425,3 +419,15 @@ Template Matching .. automodule:: spikeinterface.sortingcomponents.matching .. autofunction:: find_spikes_from_templates + +Motion Correction +~~~~~~~~~~~~~~~~~ +.. automodule:: spikeinterface.sortingcomponents.motion + + .. autoclass:: Motion + .. autofunction:: estimate_motion + .. autofunction:: interpolate_motion + .. autofunction:: correct_motion_on_peaks + .. autofunction:: interpolate_motion_on_traces + .. autofunction:: clean_motion_vector + .. autoclass:: InterpolateMotionRecording diff --git a/doc/how_to/benchmark_with_hybrid_recordings.rst b/doc/how_to/benchmark_with_hybrid_recordings.rst index 5870d87955..9975bb1a4b 100644 --- a/doc/how_to/benchmark_with_hybrid_recordings.rst +++ b/doc/how_to/benchmark_with_hybrid_recordings.rst @@ -24,7 +24,7 @@ order to smoothly inject spikes into the recording. import spikeinterface.generation as sgen import spikeinterface.widgets as sw - from spikeinterface.sortingcomponents.motion_estimation import estimate_motion + from spikeinterface.sortingcomponents.motion import estimate_motion import numpy as np import matplotlib.pyplot as plt @@ -1202,63 +1202,63 @@ drifts when injecting hybrid spikes. 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 - 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 1. 0. 0.07692308 0.07692308 0.15384615 0.15384615 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 - 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 2. 0. 0.07692308 0.07692308 0.15384615 0.15384615 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 - 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 3. 0. 0.07692308 0.07692308 0.15384615 0.15384615 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 - 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 4. 0. 0.07692308 0.07692308 0.15384615 0.15384615 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 - 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 5. 0. 0.07692308 0.07692308 0.15384615 0.15384615 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 - 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 6. 0. 0.07692308 0.07692308 0.15384615 0.15384615 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 - 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 7. 0. 0.07692308 0.07692308 0.15384615 0.15384615 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 - 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 8. 0. 0.07692308 0.07692308 0.15384615 0.15384615 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 - 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 9. 0. 0.07692308 0.07692308 0.15384615 0.15384615 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 - 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 10. 0. 0.07692308 0.07692308 0.15384615 0.15384615 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 - 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 11. 0. 0.07692308 0.07692308 0.15384615 0.15384615 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 - 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 12. 0. 0.07692308 0.07692308 0.15384615 0.15384615 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 - 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 13. 0. 0.07692308 0.07692308 0.15384615 0.15384615 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 - 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 14. 0. 0.07692308 0.07692308 0.15384615 0.15384615 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385 - 0. 0. 0.07692308 0.07692308 0.15384615 0.15384615 + 15. 0. 0.07692308 0.07692308 0.15384615 0.15384615 0.23076923 0.23076923 0.30769231 0.30769231 0.38461538 0.38461538 0.46153846 0.46153846 0.53846154 0.53846154 0.61538462 0.61538462 0.69230769 0.69230769 0.76923077 0.76923077 0.84615385 0.84615385] diff --git a/doc/how_to/handle_drift.rst b/doc/how_to/handle_drift.rst index 5c4476187b..fae4e8d2f6 100644 --- a/doc/how_to/handle_drift.rst +++ b/doc/how_to/handle_drift.rst @@ -245,7 +245,7 @@ to display the results. .. code:: ipython - from spikeinterface.sortingcomponents.motion_interpolation import correct_motion_on_peaks + from spikeinterface.sortingcomponents.motion import correct_motion_on_peaks for preset in some_presets: folder = base_folder / 'motion_folder_dataset1' / preset diff --git a/doc/modules/motion_correction.rst b/doc/modules/motion_correction.rst index af81cb42d1..076a560e31 100644 --- a/doc/modules/motion_correction.rst +++ b/doc/modules/motion_correction.rst @@ -151,8 +151,7 @@ The high-level :py:func:`~spikeinterface.preprocessing.correct_motion()` is inte from spikeinterface.sortingcomponents.peak_detection import detect_peaks from spikeinterface.sortingcomponents.peak_selection import select_peaks from spikeinterface.sortingcomponents.peak_localization import localize_peaks - from spikeinterface.sortingcomponents.motion_estimation import estimate_motion - from spikeinterface.sortingcomponents.motion_interpolation import interpolate_motion + from spikeinterface.sortingcomponents.motion import estimate_motion, interpolate_motion job_kwargs = dict(chunk_duration="1s", n_jobs=20, progress_bar=True) # Step 1 : activity profile diff --git a/doc/modules/sortingcomponents.rst b/doc/modules/sortingcomponents.rst index e7e05312bc..a32e111bd7 100644 --- a/doc/modules/sortingcomponents.rst +++ b/doc/modules/sortingcomponents.rst @@ -190,7 +190,7 @@ Here is an example with non-rigid motion estimation: peak_locations = localize_peaks(recording=recording, peaks=peaks, ...) # as above - from spikeinterface.sortingcomponents.motion_estimation import estimate_motion + from spikeinterface.sortingcomponents.motion import estimate_motion motion, temporal_bins, spatial_bins, extra_check = estimate_motion(recording=recording, peaks=peaks, peak_locations=peak_locations, direction='y', bin_s=10., bin_um=10., margin_um=0., @@ -206,7 +206,7 @@ Motion interpolation The estimated motion can be used to interpolate traces, in other words, for drift correction. One possible way is to make an interpolation sample-by-sample to compensate for the motion. -The :py:class:`~spikeinterface.sortingcomponents.motion_interpolation.InterpolateMotionRecording` is a preprocessing +The :py:class:`~spikeinterface.sortingcomponents.motion.InterpolateMotionRecording` is a preprocessing step doing this. This preprocessing is *lazy*, so that interpolation is done on-the-fly. However, the class needs the "motion vector" as input, which requires a relatively long computation (peak detection, localization and motion estimation). @@ -216,7 +216,7 @@ Here is a short example that depends on the output of "Motion interpolation": .. code-block:: python - from spikeinterface.sortingcomponents.motion_interpolation import InterpolateMotionRecording + from spikeinterface.sortingcomponents.motion import InterpolateMotionRecording recording_corrected = InterpolateMotionRecording(recording=recording_with_drift, motion=motion, temporal_bins=temporal_bins, spatial_bins=spatial_bins spatial_interpolation_method='kriging', diff --git a/examples/how_to/benchmark_with_hybrid_recordings.py b/examples/how_to/benchmark_with_hybrid_recordings.py index 5507ab7a7f..abf6a25ff5 100644 --- a/examples/how_to/benchmark_with_hybrid_recordings.py +++ b/examples/how_to/benchmark_with_hybrid_recordings.py @@ -32,7 +32,7 @@ import spikeinterface.generation as sgen import spikeinterface.widgets as sw -from spikeinterface.sortingcomponents.motion_estimation import estimate_motion +from spikeinterface.sortingcomponents.motion import estimate_motion import numpy as np import matplotlib.pyplot as plt diff --git a/examples/how_to/handle_drift.py b/examples/how_to/handle_drift.py index 79a7c899f5..ecf17a1b1f 100644 --- a/examples/how_to/handle_drift.py +++ b/examples/how_to/handle_drift.py @@ -167,7 +167,7 @@ def preprocess_chain(rec): # Case 1 is used before running a spike sorter and the case 2 is used here to display the results. # + -from spikeinterface.sortingcomponents.motion_interpolation import correct_motion_on_peaks +from spikeinterface.sortingcomponents.motion import correct_motion_on_peaks for preset in some_presets: folder = base_folder / "motion_folder_dataset1" / preset diff --git a/src/spikeinterface/preprocessing/motion.py b/src/spikeinterface/preprocessing/motion.py index 5e91623257..8e9911b47e 100644 --- a/src/spikeinterface/preprocessing/motion.py +++ b/src/spikeinterface/preprocessing/motion.py @@ -238,8 +238,8 @@ def correct_motion( * :py:func:`~spikeinterface.sortingcomponents.peak_detection.detect_peaks` * :py:func:`~spikeinterface.sortingcomponents.peak_selection.select_peaks` * :py:func:`~spikeinterface.sortingcomponents.peak_localization.localize_peaks` - * :py:func:`~spikeinterface.sortingcomponents.motion.motion_estimation.estimate_motion` - * :py:func:`~spikeinterface.sortingcomponents.motion.motion_interpolation.interpolate_motion` + * :py:func:`~spikeinterface.sortingcomponents.motion.motion.estimate_motion` + * :py:func:`~spikeinterface.sortingcomponents.motion.motion.interpolate_motion` Possible presets : {} diff --git a/src/spikeinterface/sorters/internal/tridesclous2.py b/src/spikeinterface/sorters/internal/tridesclous2.py index 2f965b0483..57755cd759 100644 --- a/src/spikeinterface/sorters/internal/tridesclous2.py +++ b/src/spikeinterface/sorters/internal/tridesclous2.py @@ -89,7 +89,7 @@ def _run_from_folder(cls, sorter_output_folder, params, verbose): from spikeinterface.sortingcomponents.clustering.main import find_cluster_from_peaks from spikeinterface.sortingcomponents.tools import remove_empty_templates from spikeinterface.preprocessing import correct_motion - from spikeinterface.sortingcomponents.motion_interpolation import InterpolateMotionRecording + from spikeinterface.sortingcomponents.motion import InterpolateMotionRecording job_kwargs = params["job_kwargs"].copy() job_kwargs = fix_job_kwargs(job_kwargs) From e3180881d72d855a4e823ef74cd86312048755c9 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 12 Jul 2024 14:06:21 +0200 Subject: [PATCH 29/31] raise error if num_windows<1 and fix motion test --- .../preprocessing/tests/test_motion.py | 4 +- .../sortingcomponents/motion/motion_utils.py | 58 +++++++++---------- 2 files changed, 27 insertions(+), 35 deletions(-) diff --git a/src/spikeinterface/preprocessing/tests/test_motion.py b/src/spikeinterface/preprocessing/tests/test_motion.py index b7d740cfd6..a1ad3766a9 100644 --- a/src/spikeinterface/preprocessing/tests/test_motion.py +++ b/src/spikeinterface/preprocessing/tests/test_motion.py @@ -13,9 +13,7 @@ def test_estimate_and_correct_motion(create_cache_folder): if folder.exists(): shutil.rmtree(folder) - rec_corrected = correct_motion( - rec, folder=folder, estimate_motion_kwargs={"win_step_um": 20, "conv_engine": "numpy"} - ) + rec_corrected = correct_motion(rec, folder=folder, estimate_motion_kwargs={"win_step_um": 50, "win_scale_um": 100}) print(rec_corrected) # test reloading motion info diff --git a/src/spikeinterface/sortingcomponents/motion/motion_utils.py b/src/spikeinterface/sortingcomponents/motion/motion_utils.py index 22c64ef8fb..a45973592b 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_utils.py @@ -296,7 +296,7 @@ def get_spatial_windows( else: if win_scale_um <= win_step_um / 5.0: warnings.warn( - f"get_spatial_windows(): spatial windows are probably not overlaping because {win_scale_um=} and {win_step_um=}" + f"get_spatial_windows(): spatial windows are probably not overlapping because {win_scale_um=} and {win_step_um=}" ) if win_margin_um is None: @@ -305,37 +305,31 @@ def get_spatial_windows( min_ = np.min(contact_depths) - win_margin_um max_ = np.max(contact_depths) + win_margin_um - if min_ >= max_: - warnings.warn(f"get_spatial_windows(): win_margin_um is too large for the probe size. Using rigid motion.") - # if the probe is too small, we use a single window - windows, window_centers = get_rigid_windows(spatial_bin_centers) - else: - num_windows = int((max_ - min_) // win_step_um) - if num_windows == 0: - warnings.warn( - f"get_spatial_windows(): win_step_um and win_margin_um are too large for the probe size. " - "Using rigid motion." - ) - windows, window_centers = get_rigid_windows(spatial_bin_centers) - else: - border = ((max_ - min_) % win_step_um) / 2 - window_centers = np.arange(num_windows + 1) * win_step_um + min_ + border - windows = [] - - for win_center in window_centers: - if win_shape == "gaussian": - win = np.exp(-((spatial_bin_centers - win_center) ** 2) / (2 * win_scale_um**2)) - elif win_shape == "rect": - win = np.abs(spatial_bin_centers - win_center) < (win_scale_um / 2.0) - win = win.astype("float64") - elif win_shape == "triangle": - center_dist = np.abs(spatial_bin_centers - win_center) - in_window = center_dist <= (win_scale_um / 2.0) - win = -center_dist - win[~in_window] = 0 - win[in_window] -= win[in_window].min() - win[in_window] /= win[in_window].max() - windows.append(win) + num_windows = int((max_ - min_) // win_step_um) + + if num_windows < 1: + raise Exception( + f"get_spatial_windows(): win_step_um/win_scale_um/win_margin_um are too large for the probe size. " + "You can try to reduce them or use rigid motion." + ) + border = ((max_ - min_) % win_step_um) / 2 + window_centers = np.arange(num_windows + 1) * win_step_um + min_ + border + windows = [] + + for win_center in window_centers: + if win_shape == "gaussian": + win = np.exp(-((spatial_bin_centers - win_center) ** 2) / (2 * win_scale_um**2)) + elif win_shape == "rect": + win = np.abs(spatial_bin_centers - win_center) < (win_scale_um / 2.0) + win = win.astype("float64") + elif win_shape == "triangle": + center_dist = np.abs(spatial_bin_centers - win_center) + in_window = center_dist <= (win_scale_um / 2.0) + win = -center_dist + win[~in_window] = 0 + win[in_window] -= win[in_window].min() + win[in_window] /= win[in_window].max() + windows.append(win) windows = np.array(windows) From 4c4e70f6d135c25005861a95089cd5d22e4d0321 Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 12 Jul 2024 14:10:30 +0200 Subject: [PATCH 30/31] Fix final(?) torch import --- src/spikeinterface/sortingcomponents/motion/decentralized.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/spikeinterface/sortingcomponents/motion/decentralized.py b/src/spikeinterface/sortingcomponents/motion/decentralized.py index 32f60f568f..41b03b1c43 100644 --- a/src/spikeinterface/sortingcomponents/motion/decentralized.py +++ b/src/spikeinterface/sortingcomponents/motion/decentralized.py @@ -320,12 +320,14 @@ def compute_pairwise_displacement( # use torch if installed try: import torch - import torch.nn.functional as F conv_engine = "torch" except ImportError: conv_engine = "numpy" + if conv_engine == "torch": + import torch + assert conv_engine in ("torch", "numpy"), f"'conv_engine' must be 'torch' or 'numpy'" size = motion_hist.shape[0] pairwise_displacement = np.zeros((size, size), dtype="float32") From abb6a7c4948a93db3a81aa8bcce7add2e97a0d7e Mon Sep 17 00:00:00 2001 From: Alessio Buccino Date: Fri, 12 Jul 2024 14:54:38 +0200 Subject: [PATCH 31/31] Fix generation tests --- src/spikeinterface/generation/tests/test_hybrid_tools.py | 8 +++++--- .../sortingcomponents/motion/motion_utils.py | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/spikeinterface/generation/tests/test_hybrid_tools.py b/src/spikeinterface/generation/tests/test_hybrid_tools.py index d31a0ec81d..bdcd8dbb8f 100644 --- a/src/spikeinterface/generation/tests/test_hybrid_tools.py +++ b/src/spikeinterface/generation/tests/test_hybrid_tools.py @@ -7,7 +7,7 @@ generate_templates, generate_unit_locations, ) -from spikeinterface.preprocessing.motion import correct_motion, load_motion_info +from spikeinterface.preprocessing.motion import correct_motion from spikeinterface.generation.hybrid_tools import ( estimate_templates_from_recording, generate_hybrid_recording, @@ -35,8 +35,10 @@ def test_generate_hybrid_with_sorting(): def test_generate_hybrid_motion(): - rec, _ = generate_ground_truth_recording(sampling_frequency=20000, durations=[10], seed=0) - _, motion_info = correct_motion(rec, output_motion_info=True) + rec, _ = generate_ground_truth_recording(sampling_frequency=20000, durations=[10], num_channels=16, seed=0) + _, motion_info = correct_motion( + rec, output_motion_info=True, estimate_motion_kwargs={"win_step_um": 20, "win_scale_um": 20} + ) motion = motion_info["motion"] hybrid, sorting_hybrid = generate_hybrid_recording(rec, motion=motion, seed=0) assert rec.get_num_channels() == hybrid.get_num_channels() diff --git a/src/spikeinterface/sortingcomponents/motion/motion_utils.py b/src/spikeinterface/sortingcomponents/motion/motion_utils.py index a45973592b..a48e10b3e1 100644 --- a/src/spikeinterface/sortingcomponents/motion/motion_utils.py +++ b/src/spikeinterface/sortingcomponents/motion/motion_utils.py @@ -309,8 +309,8 @@ def get_spatial_windows( if num_windows < 1: raise Exception( - f"get_spatial_windows(): win_step_um/win_scale_um/win_margin_um are too large for the probe size. " - "You can try to reduce them or use rigid motion." + f"get_spatial_windows(): {win_step_um=}/{win_scale_um=}/{win_margin_um=} are too large for the " + f"probe size (depth range={np.ptp(contact_depths)}). You can try to reduce them or use rigid motion." ) border = ((max_ - min_) % win_step_um) / 2 window_centers = np.arange(num_windows + 1) * win_step_um + min_ + border