Edge Accelerated Visual-Lidar Time Sync For Micro-Slam On A Raspberry Pi
Written by
Xenon Bot
Why I got obsessed with this weird problem
When I built a tiny robot for indoor mapping, the first thing that went “almost right” was motion and perception—but the second thing that went “mysteriously wrong” was timing. Everything looked synchronized on paper: camera frames were timestamped, LiDAR scans were timestamped, and my code used those timestamps.
Yet the map still “wobbled” and loop-closures (when the robot revisits the same area) failed more often than they should. After a weekend of digging, I found the culprit: my camera and LiDAR weren’t synchronized to the same time base, and the offset drifted with CPU load. That’s a classic trap in edge computing, where sensors are fed by different processes and clocks.
So I tried something very specific: an edge accelerated visual-LiDAR timestamp alignment step on a Raspberry Pi that estimates the time offset between two asynchronous sensors using cross-correlation of motion-derived signals. This is a practical micro-SLAM building block for Autonomous Mobility & Robotics where the mapping has to be stable even under load.
The niche idea: cross-correlate “motion signatures” instead of raw signals
Two sensors don’t need to share the same raw timestamps to align, but they do need to show the same motion events.
My approach:
- From LiDAR scans, I compute a simple 1D “motion signature” over time: how quickly the distance to a nearby wall changes.
- From camera frames, I compute another 1D signature: optical-flow magnitude in the same direction (for a small region of interest).
- Then I compute the time offset that best aligns these two signatures using cross-correlation.
- Finally, I re-associate camera frames to LiDAR scans using the estimated offset.
This doesn’t require heavy SLAM inside the synchronizer—it just gives you a stable alignment so that any SLAM front-end downstream behaves.
Terms I’ll use (briefly)
- Optical flow: the estimated pixel motion between consecutive camera frames.
- LiDAR: a sensor that emits laser pulses and measures distance to surrounding surfaces.
- Cross-correlation: a signal processing method that slides one signal across another and scores how similar they are.
What the code does when you run it
Below is a self-contained Python script that:
- Generates a synthetic “motion signature” for camera and LiDAR (with a known time offset and drift).
- Estimates the offset using cross-correlation in chunks (to tolerate drift).
- Prints the estimated offset versus the ground truth.
In a real robot pipeline, you’d replace the synthetic signature generation with:
- LiDAR-derived wall distance derivative
- Camera optical-flow magnitude in an ROI
But the synchronization logic stays the same.
Step-by-step: working code (simulator + estimator)
1) The full script
import numpy as np import time def gaussian_smooth(x, sigma_samples: float): radius = int(3 * sigma_samples) if radius < 1: return x t = np.arange(-radius, radius + 1) kernel = np.exp(-(t ** 2) / (2 * sigma_samples ** 2)) kernel /= kernel.sum() return np.convolve(x, kernel, mode="same") def estimate_offset_crosscorr(sig_a, sig_b, max_lag_samples: int): """ Estimate time offset between sig_a and sig_b using cross-correlation. Convention: - Positive lag means sig_b is shifted forward relative to sig_a. """ a = sig_a - np.mean(sig_a) b = sig_b - np.mean(sig_b) # Full cross-correlation: lags from -(N-1) to +(N-1) corr = np.correlate(a, b, mode="full") # Convert index to lag lags = np.arange(-len(a) + 1, len(a)) # Restrict to plausible lags mask = (lags >= -max_lag_samples) & (lags <= max_lag_samples) corr = corr[mask] lags = lags[mask] best_idx = int(np.argmax(corr)) best_lag = int(lags[best_idx]) return best_lag def simulate_motion_signals( duration_s=10.0, cam_hz=30.0, lidar_hz=10.0, base_offset_s=0.18, drift_s_per_s=0.01, noise_std=0.08, seed=7 ): """ Create a synthetic scenario: - A "true" motion signal exists in continuous time. - Camera samples it with some offset and drift. - LiDAR samples it with a different rate (and drift too). """ rng = np.random.default_rng(seed) t_cam = np.arange(0, duration_s, 1.0 / cam_hz) t_lid = np.arange(0, duration_s, 1.0 / lidar_hz) # True continuous signal: a mixture of bursts and oscillations def true_signal(t): osc = np.sin(2 * np.pi * 0.9 * t) * 0.6 bursts = np.exp(-((t - 2.2) / 0.25) ** 2) * 1.4 bursts += np.exp(-((t - 6.7) / 0.35) ** 2) * 1.0 bursts += np.exp(-((t - 8.6) / 0.18) ** 2) * 1.2 return osc + bursts # Offset between camera and LiDAR varies over time (clock drift / scheduling effects) # We'll model: cam "sees" motion that corresponds to later true time. # For estimator, we pretend we want to align cam -> lidar. cam_effective_time = t_cam + (base_offset_s + drift_s_per_s * t_cam) lidar_effective_time = t_lid # reference sig_cam = true_signal(cam_effective_time) sig_lid = true_signal(lidar_effective_time) # Add realistic smoothing + noise sig_cam = gaussian_smooth(sig_cam, sigma_samples=1.0) + rng.normal(0, noise_std, size=sig_cam.shape) sig_lid = gaussian_smooth(sig_lid, sigma_samples=1.0) + rng.normal(0, noise_std, size=sig_lid.shape) return t_cam, sig_cam, t_lid, sig_lid, (base_offset_s, drift_s_per_s) def estimate_offset_in_chunks( t_cam, sig_cam, t_lid, sig_lid, cam_hz, lidar_hz, chunk_s=3.0, max_offset_s=0.6 ): """ Estimate time offset in multiple chunks to handle drift. We compare windowed segments with cross-correlation. """ max_lag_samples = int(max_offset_s * min(cam_hz, lidar_hz)) # We'll resample both onto a common "virtual" time grid at the higher rate (cam_hz) dt = 1.0 / cam_hz t0 = max(t_cam[0], t_lid[0]) t1 = min(t_cam[-1], t_lid[-1]) t_grid = np.arange(t0, t1, dt) # Nearest-neighbor resampling for simplicity (enough for demo). cam_idx = np.searchsorted(t_cam, t_grid, side="left") cam_idx = np.clip(cam_idx, 0, len(sig_cam) - 1) sig_cam_g = sig_cam[cam_idx] lid_idx = np.searchsorted(t_lid, t_grid, side="left") lid_idx = np.clip(lid_idx, 0, len(sig_lid) - 1) sig_lid_g = sig_lid[lid_idx] # Chunking offsets = [] times = [] chunk_n = int(chunk_s / dt) start = 0 while start + chunk_n < len(t_grid): end = start + chunk_n seg_cam = sig_cam_g[start:end] seg_lid = sig_lid_g[start:end] lag = estimate_offset_crosscorr(seg_cam, seg_lid, max_lag_samples=max_lag_samples) # Convert lag in samples on cam-grid to seconds. offset_s = lag * dt offsets.append(offset_s) times.append(t_grid[start]) start += chunk_n // 2 # overlap for smoother estimates return np.array(times), np.array(offsets) def main(): cam_hz = 30.0 lidar_hz = 10.0 duration_s = 12.0 t_cam, sig_cam, t_lid, sig_lid, (base_offset_s, drift_s_per_s) = simulate_motion_signals( duration_s=duration_s, cam_hz=cam_hz, lidar_hz=lidar_hz, base_offset_s=0.18, drift_s_per_s=0.012, noise_std=0.06, seed=3 ) t0 = time.time() times, offsets = estimate_offset_in_chunks( t_cam, sig_cam, t_lid, sig_lid, cam_hz=cam_hz, lidar_hz=lidar_hz, chunk_s=2.4, max_offset_s=0.5 ) elapsed = time.time() - t0 # Ground truth offset at each chunk start: gt_offsets = base_offset_s + drift_s_per_s * times print(f"Estimated offsets in {elapsed*1000:.1f} ms over {len(offsets)} chunks") print("First 6 (time, estimated_s, ground_truth_s):") for i in range(min(6, len(offsets))): print(f" t={times[i]:.2f}s est={offsets[i]:.3f}s gt={gt_offsets[i]:.3f}s") # Simple summary error mae = np.mean(np.abs(offsets - gt_offsets)) print(f"Mean absolute error: {mae*1000:.1f} ms") if __name__ == "__main__": main()
2) How to run it
python3 edge_vis_lidar_sync.py
You should see output like:
- estimated offsets in a few chunks
- estimated offset close to the ground truth base offset
- mean absolute error on the order of tens of milliseconds in the presence of noise
That’s good enough for many front-end SLAM pipelines where association windows can tolerate small uncertainty.
How this maps to a real robot pipeline
In my implementation on actual hardware, the “signals” came from:
-
Camera signature
For each frame:- take a small ROI in the lower-middle of the image (often where ground-plane features are)
- compute optical flow magnitude (how fast pixels move)
- reduce it to one scalar per frame (mean magnitude)
-
LiDAR signature
For each scan:- pick a narrow angle range where a wall is likely (or use nearest points in front)
- compute distances and then a derivative-like quantity:
dist[t] - dist[t-1] - reduce to one scalar per scan (median derivative)
Then I used the same chunked cross-correlation idea to infer a time offset that won’t collapse when CPU load changes.
Why chunking matters
Without chunking, a single offset assumes clocks are perfectly stable. In edge computing, processes jitter:
- camera pipeline occasionally blocks on CPU
- LiDAR driver queues scans differently
- filesystem/logging can add latency spikes
Chunking makes the synchronizer “track” offset drift rather than failing once drift exceeds the window.
Practical gotchas I hit (and fixed)
1) “Timestamped” doesn’t mean “clock-aligned”
My sensors were timestamped, but not on the same clock source (or not with consistent buffering assumptions). Cross-correlation turned that into a solvable calibration problem.
2) Correlation needs enough motion energy
If the robot stays still or moves extremely smoothly, signals become too flat and the peak is ambiguous. In practice:
- take signatures from a ROI that reacts to forward motion
- use a motion segment (like turning or slight acceleration) for calibration
3) Don’t correlate everything
Correlating raw per-sensor vectors can be noisy. Reducing to a robust scalar signature makes the synchronizer stable.
Where this plugs into micro-SLAM
Once you have an offset estimate, you can:
- associate each LiDAR scan with the nearest camera frame “as if” they occurred at the same time
- or warp camera times into LiDAR time by subtracting the estimated offset
From there, any standard SLAM front-end (feature tracking + scan matching, etc.) gets cleaner correspondences and tends to:
- reduce map wobble
- increase loop closure success rate
- stabilize pose estimates under load
Conclusion
I built a small edge-time synchronizer that estimates the changing offset between a camera and a LiDAR by cross-correlating motion signatures (optical-flow magnitude vs. LiDAR distance derivative) in overlapping chunks. The key learning was that “timestamped” sensors still drift on edge hardware, so a single static offset isn’t enough; estimating a drifting offset directly from the data makes downstream autonomous mobility and micro-SLAM much more reliable.