diff --git a/README.md b/README.md index e4d853fe..63096ed5 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Currently available: - [X] Alembic 3D visualization - Point clouds - Cameras + - Landmark observations - [X] OIIO backend - Read RAW images from DSLRs - Read intermediate files of the [AliceVision](https://github.com/alicevision/AliceVision) framework stored in EXR format diff --git a/src/qmlAlembic/AlembicEntity.cpp b/src/qmlAlembic/AlembicEntity.cpp index b80f03d4..011bc08b 100644 --- a/src/qmlAlembic/AlembicEntity.cpp +++ b/src/qmlAlembic/AlembicEntity.cpp @@ -2,6 +2,7 @@ #include "IOThread.hpp" #include "CameraLocatorEntity.hpp" #include "PointCloudEntity.hpp" +#include "ObservationsEntity.hpp" #include #include #include @@ -24,6 +25,11 @@ AlembicEntity::AlembicEntity(Qt3DCore::QNode* parent) { connect(_ioThread.get(), &IOThread::finished, this, &AlembicEntity::onIOThreadFinished); createMaterials(); + + // trigger display repaint events of observations + connect(this, &AlembicEntity::viewIdChanged, this, &AlembicEntity::updateObservations); + connect(this, &AlembicEntity::displayObservationsChanged, this, &AlembicEntity::updateObservations); + connect(this, &AlembicEntity::viewer2DInfoChanged, this, &AlembicEntity::updateObservations); } void AlembicEntity::setSource(const QUrl& value) @@ -54,6 +60,30 @@ void AlembicEntity::setLocatorScale(const float& value) Q_EMIT locatorScaleChanged(); } +void AlembicEntity::setViewId(const int& value) +{ + if (_viewId == value) + return; + _viewId = value; + Q_EMIT viewIdChanged(); +} + +void AlembicEntity::setDisplayObservations(const bool& value) +{ + if (_displayObservations == value) + return; + _displayObservations = value; + Q_EMIT displayObservationsChanged(); +} + +void AlembicEntity::setViewer2DInfo(const QVariantMap& value) +{ + if (_viewer2DInfo == value) + return; + _viewer2DInfo = value; + Q_EMIT viewer2DInfoChanged(); +} + void AlembicEntity::scaleLocators() const { for(auto* entity : _cameras) @@ -63,6 +93,16 @@ void AlembicEntity::scaleLocators() const } } +void AlembicEntity::updateObservations() const +{ + for (auto* entity : _observations) + { + entity->update(static_cast(_viewId), _viewer2DInfo); + if (entity->isEnabled() != _displayObservations) + entity->setEnabled(_displayObservations); + } +} + // private void AlembicEntity::createMaterials() { @@ -129,6 +169,7 @@ void AlembicEntity::clear() removeComponent(component); _cameras.clear(); _pointClouds.clear(); + _observations.clear(); } // private @@ -160,10 +201,13 @@ void AlembicEntity::onIOThreadFinished() // store pointers to cameras and point clouds _cameras = findChildren(); _pointClouds = findChildren(); + _observations = findChildren(); // perform initial locator scaling scaleLocators(); + updateObservations(); + setStatus(AlembicEntity::Ready); } catch(...) @@ -174,6 +218,7 @@ void AlembicEntity::onIOThreadFinished() _ioThread->clear(); Q_EMIT camerasChanged(); Q_EMIT pointCloudsChanged(); + Q_EMIT observationsChanged(); } // private @@ -182,18 +227,25 @@ void AlembicEntity::visitAbcObject(const Alembic::Abc::IObject& iObj, QEntity* p using namespace Alembic::Abc; using namespace Alembic::AbcGeom; - const auto createEntity = [&](const IObject&) -> BaseAlembicObject* { + const auto createEntities = [&](const IObject&) -> std::vector { const MetaData& md = iObj.getMetaData(); if(IPoints::matches(md)) { + std::vector entities(2); IPoints points(iObj, Alembic::Abc::kWrapExisting); - PointCloudEntity* entity = new PointCloudEntity(parent); - entity->setData(iObj); - entity->addComponent(_cloudMaterial); - entity->fillArbProperties(points.getSchema().getArbGeomParams()); - entity->fillUserProperties(points.getSchema().getUserProperties()); - return entity; + PointCloudEntity* entity0 = new PointCloudEntity(parent); + entity0->setData(iObj); + entity0->addComponent(_cloudMaterial); + entity0->fillArbProperties(points.getSchema().getArbGeomParams()); + entity0->fillUserProperties(points.getSchema().getUserProperties()); + entities[0] = entity0; + ObservationsEntity* entity1 = new ObservationsEntity(_source.toLocalFile().toStdString(), parent); + entity1->setData(); + entity1->fillArbProperties(points.getSchema().getArbGeomParams()); + entity1->fillUserProperties(points.getSchema().getUserProperties()); + entities[1] = entity1; + return entities; } else if(IXform::matches(md)) { @@ -204,7 +256,7 @@ void AlembicEntity::visitAbcObject(const Alembic::Abc::IObject& iObj, QEntity* p entity->setTransform(xs.getMatrix()); entity->fillArbProperties(xform.getSchema().getArbGeomParams()); entity->fillUserProperties(xform.getSchema().getUserProperties()); - return entity; + return {entity}; } else if(ICamera::matches(md)) { @@ -213,12 +265,12 @@ void AlembicEntity::visitAbcObject(const Alembic::Abc::IObject& iObj, QEntity* p entity->addComponent(_cameraMaterial); entity->fillArbProperties(cam.getSchema().getArbGeomParams()); entity->fillUserProperties(cam.getSchema().getUserProperties()); - return entity; + return {entity}; } else { // fallback: create empty object to preserve hierarchy - return new BaseAlembicObject(parent); + return {new BaseAlembicObject(parent)}; } }; @@ -235,12 +287,17 @@ void AlembicEntity::visitAbcObject(const Alembic::Abc::IObject& iObj, QEntity* p } } - BaseAlembicObject* entity = createEntity(iObj); - entity->setObjectName(iObj.getName().c_str()); + std::vector entities = createEntities(iObj); - // visit children - for(size_t i = 0; i < iObj.getNumChildren(); i++) - visitAbcObject(iObj.getChild(i), entity); + for (size_t j = 0; j < entities.size(); j++) + { + auto entity = entities[j]; + entity->setObjectName((iObj.getName() + std::to_string(j)).c_str()); + + // visit children + for (size_t i = 0; i < iObj.getNumChildren(); i++) + visitAbcObject(iObj.getChild(i), entity); + } } } // namespace diff --git a/src/qmlAlembic/AlembicEntity.hpp b/src/qmlAlembic/AlembicEntity.hpp index 6b058ed3..649a66dd 100644 --- a/src/qmlAlembic/AlembicEntity.hpp +++ b/src/qmlAlembic/AlembicEntity.hpp @@ -13,6 +13,7 @@ namespace abcentity { class CameraLocatorEntity; class PointCloudEntity; +class ObservationsEntity; class IOThread; class AlembicEntity : public Qt3DCore::QEntity @@ -22,8 +23,13 @@ class AlembicEntity : public Qt3DCore::QEntity Q_PROPERTY(bool skipHidden MEMBER _skipHidden NOTIFY skipHiddenChanged) Q_PROPERTY(float pointSize READ pointSize WRITE setPointSize NOTIFY pointSizeChanged) Q_PROPERTY(float locatorScale READ locatorScale WRITE setLocatorScale NOTIFY locatorScaleChanged) + Q_PROPERTY(int viewId READ viewId WRITE setViewId NOTIFY viewIdChanged) + Q_PROPERTY(QVariantMap viewer2DInfo READ viewer2DInfo WRITE setViewer2DInfo NOTIFY viewer2DInfoChanged) + Q_PROPERTY(bool displayObservations READ displayObservations WRITE setDisplayObservations NOTIFY displayObservationsChanged) Q_PROPERTY(QQmlListProperty cameras READ cameras NOTIFY camerasChanged) Q_PROPERTY(QQmlListProperty pointClouds READ pointClouds NOTIFY pointCloudsChanged) + Q_PROPERTY( + QQmlListProperty observations READ observations NOTIFY observationsChanged) Q_PROPERTY(Status status READ status NOTIFY statusChanged) @@ -43,9 +49,15 @@ class AlembicEntity : public Qt3DCore::QEntity Q_SLOT const QUrl& source() const { return _source; } Q_SLOT float pointSize() const { return _pointSize; } Q_SLOT float locatorScale() const { return _locatorScale; } + Q_SLOT int viewId() const { return _viewId; } + Q_SLOT bool displayObservations() const { return _displayObservations; } + Q_SLOT QVariantMap viewer2DInfo() const { return _viewer2DInfo; } Q_SLOT void setSource(const QUrl& source); Q_SLOT void setPointSize(const float& value); Q_SLOT void setLocatorScale(const float& value); + Q_SLOT void setViewId(const int& value); + Q_SLOT void setDisplayObservations(const bool& value); + Q_SLOT void setViewer2DInfo(const QVariantMap& value); Status status() const { return _status; } void setStatus(Status status) { @@ -70,12 +82,20 @@ class AlembicEntity : public Qt3DCore::QEntity return {this, &_pointClouds}; } + QQmlListProperty observations() { + return {this, &_observations}; + } + public: Q_SIGNAL void sourceChanged(); Q_SIGNAL void camerasChanged(); Q_SIGNAL void pointSizeChanged(); Q_SIGNAL void pointCloudsChanged(); + Q_SIGNAL void observationsChanged(); Q_SIGNAL void locatorScaleChanged(); + Q_SIGNAL void viewIdChanged(); + Q_SIGNAL void viewer2DInfoChanged(); + Q_SIGNAL void displayObservationsChanged(); Q_SIGNAL void objectPicked(Qt3DCore::QTransform* transform); Q_SIGNAL void statusChanged(Status status); Q_SIGNAL void skipHiddenChanged(); @@ -84,6 +104,8 @@ class AlembicEntity : public Qt3DCore::QEntity /// Scale child locators void scaleLocators() const; + void updateObservations() const; + void onIOThreadFinished(); private: @@ -92,11 +114,15 @@ class AlembicEntity : public Qt3DCore::QEntity bool _skipHidden = false; float _pointSize = 0.5f; float _locatorScale = 1.0f; + int _viewId = 0; + bool _displayObservations = false; + QVariantMap _viewer2DInfo; Qt3DRender::QParameter* _pointSizeParameter; Qt3DRender::QMaterial* _cloudMaterial; Qt3DRender::QMaterial* _cameraMaterial; QList _cameras; QList _pointClouds; + QList _observations; std::unique_ptr _ioThread; }; diff --git a/src/qmlAlembic/CMakeLists.txt b/src/qmlAlembic/CMakeLists.txt index 68383ce3..bc32cbca 100644 --- a/src/qmlAlembic/CMakeLists.txt +++ b/src/qmlAlembic/CMakeLists.txt @@ -5,6 +5,7 @@ set(PLUGIN_SOURCES CameraLocatorEntity.cpp IOThread.cpp PointCloudEntity.cpp + ObservationsEntity.cpp ) set(PLUGIN_HEADERS @@ -13,6 +14,7 @@ set(PLUGIN_HEADERS CameraLocatorEntity.hpp IOThread.hpp PointCloudEntity.hpp + ObservationsEntity.hpp plugin.hpp ) @@ -49,8 +51,9 @@ target_link_libraries(alembicEntityQmlPlugin Qt5::3DCore Qt5::3DRender Alembic::Alembic - PRIVATE Qt5::3DExtras + aliceVision_sfmData + aliceVision_sfmDataIO ) set_target_properties(alembicEntityQmlPlugin diff --git a/src/qmlAlembic/ObservationsEntity.cpp b/src/qmlAlembic/ObservationsEntity.cpp new file mode 100644 index 00000000..6a9bfe48 --- /dev/null +++ b/src/qmlAlembic/ObservationsEntity.cpp @@ -0,0 +1,269 @@ +#include "ObservationsEntity.hpp" +#include +#include +#include +#include + +#include + +using namespace Qt3DRender; +using namespace aliceVision; + +namespace abcentity +{ + +static const auto& TC_INDEX_ATTRIBUTE_NAME = "Tc-observations indices"; +static const auto& RC_INDEX_ATTRIBUTE_NAME = "Rc-observations indices"; + +// helper functions + +QGeometryRenderer* createGeometryRenderer(const QByteArray& positionData, const std::string& indexAttributeName); +QMaterial* createMaterial(const float& r, const float& g, const float& b, const float& a); + +ObservationsEntity::ObservationsEntity(std::string source, Qt3DCore::QNode* parent) + : BaseAlembicObject(parent) + , _source(source) +{ + using sfmDataIO::ESfMData; + + // read relevant SfM data + sfmDataIO::Load(_sfmData, _source, ESfMData::ALL); + + // populate _landmarksPerView from the read SfM data + fillLandmarksPerViews(); +} + +void ObservationsEntity::setData() +{ + // contains the 3D coordinates of vertices in space in the following order: + // - the landmarks + // - the view cameras + QByteArray positionData; + + fillBytesData(positionData); + + // contains the connections between the selected view and its corresponding + // observed landmarks + auto* rcGeometry = createGeometryRenderer(positionData, RC_INDEX_ATTRIBUTE_NAME); + // contains the connections between the observed landmarks of the current view + // and their corresponding observing view cameras + auto* tcGeometry = createGeometryRenderer(positionData, TC_INDEX_ATTRIBUTE_NAME); + + // create separate QEntity objects to have different material colors + + auto* tcEntity = new QEntity(this); + tcEntity->addComponent(tcGeometry); + tcEntity->addComponent(createMaterial(0, 1, 0, 1)); + + auto* rcEntity = new QEntity(this); + rcEntity->addComponent(rcGeometry); + rcEntity->addComponent(createMaterial(0, 0, 1, 1)); +} + +void ObservationsEntity::update(const IndexT& viewId, const QVariantMap& viewer2DInfo) +{ + QByteArray tcIndexData; + QByteArray rcIndexData; + size_t tcIndexSize = 0; + size_t rcIndexCount = 0; + + const auto& it = _landmarksPerView.find(viewId); + const auto& setValidViewIds = _sfmData.getValidViews(); + if (it != _landmarksPerView.end() && setValidViewIds.find(viewId) != setValidViewIds.end()) + { + // 2D view bounds + const float& scale = viewer2DInfo["scale"].toFloat(); + const float& x1 = -viewer2DInfo["x"].toFloat() / scale; + const float& y1 = -viewer2DInfo["y"].toFloat() / scale; + const float& x2 = viewer2DInfo["width"].toFloat() / scale + x1; + const float& y2 = viewer2DInfo["height"].toFloat() / scale + y1; + + const auto& totalNbObservations = it->second.first; + const auto& totalNbLandmarks = it->second.second.size(); + + tcIndexData.resize(static_cast(totalNbObservations * sizeof(uint) * 2)); + rcIndexData.resize(static_cast(totalNbLandmarks * sizeof(uint) * 2)); + char* tcIndexIt = tcIndexData.data(); + uint* rcIndexIt = reinterpret_cast(rcIndexData.data()); + + const auto& rcVertexPos = _viewId2vertexPos.at(viewId); + for (auto& landmarkPerView : it->second.second) + { + const auto& coords2D = landmarkPerView.observation.x; + // if not observed in viewer 2D + if (coords2D.x() < x1 || coords2D.x() > x2 || coords2D.y() < y1 || coords2D.y() > y2) + continue; + + *rcIndexIt++ = rcVertexPos; + *rcIndexIt++ = landmarkPerView.landmarkId; + rcIndexCount += 2; + + auto itt = _indexBytesByLandmark.data(); + std::advance(itt, _landmarkId2IndexRange[landmarkPerView.landmarkId].first * sizeof(uint)); + const auto& d = _landmarkId2IndexRange[landmarkPerView.landmarkId].second * sizeof(uint); + memcpy(tcIndexIt, itt, d); + std::advance(tcIndexIt, d); + tcIndexSize += d; + } + tcIndexData.resize(static_cast(tcIndexSize)); + rcIndexData.resize(static_cast(rcIndexCount * sizeof(uint))); + } + + this->findChild(TC_INDEX_ATTRIBUTE_NAME)->buffer()->setData(tcIndexData); + this->findChild(TC_INDEX_ATTRIBUTE_NAME) + ->setCount(static_cast(tcIndexSize / sizeof(uint))); + + this->findChild(RC_INDEX_ATTRIBUTE_NAME)->buffer()->setData(rcIndexData); + this->findChild(RC_INDEX_ATTRIBUTE_NAME)->setCount(static_cast(rcIndexCount)); +} + +void ObservationsEntity::fillLandmarksPerViews() +{ + for (const auto& landIt : _sfmData.getLandmarks()) + { + for (const auto& obsIt : landIt.second.observations) + { + IndexT viewId = obsIt.first; + auto& [totalNbObservations, landmarksSet] = _landmarksPerView[viewId]; + totalNbObservations += landIt.second.observations.size(); + landmarksSet.push_back(LandmarkPerView(landIt.first, landIt.second, obsIt.second)); + } + } +} + +void ObservationsEntity::fillBytesData(QByteArray& positionData) +{ + // -------------- read position data ---------------------- + + const auto& nLandmarks = _sfmData.getLandmarks().size(); + const auto& nViews = _sfmData.getValidViews().size(); + positionData.resize(static_cast((nLandmarks + nViews) * 3 * sizeof(float))); + size_t nObservations = 0; + // copy positions of landmarks + { + auto* positionsIt = positionData.data(); + for (const auto& landIt : _sfmData.getLandmarks()) + { + aliceVision::Vec3f x = landIt.second.X.cast(); + // graphics to open-gl coordinates system + x.z() *= -1; + x.y() *= -1; + memcpy(positionsIt, reinterpret_cast(x.data()), 3 * sizeof(float)); + positionsIt += 3 * sizeof(float); + nObservations += landIt.second.observations.size(); + } + } + // copy positions of view cameras and construct _viewId2vertexPos + { + auto* positionsIt = positionData.data(); + uint viewPosIdx = static_cast(nLandmarks); + // view camera positions are added after landmarks' + positionsIt += 3 * sizeof(float) * nLandmarks; + for (const auto& viewId : _sfmData.getValidViews()) + { + _viewId2vertexPos[viewId] = viewPosIdx++; + aliceVision::Vec3f center = _sfmData.getPose(_sfmData.getView(viewId)).getTransform().center().cast(); + // graphics to open-gl coordinates system + center.z() *= -1; + center.y() *= -1; + memcpy(positionsIt, reinterpret_cast(center.data()), 3 * sizeof(float)); + positionsIt += 3 * sizeof(float); + } + } + + // -------------- read Tc index data ---------------------- + + { + _indexBytesByLandmark.resize(static_cast(2 * sizeof(uint) * nObservations)); + _landmarkId2IndexRange.resize(nLandmarks); + uint* indices = reinterpret_cast(_indexBytesByLandmark.data()); + uint offset = 0; + size_t obsGlobalIndex = 0; + for (const auto& landIt : _sfmData.getLandmarks()) + { + const auto& delta = static_cast(landIt.second.observations.size() * 2); + _landmarkId2IndexRange[landIt.first] = std::pair(offset, delta); + offset += delta; + for (const auto& obsIt : landIt.second.observations) + { + *indices++ = landIt.first; + const auto& pos = _viewId2vertexPos.find(obsIt.first); + if (pos != _viewId2vertexPos.end()) + { + *indices++ = pos->second; + } + else + { + *indices++ = landIt.first; + } + ++obsGlobalIndex; + } + } + } +} + +QGeometryRenderer* createGeometryRenderer(const QByteArray& positionData, const std::string& indexAttributeName) +{ + // create a new geometry renderer + auto customMeshRenderer = new QGeometryRenderer; + // the corresponding geometry consisting of: + // - a position attribute defining the 3D points + // - an index attribute defining the connectivity between points + auto customGeometry = new QGeometry; + + // mesh + customMeshRenderer->setGeometry(customGeometry); + customMeshRenderer->setPrimitiveType(QGeometryRenderer::Lines); + + // ------------- 3D points ------------------- + + // the vertices buffer + auto* vertexDataBuffer = new QBuffer; + vertexDataBuffer->setData(positionData); + // the position attribute + auto* positionAttribute = new QAttribute; + positionAttribute->setBuffer(vertexDataBuffer); + positionAttribute->setAttributeType(QAttribute::VertexAttribute); + positionAttribute->setVertexBaseType(QAttribute::Float); + positionAttribute->setVertexSize(3); // space dimension + positionAttribute->setByteOffset(0); + const auto& nbBytesPerVertex = 3 * sizeof(float); + positionAttribute->setByteStride(static_cast(nbBytesPerVertex)); + positionAttribute->setCount(static_cast(positionData.size() / nbBytesPerVertex)); + positionAttribute->setName(QAttribute::defaultPositionAttributeName()); + customGeometry->addAttribute(positionAttribute); // add to the geometry + + // ------------- connectivity between 3D points ------------------- + + // byte array containing pairs of indices to connected 3D points in poisition attribute + QByteArray indexBytes; + // the indices buffer + auto* indexBuffer = new QBuffer; + indexBuffer->setData(indexBytes); + // the index attribute + auto* indexAttribute = new QAttribute; + indexAttribute->setBuffer(indexBuffer); + indexAttribute->setAttributeType(QAttribute::IndexAttribute); + indexAttribute->setVertexBaseType(QAttribute::UnsignedInt); + indexAttribute->setCount(0); // index data not yet available + // set name for accessing the attribute and filling data later + indexAttribute->setObjectName(QString::fromStdString(indexAttributeName)); + customGeometry->addAttribute(indexAttribute); // add to the geometry + + return customMeshRenderer; +} + +QMaterial* createMaterial(const float& r, const float& g, const float& b, const float& a) +{ + // material for the connections between 3D points + auto* material = new Qt3DExtras::QPhongAlphaMaterial; + material->setAmbient(QColor::fromRgbF(r, g, b)); + material->setDiffuse(QColor::fromRgbF(r, g, b)); + material->setSpecular(QColor::fromRgbF(r, g, b)); + material->setShininess(0.0f); + material->setAlpha(a); + + return material; +} + +} // namespace diff --git a/src/qmlAlembic/ObservationsEntity.hpp b/src/qmlAlembic/ObservationsEntity.hpp new file mode 100644 index 00000000..882bbf16 --- /dev/null +++ b/src/qmlAlembic/ObservationsEntity.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include "BaseAlembicObject.hpp" +#include + +using namespace aliceVision; + +namespace abcentity +{ + +/** + * @brief encapsulates sufficient and fast retrievable information on a landmark observed by a view + */ +struct LandmarkPerView +{ + LandmarkPerView() = default; + + LandmarkPerView(const IndexT& landmarkId_, const sfmData::Landmark& landmark_, + const sfmData::Observation& observation_) + : landmarkId(landmarkId_) + , landmark(landmark_) + , observation(observation_) + { + } + + const uint& landmarkId; + const sfmData::Landmark& landmark; + const sfmData::Observation& observation; +}; + +class ObservationsEntity : public BaseAlembicObject +{ + Q_OBJECT + +public: + explicit ObservationsEntity(std::string source, Qt3DCore::QNode* parent = nullptr); + ~ObservationsEntity() override = default; + + void setData(); + void update(const IndexT& viewId, const QVariantMap& viewer2DInfo); + +private: + + void fillBytesData(QByteArray& positionData); + void fillLandmarksPerViews(); + + // file path to SfM data + std::string _source; + // contains the indices of vertices in position data used to draw lines + // between landmarks and their corresponding obsevations (ordered by landmark) + QByteArray _indexBytesByLandmark; + // each pair represents the offset position and count of indices (not bytes) + // in _indexBytesByLandmark for a landmark + std::vector> _landmarkId2IndexRange; + // maps view id to index of the corresponding position for a view camera in position data + std::map _viewId2vertexPos; + sfmData::SfMData _sfmData; + // maps view id to a pair containing: + // - the total number of observations of all landmarks observed by this view + // - a vector of LandmarkPerView structures corresponding to the landmarks observed by this view + stl::flat_map>> _landmarksPerView; +}; + +} // namespace diff --git a/src/qmlAlembic/plugin.hpp b/src/qmlAlembic/plugin.hpp index 291d96f9..071fc621 100644 --- a/src/qmlAlembic/plugin.hpp +++ b/src/qmlAlembic/plugin.hpp @@ -22,6 +22,8 @@ class AlembicEntityQmlPlugin : public QQmlExtensionPlugin "Cannot create CameraLocatorEntity instances from QML."); qmlRegisterUncreatableType(uri, 2, 0, "PointCloudEntity", "Cannot create PointCloudEntity instances from QML."); + qmlRegisterUncreatableType(uri, 2, 0, "ObservationsEntity", + "Cannot create ObservationsEntity instances from QML."); } };