Skip to content

Commit 4667a43

Browse files
jeffxtangfacebook-github-bot
authored andcommitted
{Feature} Core - Decode EMG samples and add a numpy accessor in PAT
Summary: Explanation: Project Aria Tools previously exposed each EMG sample only as a raw packed byte blob (`EmgImuSample.packed_channel_data`), forcing every consumer to reimplement the same big-endian / offset-binary / sample-major unpacking. This moves the decode into PAT so callers get decoded samples directly. - New `decodeEmgSamples(const EmgData&)` in `core/data_provider/players/EmgPlayer.{h,cpp}` returns a `DecodedEmgSamples` struct (row-major `[num_sub_samples, channel_count]` raw ADC counts). It unpacks each `EmgData.emg` blob (big-endian, unsigned 16-bit, offset-binary, sample-major), skips malformed blobs, and throws `std::invalid_argument` for an unsupported bit depth or a non-zero encoding. - Python: `EmgData.get_emg_samples()` and the module-level `sensor_data.decode_emg_samples(emg_data)` return the decoded batch as a `[num_sub_samples, channel_count]` `uint16` numpy array (`core/python/SensorDataPyBind.h`; stub in `core/python/internal/stubs/sensor_data.pyi`). No physical-unit (microvolt) conversion is provided: Aria Gen 2 recordings carry no EMG calibration (the EMG stream's configuration calibration field is empty, and the device factory-calibration JSON has no EMG section), so values remain raw ADC counts and must be treated as relative. Reproducibility: Build and run the new C++ and Python unit tests (see Test Plan). The decoded array equals an independent big-endian `uint16` reshape of the packed blobs. Differential Revision: D110117191
1 parent cfe32e6 commit 4667a43

5 files changed

Lines changed: 234 additions & 1 deletion

File tree

core/data_provider/players/EmgPlayer.cpp

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@
1717
#include <data_provider/players/EmgPlayer.h>
1818

1919
#include <algorithm>
20+
#include <cstdint>
21+
#include <stdexcept>
22+
#include <string>
2023

2124
#define DEFAULT_LOG_CHANNEL "EmgPlayer"
2225
#include <logging/Log.h>
@@ -113,4 +116,52 @@ bool EmgPlayer::onDataLayoutRead(
113116
return true;
114117
}
115118

119+
DecodedEmgSamples decodeEmgSamples(const EmgData& data) {
120+
constexpr uint32_t kSupportedBitsPerAdcReading = 16;
121+
if (data.bitsPerAdcReading != kSupportedBitsPerAdcReading) {
122+
throw std::invalid_argument(
123+
"decodeEmgSamples only supports 16-bit ADC readings, got " +
124+
std::to_string(data.bitsPerAdcReading));
125+
}
126+
127+
DecodedEmgSamples decoded;
128+
decoded.numChannels = data.channelCount;
129+
if (data.channelCount == 0 || data.samplesPerBatch == 0) {
130+
return decoded;
131+
}
132+
133+
const size_t bytesPerSample =
134+
static_cast<size_t>(data.samplesPerBatch) * data.channelCount * sizeof(uint16_t);
135+
decoded.values.reserve(
136+
data.emg.size() * static_cast<size_t>(data.samplesPerBatch) * data.channelCount);
137+
for (const EmgImuSample& sample : data.emg) {
138+
if (sample.encoding != 0) {
139+
throw std::invalid_argument(
140+
"decodeEmgSamples only supports unencoded samples (encoding == 0), got encoding " +
141+
std::to_string(sample.encoding));
142+
}
143+
if (sample.packedChannelData.size() != bytesPerSample) {
144+
XR_LOGE(
145+
"Skipping malformed EMG sample blob (size {} != expected {})",
146+
sample.packedChannelData.size(),
147+
bytesPerSample);
148+
continue;
149+
}
150+
const auto* bytes = reinterpret_cast<const uint8_t*>(sample.packedChannelData.data());
151+
for (uint32_t s = 0; s < data.samplesPerBatch; ++s) {
152+
for (uint32_t c = 0; c < data.channelCount; ++c) {
153+
const size_t byteOffset =
154+
(static_cast<size_t>(s) * data.channelCount + c) * sizeof(uint16_t);
155+
// Big-endian: the first byte is the most-significant.
156+
const uint16_t value = static_cast<uint16_t>(
157+
(static_cast<uint16_t>(bytes[byteOffset]) << 8) |
158+
static_cast<uint16_t>(bytes[byteOffset + 1]));
159+
decoded.values.push_back(value);
160+
}
161+
}
162+
}
163+
decoded.numRows = static_cast<uint32_t>(decoded.values.size() / data.channelCount);
164+
return decoded;
165+
}
166+
116167
} // namespace projectaria::tools::data_provider

core/data_provider/players/EmgPlayer.h

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,30 @@ struct EmgData {
6666
uint32_t samplesPerBatch{}; ///< @brief number of samples per batch
6767
};
6868

69+
/**
70+
* @brief Decoded EMG sub-samples of a single batch, laid out row-major as [numRows, numChannels].
71+
*
72+
* numRows == samplesPerBatch * (number of well-formed EMG samples in the batch). Values are the raw
73+
* per-channel ADC counts in offset-binary (baseline near 2^(bitsPerAdcReading - 1)); no baseline
74+
* removal or physical-unit conversion is applied (the recording carries no EMG calibration).
75+
*/
76+
struct DecodedEmgSamples {
77+
std::vector<uint16_t> values; ///< @brief row-major ADC counts, size == numRows * numChannels
78+
uint32_t numRows{}; ///< @brief number of decoded sub-samples (rows)
79+
uint32_t numChannels{}; ///< @brief number of EMG channels (columns)
80+
};
81+
82+
/**
83+
* @brief Decode the packed EMG sub-samples of a batch into raw per-channel ADC counts.
84+
*
85+
* Unpacks each EmgImuSample::packedChannelData blob in EmgData::emg (big-endian, unsigned 16-bit,
86+
* offset-binary, sample-major [samplesPerBatch, channelCount]) and concatenates the batch's samples
87+
* into one [numRows, channelCount] block. Blobs whose size != samplesPerBatch * channelCount * 2
88+
* are skipped. Throws std::invalid_argument if bitsPerAdcReading != 16 or a sample uses a non-zero
89+
* encoding, both of which are unsupported by this decoder.
90+
*/
91+
DecodedEmgSamples decodeEmgSamples(const EmgData& data);
92+
6993
using EmgCallback =
7094
std::function<bool(const EmgData& data, const EmgConfiguration& config, bool verbose)>;
7195

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
#include <data_provider/players/EmgPlayer.h>
18+
19+
#include <gtest/gtest.h>
20+
21+
#include <cstdint>
22+
#include <stdexcept>
23+
#include <string>
24+
#include <vector>
25+
26+
using namespace projectaria::tools::data_provider;
27+
28+
namespace {
29+
// Pack sample-major ADC counts into a big-endian (most-significant byte first) blob, the inverse of
30+
// the layout decodeEmgSamples is expected to unpack.
31+
std::string packBigEndian(const std::vector<uint16_t>& counts) {
32+
std::string blob;
33+
blob.reserve(counts.size() * sizeof(uint16_t));
34+
for (const uint16_t value : counts) {
35+
blob.push_back(static_cast<char>((value >> 8) & 0xFF));
36+
blob.push_back(static_cast<char>(value & 0xFF));
37+
}
38+
return blob;
39+
}
40+
41+
EmgImuSample makeSample(const std::vector<uint16_t>& counts, uint32_t encoding = 0) {
42+
EmgImuSample sample;
43+
sample.packedChannelData = packBigEndian(counts);
44+
sample.encoding = encoding;
45+
return sample;
46+
}
47+
} // namespace
48+
49+
TEST(DecodeEmgSamples, decodesBigEndianSampleMajorBatch) {
50+
EmgData data;
51+
data.channelCount = 2;
52+
data.samplesPerBatch = 2;
53+
data.bitsPerAdcReading = 16;
54+
// Two samples, each carrying samplesPerBatch * channelCount counts in sample-major order.
55+
data.emg.push_back(makeSample({0x0102, 0x0304, 0x0506, 0x0708}));
56+
data.emg.push_back(makeSample({0x1112, 0x1314, 0x1516, 0x1718}));
57+
58+
const DecodedEmgSamples decoded = decodeEmgSamples(data);
59+
60+
const std::vector<uint16_t> expectedValues{
61+
0x0102, 0x0304, 0x0506, 0x0708, 0x1112, 0x1314, 0x1516, 0x1718};
62+
EXPECT_EQ(decoded.values, expectedValues);
63+
EXPECT_EQ(decoded.numRows, 4u); // samplesPerBatch (2) * number of samples (2)
64+
EXPECT_EQ(decoded.numChannels, 2u);
65+
}
66+
67+
TEST(DecodeEmgSamples, skipsMalformedBlobAndKeepsWellFormedSamples) {
68+
EmgData data;
69+
data.channelCount = 2;
70+
data.samplesPerBatch = 2;
71+
data.bitsPerAdcReading = 16;
72+
data.emg.push_back(makeSample({0x0102, 0x0304, 0x0506, 0x0708}));
73+
EmgImuSample malformed;
74+
malformed.packedChannelData =
75+
std::string(3, '\0'); // not samplesPerBatch * channelCount * 2 bytes
76+
data.emg.push_back(malformed);
77+
78+
const DecodedEmgSamples decoded = decodeEmgSamples(data);
79+
80+
const std::vector<uint16_t> expectedValues{0x0102, 0x0304, 0x0506, 0x0708};
81+
EXPECT_EQ(decoded.values, expectedValues);
82+
EXPECT_EQ(decoded.numRows, 2u);
83+
}
84+
85+
TEST(DecodeEmgSamples, returnsEmptyWhenChannelCountIsZero) {
86+
EmgData data;
87+
data.channelCount = 0;
88+
data.samplesPerBatch = 2;
89+
data.bitsPerAdcReading = 16;
90+
91+
const DecodedEmgSamples decoded = decodeEmgSamples(data);
92+
93+
EXPECT_TRUE(decoded.values.empty());
94+
EXPECT_EQ(decoded.numRows, 0u);
95+
EXPECT_EQ(decoded.numChannels, 0u);
96+
}
97+
98+
TEST(DecodeEmgSamples, throwsOnUnsupportedBitDepth) {
99+
EmgData data;
100+
data.channelCount = 2;
101+
data.samplesPerBatch = 2;
102+
data.bitsPerAdcReading = 12;
103+
104+
EXPECT_THROW(decodeEmgSamples(data), std::invalid_argument);
105+
}
106+
107+
TEST(DecodeEmgSamples, throwsOnNonZeroEncoding) {
108+
EmgData data;
109+
data.channelCount = 2;
110+
data.samplesPerBatch = 2;
111+
data.bitsPerAdcReading = 16;
112+
data.emg.push_back(makeSample({0x0102, 0x0304, 0x0506, 0x0708}, /*encoding=*/1));
113+
114+
EXPECT_THROW(decodeEmgSamples(data), std::invalid_argument);
115+
}

core/python/SensorDataPyBind.h

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,16 @@ inline void declarePpgDataRecord(py::module& m) {
497497
"integration_time_us", &PpgData::integrationTimeUs, "PPG integration time in us");
498498
}
499499

500+
// Decode an EMG batch's packed blobs into a [num_sub_samples, channel_count] numpy array of raw ADC
501+
// counts. Shared by EmgData.get_emg_samples() and the module-level decode_emg_samples() helper.
502+
inline py::array_t<uint16_t> emgSamplesToNumpy(const EmgData& data) {
503+
const DecodedEmgSamples decoded = decodeEmgSamples(data);
504+
return py::array_t<uint16_t>(
505+
{static_cast<size_t>(decoded.numRows), static_cast<size_t>(decoded.numChannels)},
506+
{static_cast<size_t>(decoded.numChannels) * sizeof(uint16_t), sizeof(uint16_t)},
507+
decoded.values.data());
508+
}
509+
500510
inline void declareEmgDataRecord(py::module& m) {
501511
py::class_<EmgConfiguration>(m, "EmgConfiguration", "EMG sensor configuration type")
502512
.def(py::init<>())
@@ -540,7 +550,17 @@ inline void declareEmgDataRecord(py::module& m) {
540550
.def_readwrite("channel_count", &EmgData::channelCount, "number of EMG channels")
541551
.def_readwrite(
542552
"bits_per_adc_reading", &EmgData::bitsPerAdcReading, "number of bits per ADC reading")
543-
.def_readwrite("samples_per_batch", &EmgData::samplesPerBatch, "number of samples per batch");
553+
.def_readwrite("samples_per_batch", &EmgData::samplesPerBatch, "number of samples per batch")
554+
.def(
555+
"get_emg_samples",
556+
[](const EmgData& self) { return emgSamplesToNumpy(self); },
557+
"Decode this batch's packed EMG blobs into a [num_sub_samples, channel_count] numpy array of raw ADC counts (big-endian, unsigned 16-bit, offset-binary; no baseline removal or unit conversion). Raises ValueError for unsupported bit depth or encoding.");
558+
559+
m.def(
560+
"decode_emg_samples",
561+
[](const EmgData& emgData) { return emgSamplesToNumpy(emgData); },
562+
py::arg("emg_data"),
563+
"Decode an EmgData batch's packed EMG blobs into a [num_sub_samples, channel_count] numpy array of raw ADC counts (big-endian, unsigned 16-bit, offset-binary, sample-major). Equivalent to EmgData.get_emg_samples(). Raises ValueError for unsupported bit depth or encoding.");
544564
}
545565

546566
inline void declareAlsDataRecord(py::module& m) {

core/python/test/corePyBindTest.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
import numpy as np
2020
from projectaria_tools.core import calibration, data_provider
2121
from projectaria_tools.core.sensor_data import (
22+
decode_emg_samples,
23+
EmgData,
24+
EmgImuSample,
2225
SensorDataType,
2326
TimeDomain,
2427
TimeQueryOptions,
@@ -552,3 +555,23 @@ def test_device_version_gen2(self) -> None:
552555
provider = data_provider.create_vrs_data_provider(vrs_filepath_list[1])
553556
device_version = provider.get_device_version()
554557
assert device_version == calibration.DeviceVersion.Gen2
558+
559+
def test_emg_decode_samples(self) -> None:
560+
# Build an EMG batch in memory: big-endian uint16, sample-major [samples_per_batch, channels].
561+
emg_data = EmgData()
562+
emg_data.channel_count = 2
563+
emg_data.samples_per_batch = 2
564+
emg_data.bits_per_adc_reading = 16
565+
counts = np.array([[0x0102, 0x0304], [0x0506, 0x0708]], dtype=">u2")
566+
sample = EmgImuSample()
567+
sample.packed_channel_data = counts.tobytes()
568+
emg_data.emg = [sample]
569+
570+
decoded = emg_data.get_emg_samples()
571+
expected = counts.astype(np.uint16)
572+
573+
assert decoded.dtype == np.uint16
574+
assert decoded.shape == (emg_data.samples_per_batch, emg_data.channel_count)
575+
assert np.array_equal(decoded, expected)
576+
# The EmgData method and the module-level helper must agree.
577+
assert np.array_equal(decoded, decode_emg_samples(emg_data))

0 commit comments

Comments
 (0)