Skip to content

NDTimeseries is jerky sometimes #1054

Description

@kushalkolar

getting this when zoomed in on the ephys viz

jerky-ephys-1.mp4
jerky-ephys-3.mp4

Not jerkyness when zoomed out, and it's only with the heatmap.

Neither padding nor sampling onto a consistent x-grid chosen a-priori fixes this. Suspect it might be something to do with being very zoomed in in world space.

    def _compute_delta_x_avg(self) -> float | None:
        """
        Estimate a stable x step by sampling local spacing at the 20%, 40%, 60%, and 80%
        positions of the p reference range (5 consecutive data points each).

        Returns the mean step if the four estimates agree within 10%, otherwise warns and
        returns None so _create_heatmap_data falls back to a per-window x step.
        """
        p_dim = self.processor.spatial_dims[1]
        p_range = self._ref_index.ref_ranges[p_dim]
        p_map = self.processor.slider_dim_transforms[p_dim]
        p_span = p_range.stop - p_range.start

        p_mid = p_range.start + p_span / 2
        i_mid = p_map(p_mid)
        i_mid_inc = p_map(p_mid + p_range.step)
        delta_p = p_range.step / max(1, i_mid_inc - i_mid)

        # probe window that yields ~5 consecutive data points
        probe_window = 5 * delta_p

        orig_dw = self.processor.display_window
        self.processor.display_window = probe_window

        base_indices = {d: self._ref_index[d] for d in self.processor.slider_dims}

        local_steps = list()
        for frac in (0.2, 0.4, 0.6, 0.8):
            ref_val = p_range.start + frac * p_span
            indices = {**base_indices, p_dim: ref_val}
            x = (run_sync(self.processor.get(indices)))["data"][0, :, 0]
            if x.size >= 2:
                local_steps.append((x[-1] - x[0]) / (x.size - 1))

        self.processor.display_window = orig_dw

        if len(local_steps) < 2:
            return None

        steps = np.array(local_steps, dtype=np.float64)
        mean_step = float(steps.mean())
        spread = float((steps.max() - steps.min()) / mean_step)

        if spread > 0.10:
            warn(
                f"Heatmap x step varies by {spread:.1%} across the recording "
                f"({steps.min():.4g} to {steps.max():.4g}). "
                f"Using per-window x step; heatmap may jitter."
            )
            return None

        return mean_step

    def _create_heatmap_data(self, data_slice) -> tuple[np.ndarray, float, float]:
        """return padded [n_rows, cap] heatmap, world-space x offset, and x step (sample period)"""
        # data slice is of shape [n_timeseries, n_timepoints, xy]
        x = data_slice[0, :, 0]

        if self._delta_x_avg is not None:
            print("using x avg")
            x_step = self._delta_x_avg
            p_dim = self.processor.spatial_dims[1]
            center = self._ref_index[p_dim]
            dw = self.processor.display_window
            n_cols = max(2, round(dw / x_step))
            x_canonical_start = center - dw / 2
            x_canonical = x_canonical_start + np.arange(n_cols) * x_step
            y_interp = np.empty((data_slice.shape[0], n_cols), dtype=np.float32)
            for i in range(data_slice.shape[0]):
                y_interp[i] = np.interp(x_canonical, x, data_slice[i, :, 1], left=np.nan, right=np.nan)
            x0 = x_canonical_start
        else:
            # check if we need to interpolate
            norm = np.linalg.norm(np.diff(np.diff(x))) / x.size

            if norm > 1e-6:
                # x is not uniform up to float32 precision, must interpolate
                x_uniform = np.linspace(x[0], x[-1], num=x.size)
                y_interp = np.empty(shape=data_slice[..., 1].shape, dtype=np.float32)

                # this for loop is actually slightly faster than numpy.apply_along_axis()
                for i in range(data_slice.shape[0]):
                    y_interp[i] = np.interp(x_uniform, x, data_slice[i, :, 1])

            else:
                # x is sufficiently uniform
                y_interp = data_slice[..., 1]

            n_cols = y_interp.shape[1]
            x0 = x[0]
            # true sample period: n_cols-1 intervals between n_cols values
            x_step = (x[-1] - x0) / (n_cols - 1)

        # pad with NaN columns on each side so frame-to-frame fluctuations in n_cols don't
        # trigger an ImageGraphic texture rebuild. reuse existing capacity if it still fits,
        # rebuild only when too small or > 10% too big. the max() in `upper` guards the small-n
        # case where ceil(1.10 * n_cols) falls below `target` and would force a rebuild on the
        # frame right after allocating.
        pad = max(1, int(np.ceil(0.03 * n_cols)))
        target = n_cols + 2 * pad
        upper = max(int(np.ceil(1.10 * n_cols)), target)
        existing = self._graphic.data.shape[1] if self._graphic is not None else 0

        if n_cols <= existing <= upper:
            cap = existing
            pad_left = (cap - n_cols) // 2
        else:
            cap = target
            pad_left = pad

        image_data = np.full((y_interp.shape[0], cap), np.nan, dtype=np.float32)
        image_data[:, pad_left:pad_left + n_cols] = y_interp

        # shift offset by pad_left columns so data column at index pad_left lands at world-x = x0
        return image_data, x0 - (pad_left * x_step), x_step

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions