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;
|
||
}
|