@@ -41,6 +41,11 @@ def warn_once(func, message):
4141 setattr (target , "_warned" , True )
4242
4343
44+ # Smoothing factor for the per-channel EMG DC baseline (exponential moving average; ~1 s time
45+ # constant at typical EMG batch rates). Used to center each channel's AC signal around zero.
46+ EMG_BASELINE_SMOOTHING_FACTOR = 0.99
47+
48+
4449@dataclass
4550class SensorLabels :
4651 """
@@ -144,6 +149,11 @@ class AriaDataViewerConfig:
144149
145150 enable_gps = False
146151
152+ # Whether to include the EMG panel in the blueprint. Set true only when the recording
153+ # actually contains an EMG (Ceres wristband) stream, so other recordings don't get an
154+ # empty EMG view taking up vertical space.
155+ enable_emg = False
156+
147157 enable_crop_visualization = False
148158
149159 # rerun memory limit (default parameter is 75% of available memory)
@@ -237,6 +247,12 @@ def __init__(
237247 self .vio_high_freq_traj_cached_full = []
238248 # A variable to cache full VIO trajectory
239249 self .vio_traj_cached_full = []
250+ # Timestamp (sec) of the previous EMG sample, used to spread a batch's
251+ # sub-samples across the inter-batch interval when plotting.
252+ self ._prev_emg_time_sec = None
253+ # Slowly-adapting per-channel EMG DC baseline (np array, one entry per channel),
254+ # subtracted to center each channel's AC signal around zero. None until first batch.
255+ self ._emg_baseline = None
240256 # Scale ratio to convert plot sizes from RGB camera space to SLAM camera space (based on camera resolution ratio)
241257
242258 if rrd_output_path :
@@ -444,6 +460,14 @@ def _create_gen2_rerun_blueprint(self):
444460 origin = self .sensor_labels .contact_microphone_label
445461 )
446462
463+ # EMG IMU batch view: only included when an EMG (Ceres wristband) stream is present in
464+ # the recording, so recordings without one don't get an empty panel wasting space.
465+ emg_views = (
466+ [rrb .TimeSeriesView (name = "emg" , origin = "emg" )]
467+ if self .config .enable_emg
468+ else []
469+ )
470+
447471 # Create latency view if enabled (for streaming use case)
448472 latency_views = []
449473 if self .config .show_latency :
@@ -458,6 +482,7 @@ def _create_gen2_rerun_blueprint(self):
458482 _1d_view_container .contents [0 ], # IMU plots
459483 _1d_view_container .contents [1 ], # mic
460484 contact_mic_1d_view , # contact mic
485+ * emg_views , # EMG IMU batch (only when present)
461486 _1d_view_container .contents [2 ], # Tabbed baro + mag
462487 * latency_views , # latency (optional, for streaming)
463488 )
@@ -808,6 +833,93 @@ def plot_barometer(self, barometer_data):
808833 rr .Scalars (barometer_data .temperature ),
809834 ) # Degree Celsius
810835
836+ def plot_emg (self , emg_data , label , device_time_ns ):
837+ """Plot EMG IMU batch data as one scalar time series per channel.
838+
839+ Each EMG sample carries a packed blob of `samples_per_batch` sub-samples across
840+ `channel_count` ADC channels, decoded as big-endian *unsigned* 16-bit (offset-binary)
841+ shaped [samples_per_batch, channel_count]. Each channel is centered by removing a
842+ slowly-adapting per-channel DC baseline (exponential moving average) -- electrodes have
843+ individual biases, so a per-channel baseline keeps every channel readable on a shared
844+ auto-scaled axis. One Rerun time series is logged per channel.
845+
846+ The batch is anchored on `device_time_ns` (the batch capture time, on the shared device
847+ timeline) rather than the per-sample timestamp: the per-sample EMG timestamp is a
848+ sensor-internal clock that does not span the recording, so using it collapses all
849+ batches into a tiny time window. The sub-samples are evenly spread across the interval
850+ since the previous EMG batch.
851+
852+ NOTE: the decode (big-endian, unsigned/offset-binary) was confirmed empirically --
853+ little-endian or signed interpretation fills the full int16 range, while big-endian
854+ unsigned yields an EMG-like signal. The sample- vs channel-major reshape order is still
855+ assumed; confirm channel ordering with the recording team.
856+ """
857+ if emg_data is None :
858+ warn_once (self .plot_emg , "EMG data is None" )
859+ return
860+
861+ channel_count = emg_data .channel_count
862+ samples_per_batch = emg_data .samples_per_batch
863+ if channel_count <= 0 or samples_per_batch <= 0 :
864+ warn_once (
865+ self .plot_emg ,
866+ "EMG channel_count/samples_per_batch not populated; skipping EMG plot" ,
867+ )
868+ return
869+
870+ # Decode and stack every EMG sample in the batch into a single [total_samples, channel]
871+ # array. The device packs the ADC samples big-endian, unsigned (offset-binary).
872+ rows = []
873+ for sample in emg_data .emg :
874+ buffer = np .frombuffer (sample .packed_channel_data , dtype = ">u2" )
875+ if buffer .size != channel_count * samples_per_batch :
876+ warn_once (
877+ self .plot_emg ,
878+ f"EMG blob size { buffer .size } != channel_count*samples_per_batch "
879+ f"({ channel_count } *{ samples_per_batch } ); skipping EMG plot" ,
880+ )
881+ return
882+ rows .append (buffer .reshape (samples_per_batch , channel_count ))
883+ if not rows :
884+ return
885+ raw_counts = np .concatenate (rows , axis = 0 ).astype (np .float64 )
886+
887+ # Per-channel DC removal: each electrode has its own bias, so subtract a slowly-adapting
888+ # per-channel baseline to center the AC EMG around zero.
889+ batch_mean = raw_counts .mean (axis = 0 )
890+ if self ._emg_baseline is None :
891+ self ._emg_baseline = batch_mean
892+ else :
893+ self ._emg_baseline = (
894+ EMG_BASELINE_SMOOTHING_FACTOR * self ._emg_baseline
895+ + (1.0 - EMG_BASELINE_SMOOTHING_FACTOR ) * batch_mean
896+ )
897+ values = raw_counts - self ._emg_baseline
898+ total_samples = values .shape [0 ]
899+
900+ # Spread the batch's sub-samples evenly across the interval since the previous batch,
901+ # on the device timeline.
902+ end_time_sec = device_time_ns * 1e-9
903+ prev_time_sec = (
904+ self ._prev_emg_time_sec
905+ if self ._prev_emg_time_sec is not None
906+ else end_time_sec
907+ )
908+ if total_samples > 1 and end_time_sec > prev_time_sec :
909+ timestamps_sec = np .linspace (
910+ prev_time_sec , end_time_sec , total_samples , endpoint = True
911+ )
912+ else :
913+ timestamps_sec = np .full (total_samples , end_time_sec )
914+ self ._prev_emg_time_sec = end_time_sec
915+
916+ for channel in range (channel_count ):
917+ rr .send_columns (
918+ f"{ label } /channel_{ channel } " ,
919+ indexes = [rr .TimeColumn ("device_time" , timestamp = timestamps_sec )],
920+ columns = rr .Scalars .columns (scalars = values [:, channel ]),
921+ )
922+
811923 def _plot_audio_from_selected_channels (
812924 self ,
813925 audio_data_and_record ,
0 commit comments