407 lines
14 KiB
C++
407 lines
14 KiB
C++
|
|
#include "BatchValidation.h"
|
|||
|
|
#include "BatchSelectionDialog.h"
|
|||
|
|
|
|||
|
|
#include <QDir>
|
|||
|
|
#include <QDirIterator>
|
|||
|
|
#include <QFileDialog>
|
|||
|
|
#include <QFileInfo>
|
|||
|
|
#include <QMap>
|
|||
|
|
#include <QMetaObject>
|
|||
|
|
#include <QPointer>
|
|||
|
|
|
|||
|
|
#include <algorithm>
|
|||
|
|
#include <exception>
|
|||
|
|
#include <thread>
|
|||
|
|
|
|||
|
|
namespace {
|
|||
|
|
|
|||
|
|
// 文件名(含扩展名)包含关键字即排除,用于过滤复检生成的全部图片和 txt。
|
|||
|
|
bool hasExcludedKeyword(const QString& filePath, const QStringList& keywords)
|
|||
|
|
{
|
|||
|
|
const QString fileName = QFileInfo(filePath).fileName();
|
|||
|
|
for (const QString& rawKeyword : keywords) {
|
|||
|
|
const QString keyword = rawKeyword.trimmed();
|
|||
|
|
if (!keyword.isEmpty() && fileName.contains(keyword, Qt::CaseInsensitive)) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 自然排序:按数字段大小比较,得到 1, 2, 3, ..., 10, 11, ..., 101,
|
|||
|
|
// 而不是字典序的 1, 10, 101, ..., 2, 20。
|
|||
|
|
bool naturalLessThan(const QString& lhs, const QString& rhs)
|
|||
|
|
{
|
|||
|
|
int left = 0;
|
|||
|
|
int right = 0;
|
|||
|
|
const int leftSize = lhs.size();
|
|||
|
|
const int rightSize = rhs.size();
|
|||
|
|
|
|||
|
|
while (left < leftSize && right < rightSize) {
|
|||
|
|
const QChar leftChar = lhs.at(left);
|
|||
|
|
const QChar rightChar = rhs.at(right);
|
|||
|
|
|
|||
|
|
if (leftChar.isDigit() && rightChar.isDigit()) {
|
|||
|
|
int leftEnd = left;
|
|||
|
|
while (leftEnd < leftSize && lhs.at(leftEnd).isDigit()) {
|
|||
|
|
++leftEnd;
|
|||
|
|
}
|
|||
|
|
int rightEnd = right;
|
|||
|
|
while (rightEnd < rightSize && rhs.at(rightEnd).isDigit()) {
|
|||
|
|
++rightEnd;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 跳过前导零,先比位数再逐位比较,避免整数溢出。
|
|||
|
|
int leftDigit = left;
|
|||
|
|
while (leftDigit < leftEnd - 1 && lhs.at(leftDigit) == QLatin1Char('0')) {
|
|||
|
|
++leftDigit;
|
|||
|
|
}
|
|||
|
|
int rightDigit = right;
|
|||
|
|
while (rightDigit < rightEnd - 1 && rhs.at(rightDigit) == QLatin1Char('0')) {
|
|||
|
|
++rightDigit;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const int leftCount = leftEnd - leftDigit;
|
|||
|
|
const int rightCount = rightEnd - rightDigit;
|
|||
|
|
if (leftCount != rightCount) {
|
|||
|
|
return leftCount < rightCount;
|
|||
|
|
}
|
|||
|
|
for (int index = 0; index < leftCount; ++index) {
|
|||
|
|
const QChar leftValue = lhs.at(leftDigit + index);
|
|||
|
|
const QChar rightValue = rhs.at(rightDigit + index);
|
|||
|
|
if (leftValue != rightValue) {
|
|||
|
|
return leftValue < rightValue;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
left = leftEnd;
|
|||
|
|
right = rightEnd;
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const QChar leftFolded = leftChar.toCaseFolded();
|
|||
|
|
const QChar rightFolded = rightChar.toCaseFolded();
|
|||
|
|
if (leftFolded != rightFolded) {
|
|||
|
|
return leftFolded < rightFolded;
|
|||
|
|
}
|
|||
|
|
++left;
|
|||
|
|
++right;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (leftSize - left) < (rightSize - right);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
QString matchKey(const QString& filePath, const QStringList& aliases)
|
|||
|
|
{
|
|||
|
|
const QFileInfo info(filePath);
|
|||
|
|
QString directory = QDir::cleanPath(info.absolutePath());
|
|||
|
|
QString stem = info.completeBaseName();
|
|||
|
|
|
|||
|
|
QStringList orderedAliases;
|
|||
|
|
for (const QString& alias : aliases) {
|
|||
|
|
if (!alias.trimmed().isEmpty()) {
|
|||
|
|
orderedAliases.append(alias.trimmed());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
std::sort(orderedAliases.begin(), orderedAliases.end(), [](const QString& lhs, const QString& rhs) {
|
|||
|
|
return lhs.size() > rhs.size();
|
|||
|
|
});
|
|||
|
|
for (const QString& alias : orderedAliases) {
|
|||
|
|
if (stem.endsWith(alias, Qt::CaseInsensitive)) {
|
|||
|
|
stem.chop(alias.size());
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#ifdef Q_OS_WIN
|
|||
|
|
directory = directory.toCaseFolded();
|
|||
|
|
stem = stem.toCaseFolded();
|
|||
|
|
#endif
|
|||
|
|
return directory + QLatin1Char('/') + stem;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
int inputRank(const QString& inputPath, const QString& companionPath,
|
|||
|
|
const QStringList& aliases)
|
|||
|
|
{
|
|||
|
|
const QString inputStem = QFileInfo(inputPath).completeBaseName();
|
|||
|
|
const QString companionStem = QFileInfo(companionPath).completeBaseName();
|
|||
|
|
if (inputStem.compare(companionStem, Qt::CaseInsensitive) == 0) {
|
|||
|
|
return 0;
|
|||
|
|
}
|
|||
|
|
for (int index = 0; index < aliases.size(); ++index) {
|
|||
|
|
const QString alias = aliases.at(index).trimmed();
|
|||
|
|
if (!alias.isEmpty() && inputStem.endsWith(alias, Qt::CaseInsensitive)) {
|
|||
|
|
return index + 1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return aliases.size() + 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
QStringList collectFiles(const QString& rootPath,
|
|||
|
|
const QStringList& nameFilters,
|
|||
|
|
bool recursive,
|
|||
|
|
const QStringList& excludedKeywords)
|
|||
|
|
{
|
|||
|
|
QStringList files;
|
|||
|
|
if (nameFilters.isEmpty()) {
|
|||
|
|
return files;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const QDirIterator::IteratorFlags flags = recursive
|
|||
|
|
? QDirIterator::Subdirectories
|
|||
|
|
: QDirIterator::NoIteratorFlags;
|
|||
|
|
QDirIterator iterator(rootPath, QDir::Files, flags);
|
|||
|
|
while (iterator.hasNext()) {
|
|||
|
|
const QString path = iterator.next();
|
|||
|
|
const QFileInfo info(path);
|
|||
|
|
bool matchesFilter = false;
|
|||
|
|
for (const QString& filter : nameFilters) {
|
|||
|
|
const QString trimmedFilter = filter.trimmed();
|
|||
|
|
if (QDir::match(trimmedFilter, info.fileName())
|
|||
|
|
|| (trimmedFilter.startsWith(QStringLiteral("*."))
|
|||
|
|
&& info.suffix().compare(trimmedFilter.mid(2), Qt::CaseInsensitive) == 0)) {
|
|||
|
|
matchesFilter = true;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (matchesFilter && !hasExcludedKeyword(path, excludedKeywords)) {
|
|||
|
|
files.append(QFileInfo(path).absoluteFilePath());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
std::sort(files.begin(), files.end(), naturalLessThan);
|
|||
|
|
return files;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool matchesAnyFilter(const QString& filePath, const QStringList& nameFilters)
|
|||
|
|
{
|
|||
|
|
if (nameFilters.isEmpty()) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const QFileInfo info(filePath);
|
|||
|
|
for (const QString& rawFilter : nameFilters) {
|
|||
|
|
const QString filter = rawFilter.trimmed();
|
|||
|
|
if (QDir::match(filter, info.fileName())
|
|||
|
|
|| (filter.startsWith(QStringLiteral("*."))
|
|||
|
|
&& info.suffix().compare(filter.mid(2), Qt::CaseInsensitive) == 0)) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
} // namespace
|
|||
|
|
|
|||
|
|
QString BatchValidation::SelectFileOrDirectory(QWidget* parent,
|
|||
|
|
const QString& title,
|
|||
|
|
const QString& nameFilter,
|
|||
|
|
const QString& initialPath)
|
|||
|
|
{
|
|||
|
|
// 系统原生对话框一次只能在“文件”和“目录”中选择一种,
|
|||
|
|
// 因此先在 .ui 对话框中确认方式,再打开对应的系统对话框。
|
|||
|
|
BatchSelectionDialog chooser(title, parent);
|
|||
|
|
if (chooser.exec() != QDialog::Accepted) {
|
|||
|
|
return QString();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (chooser.selectedMode() == BatchSelectionDialog::Mode::File) {
|
|||
|
|
return QFileDialog::getOpenFileName(parent, title, initialPath, nameFilter);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const QFileInfo info(initialPath);
|
|||
|
|
const QString startDirectory = info.isFile() ? info.absolutePath() : initialPath;
|
|||
|
|
return QFileDialog::getExistingDirectory(parent, title, startDirectory);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
QVector<BatchValidationItem> BatchValidation::Collect(
|
|||
|
|
const QString& inputPath, const BatchValidationOptions& options)
|
|||
|
|
{
|
|||
|
|
const QFileInfo selectedInfo(inputPath);
|
|||
|
|
if (!selectedInfo.exists()) {
|
|||
|
|
return {};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (selectedInfo.isFile()) {
|
|||
|
|
if (!matchesAnyFilter(selectedInfo.fileName(), options.inputNameFilters)
|
|||
|
|
|| hasExcludedKeyword(selectedInfo.fileName(), options.excludedKeywords)) {
|
|||
|
|
return {};
|
|||
|
|
}
|
|||
|
|
return {{selectedInfo.absoluteFilePath(), QString()}};
|
|||
|
|
}
|
|||
|
|
if (!selectedInfo.isDir()) {
|
|||
|
|
return {};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const QStringList inputs = collectFiles(selectedInfo.absoluteFilePath(),
|
|||
|
|
options.inputNameFilters,
|
|||
|
|
options.recursive,
|
|||
|
|
options.excludedKeywords);
|
|||
|
|
if (!options.requireCompanion) {
|
|||
|
|
QVector<BatchValidationItem> items;
|
|||
|
|
items.reserve(inputs.size());
|
|||
|
|
for (const QString& path : inputs) {
|
|||
|
|
items.append({path, QString()});
|
|||
|
|
}
|
|||
|
|
return items;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const QStringList companions = collectFiles(selectedInfo.absoluteFilePath(),
|
|||
|
|
options.companionNameFilters,
|
|||
|
|
options.recursive,
|
|||
|
|
options.excludedKeywords);
|
|||
|
|
QMap<QString, QStringList> companionsByKey;
|
|||
|
|
for (const QString& path : companions) {
|
|||
|
|
companionsByKey[matchKey(path, options.companionSuffixAliases)].append(path);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
QMap<QString, QStringList> inputsByKey;
|
|||
|
|
for (const QString& inputPathEntry : inputs) {
|
|||
|
|
inputsByKey[matchKey(inputPathEntry, options.inputSuffixAliases)].append(inputPathEntry);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// QMap 默认按键的字典序迭代(1、10、101、2…),这里显式按自然顺序排序,
|
|||
|
|
// 保证复检顺序为 1、2、3、…、10、11、…、101。
|
|||
|
|
QStringList orderedKeys = inputsByKey.keys();
|
|||
|
|
std::sort(orderedKeys.begin(), orderedKeys.end(), naturalLessThan);
|
|||
|
|
|
|||
|
|
QVector<BatchValidationItem> items;
|
|||
|
|
for (const QString& key : orderedKeys) {
|
|||
|
|
const auto companionIt = companionsByKey.constFind(key);
|
|||
|
|
if (companionIt == companionsByKey.cend() || companionIt.value().size() != 1) {
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
QStringList candidates = inputsByKey.value(key);
|
|||
|
|
std::sort(candidates.begin(), candidates.end(), [&](const QString& lhs, const QString& rhs) {
|
|||
|
|
const int lhsRank = inputRank(lhs, companionIt.value().front(), options.inputSuffixAliases);
|
|||
|
|
const int rhsRank = inputRank(rhs, companionIt.value().front(), options.inputSuffixAliases);
|
|||
|
|
return lhsRank == rhsRank
|
|||
|
|
? naturalLessThan(lhs, rhs)
|
|||
|
|
: lhsRank < rhsRank;
|
|||
|
|
});
|
|||
|
|
const int count = options.allowMultipleInputsPerCompanion ? candidates.size() : 1;
|
|||
|
|
for (int index = 0; index < count; ++index) {
|
|||
|
|
items.append({candidates.at(index), companionIt.value().front()});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return items;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
QString BatchValidation::ProgressPercent(int index, int total)
|
|||
|
|
{
|
|||
|
|
const double percent = total > 0
|
|||
|
|
? 100.0 * static_cast<double>(index) / static_cast<double>(total)
|
|||
|
|
: 0.0;
|
|||
|
|
return QStringLiteral("%1%").arg(percent, 0, 'f', 2);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
QVector<BatchValidationItem> BatchValidation::CollectTextData(const QString& inputPath)
|
|||
|
|
{
|
|||
|
|
BatchValidationOptions options;
|
|||
|
|
options.inputNameFilters = QStringList() << QStringLiteral("*.txt");
|
|||
|
|
options.requireCompanion = false;
|
|||
|
|
return Collect(inputPath, options);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
BatchValidationSummary BatchValidation::ProcessSequentially(
|
|||
|
|
const QVector<BatchValidationItem>& items,
|
|||
|
|
const Processor& processor,
|
|||
|
|
const ProgressCallback& progress)
|
|||
|
|
{
|
|||
|
|
BatchValidationSummary summary;
|
|||
|
|
if (!processor) {
|
|||
|
|
return summary;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const int total = items.size();
|
|||
|
|
for (int index = 0; index < total; ++index) {
|
|||
|
|
const BatchValidationItem& item = items.at(index);
|
|||
|
|
if (progress) {
|
|||
|
|
progress(item, index + 1, total);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
++summary.attempted;
|
|||
|
|
int result = -1;
|
|||
|
|
try {
|
|||
|
|
result = processor(item);
|
|||
|
|
} catch (const std::exception&) {
|
|||
|
|
result = -1;
|
|||
|
|
} catch (...) {
|
|||
|
|
result = -1;
|
|||
|
|
}
|
|||
|
|
if (result == 0) {
|
|||
|
|
++summary.succeeded;
|
|||
|
|
} else {
|
|||
|
|
++summary.failed;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return summary;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bool BatchValidation::StartTextDataValidation(
|
|||
|
|
QObject* context,
|
|||
|
|
QWidget* dialogParent,
|
|||
|
|
const QString& title,
|
|||
|
|
const Processor& processor,
|
|||
|
|
const ProgressCallback& progress,
|
|||
|
|
const CompletionCallback& completion,
|
|||
|
|
QString* errorMessage,
|
|||
|
|
const StartedCallback& started)
|
|||
|
|
{
|
|||
|
|
if (errorMessage) {
|
|||
|
|
errorMessage->clear();
|
|||
|
|
}
|
|||
|
|
if (!context || !processor) {
|
|||
|
|
if (errorMessage) {
|
|||
|
|
*errorMessage = QObject::tr("复检处理器未初始化");
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const QString inputPath = SelectFileOrDirectory(
|
|||
|
|
dialogParent, title, QObject::tr("点云数据文件 (*.txt);;所有文件 (*.*)"));
|
|||
|
|
if (inputPath.isEmpty()) {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const QVector<BatchValidationItem> items = CollectTextData(inputPath);
|
|||
|
|
if (items.isEmpty()) {
|
|||
|
|
if (errorMessage) {
|
|||
|
|
*errorMessage = QObject::tr("所选文件或目录中没有找到可复检的 TXT 点云数据");
|
|||
|
|
}
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (started) {
|
|||
|
|
started(items.size());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
QPointer<QObject> contextGuard(context);
|
|||
|
|
const Processor processorCopy = processor;
|
|||
|
|
const ProgressCallback progressCopy = progress;
|
|||
|
|
const CompletionCallback completionCopy = completion;
|
|||
|
|
std::thread([contextGuard, items, processorCopy, progressCopy, completionCopy]() {
|
|||
|
|
const BatchValidationSummary summary = ProcessSequentially(
|
|||
|
|
items, processorCopy,
|
|||
|
|
[contextGuard, progressCopy](const BatchValidationItem& item, int index, int total) {
|
|||
|
|
if (!contextGuard || !progressCopy) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
QMetaObject::invokeMethod(contextGuard, [contextGuard, progressCopy, item, index, total]() {
|
|||
|
|
if (contextGuard) {
|
|||
|
|
progressCopy(item, index, total);
|
|||
|
|
}
|
|||
|
|
}, Qt::QueuedConnection);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (contextGuard && completionCopy) {
|
|||
|
|
QMetaObject::invokeMethod(contextGuard, [contextGuard, completionCopy, summary]() {
|
|||
|
|
if (contextGuard) {
|
|||
|
|
completionCopy(summary);
|
|||
|
|
}
|
|||
|
|
}, Qt::QueuedConnection);
|
|||
|
|
}
|
|||
|
|
}).detach();
|
|||
|
|
return true;
|
|||
|
|
}
|