From 37eabb4ceadeebc73a879a296f54f22dc093c572 Mon Sep 17 00:00:00 2001 From: Daniel Girardeau-Montaut Date: Sun, 4 Nov 2018 23:31:48 +0100 Subject: [PATCH] (temporary) Classify dialog added --- Classify3DMASCDialog.ui | 182 +++++++ q3DMASC.cpp | 140 +++-- q3DMASCClassifier.cpp | 141 +++++- q3DMASCClassifier.h | 3 + q3DMASCTools.cpp | 1014 ++++++++++++++++++++++--------------- q3DMASCTools.h | 11 +- qClassify3DMASCDialog.cpp | 190 +++++++ qClassify3DMASCDialog.h | 53 ++ 8 files changed, 1267 insertions(+), 467 deletions(-) create mode 100644 Classify3DMASCDialog.ui create mode 100644 qClassify3DMASCDialog.cpp create mode 100644 qClassify3DMASCDialog.h diff --git a/Classify3DMASCDialog.ui b/Classify3DMASCDialog.ui new file mode 100644 index 0000000..6a248af --- /dev/null +++ b/Classify3DMASCDialog.ui @@ -0,0 +1,182 @@ + + + Classify3DMASCDialog + + + + 0 + 0 + 700 + 250 + + + + 3DMASC Classify + + + + + + + + Classifier file + + + + + + + true + + + + + + + + + Data + + + + + + Cloud + + + + + + + Role + + + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + + + + + + 0 + 0 + + + + + + + + PC1 + + + + + + + PC2 + + + + + + + CTX + + + + + + + + + + color:red; + + + Assign each role to the right cloud, and select the role of the cloud to be classified + + + Qt::AlignCenter + + + + + + + Qt::Vertical + + + + 20 + 23 + + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + buttonBox + accepted() + Classify3DMASCDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Classify3DMASCDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/q3DMASC.cpp b/q3DMASC.cpp index f06f061..0a0a63a 100644 --- a/q3DMASC.cpp +++ b/q3DMASC.cpp @@ -21,6 +21,7 @@ #include "q3DMASCDisclaimerDialog.h" #include "q3DMASCClassifier.h" #include "q3DMASCTools.h" +#include "qClassify3DMASCDialog.h" //qCC_db #include @@ -45,7 +46,8 @@ void q3DMASCPlugin::onNewSelection(const ccHObject::Container& selectedEntities) if (m_classifyAction) { //classification: only one point cloud - m_classifyAction->setEnabled(selectedEntities.size() == 1 && selectedEntities[0]->isA(CC_TYPES::POINT_CLOUD)); + //m_classifyAction->setEnabled(selectedEntities.size() == 1 && selectedEntities[0]->isA(CC_TYPES::POINT_CLOUD)); + m_classifyAction->setEnabled(m_app->dbRootObject()->getChildrenNumber() != 0); } if (m_trainAction) @@ -96,7 +98,84 @@ void q3DMASCPlugin::doClassifyAction() return; } - //TODO + QString inputFilename; + { + QSettings settings; + settings.beginGroup("3DMASC"); + QString inputPath = settings.value("FilePath", QCoreApplication::applicationDirPath()).toString(); + inputFilename = QFileDialog::getOpenFileName(m_app->getMainWindow(), "Load 3DMASC classifier file", inputPath, "*.txt"); + if (inputFilename.isNull()) + { + //process cancelled by the user + return; + } + settings.setValue("FilePath", QFileInfo(inputFilename).absolutePath()); + settings.endGroup(); + } + + QSet cloudLabels; + if (!masc::Tools::LoadClassifierCloudLabels(inputFilename, cloudLabels)) + { + m_app->dispToConsole("Failed to read classifier file (see Console)", ccMainAppInterface::ERR_CONSOLE_MESSAGE); + return; + } + if (cloudLabels.empty()) + { + m_app->dispToConsole("Invalid classifier file (no cloud label defined)", ccMainAppInterface::ERR_CONSOLE_MESSAGE); + return; + } + else if (cloudLabels.size() > 3) + { + m_app->dispToConsole("This classifier uses more than 3 clouds (the GUI version cannot handle it)", ccMainAppInterface::ERR_CONSOLE_MESSAGE); + return; + } + + //now show a dialog where the user will be able to set the cloud roles + Classify3DMASCDialog classifDlg(m_app); + classifDlg.setCloudRoles(cloudLabels); + if (!classifDlg.exec()) + { + //process cancelled by the user + return; + } + + masc::Tools::NamedClouds clouds; + QString mainCloudLabel; + classifDlg.getClouds(clouds, mainCloudLabel); + + masc::Feature::Set features; + masc::Classifier classifier; + if (!masc::Tools::LoadClassifier(inputFilename, clouds, features, classifier, m_app->getMainWindow())) + { + return; + } + + //the 'main cloud' is the cloud that should be classified + masc::CorePoints corePoints; + corePoints.origin = corePoints.cloud = clouds[mainCloudLabel]; + + //prepare the main cloud + ccProgressDialog pDlg(true, m_app->getMainWindow()); + pDlg.setAutoClose(false); //we don't want the progress dialog to 'pop' for each feature + QString error; + if (!masc::Tools::PrepareFeatures(corePoints, features, error, &pDlg)) + { + m_app->dispToConsole(error, ccMainAppInterface::ERR_CONSOLE_MESSAGE); + return; + } + pDlg.close(); + QCoreApplication::processEvents(); + pDlg.setAutoClose(true); //restore the default behavior of the progress dialog + + //apply classifier + { + QString errorMessage; + if (!classifier.classify(features, corePoints.cloud, errorMessage, m_app->getMainWindow())) + { + m_app->dispToConsole(errorMessage, ccMainAppInterface::ERR_CONSOLE_MESSAGE); + return; + } + } } void q3DMASCPlugin::doTrainAction() @@ -105,17 +184,6 @@ void q3DMASCPlugin::doTrainAction() if (!ShowTrainDisclaimer(m_app)) return; - //if (m_selectedEntities.size() != 2 - // || !m_selectedEntities[0]->isA(CC_TYPES::POINT_CLOUD) - // || !m_selectedEntities[1]->isA(CC_TYPES::POINT_CLOUD)) - //{ - // m_app->dispToConsole("Select two point clouds!",ccMainAppInterface::ERR_CONSOLE_MESSAGE); - // return; - //} - // - //ccPointCloud* cloud1 = static_cast(m_selectedEntities[0]); - //ccPointCloud* cloud2 = static_cast(m_selectedEntities[1]); - masc::TrainParameters params; if (params.testDataRatio < 0 || params.testDataRatio > 0.99f) { @@ -123,31 +191,12 @@ void q3DMASCPlugin::doTrainAction() return; } - masc::Feature::Set features; -#if 0 - if (m_selectedEntities.empty() || !m_selectedEntities.front()->isA(CC_TYPES::POINT_CLOUD)) - { - m_app->dispToConsole("Select one and only one point cloud!", ccMainAppInterface::ERR_CONSOLE_MESSAGE); - return; - } - - ccPointCloud* cloud = static_cast(m_selectedEntities.front()); - - //features - { - Feature::Shared featureZ(new PointFeature(PointFeature::Z, cloud)); - features.push_back(featureZ); - - Feature::Shared featureIntensity(new PointFeature(PointFeature::Intensity, cloud)); - features.push_back(featureIntensity); - } -#else QString inputFilename; { QSettings settings; settings.beginGroup("3DMASC"); QString inputPath = settings.value("FilePath", QCoreApplication::applicationDirPath()).toString(); - inputFilename = QFileDialog::getOpenFileName(m_app->getMainWindow(), "Load 3DMASC script file", inputPath, "*.txt"); + inputFilename = QFileDialog::getOpenFileName(m_app->getMainWindow(), "Load 3DMASC training file", inputPath, "*.txt"); if (inputFilename.isNull()) { //process cancelled by the user @@ -159,7 +208,8 @@ void q3DMASCPlugin::doTrainAction() std::vector loadedClouds; masc::CorePoints corePoints; - if (!masc::Tools::LoadFile(inputFilename, features, loadedClouds, corePoints)) + masc::Feature::Set features; + if (!masc::Tools::LoadTrainingFile(inputFilename, features, loadedClouds, corePoints)) { while (!loadedClouds.empty()) { @@ -223,8 +273,6 @@ void q3DMASCPlugin::doTrainAction() QCoreApplication::processEvents(); pDlg.setAutoClose(true); //restore the default behavior of the progress dialog -#endif - //randomly select the training points QScopedPointer trainSubset(new CCLib::ReferenceCloud(corePoints.cloud)); QScopedPointer testSubset(new CCLib::ReferenceCloud(corePoints.cloud)); @@ -234,18 +282,8 @@ void q3DMASCPlugin::doTrainAction() return; } + //train the classifier masc::Classifier classifier; - //QString outputFilename = QCoreApplication::applicationDirPath() + "/classifier.yaml"; - //if (QFile(outputFilename).exists()) - //{ - // if (!classifier.fromFile(outputFilename, m_app->getMainWindow())) - // { - // m_app->dispToConsole("Failed to load previous classifier file", ccMainAppInterface::ERR_CONSOLE_MESSAGE); - // return; - // } - // m_app->dispToConsole("Previous classifier loaded", ccMainAppInterface::WRN_CONSOLE_MESSAGE); - //} - //else { QString errorMessage; if (!classifier.train(corePoints.cloud, params.rt, features, errorMessage, trainSubset.data(), m_app->getMainWindow())) @@ -259,7 +297,7 @@ void q3DMASCPlugin::doTrainAction() QSettings settings; settings.beginGroup("3DMASC"); QString outputPath = settings.value("FilePath", QCoreApplication::applicationDirPath()).toString(); - outputFilename = QFileDialog::getSaveFileName(m_app->getMainWindow(), "Save 3DMASC classifier", outputPath, "*.yaml"); + outputFilename = QFileDialog::getSaveFileName(m_app->getMainWindow(), "Save 3DMASC classifier", outputPath, "*.txt"); if (outputFilename.isNull()) { //process cancelled by the user @@ -270,8 +308,10 @@ void q3DMASCPlugin::doTrainAction() } //save the classifier - classifier.toFile(outputFilename, m_app->getMainWindow()); - m_app->dispToConsole("Classifier succesfully created", ccMainAppInterface::WRN_CONSOLE_MESSAGE); + if (masc::Tools::SaveClassifier(outputFilename, features, classifier, m_app->getMainWindow())) + { + m_app->dispToConsole("Classifier succesfully saved to " + outputFilename, ccMainAppInterface::STD_CONSOLE_MESSAGE); + } } //test classifier diff --git a/q3DMASCClassifier.cpp b/q3DMASCClassifier.cpp index 25be940..d46cae2 100644 --- a/q3DMASCClassifier.cpp +++ b/q3DMASCClassifier.cpp @@ -22,6 +22,8 @@ //qCC_db #include +#include +#include #include //qCC_io @@ -94,6 +96,126 @@ static QSharedPointer GetSource(const Feature::Shared& f, c return source; } +bool Classifier::classify(const Feature::Set& features, ccPointCloud* cloud, QString& errorMessage, QWidget* parentWidget/*=nullptr*/) +{ + if (!cloud) + { + assert(false); + errorMessage = QObject::tr("Invalid input"); + return false; + } + + if (!m_rtrees || !m_rtrees->isTrained()) + { + errorMessage = QObject::tr("Classifier hasn't been trained yet"); + return false; + } + + if (features.empty()) + { + errorMessage = QObject::tr("Training method called without any feature?!"); + return false; + } + + //look for the classification field + CCLib::ScalarField* classificationSF = nullptr; + int classifSFIdx = cloud->getScalarFieldIndexByName(LAS_FIELD_NAMES[LAS_CLASSIFICATION]); //LAS_FIELD_NAMES[LAS_CLASSIFICATION] = "Classification" + if (!classifSFIdx) + { + //create it if necessary + ccScalarField* _classificationSF = new ccScalarField(LAS_FIELD_NAMES[LAS_CLASSIFICATION]); + if (!_classificationSF->resizeSafe(cloud->size())) + { + _classificationSF->release(); + errorMessage = QObject::tr("Not enough memory"); + return false; + } + classifSFIdx = cloud->addScalarField(_classificationSF); + classificationSF = _classificationSF; + cloud->setCurrentDisplayedScalarField(classifSFIdx); + } + else + { + classificationSF = cloud->getScalarField(classifSFIdx); + } + assert(classificationSF); + classificationSF->fill(0); //0 = no classification? + + int sampleCount = static_cast(cloud->size()); + int attributesPerSample = static_cast(features.size()); + + ccLog::Print(QObject::tr("[3DMASC] Classifying %1 points with %2 feature(s)").arg(sampleCount).arg(attributesPerSample)); + + //allocate the data matrix + cv::Mat test_data; + try + { + test_data.create(1, attributesPerSample, CV_32FC1); + } + catch (const cv::Exception& cvex) + { + errorMessage = cvex.msg.c_str(); + return false; + } + + //create the field wrappers + std::vector< QSharedPointer > wrappers; + { + wrappers.reserve(attributesPerSample); + for (int fIndex = 0; fIndex < attributesPerSample; ++fIndex) + { + const Feature::Shared &f = features[fIndex]; + if (!f) + { + assert(false); + return false; + } + + QSharedPointer source = GetSource(f, cloud); + if (!source || !source->isValid()) + { + assert(false); + errorMessage = QObject::tr("Internal error: invalid source '%1'").arg(f->sourceName); + return false; + } + + wrappers.push_back(source); + } + } + + QScopedPointer pDlg; + if (parentWidget) + { + pDlg.reset(new ccProgressDialog(parentWidget)); + pDlg->setLabelText(QString("Classify (%1 points)").arg(sampleCount)); + pDlg->show(); + QCoreApplication::processEvents(); + } + CCLib::NormalizedProgress nProgress(pDlg.data(), cloud->size()); + + for (unsigned i = 0; i < cloud->size(); ++i) + { + for (int fIndex = 0; fIndex < attributesPerSample; ++fIndex) + { + double value = wrappers[fIndex]->pointValue(i); + test_data.at(0, fIndex) = static_cast(value); + } + + float predictedClass = m_rtrees->predict(test_data.row(0)); + classificationSF->setValue(i, static_cast(predictedClass)); + + if (pDlg && !nProgress.oneStep()) + { + //process cancelled by the user + classificationSF->computeMinAndMax(); + return false; + } + } + classificationSF->computeMinAndMax(); + + return true; +} + bool Classifier::evaluate(const Feature::Set& features, CCLib::ReferenceCloud* testSubset, AccuracyMetrics& metrics, QString& errorMessage, QWidget* parentWidget/*=nullptr*/) { metrics.sampleCount = metrics.goodGuess = 0; @@ -154,6 +276,16 @@ bool Classifier::evaluate(const Feature::Set& features, CCLib::ReferenceCloud* t return false; } + QScopedPointer pDlg; + if (parentWidget) + { + pDlg.reset(new ccProgressDialog(parentWidget)); + pDlg->setLabelText(QString("Evaluating the classifier on %1 points").arg(testSampleCount)); + pDlg->show(); + QCoreApplication::processEvents(); + } + CCLib::NormalizedProgress nProgress(pDlg.data(), testSampleCount); + //fill the data matrix for (int fIndex = 0; fIndex < attributesPerSample; ++fIndex) { @@ -180,7 +312,7 @@ bool Classifier::evaluate(const Feature::Set& features, CCLib::ReferenceCloud* t } } - //estimate the efficiency of the classiier + //estimate the efficiency of the classifier { metrics.sampleCount = testSubset->size(); metrics.goodGuess = 0; @@ -201,6 +333,12 @@ bool Classifier::evaluate(const Feature::Set& features, CCLib::ReferenceCloud* t { ++metrics.goodGuess; } + + if (pDlg && !nProgress.oneStep()) + { + //process cancelled by the user + return false; + } } metrics.ratio = static_cast(metrics.goodGuess) / metrics.sampleCount; @@ -310,6 +448,7 @@ bool Classifier::train( const ccPointCloud* cloud, pDlg->setRange(0, 0); //infinite loop pDlg->setLabelText("Training classifier"); pDlg->show(); + QCoreApplication::processEvents(); } m_rtrees = cv::ml::RTrees::create(); diff --git a/q3DMASCClassifier.h b/q3DMASCClassifier.h index 027c47b..1d5fa5a 100644 --- a/q3DMASCClassifier.h +++ b/q3DMASCClassifier.h @@ -56,6 +56,9 @@ namespace masc //! Evaluates the classifier bool evaluate(const Feature::Set& features, CCLib::ReferenceCloud* testSubset, AccuracyMetrics& metrics, QString& errorMessage, QWidget* parentWidget = nullptr); + //! Applies the classifier + bool classify(const Feature::Set& features, ccPointCloud* cloud, QString& errorMessage, QWidget* parentWidget = nullptr); + //! Returns whether the classifier is valid or not bool isValid() const; diff --git a/q3DMASCTools.cpp b/q3DMASCTools.cpp index 3587565..fa315dc 100644 --- a/q3DMASCTools.cpp +++ b/q3DMASCTools.cpp @@ -41,8 +41,17 @@ using namespace masc; -bool Tools::SaveFeatureDescriptors(QString filename, const Feature::Set& features) +bool Tools::SaveClassifier(QString filename, const Feature::Set& features, const masc::Classifier& classifier, QWidget* parent/*=nullptr*/) { + //first save the classifier data (same base filename but with the ymal extension) + QFileInfo fi(filename); + QString yamlFilename = fi.completeBaseName() + ".yaml"; + if (!classifier.toFile(fi.absoluteFilePath() + "/" + yamlFilename, parent)) + { + ccLog::Error("Failed to save the classifier data"); + return false; + } + QFile file(filename); if (!file.open(QFile::Text | QFile::WriteOnly)) { @@ -52,16 +61,522 @@ bool Tools::SaveFeatureDescriptors(QString filename, const Feature::Set& feature QTextStream stream(&file); - //header - stream << "#3DMASC classifier" << endl; + stream << "# 3DMASC classifier file" << endl; + stream << "classifier: " << yamlFilename << endl; + + //look for all clouds (labels) + QSet cloudLabels; + for (Feature::Shared f : features) + { + if (f->cloud1) + cloudLabels.insert(f->cloud1Label); + if (f->cloud2) + cloudLabels.insert(f->cloud2Label); + } + + stream << "# Clouds (roles)" << endl; + for (const QString& label : cloudLabels) + { + stream << "cloud: " << label << endl; + } + + stream << "# Features" << endl; + for (Feature::Shared f : features) + { + stream << "feature: " << f->toString() << endl; + } return true; } -bool Tools::LoadFile( QString filename, - Feature::Set& rawFeatures, - std::vector& loadedClouds, - CorePoints& corePoints) +bool Tools::LoadClassifierCloudLabels(QString filename, QSet& labels) +{ + QFile file(filename); + if (!file.open(QFile::Text | QFile::ReadOnly)) + { + ccLog::Warning(QString("Can't open file '%1'").arg(filename)); + return false; + } + + QTextStream stream(&file); + for (int lineNumber = 0; ; ++lineNumber) + { + QString line = stream.readLine(); + if (line.isNull()) + { + //eof + break; + } + ++lineNumber; + + if (line.startsWith("CLOUD:")) + { + QString command = line.mid(6).trimmed(); + QStringList tokens = command.split('='); + if (tokens.size() == 0) + { + ccLog::Warning("Malformed file: expecting some tokens after 'cloud:' on line #" + QString::number(lineNumber)); + return false; + } + + QString label = tokens.front(); + labels.insert(label); + } + } + + return true; +} + +static bool CreateFeaturesFromCommand(const QString& command, int lineNumber, const Tools::NamedClouds& clouds, std::vector& rawFeatures, std::vector& scales) +{ + QStringList tokens = command.split('_'); + if (tokens.empty()) + { + ccLog::Warning("Malformed file: expecting at least one token after 'feature:' on line #" + QString::number(lineNumber)); + return false; + } + + Feature::Shared feature; + + //read the type + QString typeStr = tokens[0].trimmed().toUpper(); + { + for (int iteration = 0; iteration < 1; ++iteration) //fake loop for easy break + { + PointFeature::PointFeatureType pointFeatureType = PointFeature::FromUpperString(typeStr); + if (pointFeatureType != PointFeature::Invalid) + { + //we have a point feature + PointFeature* pointFeature = new PointFeature(pointFeatureType); + + //specific case: 'SF#' + if (pointFeatureType == PointFeature::SF) + { + QString sfIndexStr = typeStr.mid(2); + bool ok = true; + int sfIndex = sfIndexStr.toInt(&ok); + if (!ok) + { + ccLog::Warning(QString("Malformed file: expecting a valid integer value after 'SF' on line #%1").arg(lineNumber)); + delete pointFeature; + return false; + } + pointFeature->sourceSFIndex = sfIndex; + } + + feature.reset(pointFeature); + break; + } + + NeighborhoodFeature::NeighborhoodFeatureType neighborhoodFeatureType = NeighborhoodFeature::FromUpperString(typeStr); + if (neighborhoodFeatureType != NeighborhoodFeature::Invalid) + { + //we have a neighborhood feature + feature = NeighborhoodFeature::Shared(new NeighborhoodFeature(neighborhoodFeatureType)); + break; + } + + ContextBasedFeature::ContextBasedFeatureType contextBasedFeatureType = ContextBasedFeature::FromUpperString(typeStr); + if (contextBasedFeatureType != ContextBasedFeature::Invalid) + { + //we have a context-based feature + feature = ContextBasedFeature::Shared(new ContextBasedFeature(contextBasedFeatureType)); + break; + } + + DualCloudFeature::DualCloudFeatureType dualCloudFeatureType = DualCloudFeature::FromUpperString(typeStr); + if (dualCloudFeatureType != DualCloudFeature::Invalid) + { + //we have a dual cloud feature + feature = DualCloudFeature::Shared(new DualCloudFeature(dualCloudFeatureType)); + break; + } + + if (!feature) + { + ccLog::Warning(QString("Malformed file: unrecognized token '%1' after 'feature:' on line #%2").arg(typeStr).arg(lineNumber)); + return false; + } + } + } + assert(feature); + + //read the scales + bool useAllScales = false; + { + QString scaleStr = tokens[1].toUpper(); + if (!scaleStr.startsWith("SC")) + { + ccLog::Warning(QString("Malformed file: unrecognized token '%1' (expecting the scale descriptor 'SC...' on line #%2").arg(typeStr).arg(lineNumber)); + return false; + } + + if (scaleStr == "SC0") + { + //no scale + } + else if (scaleStr == "SCX") + { + //all scales + useAllScales = true; + } + else + { + //read the specific scale index + bool ok = true; + feature->scale = scaleStr.mid(2).toDouble(&ok); + if (!ok) + { + ccLog::Warning(QString("Malformed file: expecting a valid number after 'SC:' on line #%1").arg(lineNumber)); + return false; + } + } + } + + //process the next tokens (may not be ordered) + int cloudCount = 0; + bool statDefined = false; + bool mathDefined = false; + for (int i = 2; i < tokens.size(); ++i) + { + QString token = tokens[i].trimmed().toUpper(); + + //is the token a 'stat' one? + if (!statDefined) + { + if (token == "MEAN") + { + feature->stat = Feature::MEAN; + statDefined = true; + } + else if (token == "MODE") + { + feature->stat = Feature::MODE; + statDefined = true; + } + else if (token == "STD") + { + feature->stat = Feature::STD; + statDefined = true; + } + else if (token == "RANGE") + { + feature->stat = Feature::RANGE; + statDefined = true; + } + else if (token == "SKEW") + { + feature->stat = Feature::SKEW; + statDefined = true; + } + + if (statDefined) + { + continue; + } + } + + //is the token a cloud name? + if (cloudCount < 2) + { + bool cloudNameMatches = false; + for (QMap::const_iterator it = clouds.begin(); it != clouds.end(); ++it) + { + QString key = it.key().toUpper(); + if (key == token) + { + if (cloudCount == 0) + { + feature->cloud1 = it.value(); + feature->cloud1Label = key; + } + else if (cloudCount == 1) + { + feature->cloud2 = it.value(); + feature->cloud2Label = key; + } + else + { + //we can't fall here + assert(false); + } + ++cloudCount; + cloudNameMatches = true; + break; + } + } + + if (cloudNameMatches) + { + continue; + } + } + + //is the token a 'math' one? + if (!mathDefined) + { + if (token == "MINUS") + { + feature->op = Feature::MINUS; + mathDefined = true; + } + else if (token == "PLUS") + { + feature->op = Feature::PLUS; + mathDefined = true; + } + else if (token == "DIVIDE") + { + feature->op = Feature::DIVIDE; + mathDefined = true; + } + else if (token == "MULTIPLY") + { + feature->op = Feature::MULTIPLY; + mathDefined = true; + } + + if (mathDefined) + { + continue; + } + } + + //is the token a 'context' descriptor? + if (feature->getType() == Feature::Type::ContextBasedFeature && token.startsWith("CTX")) + { + //read the context label + QString ctxLabelStr = token.mid(2); + bool ok = true; + int ctxLabel = ctxLabelStr.toInt(&ok); + if (!ok) + { + ccLog::Warning(QString("Malformed file: expecting a valid integer value after 'CTX' on line #%1").arg(lineNumber)); + return false; + } + static_cast(feature.data())->ctxClassLabel = ctxLabel; + continue; + } + + //if we are here, it means we couldn't find a correspondance for the current token + ccLog::Warning(QString("Malformed file: unrecognized or unexpected token '%1' on line #%2").arg(token).arg(lineNumber)); + return false; + } + + //now create the various versions of rules (if any) + if (useAllScales) + { + if (scales.empty()) + { + ccLog::Warning("Malformed file: 'SCx' token used while no scale is defined" + QString(" (line %1)").arg(lineNumber)); + return false; + } + feature->scale = scales.front(); + + //we will duplicate the original feature AFTER having checked its consistency! + } + + //now check the consistency of the rule + QString errorMessage; + if (!feature->checkValidity(errorMessage)) + { + ccLog::Warning("Malformed feature: " + errorMessage + QString(" (line %1)").arg(lineNumber)); + return false; + } + + //save it + rawFeatures.push_back(feature); + + if (useAllScales) + { + for (size_t i = 1; i < scales.size(); ++i) + { + //copy the original rule + Feature::Shared newFeature = feature->clone(); + newFeature->scale = scales.at(i); + + //as we only change the scale value, all the duplicated features should be valid + assert(newFeature->checkValidity(errorMessage)); + + rawFeatures.push_back(newFeature); + } + } + + return true; +} + +static bool ReadScales(const QString& command, std::vector& scales, int lineNumber) +{ + assert(scales.empty()); + + QStringList tokens = command.split(';'); + if (tokens.empty()) + { + ccLog::Warning("Malformed file: expecting at least one token after 'scales:' on line #" + QString::number(lineNumber)); + return false; + } + + for (const QString& token : tokens) + { + if (token.contains(':')) + { + //it's probably a range + QStringList subTokens = token.trimmed().split(':'); + if (subTokens.size() != 3) + { + ccLog::Warning(QString("Malformed file: expecting 3 tokens for a range of scales (%1)").arg(token)); + return false; + } + bool ok[3] = { true, true, true }; + double start = subTokens[0].trimmed().toDouble(ok); + double step = subTokens[1].toDouble(ok + 1); + double stop = subTokens[2].toDouble(ok + 2); + if (!ok[0] || !ok[1] || !ok[2]) + { + ccLog::Warning(QString("Malformed file: invalid values in scales range (%1) on line #%2").arg(token).arg(lineNumber)); + return false; + } + if (stop < start || step <= 1.0 - 6) + { + ccLog::Warning(QString("Malformed file: invalid range (%1) on line #%2").arg(token).arg(lineNumber)); + return false; + } + + for (double v = start; v <= stop + 1.0e-6; v += step) + { + scales.push_back(v); + } + } + else + { + bool ok = true; + double v = token.trimmed().toDouble(&ok); + if (!ok) + { + ccLog::Warning(QString("Malformed file: invalid scale value (%1) on line #%2").arg(token).arg(lineNumber)); + return false; + } + scales.push_back(v); + } + } + + scales.shrink_to_fit(); + return true; +} + +static bool ReadCorePoints(const QString& command, const Tools::NamedClouds& clouds, masc::CorePoints& corePoints, int lineNumber) +{ + QStringList tokens = command.split('_'); + if (tokens.empty()) + { + ccLog::Warning("Malformed file: expecting tokens after 'core_points:' on line #" + QString::number(lineNumber)); + return false; + } + QString pcName = tokens[0].trimmed(); + if (!clouds.contains(pcName)) + { + ccLog::Warning(QString("Malformed file: unknown cloud '%1' on line #%2 (make sure it is declared before the core points)").arg(pcName).arg(lineNumber)); + return false; + } + corePoints.origin = clouds[pcName]; + + //should we sub-sample the origin cloud? + if (tokens.size() > 1) + { + if (tokens[1].toUpper() == "SS") + { + if (tokens.size() < 3) + { + ccLog::Warning("Malformed file: missing token after 'SS' on line #" + QString::number(lineNumber)); + return false; + } + QString options = tokens[2]; + if (options.startsWith('R')) + { + corePoints.selectionMethod = CorePoints::RANDOM; + } + else if (options.startsWith('S')) + { + corePoints.selectionMethod = CorePoints::SPATIAL; + } + else + { + ccLog::Warning("Malformed file: unknown option after 'SS' on line #" + QString::number(lineNumber)); + return false; + } + + //read the subsampling parameter (ignore the first character) + bool ok = false; + corePoints.selectionParam = options.mid(1).toDouble(&ok); + if (!ok) + { + ccLog::Warning("Malformed file: expecting a number after 'SS_X' on line #" + QString::number(lineNumber)); + return false; + } + + } //end of subsampling options + } + + return true; +} + +static bool ReadCloud(const QString& command, Tools::NamedClouds& clouds, QDir& defaultDir, int lineNumber) +{ + QStringList tokens = command.split('='); + if (tokens.size() != 2) + { + ccLog::Warning("Malformed file: expecting 2 tokens after 'cloud:' on line #" + QString::number(lineNumber)); + return false; + } + + QString pcName = tokens[0].trimmed(); + QString pcFilename = defaultDir.absoluteFilePath(tokens[1].trimmed()); + //try to open the cloud + { + FileIOFilter::LoadParameters parameters; + parameters.alwaysDisplayLoadDialog = false; + CC_FILE_ERROR error = CC_FERR_NO_ERROR; + ccHObject* object = FileIOFilter::LoadFromFile(pcFilename, parameters, error); + if (error != CC_FERR_NO_ERROR || !object) + { + //error message already issued + if (object) + delete object; + return false; + } + ccHObject::Container cloudsInFile; + object->filterChildren(cloudsInFile, false, CC_TYPES::POINT_CLOUD, true); + if (cloudsInFile.empty()) + { + ccLog::Warning("File doesn't contain a single cloud"); + delete object; + return false; + } + else if (cloudsInFile.size() > 1) + { + ccLog::Warning("File contains more than one cloud, only the first one will be kept"); + } + ccPointCloud* pc = static_cast(cloudsInFile.front()); + for (size_t i = 1; i < cloudsInFile.size(); ++i) + { + delete cloudsInFile[i]; + } + if (pc->getParent()) + pc->getParent()->detachChild(pc); + pc->setName(pcName); //DGM: warning, may not be acceptable in the GUI version? + clouds.insert(pcName, pc); + } + + return true; +} + +static bool LoadFileCommon( const QString& filename, + Tools::NamedClouds& clouds, + bool cloudsAreProvided, + std::vector& rawFeatures, + masc::CorePoints* corePoints = nullptr, + masc::Classifier* classifier = nullptr, + QWidget* parent = nullptr) { QFileInfo fi(filename); if (!fi.exists()) @@ -81,7 +596,6 @@ bool Tools::LoadFile( QString filename, { assert(rawFeatures.empty()); std::vector scales; - QMap clouds; QTextStream stream(&file); for (int lineNumber = 0; ; ++lineNumber) @@ -106,112 +620,57 @@ bool Tools::LoadFile( QString filename, line = line.left(commentIndex); QString upperLine = line.toUpper(); - if (upperLine.startsWith("CLOUD:")) //clouds + if (upperLine.startsWith("CLASSIFIER:")) //classifier { - QString command = line.mid(6); - QStringList tokens = command.split('='); - if (tokens.size() != 2) + if (!classifier) { - ccLog::Warning("Malformed file: expecting 2 tokens after 'cloud:' on line #" + QString::number(lineNumber)); + //no need to load the classifier + continue; + } + if (classifier->isValid()) + { + ccLog::Warning("Malformed file: can't declare the classifier file twice! (line #" + QString::number(lineNumber) + ")"); return false; } - QString pcName = tokens[0].trimmed(); - QString pcFilename = fi.absoluteDir().absoluteFilePath(tokens[1].trimmed()); - //try to open the cloud + QString yamlFilename = line.mid(11).trimmed(); + if (!classifier->fromFile(fi.absolutePath() + "/" + yamlFilename, parent)) { - FileIOFilter::LoadParameters parameters; - parameters.alwaysDisplayLoadDialog = false; - CC_FILE_ERROR error = CC_FERR_NO_ERROR; - ccHObject* object = FileIOFilter::LoadFromFile(pcFilename, parameters, error); - if (error != CC_FERR_NO_ERROR || !object) - { - //error message already issued - if (object) - delete object; - return false; - } - ccHObject::Container cloudsInFile; - object->filterChildren(cloudsInFile, false, CC_TYPES::POINT_CLOUD, true); - if (cloudsInFile.empty()) - { - ccLog::Warning("File doesn't contain a single cloud"); - delete object; - return false; - } - else if (cloudsInFile.size() > 1) - { - ccLog::Warning("File contains more than one cloud, only the first one will be kept"); - } - ccPointCloud* pc = static_cast(cloudsInFile.front()); - for (size_t i = 1; i < cloudsInFile.size(); ++i) - { - delete cloudsInFile[i]; - } - if (pc->getParent()) - pc->getParent()->detachChild(pc); - pc->setName(pcName); //DGM: warning, may not be acceptable in the GUI version? - clouds.insert(pcName, pc); - loadedClouds.push_back(pc); + ccLog::Warning("Failed to load the classifier file from " + yamlFilename); + return false; + } + ccLog::Print("[3DMASC] Classifier data loaded from " + yamlFilename); + } + else if (upperLine.startsWith("CLOUD:")) //clouds + { + if (cloudsAreProvided) + { + //no need to load the clouds in this case + continue; + } + QString command = line.mid(6); + if (!ReadCloud(command, clouds, fi.absoluteDir(), lineNumber)) + { + return false; } } else if (upperLine.startsWith("CORE_POINTS:")) //core points { - if (corePoints.origin) + if (!corePoints) + { + //no need to load the core points + continue; + } + if (corePoints->origin) { ccLog::Warning("Malformed file: can't declare core points twice! (line #" + QString::number(lineNumber) + ")"); return false; } QString command = line.mid(12); - QStringList tokens = command.split('_'); - if (tokens.empty()) + + if (!ReadCorePoints(command, clouds, *corePoints, lineNumber)) { - ccLog::Warning("Malformed file: expecting tokens after 'core_points:' on line #" + QString::number(lineNumber)); return false; } - QString pcName = tokens[0].trimmed(); - if (!clouds.contains(pcName)) - { - ccLog::Warning(QString("Malformed file: unknown cloud '%1' on line #%2 (make sure it is declared before the core points)").arg(pcName).arg(lineNumber)); - return false; - } - corePoints.origin = clouds[pcName]; - - //should we sub-sample the origin cloud? - if (tokens.size() > 1) - { - if (tokens[1].toUpper() == "SS") - { - if (tokens.size() < 3) - { - ccLog::Warning("Malformed file: missing token after 'SS' on line #" + QString::number(lineNumber)); - return false; - } - QString options = tokens[2]; - if (options.startsWith('R')) - { - corePoints.selectionMethod = CorePoints::RANDOM; - } - else if (options.startsWith('S')) - { - corePoints.selectionMethod = CorePoints::SPATIAL; - } - else - { - ccLog::Warning("Malformed file: unknown option after 'SS' on line #" + QString::number(lineNumber)); - return false; - } - - //read the subsampling parameter (ignore the first character) - bool ok = false; - corePoints.selectionParam = options.mid(1).toDouble(&ok); - if (!ok) - { - ccLog::Warning("Malformed file: expecting a number after 'SS_X' on line #" + QString::number(lineNumber)); - return false; - } - - } //end of subsampling options - } } else if (upperLine.startsWith("SCALES:")) //scales { @@ -222,333 +681,20 @@ bool Tools::LoadFile( QString filename, } QString command = line.mid(7); - QStringList tokens = command.split(';'); - if (tokens.empty()) + if (!ReadScales(command, scales, lineNumber)) { - ccLog::Warning("Malformed file: expecting at least one token after 'scales:' on line #" + QString::number(lineNumber)); return false; } - - for (const QString& token : tokens) - { - if (token.contains(':')) - { - //it's probably a range - QStringList subTokens = token.trimmed().split(':'); - if (subTokens.size() != 3) - { - ccLog::Warning(QString("Malformed file: expecting 3 tokens for a range of scales (%1)").arg(token)); - return false; - } - bool ok[3] = { true, true, true }; - double start = subTokens[0].trimmed().toDouble(ok); - double step = subTokens[1].toDouble(ok + 1); - double stop = subTokens[2].toDouble(ok + 2); - if (!ok[0] || !ok[1] || !ok[2]) - { - ccLog::Warning(QString("Malformed file: invalid values in scales range (%1) on line #%2").arg(token).arg(lineNumber)); - return false; - } - if (stop < start || step <= 1.0 - 6) - { - ccLog::Warning(QString("Malformed file: invalid range (%1) on line #%2").arg(token).arg(lineNumber)); - return false; - } - - for (double v = start; v <= stop + 1.0e-6; v += step) - { - scales.push_back(v); - } - } - else - { - bool ok = true; - double v = token.trimmed().toDouble(&ok); - if (!ok) - { - ccLog::Warning(QString("Malformed file: invalid scale value (%1) on line #%2").arg(token).arg(lineNumber)); - return false; - } - scales.push_back(v); - } - } - scales.shrink_to_fit(); } else if (upperLine.startsWith("FEATURE:")) //feature { QString command = line.mid(8); - QStringList tokens = command.split('_'); - if (tokens.empty()) + + if (!CreateFeaturesFromCommand(command, lineNumber, clouds, rawFeatures, scales)) { - ccLog::Warning("Malformed file: expecting at least one token after 'feature:' on line #" + QString::number(lineNumber)); + //error message already issued return false; } - - Feature::Shared feature; - - //read the type - QString typeStr = tokens[0].trimmed().toUpper(); - { - for (int iteration = 0; iteration < 1; ++iteration) //fake loop for easy break - { - PointFeature::PointFeatureType pointFeatureType = PointFeature::FromUpperString(typeStr); - if (pointFeatureType != PointFeature::Invalid) - { - //we have a point feature - PointFeature* pointFeature = new PointFeature(pointFeatureType); - - //specific case: 'SF#' - if (pointFeatureType == PointFeature::SF) - { - QString sfIndexStr = typeStr.mid(2); - bool ok = true; - int sfIndex = sfIndexStr.toInt(&ok); - if (!ok) - { - ccLog::Warning(QString("Malformed file: expecting a valid integer value after 'SF' on line #%1").arg(lineNumber)); - delete pointFeature; - return false; - } - pointFeature->sourceSFIndex = sfIndex; - } - - feature.reset(pointFeature); - break; - } - - NeighborhoodFeature::NeighborhoodFeatureType neighborhoodFeatureType = NeighborhoodFeature::FromUpperString(typeStr); - if (neighborhoodFeatureType != NeighborhoodFeature::Invalid) - { - //we have a neighborhood feature - feature = NeighborhoodFeature::Shared(new NeighborhoodFeature(neighborhoodFeatureType)); - break; - } - - ContextBasedFeature::ContextBasedFeatureType contextBasedFeatureType = ContextBasedFeature::FromUpperString(typeStr); - if (contextBasedFeatureType != ContextBasedFeature::Invalid) - { - //we have a context-based feature - feature = ContextBasedFeature::Shared(new ContextBasedFeature(contextBasedFeatureType)); - break; - } - - DualCloudFeature::DualCloudFeatureType dualCloudFeatureType = DualCloudFeature::FromUpperString(typeStr); - if (dualCloudFeatureType != DualCloudFeature::Invalid) - { - //we have a dual cloud feature - feature = DualCloudFeature::Shared(new DualCloudFeature(dualCloudFeatureType)); - break; - } - - if (!feature) - { - ccLog::Warning(QString("Malformed file: unrecognized token '%1' after 'feature:' on line #%2").arg(typeStr).arg(lineNumber)); - return false; - } - } - } - assert(feature); - - //read the scales - bool useAllScales = false; - { - QString scaleStr = tokens[1].toUpper(); - if (!scaleStr.startsWith("SC")) - { - ccLog::Warning(QString("Malformed file: unrecognized token '%1' (expecting the scale descriptor 'SC...' on line #%2").arg(typeStr).arg(lineNumber)); - return false; - } - - if (scaleStr == "SC0") - { - //no scale - } - else if (scaleStr == "SCX") - { - //all scales - useAllScales = true; - } - else - { - //read the specific scale index - bool ok = true; - feature->scale = scaleStr.mid(2).toDouble(&ok); - if (!ok) - { - ccLog::Warning(QString("Malformed file: expecting a valid number after 'SC:' on line #%1").arg(lineNumber)); - return false; - } - } - } - - //process the next tokens (may not be ordered) - int cloudCount = 0; - bool statDefined = false; - bool mathDefined = false; - for (int i = 2; i < tokens.size(); ++i) - { - QString token = tokens[i].trimmed().toUpper(); - - //is the token a 'stat' one? - if (!statDefined) - { - if (token == "MEAN") - { - feature->stat = Feature::MEAN; - statDefined = true; - } - else if (token == "MODE") - { - feature->stat = Feature::MODE; - statDefined = true; - } - else if (token == "STD") - { - feature->stat = Feature::STD; - statDefined = true; - } - else if (token == "RANGE") - { - feature->stat = Feature::RANGE; - statDefined = true; - } - else if (token == "SKEW") - { - feature->stat = Feature::SKEW; - statDefined = true; - } - - if (statDefined) - { - continue; - } - } - - //is the token a cloud name? - if (cloudCount < 2) - { - bool cloudNameMatches = false; - for (QMap::const_iterator it = clouds.begin(); it != clouds.end(); ++it) - { - QString key = it.key().toUpper(); - if (key == token) - { - if (cloudCount == 0) - { - feature->cloud1 = it.value(); - feature->cloud1Label = key; - } - else if (cloudCount == 1) - { - feature->cloud2 = it.value(); - feature->cloud2Label = key; - } - else - { - //we can't fall here - assert(false); - } - ++cloudCount; - cloudNameMatches = true; - break; - } - } - - if (cloudNameMatches) - { - continue; - } - } - - //is the token a 'math' one? - if (!mathDefined) - { - if (token == "MINUS") - { - feature->op = Feature::MINUS; - mathDefined = true; - } - else if (token == "PLUS") - { - feature->op = Feature::PLUS; - mathDefined = true; - } - else if (token == "DIVIDE") - { - feature->op = Feature::DIVIDE; - mathDefined = true; - } - else if (token == "MULTIPLY") - { - feature->op = Feature::MULTIPLY; - mathDefined = true; - } - - if (mathDefined) - { - continue; - } - } - - //is the token a 'context' descriptor? - if (feature->getType() == Feature::Type::ContextBasedFeature && token.startsWith("CTX")) - { - //read the context label - QString ctxLabelStr = token.mid(2); - bool ok = true; - int ctxLabel = ctxLabelStr.toInt(&ok); - if (!ok) - { - ccLog::Warning(QString("Malformed file: expecting a valid integer value after 'CTX' on line #%1").arg(lineNumber)); - return false; - } - static_cast(feature.data())->ctxClassLabel = ctxLabel; - continue; - } - - //if we are here, it means we couldn't find a correspondance for the current token - ccLog::Warning(QString("Malformed file: unrecognized or unexpected token '%1' on line #%2").arg(token).arg(lineNumber)); - return false; - } - - //now create the various versions of rules (if any) - if (useAllScales) - { - if (scales.empty()) - { - ccLog::Warning("Malformed file: 'SCx' token used but not scales were defined" + QString(" (line %1)").arg(lineNumber)); - return false; - } - feature->scale = scales.front(); - - //we will duplicate the original feature AFTER having checked its consistency! - } - - //now check the consistency of the rule - QString errorMessage; - if (!feature->checkValidity(errorMessage)) - { - ccLog::Warning("Malformed feature: " + errorMessage + QString(" (line %1)").arg(lineNumber)); - return false; - } - - //save it - rawFeatures.push_back(feature); - - if (useAllScales) - { - for (size_t i = 1; i < scales.size(); ++i) - { - //copy the original rule - Feature::Shared newFeature = feature->clone(); - newFeature->scale = scales[i]; - - //as we only change the scale value, all the duplicated features should be valid - assert(newFeature->checkValidity(errorMessage)); - - rawFeatures.push_back(newFeature); - } - } } else { @@ -568,6 +714,46 @@ bool Tools::LoadFile( QString filename, return true; } +bool Tools::LoadClassifier(QString filename, const NamedClouds& clouds, Feature::Set& rawFeatures, masc::Classifier& classifier, QWidget* parent/*=nullptr*/) +{ + return LoadFileCommon(filename, const_cast(clouds), true, rawFeatures, nullptr, &classifier, parent); +} + +bool Tools::LoadTrainingFile( QString filename, + Feature::Set& rawFeatures, + std::vector& loadedClouds, + CorePoints& corePoints) +{ + NamedClouds clouds; + if (LoadFileCommon(filename, clouds, false, rawFeatures, &corePoints, nullptr, nullptr)) + { + try + { + loadedClouds.reserve(loadedClouds.size() + clouds.size()); + } + catch (const std::bad_alloc&) + { + ccLog::Warning("Not enough memory"); + //release some memory + for (NamedClouds::const_iterator it = clouds.begin(); it != clouds.end(); ++it) + { + delete it.value(); + } + return false; + } + + //otherwise transfer the clouds to the 'loadedClouds' vector + for (NamedClouds::const_iterator it = clouds.begin(); it != clouds.end(); ++it) + { + loadedClouds.push_back(it.value()); + } + } + else + { + return false; + } +} + CCLib::ScalarField* Tools::RetrieveSF(const ccPointCloud* cloud, const QString& sfName, bool caseSensitive/*=true*/) { if (!cloud) diff --git a/q3DMASCTools.h b/q3DMASCTools.h index 00a53f7..f1a3c2e 100644 --- a/q3DMASCTools.h +++ b/q3DMASCTools.h @@ -19,6 +19,7 @@ //Local #include "FeaturesInterface.h" +#include "q3DMASCClassifier.h" //CCLib #include @@ -35,9 +36,15 @@ namespace masc { public: - static bool LoadFile(QString filename, Feature::Set& rawFeatures, std::vector& loadedClouds, CorePoints& corePoints); + static bool LoadTrainingFile(QString filename, Feature::Set& rawFeatures, std::vector& loadedClouds, CorePoints& corePoints); - static bool SaveFeatureDescriptors(QString filename, const Feature::Set& features); + static bool SaveClassifier(QString filename, const Feature::Set& features, const masc::Classifier& classifier, QWidget* parent = nullptr); + + static bool LoadClassifierCloudLabels(QString filename, QSet& labels); + + typedef QMap NamedClouds; + + static bool LoadClassifier(QString filename, const NamedClouds& clouds, Feature::Set& rawFeatures, masc::Classifier& classifier, QWidget* parent = nullptr); static bool PrepareFeatures(const CorePoints& corePoints, Feature::Set& features, QString& error, CCLib::GenericProgressCallback* progressCb = nullptr); diff --git a/qClassify3DMASCDialog.cpp b/qClassify3DMASCDialog.cpp new file mode 100644 index 0000000..fc65a8d --- /dev/null +++ b/qClassify3DMASCDialog.cpp @@ -0,0 +1,190 @@ +//########################################################################## +//# # +//# CLOUDCOMPARE PLUGIN: qCANUPO # +//# # +//# This program is free software; you can redistribute it and/or modify # +//# it under the terms of the GNU General Public License as published by # +//# the Free Software Foundation; version 2 or later of the License. # +//# # +//# This program is distributed in the hope that it will be useful, # +//# but WITHOUT ANY WARRANTY; without even the implied warranty of # +//# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +//# GNU General Public License for more details. # +//# # +//# COPYRIGHT: UNIVERSITE EUROPEENNE DE BRETAGNE # +//# # +//########################################################################## + +#include "qClassify3DMASCDialog.h" + +//qCC_plugins +#include "../../ccMainAppInterface.h" + +//qCC_db +#include + +//Qt +#include +#include +#include +//#include + +//system +#include + +static ccPointCloud* GetCloudFromCombo(QComboBox* comboBox, ccHObject* dbRoot) +{ + assert(comboBox && dbRoot); + if (!comboBox || !dbRoot) + { + assert(false); + return 0; + } + + //return the cloud currently selected in the combox box + int index = comboBox->currentIndex(); + if (index < 0) + { + assert(false); + return 0; + } + unsigned uniqueID = comboBox->itemData(index).toUInt(); + ccHObject* item = dbRoot->find(uniqueID); + if (!item || !item->isA(CC_TYPES::POINT_CLOUD)) + { + assert(false); + return 0; + } + return static_cast(item); +} + +Classify3DMASCDialog::Classify3DMASCDialog(ccMainAppInterface* app) + : QDialog(app ? app->getMainWindow() : 0) + , Ui::Classify3DMASCDialog() + , m_app(app) +{ + setupUi(this); + + if (m_app) + { + //add list of clouds to the combo-boxes + ccHObject::Container clouds; + if (m_app->dbRootObject()) + { + m_app->dbRootObject()->filterChildren(clouds, true, CC_TYPES::POINT_CLOUD); + } + + unsigned cloudCount = 0; + for (size_t i = 0; i < clouds.size(); ++i) + { + if (clouds[i]->isA(CC_TYPES::POINT_CLOUD)) //as filterChildren only test 'isKindOf' + { + QString name = clouds[i]->getName() + QString(" [%1]").arg(clouds[i]->getUniqueID()); + QVariant uniqueID(clouds[i]->getUniqueID()); + cloud1ComboBox->addItem(name, uniqueID); + cloud2ComboBox->addItem(name, uniqueID); + cloud3ComboBox->addItem(name, uniqueID); + ++cloudCount; + } + } + + //if 3 clouds are loaded, then there's chances that the first one is the global cloud! + cloud1ComboBox->setCurrentIndex(cloudCount > 0 ? (cloudCount > 2 ? 1 : 0) : -1); + cloud2ComboBox->setCurrentIndex(cloudCount > 1 ? (cloudCount > 2 ? 2 : 1) : -1); + cloud3ComboBox->setCurrentIndex(cloudCount > 2 ? 0 : -1); + + if (cloudCount == 0 && app) + { + app->dispToConsole("You need at least 1 loaded cloud to classify it...", ccMainAppInterface::ERR_CONSOLE_MESSAGE); + } + } + + cloud1RadioButton->setEnabled(false); + cloud2RadioButton->setEnabled(false); + cloud3RadioButton->setEnabled(false); + cloud1ComboBox->setEnabled(false); + cloud2ComboBox->setEnabled(false); + cloud3ComboBox->setEnabled(false); + + onCloudChanged(0); +} + +void Classify3DMASCDialog::setCloudRoles(const QSet& roles) +{ + int index = 0; + for (const QString& role : roles) + { + switch (index) + { + case 0: + cloud1RadioButton->setText(role); + + cloud1RadioButton->setEnabled(true); + cloud1ComboBox->setEnabled(false); + + cloud1RadioButton->setChecked(true); + break; + case 1: + cloud2RadioButton->setText(role); + + cloud2RadioButton->setEnabled(true); + cloud2ComboBox->setEnabled(false); + break; + case 2: + cloud3RadioButton->setText(role); + + cloud3RadioButton->setEnabled(true); + cloud3ComboBox->setEnabled(false); + break; + default: + //this dialog can't handle more than 3 roles! + break; + } + } +} + +void Classify3DMASCDialog::getClouds(QMap& clouds, QString& mainCloud) +{ + if (!m_app) + { + assert(false); + return; + } + + if (cloud1RadioButton->isEnabled()) + { + clouds.insert(cloud1RadioButton->text(), GetCloudFromCombo(cloud1ComboBox, m_app->dbRootObject())); + if (cloud1RadioButton->isChecked()) + { + mainCloud = cloud1RadioButton->text(); + } + } + if (cloud2RadioButton->isEnabled()) + { + clouds.insert(cloud2RadioButton->text(), GetCloudFromCombo(cloud2ComboBox, m_app->dbRootObject())); + if (cloud2RadioButton->isChecked()) + { + mainCloud = cloud2RadioButton->text(); + } + } + if (cloud3RadioButton->isEnabled()) + { + clouds.insert(cloud3RadioButton->text(), GetCloudFromCombo(cloud3ComboBox, m_app->dbRootObject())); + if (cloud3RadioButton->isChecked()) + { + mainCloud = cloud3RadioButton->text(); + } + } +} + +void Classify3DMASCDialog::onCloudChanged(int dummy) +{ + if (!cloud1RadioButton->isEnabled()) + { + //this means that no role has been defined yet + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + return; + } + + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(cloud1ComboBox->currentIndex() >= 0); +} diff --git a/qClassify3DMASCDialog.h b/qClassify3DMASCDialog.h new file mode 100644 index 0000000..1b48194 --- /dev/null +++ b/qClassify3DMASCDialog.h @@ -0,0 +1,53 @@ +#pragma once + +//########################################################################## +//# # +//# CLOUDCOMPARE PLUGIN: q3DMASC # +//# # +//# This program is free software; you can redistribute it and/or modify # +//# it under the terms of the GNU General Public License as published by # +//# the Free Software Foundation; version 2 or later of the License. # +//# # +//# This program is distributed in the hope that it will be useful, # +//# but WITHOUT ANY WARRANTY; without even the implied warranty of # +//# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +//# GNU General Public License for more details. # +//# # +//# COPYRIGHT: Dimitri Lague / CNRS / UEB # +//# # +//########################################################################## + +//Qt +#include + +#include + +class ccMainAppInterface; +class ccPointCloud; + +//! 3DMASC plugin 'classify' dialog +class Classify3DMASCDialog : public QDialog, public Ui::Classify3DMASCDialog +{ + Q_OBJECT + +public: + + //! Default constructor + Classify3DMASCDialog(ccMainAppInterface* app); + + //! Sets the clouds roles + void setCloudRoles(const QSet& roles); + + //! Get point clouds + void getClouds(QMap& clouds, QString& mainCloud); + +protected slots: + + void onCloudChanged(int); + +protected: + + //! Gives access to the application (data-base, UI, etc.) + ccMainAppInterface* m_app; + +};