Skip to content

Commit c3bd395

Browse files
jeffxtangmeta-codesync[bot]
authored andcommitted
Feature Visualization - Visualize EMG stream in aria_rerun_viewer
Summary: Explanation: Follow-up to the EMG IMU batch reader (D107717690). Adds EMG visualization to `aria_rerun_viewer`: the `emg` stream is now enabled by default (added to `ALL_STREAM_LABELS_GEN2`) and gets a dedicated Rerun `TimeSeriesView` in the Gen2 blueprint. A new `AriaDataViewer.plot_emg()` decodes each batch's `packed_channel_data` blob (`channel_count` x 16-bit x `samples_per_batch`) into per-channel int16 series via numpy and logs one Rerun time series per channel, spreading each batch's sub-samples across the interval since the previous EMG sample (each sample carries only a single batch timestamp). Note: the blob byte layout (int16 endianness, sample- vs channel-major order) is assumed from the metadata sizes (`channel_count * samples_per_batch * 2 == len(blob)`), with a guard that skips and warns on mismatch; to be confirmed with the recording team. EMG is Gen2-only. Reproducibility: buck2 build of the `aria_rerun_viewer` target passes and `py_compile` is clean. See the Test Plan. Reviewed By: kongchen1992 Differential Revision: D108698585 fbshipit-source-id: 33a8471a5954f98a25908e1c12afbdb1495851ce
1 parent b951be7 commit c3bd395

2 files changed

Lines changed: 119 additions & 0 deletions

File tree

projectaria_tools/tools/aria_rerun_viewer/aria_data_plotter.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
4550
class 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,

projectaria_tools/tools/aria_rerun_viewer/aria_rerun_viewer.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"mag0",
4747
"gps",
4848
"gps-app",
49+
"emg",
4950
"handtracking",
5051
"eyegaze",
5152
"vio",
@@ -290,6 +291,8 @@ def plot_queued_sensor_data(
290291
aria_data_viewer.plot_magnetometer(data.magnetometer_data())
291292
elif data.sensor_data_type() == SensorDataType.BAROMETER:
292293
aria_data_viewer.plot_barometer(data.barometer_data())
294+
elif data.sensor_data_type() == SensorDataType.EMG:
295+
aria_data_viewer.plot_emg(data.emg_data(), label, device_time_ns)
293296
elif data.sensor_data_type() == SensorDataType.AUDIO:
294297
aria_data_viewer.plot_audio(
295298
data.audio_data_and_record(),
@@ -335,6 +338,10 @@ def log_vrs_to_rerun(
335338
# Step 3: Create config
336339
viewer_config: AriaDataViewerConfig = AriaDataViewerConfig()
337340
viewer_config.enable_gps = True
341+
# Only show the EMG panel when this recording actually has an EMG (Ceres wristband) stream.
342+
viewer_config.enable_emg = (
343+
vrs_data_provider.get_stream_id_from_label("emg") is not None
344+
)
338345

339346
# Step 4: Get configured deliver options
340347
parsed_subsample_rates: dict[str, int] = (

0 commit comments

Comments
 (0)