diff --git a/bindings/python/src/pipeline/datatype/EncodedFrameBindings.cpp b/bindings/python/src/pipeline/datatype/EncodedFrameBindings.cpp index d1d1099627..bbd45838d5 100644 --- a/bindings/python/src/pipeline/datatype/EncodedFrameBindings.cpp +++ b/bindings/python/src/pipeline/datatype/EncodedFrameBindings.cpp @@ -120,6 +120,10 @@ void bind_encodedframe(pybind11::module& m, void* pCallstack) { .def("setLossless", &EncodedFrame::setLossless, DOC(dai, EncodedFrame, getLossless)) .def("setProfile", &EncodedFrame::setProfile, DOC(dai, EncodedFrame, getProfile)) .def("setTransformation", [](EncodedFrame& msg, const ImgTransformation& transformation) { msg.transformation = transformation; }); +#ifdef DEPTHAI_ENABLE_PROTOBUF + encodedFrame.def("save", &EncodedFrame::save, py::arg("path"), py::arg("metadataOnly") = false) + .def("load", &EncodedFrame::load, py::arg("path"), py::arg("metadataOnly") = false); +#endif // // add aliases dai.ImgFrame.Type and dai.ImgFrame.Specs // m.attr("EncodedFrame").attr("FrameType") = // m.attr("RawEncodedFrame").attr("FrameType"); diff --git a/bindings/python/src/pipeline/datatype/IMUDataBindings.cpp b/bindings/python/src/pipeline/datatype/IMUDataBindings.cpp index 90ee44e37f..ca86cf32da 100644 --- a/bindings/python/src/pipeline/datatype/IMUDataBindings.cpp +++ b/bindings/python/src/pipeline/datatype/IMUDataBindings.cpp @@ -156,4 +156,8 @@ void bind_imudata(pybind11::module& m, void* pCallstack) { [](IMUData& imuDta) -> std::vector& { return imuDta.packets; }, [](IMUData& imuDta, std::vector& val) { imuDta.packets = val; }, DOC(dai, IMUData, packets)); +#ifdef DEPTHAI_ENABLE_PROTOBUF + imuData.def("save", &IMUData::save, py::arg("path"), py::arg("metadataOnly") = false) + .def("load", &IMUData::load, py::arg("path"), py::arg("metadataOnly") = false); +#endif } diff --git a/bindings/python/src/pipeline/datatype/ImgFrameBindings.cpp b/bindings/python/src/pipeline/datatype/ImgFrameBindings.cpp index 373fe247be..85be23daf3 100644 --- a/bindings/python/src/pipeline/datatype/ImgFrameBindings.cpp +++ b/bindings/python/src/pipeline/datatype/ImgFrameBindings.cpp @@ -326,6 +326,21 @@ void bind_imgframe(pybind11::module& m, void* pCallstack) { .def("setTransformation", &ImgFrame::setTransformation, py::arg("transformation"), DOC(dai, ImgFrame, setTransformation)) // .def("set", &ImgFrame::set, py::arg("type"), DOC(dai, ImgFrame, set)) ; + +#ifdef DEPTHAI_ENABLE_PROTOBUF + imgFrame + .def( + "save", + &ImgFrame::save, + py::arg("path"), + py::arg("metadataOnly") = false) + .def( + "load", + &ImgFrame::load, + py::arg("path"), + py::arg("metadataOnly") = false); +#endif + // add aliases dai.ImgFrame.Type and dai.ImgFrame.Specs // m.attr("ImgFrame").attr("Type") = m.attr("RawImgFrame").attr("Type"); // m.attr("ImgFrame").attr("Specs") = m.attr("RawImgFrame").attr("Specs"); diff --git a/bindings/python/src/pipeline/datatype/PointCloudDataBindings.cpp b/bindings/python/src/pipeline/datatype/PointCloudDataBindings.cpp index e063bec7dc..d43953e963 100644 --- a/bindings/python/src/pipeline/datatype/PointCloudDataBindings.cpp +++ b/bindings/python/src/pipeline/datatype/PointCloudDataBindings.cpp @@ -183,4 +183,8 @@ void bind_pointclouddata(pybind11::module& m, void* pCallstack) { .def("updateBoundingBox", &PointCloudData::updateBoundingBox, DOC(dai, PointCloudData, updateBoundingBox)) .def("getTransformation", &PointCloudData::getTransformation, py::return_value_policy::reference_internal) .def("setTransformation", &PointCloudData::setTransformation, py::arg("transformation")); +#ifdef DEPTHAI_ENABLE_PROTOBUF + pointCloudData.def("save", &PointCloudData::save, py::arg("path"), py::arg("metadataOnly") = false) + .def("load", &PointCloudData::load, py::arg("path"), py::arg("metadataOnly") = false); +#endif } diff --git a/bindings/python/src/pipeline/datatype/RGBDDataBindings.cpp b/bindings/python/src/pipeline/datatype/RGBDDataBindings.cpp index b7aa387fec..2aab938826 100644 --- a/bindings/python/src/pipeline/datatype/RGBDDataBindings.cpp +++ b/bindings/python/src/pipeline/datatype/RGBDDataBindings.cpp @@ -36,4 +36,8 @@ void bind_rgbddata(pybind11::module& m, void* pCallstack) { .def("getDepthFrame", &RGBDData::getDepthFrame, DOC(dai, RGBDData, getDepthFrame)) .def("setRGBFrame", &RGBDData::setRGBFrame, py::arg("frame"), DOC(dai, RGBDData, setRGBFrame)) .def("setDepthFrame", &RGBDData::setDepthFrame, py::arg("frame"), DOC(dai, RGBDData, setDepthFrame)); +#ifdef DEPTHAI_ENABLE_PROTOBUF + rgbdData.def("save", &RGBDData::save, py::arg("path"), py::arg("metadataOnly") = false) + .def("load", &RGBDData::load, py::arg("path"), py::arg("metadataOnly") = false); +#endif } diff --git a/bindings/python/tests/imgframe_test.py b/bindings/python/tests/imgframe_test.py index 902fa92517..81ddd87078 100644 --- a/bindings/python/tests/imgframe_test.py +++ b/bindings/python/tests/imgframe_test.py @@ -3,6 +3,7 @@ import numpy as np import pytest import numpy.typing as npt +from pathlib import Path DEBUG = False @@ -44,6 +45,38 @@ def assert_images_close(expected, recovered, tolerance, msg): assert max_diff <= tolerance, f"{msg} max abs diff too high: {max_diff} > {tolerance}" +def make_test_frame(): + image = generate_color_image() + frame = dai.ImgFrame() + frame.setCvFrame(image, dai.ImgFrame.Type.BGR888i) + frame.setTimestamp(frame.getTimestamp()) + frame.setTimestampDevice(frame.getTimestampDevice()) + frame.setSequenceNum(123) + frame.setInstanceNum(7) + frame.setCategory(11) + + transformation = dai.ImgTransformation(160, 120, image.shape[1], image.shape[0]) + transformation.addCrop(8, 6, image.shape[1] - 12, image.shape[0] - 10) + frame.setTransformation(transformation) + + return frame, image + + +def assert_frame_metadata_equal(expected, actual): + assert actual.getSequenceNum() == expected.getSequenceNum() + assert actual.getInstanceNum() == expected.getInstanceNum() + assert actual.getCategory() == expected.getCategory() + assert actual.getWidth() == expected.getWidth() + assert actual.getHeight() == expected.getHeight() + assert actual.getType() == expected.getType() + assert actual.getTimestamp() == expected.getTimestamp() + assert actual.getTimestampDevice() == expected.getTimestampDevice() + + expected_transform = np.array(expected.getTransformation().getTransformationMatrix()) + actual_transform = np.array(actual.getTransformation().getTransformationMatrix()) + np.testing.assert_allclose(actual_transform, expected_transform) + + COLOR_TYPES = [ pytest.param(dai.ImgFrame.Type.BGR888p, 0.5, id="BGR888p"), @@ -127,3 +160,55 @@ def test_setcvframe_raw32(): assert recovered.shape == image.shape assert recovered.dtype == np.int32 assert_images_close(image, recovered, tolerance=0.0, msg="RAW32") + + +def test_imgframe_file_roundtrip(tmp_path: Path): + frame, image = make_test_frame() + path = tmp_path / "frame.pb" + + frame.save(path) + recovered = dai.ImgFrame() + recovered.load(path) + + assert_frame_metadata_equal(frame, recovered) + assert np.array_equal(np.asarray(recovered.getData()), np.asarray(frame.getData())) + assert_images_close(image, recovered.getCvFrame(), tolerance=0.5, msg="imgframe file roundtrip") + + +def test_imgframe_metadata_only_roundtrip(tmp_path: Path): + frame, _ = make_test_frame() + path = tmp_path / "frame-metadata.pb" + + frame.save(path, metadataOnly=True) + recovered = dai.ImgFrame() + recovered.load(path, metadataOnly=True) + + assert_frame_metadata_equal(frame, recovered) + assert recovered.getData().size == 0 + + +def test_encodedframe_file_roundtrip(tmp_path: Path): + frame = dai.EncodedFrame() + frame.setWidth(320) + frame.setHeight(180) + frame.setQuality(90) + frame.setBitrate(1_000_000) + frame.setProfile(dai.EncodedFrame.Profile.JPEG) + frame.setFrameType(dai.EncodedFrame.FrameType.I) + frame.setLossless(False) + frame.setData(np.arange(64, dtype=np.uint8)) + + path = tmp_path / "encoded-frame.pb" + frame.save(path) + + recovered = dai.EncodedFrame() + recovered.load(path) + + assert recovered.getWidth() == frame.getWidth() + assert recovered.getHeight() == frame.getHeight() + assert recovered.getQuality() == frame.getQuality() + assert recovered.getBitrate() == frame.getBitrate() + assert recovered.getProfile() == frame.getProfile() + assert recovered.getFrameType() == frame.getFrameType() + assert recovered.getLossless() == frame.getLossless() + assert np.array_equal(np.asarray(recovered.getData()), np.asarray(frame.getData())) diff --git a/include/depthai/pipeline/datatype/EncodedFrame.hpp b/include/depthai/pipeline/datatype/EncodedFrame.hpp index 317d9af768..405319db9c 100644 --- a/include/depthai/pipeline/datatype/EncodedFrame.hpp +++ b/include/depthai/pipeline/datatype/EncodedFrame.hpp @@ -215,6 +215,11 @@ class EncodedFrame : public Buffer, public ProtoSerializable { * @returns serialized schema */ ProtoSerializable::SchemaPair serializeSchema() const override; + + protected: + void deserializeProtoMessage(const std::vector& bytes, bool metadataOnly) override; + + public: #endif DEPTHAI_SERIALIZE(EncodedFrame, diff --git a/include/depthai/pipeline/datatype/IMUData.hpp b/include/depthai/pipeline/datatype/IMUData.hpp index 57422810d4..b47914fa63 100644 --- a/include/depthai/pipeline/datatype/IMUData.hpp +++ b/include/depthai/pipeline/datatype/IMUData.hpp @@ -257,6 +257,11 @@ class IMUData : public Buffer, public ProtoSerializable { * @returns serialized schema */ ProtoSerializable::SchemaPair serializeSchema() const override; + + protected: + void deserializeProtoMessage(const std::vector& bytes, bool metadataOnly) override; + + public: #endif DEPTHAI_SERIALIZE(IMUData, Buffer::ts, Buffer::tsDevice, Buffer::tsSystem, Buffer::sequenceNum, packets); diff --git a/include/depthai/pipeline/datatype/ImgFrame.hpp b/include/depthai/pipeline/datatype/ImgFrame.hpp index 055d999d1c..409e1d89be 100644 --- a/include/depthai/pipeline/datatype/ImgFrame.hpp +++ b/include/depthai/pipeline/datatype/ImgFrame.hpp @@ -101,8 +101,12 @@ class ImgFrame : public Buffer, public ProtoSerializable { * @returns serialized schema */ ProtoSerializable::SchemaPair serializeSchema() const override; -#endif + protected: + void deserializeProtoMessage(const std::vector& bytes, bool metadataOnly) override; + + public: +#endif // getters /** * Retrieves image timestamp (at the specified offset of exposure) related to dai::Clock::now() @@ -768,7 +772,6 @@ class ImgFrame : public Buffer, public ProtoSerializable { dai::FrameEvent event = dai::FrameEvent::NONE; ImgTransformation transformation; - public: DEPTHAI_SERIALIZE(ImgFrame, Buffer::ts, Buffer::tsDevice, Buffer::tsSystem, Buffer::sequenceNum, fb, sourceFb, cam, category, instanceNum, transformation); }; diff --git a/include/depthai/pipeline/datatype/PointCloudData.hpp b/include/depthai/pipeline/datatype/PointCloudData.hpp index f3ebee3f72..bb1442ef26 100644 --- a/include/depthai/pipeline/datatype/PointCloudData.hpp +++ b/include/depthai/pipeline/datatype/PointCloudData.hpp @@ -266,6 +266,11 @@ class PointCloudData : public Buffer, public ProtoSerializable, public Transform * @returns serialized schema */ ProtoSerializable::SchemaPair serializeSchema() const override; + + protected: + void deserializeProtoMessage(const std::vector& bytes, bool metadataOnly) override; + + public: #endif #ifdef DEPTHAI_HAVE_PCL_SUPPORT diff --git a/include/depthai/pipeline/datatype/RGBDData.hpp b/include/depthai/pipeline/datatype/RGBDData.hpp index 7b0ec3b59e..2c6ef350c8 100644 --- a/include/depthai/pipeline/datatype/RGBDData.hpp +++ b/include/depthai/pipeline/datatype/RGBDData.hpp @@ -61,6 +61,11 @@ class RGBDData : public Buffer, public ProtoSerializable { * @returns serialized schema */ ProtoSerializable::SchemaPair serializeSchema() const override; + + protected: + void deserializeProtoMessage(const std::vector& bytes, bool metadataOnly) override; + + public: #endif DEPTHAI_SERIALIZE(RGBDData, colorFrame, depthFrame, Buffer::ts, Buffer::tsDevice, Buffer::tsSystem, Buffer::sequenceNum); diff --git a/include/depthai/utility/ProtoSerializable.hpp b/include/depthai/utility/ProtoSerializable.hpp index 46553f20c9..f920bdc0a0 100644 --- a/include/depthai/utility/ProtoSerializable.hpp +++ b/include/depthai/utility/ProtoSerializable.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -16,6 +17,20 @@ class ProtoSerializable { virtual ~ProtoSerializable(); #ifdef DEPTHAI_ENABLE_PROTOBUF + /** + * @brief Serialize this object and write it to disk + * @param path Output path. If it has no extension, the final on-disk filename is resolved to `.dai`. + * @param metadataOnly If true, serialize only metadata and omit payload data where supported by the concrete type. + */ + void save(const std::filesystem::path& path, bool metadataOnly = false) const; + + /** + * @brief Load this object from a serialized protobuf file on disk + * @param path Input path. If it has no extension, the file is resolved as `.dai`. + * @param metadataOnly If true, load only metadata and omit payload data where supported by the concrete type. + */ + void load(const std::filesystem::path& path, bool metadataOnly = false); + /** * @brief Serialize the protobuf message of this object * @return serialized protobuf message @@ -28,6 +43,9 @@ class ProtoSerializable { */ virtual SchemaPair serializeSchema() const = 0; + protected: + virtual void deserializeProtoMessage(const std::vector& bytes, bool metadataOnly); + #else // Helper struct for compile-time check template diff --git a/src/opencv/ImgFrame.cpp b/src/opencv/ImgFrame.cpp index 2b36061d69..c6816a76ce 100644 --- a/src/opencv/ImgFrame.cpp +++ b/src/opencv/ImgFrame.cpp @@ -10,6 +10,7 @@ // #include "spdlog/spdlog.h" namespace dai { + #pragma GCC diagnostic push #pragma GCC diagnostic ignored "-Wswitch-enum" ImgFrame& ImgFrame::setFrame(cv::Mat frame) { diff --git a/src/pipeline/datatype/EncodedFrame.cpp b/src/pipeline/datatype/EncodedFrame.cpp index 64085816c0..4d4aa24c41 100644 --- a/src/pipeline/datatype/EncodedFrame.cpp +++ b/src/pipeline/datatype/EncodedFrame.cpp @@ -1,6 +1,7 @@ #include "depthai/pipeline/datatype/EncodedFrame.hpp" #ifdef DEPTHAI_ENABLE_PROTOBUF #include "depthai/schemas/EncodedFrame.pb.h" + #include "utility/ProtoFileIO.hpp" #include "utility/ProtoSerialize.hpp" #endif @@ -161,6 +162,10 @@ ImgFrame EncodedFrame::getImgFrameMeta() const { } #ifdef DEPTHAI_ENABLE_PROTOBUF +void EncodedFrame::deserializeProtoMessage(const std::vector& bytes, bool metadataOnly) { + utility::loadProtoMessageFromBytes(*this, bytes, metadataOnly); +} + ProtoSerializable::SchemaPair EncodedFrame::serializeSchema() const { return utility::serializeSchema(utility::getProtoMessage(this)); } diff --git a/src/pipeline/datatype/IMUData.cpp b/src/pipeline/datatype/IMUData.cpp index f4ef46f28c..0966c0d04e 100644 --- a/src/pipeline/datatype/IMUData.cpp +++ b/src/pipeline/datatype/IMUData.cpp @@ -3,6 +3,7 @@ #if DEPTHAI_ENABLE_PROTOBUF #include "depthai/schemas/IMUData.pb.h" #include "depthai/schemas/common.pb.h" + #include "utility/ProtoFileIO.hpp" #include "utility/ProtoSerialize.hpp" #endif @@ -16,6 +17,9 @@ void IMUData::serialize(std::vector& metadata, DatatypeEnum& datat } #ifdef DEPTHAI_ENABLE_PROTOBUF +void IMUData::deserializeProtoMessage(const std::vector& bytes, bool metadataOnly) { + utility::loadProtoMessageFromBytes(*this, bytes, metadataOnly); +} ProtoSerializable::SchemaPair IMUData::serializeSchema() const { return utility::serializeSchema(utility::getProtoMessage(this)); diff --git a/src/pipeline/datatype/ImgFrame.cpp b/src/pipeline/datatype/ImgFrame.cpp index f9c05fd83b..cbe04b007a 100644 --- a/src/pipeline/datatype/ImgFrame.cpp +++ b/src/pipeline/datatype/ImgFrame.cpp @@ -6,6 +6,7 @@ #include "depthai/utility/SharedMemory.hpp" #ifdef DEPTHAI_ENABLE_PROTOBUF #include "depthai/schemas/ImgFrame.pb.h" + #include "utility/ProtoFileIO.hpp" #include "utility/ProtoSerialize.hpp" #endif namespace dai { @@ -361,6 +362,10 @@ Rect ImgFrame::remapRectBetweenFrames(const Rect& originRect, const ImgFrame& or } #ifdef DEPTHAI_ENABLE_PROTOBUF +void ImgFrame::deserializeProtoMessage(const std::vector& bytes, bool metadataOnly) { + utility::loadProtoMessageFromBytes(*this, bytes, metadataOnly); +} + ProtoSerializable::SchemaPair ImgFrame::serializeSchema() const { return utility::serializeSchema(utility::getProtoMessage(this)); } diff --git a/src/pipeline/datatype/PointCloudData.cpp b/src/pipeline/datatype/PointCloudData.cpp index 20b107992a..50c44fc093 100644 --- a/src/pipeline/datatype/PointCloudData.cpp +++ b/src/pipeline/datatype/PointCloudData.cpp @@ -7,6 +7,7 @@ #include "depthai/common/Point3f.hpp" #ifdef DEPTHAI_ENABLE_PROTOBUF #include "depthai/schemas/PointCloudData.pb.h" + #include "utility/ProtoFileIO.hpp" #include "utility/ProtoSerialize.hpp" #endif namespace dai { @@ -244,6 +245,10 @@ PointCloudData& PointCloudData::setColor(bool val) { } #ifdef DEPTHAI_ENABLE_PROTOBUF +void PointCloudData::deserializeProtoMessage(const std::vector& bytes, bool metadataOnly) { + utility::loadProtoMessageFromBytes(*this, bytes, metadataOnly); +} + std::vector PointCloudData::serializeProto(bool metadataOnly) const { return utility::serializeProto(utility::getProtoMessage(this, metadataOnly)); } diff --git a/src/pipeline/datatype/RGBDData.cpp b/src/pipeline/datatype/RGBDData.cpp index 85a0069cf2..850b253958 100644 --- a/src/pipeline/datatype/RGBDData.cpp +++ b/src/pipeline/datatype/RGBDData.cpp @@ -2,6 +2,7 @@ #ifdef DEPTHAI_ENABLE_PROTOBUF #include "depthai/schemas/RGBDData.pb.h" + #include "utility/ProtoFileIO.hpp" #include "utility/ProtoSerialize.hpp" #endif @@ -62,6 +63,10 @@ std::optional RGBDData::getDepthFrame() const { } #ifdef DEPTHAI_ENABLE_PROTOBUF +void RGBDData::deserializeProtoMessage(const std::vector& bytes, bool metadataOnly) { + utility::loadProtoMessageFromBytes(*this, bytes, metadataOnly); +} + std::vector RGBDData::serializeProto(bool metadataOnly) const { return utility::serializeProto(utility::getProtoMessage(this, metadataOnly)); } diff --git a/src/utility/ProtoFileIO.hpp b/src/utility/ProtoFileIO.hpp new file mode 100644 index 0000000000..72151a51ae --- /dev/null +++ b/src/utility/ProtoFileIO.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include + +#include "ProtoSerialize.hpp" + +namespace dai { +namespace utility { + +namespace detail { + +template +struct ProtoMessageType; + +template <> +struct ProtoMessageType { + using type = proto::img_frame::ImgFrame; +}; + +template <> +struct ProtoMessageType { + using type = proto::encoded_frame::EncodedFrame; +}; + +template <> +struct ProtoMessageType { + using type = proto::imu_data::IMUData; +}; + +template <> +struct ProtoMessageType { + using type = proto::point_cloud_data::PointCloudData; +}; + +template <> +struct ProtoMessageType { + using type = proto::rgbd_data::RGBDData; +}; + +} // namespace detail + +template +void loadProtoMessageFromBytes(T& message, const std::vector& bytes, bool metadataOnly = false) { + using ProtoType = typename detail::ProtoMessageType::type; + + ProtoType protoMessage; + if(!protoMessage.ParseFromArray(bytes.data(), static_cast(bytes.size()))) { + throw std::runtime_error("Failed to parse protobuf message"); + } + + utility::setProtoMessage(message, &protoMessage, metadataOnly); +} + +} // namespace utility +} // namespace dai diff --git a/src/utility/ProtoSerializable.cpp b/src/utility/ProtoSerializable.cpp index ee954f105e..ec9f3392f9 100644 --- a/src/utility/ProtoSerializable.cpp +++ b/src/utility/ProtoSerializable.cpp @@ -1,7 +1,75 @@ #include "depthai/utility/ProtoSerializable.hpp" +#ifdef DEPTHAI_ENABLE_PROTOBUF + #include + #include +#endif + namespace dai { ProtoSerializable::~ProtoSerializable() = default; -} // namespace dai \ No newline at end of file +#ifdef DEPTHAI_ENABLE_PROTOBUF +namespace { + +std::filesystem::path resolveDataPath(const std::filesystem::path& path) { + if(path.has_extension()) { + return path; + } + auto resolved = path; + resolved += ".dai"; + return resolved; +} + +std::vector readBinaryFile(const std::filesystem::path& path) { + std::ifstream file(path, std::ios::binary); + if(!file) { + throw std::runtime_error("Failed to open file for reading: " + path.string()); + } + + file.seekg(0, std::ios::end); + const auto size = file.tellg(); + if(size < 0) { + throw std::runtime_error("Failed to determine file size: " + path.string()); + } + file.seekg(0, std::ios::beg); + + std::vector buffer(static_cast(size)); + if(!buffer.empty()) { + file.read(reinterpret_cast(buffer.data()), static_cast(buffer.size())); + if(!file) { + throw std::runtime_error("Failed to read file: " + path.string()); + } + } + return buffer; +} + +void writeBinaryFile(const std::filesystem::path& path, const std::vector& bytes) { + std::ofstream file(path, std::ios::binary); + if(!file) { + throw std::runtime_error("Failed to open file for writing: " + path.string()); + } + if(!bytes.empty()) { + file.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + if(!file) { + throw std::runtime_error("Failed to write file: " + path.string()); + } + } +} + +} // namespace + +void ProtoSerializable::save(const std::filesystem::path& path, bool metadataOnly) const { + writeBinaryFile(resolveDataPath(path), serializeProto(metadataOnly)); +} + +void ProtoSerializable::load(const std::filesystem::path& path, bool metadataOnly) { + deserializeProtoMessage(readBinaryFile(resolveDataPath(path)), metadataOnly); +} + +void ProtoSerializable::deserializeProtoMessage(const std::vector&, bool) { + throw std::runtime_error("Protobuf deserialization is not implemented for this message type"); +} +#endif + +} // namespace dai diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b91384b972..c6ec51d03a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -516,6 +516,10 @@ dai_set_test_labels(imgdetections_test onhost ci) dai_add_test(imgframe_test src/onhost_tests/pipeline/datatype/imgframe_test.cpp) dai_set_test_labels(imgframe_test onhost ci) +# ProtoSerializable file I/O tests +dai_add_test(proto_file_io_test src/onhost_tests/pipeline/datatype/proto_file_io_test.cpp) +dai_set_test_labels(proto_file_io_test onhost ci) + # PointCloudData / PointCloudConfig / PointCloudProperties datatype tests dai_add_test(pointclouddata_test src/onhost_tests/pipeline/datatype/pointclouddata_test.cpp) dai_set_test_labels(pointclouddata_test onhost ci) diff --git a/tests/src/onhost_tests/pipeline/datatype/proto_file_io_test.cpp b/tests/src/onhost_tests/pipeline/datatype/proto_file_io_test.cpp new file mode 100644 index 0000000000..76ebdda6da --- /dev/null +++ b/tests/src/onhost_tests/pipeline/datatype/proto_file_io_test.cpp @@ -0,0 +1,443 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "depthai/pipeline/datatype/EncodedFrame.hpp" +#include "depthai/pipeline/datatype/IMUData.hpp" +#include "depthai/pipeline/datatype/ImgFrame.hpp" +#include "depthai/pipeline/datatype/PointCloudData.hpp" +#include "depthai/pipeline/datatype/RGBDData.hpp" + +namespace fs = std::filesystem; + +namespace { + +class TempFile { + public: + explicit TempFile(const std::string& stem) + : path(fs::temp_directory_path() / (stem + "_" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()))) {} + + ~TempFile() { + std::error_code ec; + fs::remove(path, ec); + fs::remove(path.string() + ".dai", ec); + } + + fs::path path; +}; + +fs::path withDaiExtension(const fs::path& path) { + auto resolved = path; + resolved += ".dai"; + return resolved; +} + +fs::path withCustomExtension(const fs::path& path, const std::string& extension) { + auto resolved = path; + resolved += extension; + return resolved; +} + +std::vector toVector(dai::span data) { + return std::vector(data.begin(), data.end()); +} + +} // namespace + +#ifndef DEPTHAI_ENABLE_PROTOBUF + +TEST_CASE("Proto file I/O requires protobuf support", "[ProtoFileIO]") { + SUCCEED("Protobuf support disabled"); +} + +#else + +TEST_CASE("ImgFrame save/load roundtrip", "[ProtoFileIO][ImgFrame]") { + dai::ImgFrame frame; + frame.setInstanceNum(4).setCategory(9).setSize(8, 6).setSourceSize(8, 6).setType(dai::ImgFrame::Type::RAW8); + frame.setSequenceNum(123); + frame.cam.exposureTimeUs = 2500; + frame.setData(std::vector{1, 2, 3, 4, 5, 6, 7, 8}); + + SECTION("Extensionless path resolves to .dai for save and load") { + TempFile file("imgframe_roundtrip_no_ext"); + frame.save(file.path); + REQUIRE(fs::exists(withDaiExtension(file.path))); + + dai::ImgFrame restored; + restored.load(file.path); + + REQUIRE(restored.getInstanceNum() == frame.getInstanceNum()); + REQUIRE(restored.getCategory() == frame.getCategory()); + REQUIRE(restored.getWidth() == frame.getWidth()); + REQUIRE(restored.getHeight() == frame.getHeight()); + REQUIRE(restored.getType() == frame.getType()); + REQUIRE(restored.getSequenceNum() == frame.getSequenceNum()); + REQUIRE(restored.getExposureTime() == frame.getExposureTime()); + REQUIRE(toVector(restored.getData()) == toVector(frame.getData())); + } + + SECTION("Explicit .dai path works the same for save and load") { + TempFile file("imgframe_roundtrip_ext"); + auto explicitPath = withDaiExtension(file.path); + frame.save(explicitPath); + REQUIRE(fs::exists(explicitPath)); + + dai::ImgFrame restored; + restored.load(explicitPath); + + REQUIRE(restored.getInstanceNum() == frame.getInstanceNum()); + REQUIRE(restored.getCategory() == frame.getCategory()); + REQUIRE(restored.getWidth() == frame.getWidth()); + REQUIRE(restored.getHeight() == frame.getHeight()); + REQUIRE(restored.getType() == frame.getType()); + REQUIRE(restored.getSequenceNum() == frame.getSequenceNum()); + REQUIRE(restored.getExposureTime() == frame.getExposureTime()); + REQUIRE(toVector(restored.getData()) == toVector(frame.getData())); + } +} + +TEST_CASE("ImgFrame metadata-only save/load clears payload", "[ProtoFileIO][ImgFrame]") { + dai::ImgFrame frame; + frame.setSize(4, 4).setSourceSize(4, 4).setType(dai::ImgFrame::Type::RAW8); + frame.setData(std::vector{9, 8, 7, 6}); + + SECTION("Extensionless metadata-only path resolves to .dai") { + TempFile file("imgframe_metadata_only_no_ext"); + frame.save(file.path, true); + REQUIRE(fs::exists(withDaiExtension(file.path))); + + dai::ImgFrame restored; + restored.load(file.path, true); + + REQUIRE(restored.getWidth() == frame.getWidth()); + REQUIRE(restored.getHeight() == frame.getHeight()); + REQUIRE(restored.getType() == frame.getType()); + REQUIRE(restored.getData().empty()); + } + + SECTION("Explicit .dai metadata-only path works the same") { + TempFile file("imgframe_metadata_only_ext"); + auto explicitPath = withDaiExtension(file.path); + frame.save(explicitPath, true); + REQUIRE(fs::exists(explicitPath)); + + dai::ImgFrame restored; + restored.load(explicitPath, true); + + REQUIRE(restored.getWidth() == frame.getWidth()); + REQUIRE(restored.getHeight() == frame.getHeight()); + REQUIRE(restored.getType() == frame.getType()); + REQUIRE(restored.getData().empty()); + } +} + +TEST_CASE("EncodedFrame save/load roundtrip", "[ProtoFileIO][EncodedFrame]") { + dai::EncodedFrame frame; + frame.setInstanceNum(3).setSize(320, 180).setQuality(91).setBitrate(1234567).setProfile(dai::EncodedFrame::Profile::JPEG).setFrameType( + dai::EncodedFrame::FrameType::I); + frame.setLossless(false); + frame.setSequenceNum(777); + frame.setData(std::vector{10, 20, 30, 40, 50}); + + SECTION("Extensionless path resolves to .dai for save and load") { + TempFile file("encodedframe_roundtrip_no_ext"); + frame.save(file.path); + REQUIRE(fs::exists(withDaiExtension(file.path))); + + dai::EncodedFrame restored; + restored.load(file.path); + + REQUIRE(restored.getInstanceNum() == frame.getInstanceNum()); + REQUIRE(restored.getWidth() == frame.getWidth()); + REQUIRE(restored.getHeight() == frame.getHeight()); + REQUIRE(restored.getQuality() == frame.getQuality()); + REQUIRE(restored.getBitrate() == frame.getBitrate()); + REQUIRE(restored.getProfile() == frame.getProfile()); + REQUIRE(restored.getFrameType() == frame.getFrameType()); + REQUIRE(restored.getLossless() == frame.getLossless()); + REQUIRE(restored.getSequenceNum() == frame.getSequenceNum()); + REQUIRE(toVector(restored.getData()) == toVector(frame.getData())); + } + + SECTION("Explicit .dai path works the same for save and load") { + TempFile file("encodedframe_roundtrip_ext"); + auto explicitPath = withDaiExtension(file.path); + frame.save(explicitPath); + REQUIRE(fs::exists(explicitPath)); + + dai::EncodedFrame restored; + restored.load(explicitPath); + + REQUIRE(restored.getInstanceNum() == frame.getInstanceNum()); + REQUIRE(restored.getWidth() == frame.getWidth()); + REQUIRE(restored.getHeight() == frame.getHeight()); + REQUIRE(restored.getQuality() == frame.getQuality()); + REQUIRE(restored.getBitrate() == frame.getBitrate()); + REQUIRE(restored.getProfile() == frame.getProfile()); + REQUIRE(restored.getFrameType() == frame.getFrameType()); + REQUIRE(restored.getLossless() == frame.getLossless()); + REQUIRE(restored.getSequenceNum() == frame.getSequenceNum()); + REQUIRE(toVector(restored.getData()) == toVector(frame.getData())); + } + + SECTION("Explicit non-.dai extension is preserved") { + TempFile file("encodedframe_roundtrip_bin"); + auto explicitPath = withCustomExtension(file.path, ".bin"); + frame.save(explicitPath); + REQUIRE(fs::exists(explicitPath)); + REQUIRE_FALSE(fs::exists(withDaiExtension(explicitPath))); + + dai::EncodedFrame restored; + restored.load(explicitPath); + + REQUIRE(restored.getWidth() == frame.getWidth()); + REQUIRE(restored.getProfile() == frame.getProfile()); + REQUIRE(toVector(restored.getData()) == toVector(frame.getData())); + } +} + +TEST_CASE("EncodedFrame metadata-only save/load clears payload", "[ProtoFileIO][EncodedFrame]") { + dai::EncodedFrame frame; + frame.setInstanceNum(5).setSize(128, 72).setProfile(dai::EncodedFrame::Profile::JPEG).setFrameType(dai::EncodedFrame::FrameType::I); + frame.setQuality(80); + frame.setData(std::vector{5, 4, 3, 2, 1}); + + SECTION("Extensionless metadata-only path resolves to .dai") { + TempFile file("encodedframe_metadata_only_no_ext"); + frame.save(file.path, true); + REQUIRE(fs::exists(withDaiExtension(file.path))); + + dai::EncodedFrame restored; + restored.load(file.path, true); + + REQUIRE(restored.getInstanceNum() == frame.getInstanceNum()); + REQUIRE(restored.getWidth() == frame.getWidth()); + REQUIRE(restored.getHeight() == frame.getHeight()); + REQUIRE(restored.getProfile() == frame.getProfile()); + REQUIRE(restored.getFrameType() == frame.getFrameType()); + REQUIRE(restored.getData().empty()); + } + + SECTION("Explicit .dai metadata-only path works the same") { + TempFile file("encodedframe_metadata_only_ext"); + auto explicitPath = withDaiExtension(file.path); + frame.save(explicitPath, true); + REQUIRE(fs::exists(explicitPath)); + + dai::EncodedFrame restored; + restored.load(explicitPath, true); + + REQUIRE(restored.getInstanceNum() == frame.getInstanceNum()); + REQUIRE(restored.getWidth() == frame.getWidth()); + REQUIRE(restored.getHeight() == frame.getHeight()); + REQUIRE(restored.getProfile() == frame.getProfile()); + REQUIRE(restored.getFrameType() == frame.getFrameType()); + REQUIRE(restored.getData().empty()); + } +} + +TEST_CASE("IMUData save/load roundtrip", "[ProtoFileIO][IMUData]") { + dai::IMUData imu; + imu.setSequenceNum(42); + dai::IMUPacket packet; + packet.acceleroMeter.sequence = 100; + packet.acceleroMeter.x = 1.5F; + packet.acceleroMeter.y = -2.0F; + packet.acceleroMeter.z = 9.81F; + packet.gyroscope.sequence = 101; + packet.gyroscope.x = 0.1F; + packet.gyroscope.y = 0.2F; + packet.gyroscope.z = 0.3F; + imu.packets.push_back(packet); + + SECTION("Extensionless path resolves to .dai for save and load") { + TempFile file("imudata_roundtrip_no_ext"); + imu.save(file.path); + REQUIRE(fs::exists(withDaiExtension(file.path))); + + dai::IMUData restored; + restored.load(file.path); + + REQUIRE(restored.getSequenceNum() == imu.getSequenceNum()); + REQUIRE(restored.packets.size() == 1); + REQUIRE(restored.packets[0].acceleroMeter.sequence == 100); + REQUIRE(restored.packets[0].acceleroMeter.x == Catch::Approx(1.5F)); + REQUIRE(restored.packets[0].acceleroMeter.z == Catch::Approx(9.81F)); + REQUIRE(restored.packets[0].gyroscope.sequence == 101); + REQUIRE(restored.packets[0].gyroscope.y == Catch::Approx(0.2F)); + } + + SECTION("Explicit .dai path works the same for save and load") { + TempFile file("imudata_roundtrip_ext"); + auto explicitPath = withDaiExtension(file.path); + imu.save(explicitPath); + REQUIRE(fs::exists(explicitPath)); + + dai::IMUData restored; + restored.load(explicitPath); + + REQUIRE(restored.getSequenceNum() == imu.getSequenceNum()); + REQUIRE(restored.packets.size() == 1); + REQUIRE(restored.packets[0].acceleroMeter.sequence == 100); + REQUIRE(restored.packets[0].acceleroMeter.x == Catch::Approx(1.5F)); + REQUIRE(restored.packets[0].acceleroMeter.z == Catch::Approx(9.81F)); + REQUIRE(restored.packets[0].gyroscope.sequence == 101); + REQUIRE(restored.packets[0].gyroscope.y == Catch::Approx(0.2F)); + } +} + +TEST_CASE("PointCloudData save/load roundtrip", "[ProtoFileIO][PointCloudData]") { + dai::PointCloudData pcd; + pcd.setWidth(2).setHeight(2).setInstanceNum(6).setPoints({{1.F, 2.F, 3.F}, {4.F, 5.F, 6.F}, {7.F, 8.F, 9.F}, {10.F, 11.F, 12.F}}); + pcd.updateBoundingBox(); + pcd.setSequenceNum(55); + + SECTION("Extensionless path resolves to .dai for save and load") { + TempFile file("pointcloud_roundtrip_no_ext"); + pcd.save(file.path); + REQUIRE(fs::exists(withDaiExtension(file.path))); + + dai::PointCloudData restored; + restored.load(file.path); + + REQUIRE(restored.getWidth() == pcd.getWidth()); + REQUIRE(restored.getHeight() == pcd.getHeight()); + REQUIRE(restored.getInstanceNum() == pcd.getInstanceNum()); + REQUIRE(restored.getSequenceNum() == pcd.getSequenceNum()); + REQUIRE(restored.getMinX() == Catch::Approx(pcd.getMinX())); + REQUIRE(restored.getMaxZ() == Catch::Approx(pcd.getMaxZ())); + REQUIRE(restored.getPoints().size() == pcd.getPoints().size()); + REQUIRE(restored.getPoints()[2].z == Catch::Approx(9.F)); + } + + SECTION("Explicit .dai path works the same for save and load") { + TempFile file("pointcloud_roundtrip_ext"); + auto explicitPath = withDaiExtension(file.path); + pcd.save(explicitPath); + REQUIRE(fs::exists(explicitPath)); + + dai::PointCloudData restored; + restored.load(explicitPath); + + REQUIRE(restored.getWidth() == pcd.getWidth()); + REQUIRE(restored.getHeight() == pcd.getHeight()); + REQUIRE(restored.getInstanceNum() == pcd.getInstanceNum()); + REQUIRE(restored.getSequenceNum() == pcd.getSequenceNum()); + REQUIRE(restored.getMinX() == Catch::Approx(pcd.getMinX())); + REQUIRE(restored.getMaxZ() == Catch::Approx(pcd.getMaxZ())); + REQUIRE(restored.getPoints().size() == pcd.getPoints().size()); + REQUIRE(restored.getPoints()[2].z == Catch::Approx(9.F)); + } +} + +TEST_CASE("RGBDData save/load roundtrip", "[ProtoFileIO][RGBDData]") { + auto color = std::make_shared(); + color->setSize(4, 3).setSourceSize(4, 3).setType(dai::ImgFrame::Type::RAW8).setInstanceNum(1); + color->setData(std::vector{1, 2, 3, 4}); + + auto depth = std::make_shared(); + depth->setSize(4, 3).setProfile(dai::EncodedFrame::Profile::JPEG).setFrameType(dai::EncodedFrame::FrameType::I).setInstanceNum(2); + depth->setData(std::vector{9, 8, 7}); + + dai::RGBDData rgbd; + rgbd.setSequenceNum(999); + rgbd.setRGBFrame(color); + rgbd.setDepthFrame(depth); + + SECTION("Extensionless path resolves to .dai for save and load") { + TempFile file("rgbd_roundtrip_no_ext"); + rgbd.save(file.path); + REQUIRE(fs::exists(withDaiExtension(file.path))); + + dai::RGBDData restored; + restored.load(file.path); + + REQUIRE(restored.getSequenceNum() == rgbd.getSequenceNum()); + + auto rgb = restored.getRGBFrame(); + REQUIRE(rgb.has_value()); + REQUIRE(std::holds_alternative>(*rgb)); + auto restoredColor = std::get>(*rgb); + REQUIRE(restoredColor != nullptr); + REQUIRE(restoredColor->getWidth() == color->getWidth()); + REQUIRE(restoredColor->getType() == color->getType()); + REQUIRE(toVector(restoredColor->getData()) == toVector(color->getData())); + + auto depthFrame = restored.getDepthFrame(); + REQUIRE(depthFrame.has_value()); + REQUIRE(std::holds_alternative>(*depthFrame)); + auto restoredDepth = std::get>(*depthFrame); + REQUIRE(restoredDepth != nullptr); + REQUIRE(restoredDepth->getWidth() == depth->getWidth()); + REQUIRE(restoredDepth->getProfile() == depth->getProfile()); + REQUIRE(toVector(restoredDepth->getData()) == toVector(depth->getData())); + } + + SECTION("Explicit .dai path works the same for save and load") { + TempFile file("rgbd_roundtrip_ext"); + auto explicitPath = withDaiExtension(file.path); + rgbd.save(explicitPath); + REQUIRE(fs::exists(explicitPath)); + + dai::RGBDData restored; + restored.load(explicitPath); + + REQUIRE(restored.getSequenceNum() == rgbd.getSequenceNum()); + + auto rgb = restored.getRGBFrame(); + REQUIRE(rgb.has_value()); + REQUIRE(std::holds_alternative>(*rgb)); + auto restoredColor = std::get>(*rgb); + REQUIRE(restoredColor != nullptr); + REQUIRE(restoredColor->getWidth() == color->getWidth()); + REQUIRE(restoredColor->getType() == color->getType()); + REQUIRE(toVector(restoredColor->getData()) == toVector(color->getData())); + + auto depthFrame = restored.getDepthFrame(); + REQUIRE(depthFrame.has_value()); + REQUIRE(std::holds_alternative>(*depthFrame)); + auto restoredDepth = std::get>(*depthFrame); + REQUIRE(restoredDepth != nullptr); + REQUIRE(restoredDepth->getWidth() == depth->getWidth()); + REQUIRE(restoredDepth->getProfile() == depth->getProfile()); + REQUIRE(toVector(restoredDepth->getData()) == toVector(depth->getData())); + } +} + +TEST_CASE("Proto file I/O reports missing and invalid files", "[ProtoFileIO][Failures]") { + SECTION("Missing file throws for extensionless path") { + TempFile file("missing_proto_file"); + dai::ImgFrame frame; + REQUIRE_THROWS_AS(frame.load(file.path), std::runtime_error); + } + + SECTION("Missing file throws for explicit .dai path") { + TempFile file("missing_proto_file_dai"); + auto explicitPath = withDaiExtension(file.path); + dai::EncodedFrame frame; + REQUIRE_THROWS_AS(frame.load(explicitPath), std::runtime_error); + } + + SECTION("Invalid protobuf payload throws") { + TempFile file("invalid_proto_file"); + auto invalidPath = withDaiExtension(file.path); + { + std::ofstream out(invalidPath, std::ios::binary); + REQUIRE(out.good()); + out << "not a protobuf payload"; + } + + dai::IMUData imu; + REQUIRE_THROWS_AS(imu.load(invalidPath), std::runtime_error); + } +} + +#endif