Skip to content

NDTimeseries is jerky sometimes #1054

@kushalkolar

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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions