Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions core/data_provider/players/EmgPlayer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
#include <data_provider/players/EmgPlayer.h>

#include <algorithm>
#include <cstdint>
#include <stdexcept>
#include <string>

#define DEFAULT_LOG_CHANNEL "EmgPlayer"
#include <logging/Log.h>
Expand Down Expand Up @@ -113,4 +116,52 @@ bool EmgPlayer::onDataLayoutRead(
return true;
}

DecodedEmgSamples decodeEmgSamples(const EmgData& data) {
constexpr uint32_t kSupportedBitsPerAdcReading = 16;
if (data.bitsPerAdcReading != kSupportedBitsPerAdcReading) {
throw std::invalid_argument(
"decodeEmgSamples only supports 16-bit ADC readings, got " +
std::to_string(data.bitsPerAdcReading));
}

DecodedEmgSamples decoded;
decoded.numChannels = data.channelCount;
if (data.channelCount == 0 || data.samplesPerBatch == 0) {
return decoded;
}

const size_t bytesPerSample =
static_cast<size_t>(data.samplesPerBatch) * data.channelCount * sizeof(uint16_t);
decoded.values.reserve(
data.emg.size() * static_cast<size_t>(data.samplesPerBatch) * data.channelCount);
for (const EmgImuSample& sample : data.emg) {
if (sample.encoding != 0) {
throw std::invalid_argument(
"decodeEmgSamples only supports unencoded samples (encoding == 0), got encoding " +
std::to_string(sample.encoding));
}
if (sample.packedChannelData.size() != bytesPerSample) {
XR_LOGE(
"Skipping malformed EMG sample blob (size {} != expected {})",
sample.packedChannelData.size(),
bytesPerSample);
continue;
}
const auto* bytes = reinterpret_cast<const uint8_t*>(sample.packedChannelData.data());
for (uint32_t s = 0; s < data.samplesPerBatch; ++s) {
for (uint32_t c = 0; c < data.channelCount; ++c) {
const size_t byteOffset =
(static_cast<size_t>(s) * data.channelCount + c) * sizeof(uint16_t);
// Big-endian: the first byte is the most-significant.
const uint16_t value = static_cast<uint16_t>(
(static_cast<uint16_t>(bytes[byteOffset]) << 8) |
static_cast<uint16_t>(bytes[byteOffset + 1]));
decoded.values.push_back(value);
}
}
}
decoded.numRows = static_cast<uint32_t>(decoded.values.size() / data.channelCount);
return decoded;
}

} // namespace projectaria::tools::data_provider
24 changes: 24 additions & 0 deletions core/data_provider/players/EmgPlayer.h
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,30 @@ struct EmgData {
uint32_t samplesPerBatch{}; ///< @brief number of samples per batch
};

/**
* @brief Decoded EMG sub-samples of a single batch, laid out row-major as [numRows, numChannels].
*
* numRows == samplesPerBatch * (number of well-formed EMG samples in the batch). Values are the raw
* per-channel ADC counts in offset-binary (baseline near 2^(bitsPerAdcReading - 1)); no baseline
* removal or physical-unit conversion is applied (the recording carries no EMG calibration).
*/
struct DecodedEmgSamples {
std::vector<uint16_t> values; ///< @brief row-major ADC counts, size == numRows * numChannels
uint32_t numRows{}; ///< @brief number of decoded sub-samples (rows)
uint32_t numChannels{}; ///< @brief number of EMG channels (columns)
};

/**
* @brief Decode the packed EMG sub-samples of a batch into raw per-channel ADC counts.
*
* Unpacks each EmgImuSample::packedChannelData blob in EmgData::emg (big-endian, unsigned 16-bit,
* offset-binary, sample-major [samplesPerBatch, channelCount]) and concatenates the batch's samples
* into one [numRows, channelCount] block. Blobs whose size != samplesPerBatch * channelCount * 2
* are skipped. Throws std::invalid_argument if bitsPerAdcReading != 16 or a sample uses a non-zero
* encoding, both of which are unsupported by this decoder.
*/
DecodedEmgSamples decodeEmgSamples(const EmgData& data);

using EmgCallback =
std::function<bool(const EmgData& data, const EmgConfiguration& config, bool verbose)>;

Expand Down
115 changes: 115 additions & 0 deletions core/data_provider/test/EmgPlayerTest.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

#include <data_provider/players/EmgPlayer.h>

#include <gtest/gtest.h>

#include <cstdint>
#include <stdexcept>
#include <string>
#include <vector>

using namespace projectaria::tools::data_provider;

namespace {
// Pack sample-major ADC counts into a big-endian (most-significant byte first) blob, the inverse of
// the layout decodeEmgSamples is expected to unpack.
std::string packBigEndian(const std::vector<uint16_t>& counts) {
std::string blob;
blob.reserve(counts.size() * sizeof(uint16_t));
for (const uint16_t value : counts) {
blob.push_back(static_cast<char>((value >> 8) & 0xFF));
blob.push_back(static_cast<char>(value & 0xFF));
}
return blob;
}

EmgImuSample makeSample(const std::vector<uint16_t>& counts, uint32_t encoding = 0) {
EmgImuSample sample;
sample.packedChannelData = packBigEndian(counts);
sample.encoding = encoding;
return sample;
}
} // namespace

TEST(DecodeEmgSamples, decodesBigEndianSampleMajorBatch) {
EmgData data;
data.channelCount = 2;
data.samplesPerBatch = 2;
data.bitsPerAdcReading = 16;
// Two samples, each carrying samplesPerBatch * channelCount counts in sample-major order.
data.emg.push_back(makeSample({0x0102, 0x0304, 0x0506, 0x0708}));
data.emg.push_back(makeSample({0x1112, 0x1314, 0x1516, 0x1718}));

const DecodedEmgSamples decoded = decodeEmgSamples(data);

const std::vector<uint16_t> expectedValues{
0x0102, 0x0304, 0x0506, 0x0708, 0x1112, 0x1314, 0x1516, 0x1718};
EXPECT_EQ(decoded.values, expectedValues);
EXPECT_EQ(decoded.numRows, 4u); // samplesPerBatch (2) * number of samples (2)
EXPECT_EQ(decoded.numChannels, 2u);
}

TEST(DecodeEmgSamples, skipsMalformedBlobAndKeepsWellFormedSamples) {
EmgData data;
data.channelCount = 2;
data.samplesPerBatch = 2;
data.bitsPerAdcReading = 16;
data.emg.push_back(makeSample({0x0102, 0x0304, 0x0506, 0x0708}));
EmgImuSample malformed;
malformed.packedChannelData =
std::string(3, '\0'); // not samplesPerBatch * channelCount * 2 bytes
data.emg.push_back(malformed);

const DecodedEmgSamples decoded = decodeEmgSamples(data);

const std::vector<uint16_t> expectedValues{0x0102, 0x0304, 0x0506, 0x0708};
EXPECT_EQ(decoded.values, expectedValues);
EXPECT_EQ(decoded.numRows, 2u);
}

TEST(DecodeEmgSamples, returnsEmptyWhenChannelCountIsZero) {
EmgData data;
data.channelCount = 0;
data.samplesPerBatch = 2;
data.bitsPerAdcReading = 16;

const DecodedEmgSamples decoded = decodeEmgSamples(data);

EXPECT_TRUE(decoded.values.empty());
EXPECT_EQ(decoded.numRows, 0u);
EXPECT_EQ(decoded.numChannels, 0u);
}

TEST(DecodeEmgSamples, throwsOnUnsupportedBitDepth) {
EmgData data;
data.channelCount = 2;
data.samplesPerBatch = 2;
data.bitsPerAdcReading = 12;

EXPECT_THROW(decodeEmgSamples(data), std::invalid_argument);
}

TEST(DecodeEmgSamples, throwsOnNonZeroEncoding) {
EmgData data;
data.channelCount = 2;
data.samplesPerBatch = 2;
data.bitsPerAdcReading = 16;
data.emg.push_back(makeSample({0x0102, 0x0304, 0x0506, 0x0708}, /*encoding=*/1));

EXPECT_THROW(decodeEmgSamples(data), std::invalid_argument);
}
22 changes: 21 additions & 1 deletion core/python/SensorDataPyBind.h
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,16 @@ inline void declarePpgDataRecord(py::module& m) {
"integration_time_us", &PpgData::integrationTimeUs, "PPG integration time in us");
}

// Decode an EMG batch's packed blobs into a [num_sub_samples, channel_count] numpy array of raw ADC
// counts. Shared by EmgData.get_emg_samples() and the module-level decode_emg_samples() helper.
inline py::array_t<uint16_t> emgSamplesToNumpy(const EmgData& data) {
const DecodedEmgSamples decoded = decodeEmgSamples(data);
return py::array_t<uint16_t>(
{static_cast<size_t>(decoded.numRows), static_cast<size_t>(decoded.numChannels)},
{static_cast<size_t>(decoded.numChannels) * sizeof(uint16_t), sizeof(uint16_t)},
decoded.values.data());
}

inline void declareEmgDataRecord(py::module& m) {
py::class_<EmgConfiguration>(m, "EmgConfiguration", "EMG sensor configuration type")
.def(py::init<>())
Expand Down Expand Up @@ -540,7 +550,17 @@ inline void declareEmgDataRecord(py::module& m) {
.def_readwrite("channel_count", &EmgData::channelCount, "number of EMG channels")
.def_readwrite(
"bits_per_adc_reading", &EmgData::bitsPerAdcReading, "number of bits per ADC reading")
.def_readwrite("samples_per_batch", &EmgData::samplesPerBatch, "number of samples per batch");
.def_readwrite("samples_per_batch", &EmgData::samplesPerBatch, "number of samples per batch")
.def(
"get_emg_samples",
[](const EmgData& self) { return emgSamplesToNumpy(self); },
"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.");

m.def(
"decode_emg_samples",
[](const EmgData& emgData) { return emgSamplesToNumpy(emgData); },
py::arg("emg_data"),
"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.");
}

inline void declareAlsDataRecord(py::module& m) {
Expand Down
23 changes: 23 additions & 0 deletions core/python/test/corePyBindTest.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
import numpy as np
from projectaria_tools.core import calibration, data_provider
from projectaria_tools.core.sensor_data import (
decode_emg_samples,
EmgData,
EmgImuSample,
SensorDataType,
TimeDomain,
TimeQueryOptions,
Expand Down Expand Up @@ -552,3 +555,23 @@ def test_device_version_gen2(self) -> None:
provider = data_provider.create_vrs_data_provider(vrs_filepath_list[1])
device_version = provider.get_device_version()
assert device_version == calibration.DeviceVersion.Gen2

def test_emg_decode_samples(self) -> None:
# Build an EMG batch in memory: big-endian uint16, sample-major [samples_per_batch, channels].
emg_data = EmgData()
emg_data.channel_count = 2
emg_data.samples_per_batch = 2
emg_data.bits_per_adc_reading = 16
counts = np.array([[0x0102, 0x0304], [0x0506, 0x0708]], dtype=">u2")
sample = EmgImuSample()
sample.packed_channel_data = counts.tobytes()
emg_data.emg = [sample]

decoded = emg_data.get_emg_samples()
expected = counts.astype(np.uint16)

assert decoded.dtype == np.uint16
assert decoded.shape == (emg_data.samples_per_batch, emg_data.channel_count)
assert np.array_equal(decoded, expected)
# The EmgData method and the module-level helper must agree.
assert np.array_equal(decoded, decode_emg_samples(emg_data))
Loading