From 3f04c7a454edb624f29a6a53e0d1513ee86f7088 Mon Sep 17 00:00:00 2001 From: Lukasz Stachowicz Date: Sun, 1 Feb 2026 12:22:11 +0100 Subject: [PATCH 1/8] feat(metadata): add numerical metadata import functionality Add MetadataImporter class for importing numerical data files (.txt, .dat, .meta) as grayscale images with memory-optimized streaming processing. Includes comprehensive error handling, range normalization, cropping functionality, and Qt integration. Features: - Memory-optimized streaming import for large datasets - MetadataImportSettingsDialog with range and crop configuration - 7 detailed error types with user-friendly messages - Integration with MainWindow File menu - Comprehensive GoogleTest unit tests (9 test cases) - 50.6% code coverage for MetadataImporter module Resolves metadata import requirements from specification. --- CMakeLists.txt | 5 + src/mainwindow.cpp | 204 ++++++++++- src/mainwindow.h | 1 + src/mainwindow.ui | 19 +- src/metadataimporter.cpp | 486 +++++++++++++++++++++++++++ src/metadataimporter.h | 182 ++++++++++ src/metadataimportsettingsdialog.cpp | 196 +++++++++++ src/metadataimportsettingsdialog.h | 64 ++++ src/metadataimportsettingsdialog.ui | 350 +++++++++++++++++++ tests/main_test.cpp | 157 +++++++++ 10 files changed, 1652 insertions(+), 12 deletions(-) create mode 100644 src/metadataimporter.cpp create mode 100644 src/metadataimporter.h create mode 100644 src/metadataimportsettingsdialog.cpp create mode 100644 src/metadataimportsettingsdialog.h create mode 100644 src/metadataimportsettingsdialog.ui diff --git a/CMakeLists.txt b/CMakeLists.txt index 6076d2b..34b190a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -79,6 +79,8 @@ set(POLYSEG_SOURCES src/shortcutssettingstab.cpp src/aipluginmanager.cpp src/pluginwizard.cpp + src/metadataimporter.cpp + src/metadataimportsettingsdialog.cpp src/wizardpages/welcomepage.cpp src/wizardpages/pluginselectionpage.cpp src/wizardpages/custompluginpage.cpp @@ -112,6 +114,8 @@ set(POLYSEG_HEADERS src/shortcutssettingstab.h src/aipluginmanager.h src/pluginwizard.h + src/metadataimporter.h + src/metadataimportsettingsdialog.h src/wizardpages/welcomepage.h src/wizardpages/pluginselectionpage.h src/wizardpages/custompluginpage.h @@ -138,6 +142,7 @@ set(POLYSEG_UI_FILES src/shortcuteditdialog.ui src/modelcomparisondialog.ui src/modelregistrationdialog.ui + src/metadataimportsettingsdialog.ui src/wizardpages/welcomepage.ui src/wizardpages/pluginselectionpage.ui src/wizardpages/custompluginpage.ui diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 2d9fd24..6e7bffe 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -39,6 +39,8 @@ #include #include "aipluginmanager.h" +#include "metadataimporter.h" +#include "metadataimportsettingsdialog.h" #include "pluginwizard.h" #include "polygoncanvas.h" #include "settingsdialog.h" @@ -91,8 +93,8 @@ MainWindow::MainWindow(QWidget* parent) connect(ui->actionNewProject, &QAction::triggered, this, &MainWindow::CreateNewProject); connect(ui->actionOpenProject, &QAction::triggered, this, &MainWindow::OpenProject); - connect(ui->actionOpenImage, &QAction::triggered, this, &MainWindow::Load); connect(ui->actionAddImages, &QAction::triggered, this, &MainWindow::AddImagesToProject); + connect(ui->actionImportDataAsImage, &QAction::triggered, this, &MainWindow::ImportDataAsImage); connect(ui->actionSave, &QAction::triggered, this, &MainWindow::Save); connect(ui->actionExit, &QAction::triggered, this, &QMainWindow::close); @@ -1836,4 +1838,204 @@ void MainWindow::LoadLastProject() } } +void MainWindow::ImportDataAsImage() +{ + // Select metadata file for import + QString filepath = QFileDialog::getOpenFileName( + this, "Import Data as Image", QDir::homePath(), + "Data Files (*.txt *.dat *.meta);;All Files (*)"); + + if (filepath.isEmpty()) + { + return; + } + + // Parse header to get dimensions with detailed error reporting + int width, height; + MetadataImporter::ImportError parse_error; + if (!MetadataImporter::ParseHeaderWithError(filepath, width, height, parse_error)) + { + QString error_title; + switch (parse_error.type) + { + case MetadataImporter::ImportError::FILE_NOT_FOUND: + error_title = "File Access Error"; + break; + case MetadataImporter::ImportError::INVALID_HEADER_FORMAT: + error_title = "Invalid File Format"; + break; + case MetadataImporter::ImportError::INVALID_DIMENSIONS: + error_title = "Invalid Dimensions"; + break; + default: + error_title = "Parse Error"; + break; + } + QMessageBox::critical(this, error_title, parse_error.message); + return; + } + + // Show import settings dialog + MetadataImportSettingsDialog dialog(filepath, width, height, this); + if (dialog.exec() != QDialog::Accepted) + { + return; + } + + // Get import settings from dialog + MetadataImporter::ImportSettings settings = dialog.GetSettings(); + + // Process metadata file with detailed error reporting + statusBar()->showMessage("Processing metadata file...", 5000); + QApplication::processEvents(); + + MetadataImporter::ImportError import_error; + QImage image = MetadataImporter::ImportMetadataFileWithError(filepath, settings, import_error); + if (image.isNull()) + { + QString error_title; + switch (import_error.type) + { + case MetadataImporter::ImportError::DATA_MISMATCH: + error_title = "Data Mismatch"; + break; + case MetadataImporter::ImportError::INVALID_NUMERIC_DATA: + error_title = "File Format Error"; + break; + case MetadataImporter::ImportError::CROP_BOUNDARY_ERROR: + error_title = "Invalid Crop Region"; + break; + case MetadataImporter::ImportError::FILE_NOT_FOUND: + error_title = "File Access Error"; + break; + default: + error_title = "Import Failed"; + break; + } + QMessageBox::critical(this, error_title, import_error.message); + statusBar()->showMessage("Import failed", 3000); + return; + } + + // Generate output filename + QFileInfo file_info(filepath); + QString base_name = file_info.baseName(); + QString temp_filename = QString("metadata_%1_%2x%3.png") + .arg(base_name) + .arg(image.width()) + .arg(image.height()); + + QString temp_path; + bool save_to_project = false; + + if (!project_directory_.isEmpty()) + { + // Save to project if one is loaded + QString images_dir = project_directory_ + "/images"; + QDir dir; + if (!dir.exists(images_dir)) + { + dir.mkpath(images_dir); + } + + temp_path = images_dir + "/" + temp_filename; + save_to_project = true; + } + else + { + // Save to temporary location if no project is loaded + QString temp_dir = QStandardPaths::writableLocation(QStandardPaths::TempLocation); + temp_path = temp_dir + "/" + temp_filename; + } + + // Save processed image + if (!image.save(temp_path, "PNG")) + { + QMessageBox::critical(this, "Save Failed", + "Failed to save processed image.\n\n" + "Path: " + temp_path); + statusBar()->showMessage("Save failed", 3000); + return; + } + + // Apply project-level crop if enabled and saving to project + if (save_to_project && project_config_.IsCropEnabled()) + { + const CropConfig& crop = project_config_.GetCropConfig(); + QImage loaded_image(temp_path); + + if (!loaded_image.isNull()) + { + int crop_width = crop.width > 0 ? crop.width : loaded_image.width() - crop.x; + int crop_height = crop.height > 0 ? crop.height : loaded_image.height() - crop.y; + + // Validate crop bounds + if (crop.x >= 0 && crop.y >= 0 && + crop.x + crop_width <= loaded_image.width() && + crop.y + crop_height <= loaded_image.height()) + { + QImage cropped = loaded_image.copy(crop.x, crop.y, crop_width, crop_height); + + // Save cropped image (overwrite the original) + if (!cropped.save(temp_path)) + { + QMessageBox::warning(this, "Crop Failed", + "Failed to apply project crop settings.\n\n" + "Using original processed image."); + } + } + } + } + + if (save_to_project) + { + // Rescan project images and save config + ScanProjectImages(); + SaveProjectConfig(); + + // Load the new image + int image_index = image_list_.indexOf(temp_filename); + if (image_index >= 0) + { + LoadImageAtIndex(image_index); + } + else if (current_image_path_.isEmpty() && !image_list_.isEmpty()) + { + LoadImageAtIndex(0); + } + + statusBar()->showMessage(QString("Metadata imported as: %1").arg(temp_filename), 5000); + QMessageBox::information(this, "Import Complete", + QString("Metadata successfully imported as grayscale image.\n\n" + "File: %1\n" + "Dimensions: %2 x %3\n" + "Added to project images.") + .arg(temp_filename) + .arg(image.width()) + .arg(image.height())); + } + else + { + // Load image directly if no project + auto pixmap = QPixmap(temp_path); + ui->label->setPixmap(pixmap); + ui->label->setFixedSize(pixmap.size()); + current_image_path_ = temp_path; + current_image_index_ = -1; + + UpdateWindowTitle(); + UpdateStatusBar(); + + statusBar()->showMessage(QString("Metadata imported: %1").arg(temp_filename), 5000); + QMessageBox::information(this, "Import Complete", + QString("Metadata successfully imported as grayscale image.\n\n" + "File: %1\n" + "Dimensions: %2 x %3\n" + "No project loaded - image loaded directly.") + .arg(temp_filename) + .arg(image.width()) + .arg(image.height())); + } +} + diff --git a/src/mainwindow.h b/src/mainwindow.h index 9dc6af1..29cf69c 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -36,6 +36,7 @@ class MainWindow : public QMainWindow void PreviousClass(); void SelectClassByNumber(int number); void AddImagesToProject(); + void ImportDataAsImage(); // Project Management void CreateNewProject(); diff --git a/src/mainwindow.ui b/src/mainwindow.ui index 731332c..e326b14 100644 --- a/src/mainwindow.ui +++ b/src/mainwindow.ui @@ -56,8 +56,8 @@ - + @@ -129,26 +129,23 @@ - + - Open Image + Import Images - Open Image (Ctrl+O) - Load a single image + Import Images - Add images to existing project - Ctrl+O + Ctrl+Shift+A - + - Add Images to Project... + Import Data as Image - Add Images to Project - Add more images to existing project - - - Ctrl+Shift+A + Import numerical data file as grayscale image diff --git a/src/metadataimporter.cpp b/src/metadataimporter.cpp new file mode 100644 index 0000000..df3748b --- /dev/null +++ b/src/metadataimporter.cpp @@ -0,0 +1,486 @@ +#include "metadataimporter.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +QImage MetadataImporter::ImportMetadataFile(const QString& filepath, + const ImportSettings& settings) +{ + // Parse header to get dimensions + int width, height; + if (!ParseHeader(filepath, width, height)) + { + qWarning() << "Failed to parse header for file:" << filepath; + return QImage(); + } + + // Process data stream with memory optimization + return ProcessDataStream(filepath, settings, width, height); +} + +bool MetadataImporter::ParseHeader(const QString& filepath, int& width, int& height) +{ + QFile file(filepath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + { + qWarning() << "Cannot open file:" << filepath; + return false; + } + + QTextStream stream(&file); + if (stream.atEnd()) + { + qWarning() << "Empty file:" << filepath; + return false; + } + + QString firstLine = stream.readLine().trimmed(); + QStringList parts = firstLine.split(' ', Qt::SkipEmptyParts); + + return ValidateHeader(parts, width, height); +} + +bool MetadataImporter::ValidateHeader(const QStringList& header_parts, int& width, int& height) +{ + if (header_parts.size() != 2) + { + return false; + } + + bool ok1, ok2; + width = header_parts[0].toInt(&ok1); + height = header_parts[1].toInt(&ok2); + + return ok1 && ok2 && width >= MIN_DIMENSION && height >= MIN_DIMENSION; +} + +QImage MetadataImporter::ProcessDataStream(const QString& filepath, + const ImportSettings& settings, + int width, int height) +{ + QFile file(filepath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + { + qWarning() << "Cannot open file for data processing:" << filepath; + return QImage(); + } + + QTextStream stream(&file); + + // Skip header line + if (!stream.atEnd()) + { + stream.readLine(); + } + + // Calculate effective dimensions after cropping + int effective_width = width; + int effective_height = height; + int start_x = 0; + int start_y = 0; + + if (settings.enable_cropping) + { + // Validate crop boundaries + if (settings.crop_start_x < 0 || settings.crop_start_y < 0 || + settings.crop_end_x > width || settings.crop_end_y > height || + settings.crop_start_x >= settings.crop_end_x || + settings.crop_start_y >= settings.crop_end_y) + { + qWarning() << "Invalid crop boundaries"; + return QImage(); + } + + effective_width = settings.crop_end_x - settings.crop_start_x; + effective_height = settings.crop_end_y - settings.crop_start_y; + start_x = settings.crop_start_x; + start_y = settings.crop_start_y; + } + + // Allocate memory for processed data + std::unique_ptr pixel_data(new uint8_t[effective_width * effective_height]); + + int current_row = 0; + int output_row = 0; + + // Process data line by line with streaming approach + while (!stream.atEnd() && current_row < height) + { + QString line = stream.readLine().trimmed(); + if (line.isEmpty()) + { + qWarning() << "Empty line found at row:" << current_row; + return QImage(); + } + + QStringList values = line.split(' ', Qt::SkipEmptyParts); + if (values.size() != width) + { + qWarning() << "Row" << current_row << "has" << values.size() + << "values, expected" << width; + return QImage(); + } + + // Check if this row is within crop region + bool row_in_crop = !settings.enable_cropping || + (current_row >= start_y && current_row < settings.crop_end_y); + + if (row_in_crop) + { + int output_col = 0; + for (int col = 0; col < width; ++col) + { + // Check if this column is within crop region + bool col_in_crop = !settings.enable_cropping || + (col >= start_x && col < settings.crop_end_x); + + if (col_in_crop) + { + bool ok; + double value = values[col].toDouble(&ok); + if (!ok) + { + qWarning() << "Invalid numeric value at row" << current_row + << "col" << col << ":" << values[col]; + return QImage(); + } + + // Process range and convert to grayscale + double processed_value = ProcessRangeValue(value, settings); + uint8_t grayscale = ConvertToGrayscale(processed_value, settings); + + // Store in output array + pixel_data[output_row * effective_width + output_col] = grayscale; + output_col++; + } + } + output_row++; + } + + current_row++; + + // Progress indicator for large files + if (current_row % 1000 == 0) + { + QApplication::processEvents(); + } + } + + if (current_row != height) + { + qWarning() << "Expected" << height << "data rows, found" << current_row; + return QImage(); + } + + // Create QImage from processed data + return CreateImageFromData(pixel_data.get(), effective_width, effective_height); +} + +double MetadataImporter::ProcessRangeValue(double input, const ImportSettings& settings) +{ + if (input < settings.range_min) + { + switch (settings.out_of_range_handling) + { + case ImportSettings::CLAMP_TO_BOUNDS: + return settings.range_min; + case ImportSettings::SET_TO_ZERO: + return settings.range_min; + case ImportSettings::SET_TO_MAX: + return settings.range_max; + } + } + else if (input > settings.range_max) + { + switch (settings.out_of_range_handling) + { + case ImportSettings::CLAMP_TO_BOUNDS: + return settings.range_max; + case ImportSettings::SET_TO_ZERO: + return settings.range_min; + case ImportSettings::SET_TO_MAX: + return settings.range_max; + } + } + + return input; +} + +uint8_t MetadataImporter::ConvertToGrayscale(double value, const ImportSettings& settings) +{ + double range = settings.range_max - settings.range_min; + if (range <= 0.0) + { + return GRAYSCALE_MIN; + } + + double normalized = (value - settings.range_min) / range; + normalized = std::max(0.0, std::min(1.0, normalized)); + + return static_cast(normalized * GRAYSCALE_MAX); +} + +bool MetadataImporter::IsInCropRegion(int x, int y, const ImportSettings& settings) +{ + if (!settings.enable_cropping) + { + return true; + } + + return x >= settings.crop_start_x && x < settings.crop_end_x && + y >= settings.crop_start_y && y < settings.crop_end_y; +} + +QImage MetadataImporter::CreateImageFromData(const uint8_t* data, int width, int height) +{ + QImage image(width, height, QImage::Format_Grayscale8); + + for (int y = 0; y < height; ++y) + { + uint8_t* scan_line = image.scanLine(y); + const uint8_t* source_line = &data[y * width]; + + // Copy entire row at once for efficiency + std::memcpy(scan_line, source_line, width); + } + + return image; +} + +QImage MetadataImporter::ImportMetadataFileWithError(const QString& filepath, + const ImportSettings& settings, + ImportError& error) +{ + // Initialize error + error.type = ImportError::NO_ERROR; + error.message.clear(); + error.row_number = -1; + error.expected_count = -1; + error.actual_count = -1; + error.invalid_value.clear(); + + // Parse header to get dimensions + int width, height; + if (!ParseHeaderWithError(filepath, width, height, error)) + { + return QImage(); + } + + // Validate crop settings if enabled + if (settings.enable_cropping) + { + if (settings.crop_start_x < 0 || settings.crop_start_y < 0 || + settings.crop_end_x > width || settings.crop_end_y > height || + settings.crop_start_x >= settings.crop_end_x || + settings.crop_start_y >= settings.crop_end_y) + { + error.type = ImportError::CROP_BOUNDARY_ERROR; + error.message = QString("Crop region extends outside data boundaries.\n\n" + "Data size: %1 x %2\n" + "Crop region: (%3,%4) to (%5,%6)") + .arg(width).arg(height) + .arg(settings.crop_start_x).arg(settings.crop_start_y) + .arg(settings.crop_end_x).arg(settings.crop_end_y); + return QImage(); + } + } + + // Process data stream + return ProcessDataStreamWithError(filepath, settings, width, height, error); +} + +bool MetadataImporter::ParseHeaderWithError(const QString& filepath, int& width, int& height, + ImportError& error) +{ + // Initialize error + error.type = ImportError::NO_ERROR; + error.message.clear(); + + QFile file(filepath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + { + error.type = ImportError::FILE_NOT_FOUND; + error.message = QString("Cannot open file: %1").arg(filepath); + return false; + } + + QTextStream stream(&file); + if (stream.atEnd()) + { + error.type = ImportError::INVALID_HEADER_FORMAT; + error.message = "File is empty.\n\nExpected header format: 'width height'"; + return false; + } + + QString firstLine = stream.readLine().trimmed(); + QStringList parts = firstLine.split(' ', Qt::SkipEmptyParts); + + if (parts.size() != 2) + { + error.type = ImportError::INVALID_HEADER_FORMAT; + error.message = QString("Header must contain two integers: 'width height'\n\n" + "Found: '%1'").arg(firstLine); + return false; + } + + bool ok1, ok2; + width = parts[0].toInt(&ok1); + height = parts[1].toInt(&ok2); + + if (!ok1 || !ok2) + { + error.type = ImportError::INVALID_HEADER_FORMAT; + error.message = QString("Header values must be integers.\n\n" + "Found: '%1'").arg(firstLine); + return false; + } + + if (width < MIN_DIMENSION || height < MIN_DIMENSION) + { + error.type = ImportError::INVALID_DIMENSIONS; + error.message = QString("Width and height must be >= %1\n\n" + "Found: width=%2, height=%3") + .arg(MIN_DIMENSION).arg(width).arg(height); + return false; + } + + return true; +} + +QImage MetadataImporter::ProcessDataStreamWithError(const QString& filepath, + const ImportSettings& settings, + int width, int height, + ImportError& error) +{ + QFile file(filepath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + { + error.type = ImportError::FILE_NOT_FOUND; + error.message = QString("Cannot open file for data processing: %1").arg(filepath); + return QImage(); + } + + QTextStream stream(&file); + + // Skip header line + if (!stream.atEnd()) + { + stream.readLine(); + } + + // Calculate effective dimensions after cropping + int effective_width = width; + int effective_height = height; + int start_x = 0; + int start_y = 0; + + if (settings.enable_cropping) + { + effective_width = settings.crop_end_x - settings.crop_start_x; + effective_height = settings.crop_end_y - settings.crop_start_y; + start_x = settings.crop_start_x; + start_y = settings.crop_start_y; + } + + // Allocate memory for processed data + std::unique_ptr pixel_data(new uint8_t[effective_width * effective_height]); + + int current_row = 0; + int output_row = 0; + + // Process data line by line with streaming approach + while (!stream.atEnd() && current_row < height) + { + QString line = stream.readLine().trimmed(); + if (line.isEmpty()) + { + error.type = ImportError::DATA_MISMATCH; + error.message = QString("Empty line found at row %1.\n\n" + "All data rows must contain numeric values.") + .arg(current_row + 1); + error.row_number = current_row + 1; + return QImage(); + } + + QStringList values = line.split(' ', Qt::SkipEmptyParts); + if (values.size() != width) + { + error.type = ImportError::DATA_MISMATCH; + error.message = QString("Expected %1 data rows, found %2 rows\n" + "Expected %3 columns per row, found %4 columns in row %5") + .arg(height).arg(current_row) + .arg(width).arg(values.size()).arg(current_row + 1); + error.row_number = current_row + 1; + error.expected_count = width; + error.actual_count = values.size(); + return QImage(); + } + + // Check if this row is within crop region + bool row_in_crop = !settings.enable_cropping || + (current_row >= start_y && current_row < settings.crop_end_y); + + if (row_in_crop) + { + int output_col = 0; + for (int col = 0; col < width; ++col) + { + // Check if this column is within crop region + bool col_in_crop = !settings.enable_cropping || + (col >= start_x && col < settings.crop_end_x); + + if (col_in_crop) + { + bool ok; + double value = values[col].toDouble(&ok); + if (!ok) + { + error.type = ImportError::INVALID_NUMERIC_DATA; + error.message = QString("Non-numeric data found in row %1: '%2'\n\n" + "All data must be integers or floating point numbers") + .arg(current_row + 1).arg(values[col]); + error.row_number = current_row + 1; + error.invalid_value = values[col]; + return QImage(); + } + + // Process range and convert to grayscale + double processed_value = ProcessRangeValue(value, settings); + uint8_t grayscale = ConvertToGrayscale(processed_value, settings); + + // Store in output array + pixel_data[output_row * effective_width + output_col] = grayscale; + output_col++; + } + } + output_row++; + } + + current_row++; + + // Progress indicator for large files + if (current_row % 1000 == 0) + { + QApplication::processEvents(); + } + } + + if (current_row != height) + { + error.type = ImportError::DATA_MISMATCH; + error.message = QString("Expected %1 data rows, found %2 rows") + .arg(height).arg(current_row); + error.expected_count = height; + error.actual_count = current_row; + return QImage(); + } + + // Create QImage from processed data + return CreateImageFromData(pixel_data.get(), effective_width, effective_height); +} \ No newline at end of file diff --git a/src/metadataimporter.h b/src/metadataimporter.h new file mode 100644 index 0000000..07e0524 --- /dev/null +++ b/src/metadataimporter.h @@ -0,0 +1,182 @@ +#ifndef METADATAIMPORTER_H +#define METADATAIMPORTER_H + +#include +#include +#include +#include + +/** + * @brief Static utility class for importing numerical metadata files as grayscale images + * + * Supports importing numerical data arranged in matrix format and converting to + * grayscale images suitable for annotation in PolySeg. Provides memory-optimized + * streaming processing for large datasets. + */ +class MetadataImporter +{ +public: + /** + * @brief Detailed error information for import operations + */ + struct ImportError + { + enum ErrorType + { + NO_ERROR, + FILE_NOT_FOUND, + INVALID_HEADER_FORMAT, + INVALID_DIMENSIONS, + DATA_MISMATCH, + INVALID_NUMERIC_DATA, + CROP_BOUNDARY_ERROR, + FILE_SAVE_ERROR + } type; + + QString message; + int row_number; + int expected_count; + int actual_count; + QString invalid_value; + }; + + /** + * @brief Configuration settings for metadata import operation + */ + struct ImportSettings + { + double range_min; + double range_max; + + enum OutOfRangeHandling + { + CLAMP_TO_BOUNDS, + SET_TO_ZERO, + SET_TO_MAX + } out_of_range_handling; + + // Crop settings - follows existing CropConfig pattern + bool enable_cropping; + int crop_start_x; + int crop_start_y; + int crop_end_x; + int crop_end_y; + }; + + /** + * @brief Import metadata file as grayscale QImage + * @param filepath Path to metadata file (.txt, .dat, .meta) + * @param settings Import configuration (range, cropping, etc.) + * @return Converted grayscale image, null image on error + */ + static QImage ImportMetadataFile(const QString& filepath, + const ImportSettings& settings); + + /** + * @brief Import metadata file with detailed error reporting + * @param filepath Path to metadata file (.txt, .dat, .meta) + * @param settings Import configuration (range, cropping, etc.) + * @param error [out] Detailed error information if import fails + * @return Converted grayscale image, null image on error + */ + static QImage ImportMetadataFileWithError(const QString& filepath, + const ImportSettings& settings, + ImportError& error); + + /** + * @brief Parse header to validate format and extract dimensions + * @param filepath Path to metadata file + * @param width [out] File width in pixels + * @param height [out] File height in pixels + * @return true if valid header format, false on error + */ + static bool ParseHeader(const QString& filepath, int& width, int& height); + + /** + * @brief Parse header with detailed error reporting + * @param filepath Path to metadata file + * @param width [out] File width in pixels + * @param height [out] File height in pixels + * @param error [out] Detailed error information if parsing fails + * @return true if valid header format, false on error + */ + static bool ParseHeaderWithError(const QString& filepath, int& width, int& height, + ImportError& error); + +private: + /** + * @brief Process data stream with memory optimization + * @param filepath Path to metadata file + * @param settings Import configuration + * @param width File width + * @param height File height + * @return Processed grayscale image + */ + static QImage ProcessDataStream(const QString& filepath, + const ImportSettings& settings, + int width, int height); + + /** + * @brief Process data stream with detailed error reporting + * @param filepath Path to metadata file + * @param settings Import configuration + * @param width File width + * @param height File height + * @param error [out] Detailed error information if processing fails + * @return Processed grayscale image + */ + static QImage ProcessDataStreamWithError(const QString& filepath, + const ImportSettings& settings, + int width, int height, + ImportError& error); + + /** + * @brief Convert data value to grayscale uint8_t (0-255) + * @param value Input data value + * @param settings Import settings for range normalization + * @return Grayscale value (0-255) + */ + static uint8_t ConvertToGrayscale(double value, const ImportSettings& settings); + + /** + * @brief Check if coordinates are within crop region + * @param x X coordinate + * @param y Y coordinate + * @param settings Import settings with crop configuration + * @return true if inside crop region or cropping disabled + */ + static bool IsInCropRegion(int x, int y, const ImportSettings& settings); + + /** + * @brief Validate header format and extract dimensions + * @param header_parts Split header line components + * @param width [out] Parsed width value + * @param height [out] Parsed height value + * @return true if valid format + */ + static bool ValidateHeader(const QStringList& header_parts, int& width, int& height); + + /** + * @brief Process range value according to out-of-range handling + * @param input Raw input value + * @param settings Import settings + * @return Processed value within range + */ + static double ProcessRangeValue(double input, const ImportSettings& settings); + + /** + * @brief Create QImage from processed uint8_t data array + * @param data Grayscale pixel data (0-255) + * @param width Image width + * @param height Image height + * @return Grayscale QImage + */ + static QImage CreateImageFromData(const uint8_t* data, int width, int height); + + // Constants + static constexpr int MIN_DIMENSION = 2; + static constexpr uint8_t GRAYSCALE_MIN = 0; + static constexpr uint8_t GRAYSCALE_MAX = 255; +}; + +#endif // METADATAIMPORTER_H \ No newline at end of file diff --git a/src/metadataimportsettingsdialog.cpp b/src/metadataimportsettingsdialog.cpp new file mode 100644 index 0000000..0eb2e7d --- /dev/null +++ b/src/metadataimportsettingsdialog.cpp @@ -0,0 +1,196 @@ +#include "metadataimportsettingsdialog.h" + +#include +#include +#include + +#include "ui_metadataimportsettingsdialog.h" + +MetadataImportSettingsDialog::MetadataImportSettingsDialog(const QString& filepath, + int width, int height, + QWidget* parent) + : QDialog(parent), + ui_(new Ui::MetadataImportSettingsDialog), + filepath_(filepath), + file_width_(width), + file_height_(height) +{ + ui_->setupUi(this); + + // Initialize controls with file information and default values + InitializeControls(); + + // Set up signal connections + connect(ui_->crop_enabled_checkbox_, &QCheckBox::toggled, + this, &MetadataImportSettingsDialog::OnCropEnabledChanged); + connect(ui_->import_button_, &QPushButton::clicked, + this, &MetadataImportSettingsDialog::ValidateSettings); + + // Update crop control limits + UpdateCropLimits(); + + // Initially disable crop controls if cropping is not enabled + OnCropEnabledChanged(ui_->crop_enabled_checkbox_->isChecked()); +} + +MetadataImportSettingsDialog::~MetadataImportSettingsDialog() +{ + delete ui_; +} + +void MetadataImportSettingsDialog::InitializeControls() +{ + // Set file information display + QFileInfo file_info(filepath_); + ui_->file_path_value_->setText(file_info.fileName()); + ui_->file_path_value_->setToolTip(filepath_); // Show full path on hover + ui_->dimensions_value_->setText(QString("%1 x %2").arg(file_width_).arg(file_height_)); + + // Set default range values + ui_->range_min_spinbox_->setValue(0.0); + ui_->range_max_spinbox_->setValue(100.0); + + // Set default out-of-range handling (clamp is already checked by default in UI) + ui_->clamp_radio_->setChecked(true); + + // Set default crop settings + ui_->crop_enabled_checkbox_->setChecked(false); + ui_->crop_start_x_spinbox_->setValue(0); + ui_->crop_start_y_spinbox_->setValue(0); + ui_->crop_end_x_spinbox_->setValue(file_width_); + ui_->crop_end_y_spinbox_->setValue(file_height_); +} + +void MetadataImportSettingsDialog::UpdateCropLimits() +{ + // Update maximum values for crop controls based on file dimensions + ui_->crop_start_x_spinbox_->setMaximum(file_width_ - 1); + ui_->crop_start_y_spinbox_->setMaximum(file_height_ - 1); + ui_->crop_end_x_spinbox_->setMaximum(file_width_); + ui_->crop_end_y_spinbox_->setMaximum(file_height_); + + // Set default end values to full dimensions + ui_->crop_end_x_spinbox_->setValue(file_width_); + ui_->crop_end_y_spinbox_->setValue(file_height_); +} + +void MetadataImportSettingsDialog::OnCropEnabledChanged(bool enabled) +{ + // Enable/disable crop controls based on checkbox state + ui_->crop_start_x_spinbox_->setEnabled(enabled); + ui_->crop_start_y_spinbox_->setEnabled(enabled); + ui_->crop_end_x_spinbox_->setEnabled(enabled); + ui_->crop_end_y_spinbox_->setEnabled(enabled); + ui_->crop_start_x_label_->setEnabled(enabled); + ui_->crop_start_y_label_->setEnabled(enabled); + ui_->crop_end_x_label_->setEnabled(enabled); + ui_->crop_end_y_label_->setEnabled(enabled); + ui_->crop_start_label_->setEnabled(enabled); + ui_->crop_end_label_->setEnabled(enabled); +} + +void MetadataImportSettingsDialog::ValidateSettings() +{ + // Validate range settings + double range_min = ui_->range_min_spinbox_->value(); + double range_max = ui_->range_max_spinbox_->value(); + + if (range_min >= range_max) + { + QMessageBox::warning(this, "Invalid Range", + "Maximum value must be greater than minimum value.\n\n" + "Please adjust the range values."); + ui_->range_max_spinbox_->setFocus(); + ui_->range_max_spinbox_->selectAll(); + return; + } + + // Validate crop settings if enabled + if (ui_->crop_enabled_checkbox_->isChecked()) + { + int start_x = ui_->crop_start_x_spinbox_->value(); + int start_y = ui_->crop_start_y_spinbox_->value(); + int end_x = ui_->crop_end_x_spinbox_->value(); + int end_y = ui_->crop_end_y_spinbox_->value(); + + if (start_x >= end_x || start_y >= end_y) + { + QMessageBox::warning(this, "Invalid Crop Region", + "End coordinates must be greater than start coordinates.\n\n" + "Please adjust the crop region."); + return; + } + + if (end_x > file_width_ || end_y > file_height_) + { + QMessageBox::warning(this, "Invalid Crop Region", + QString("Crop region extends outside data boundaries.\n\n" + "Data size: %1 x %2\n" + "Crop region: (%3,%4) to (%5,%6)") + .arg(file_width_).arg(file_height_) + .arg(start_x).arg(start_y) + .arg(end_x).arg(end_y)); + return; + } + + // Check for minimum crop size + if ((end_x - start_x) < 2 || (end_y - start_y) < 2) + { + QMessageBox::warning(this, "Invalid Crop Region", + "Crop region must be at least 2x2 pixels.\n\n" + "Please adjust the crop region."); + return; + } + } + + // All validations passed, accept the dialog + accept(); +} + +MetadataImporter::ImportSettings MetadataImportSettingsDialog::GetSettings() const +{ + MetadataImporter::ImportSettings settings; + + // Range settings + settings.range_min = ui_->range_min_spinbox_->value(); + settings.range_max = ui_->range_max_spinbox_->value(); + + // Out-of-range handling + if (ui_->clamp_radio_->isChecked()) + { + settings.out_of_range_handling = MetadataImporter::ImportSettings::CLAMP_TO_BOUNDS; + } + else if (ui_->zero_radio_->isChecked()) + { + settings.out_of_range_handling = MetadataImporter::ImportSettings::SET_TO_ZERO; + } + else if (ui_->max_radio_->isChecked()) + { + settings.out_of_range_handling = MetadataImporter::ImportSettings::SET_TO_MAX; + } + else + { + // Default fallback + settings.out_of_range_handling = MetadataImporter::ImportSettings::CLAMP_TO_BOUNDS; + } + + // Crop settings + settings.enable_cropping = ui_->crop_enabled_checkbox_->isChecked(); + if (settings.enable_cropping) + { + settings.crop_start_x = ui_->crop_start_x_spinbox_->value(); + settings.crop_start_y = ui_->crop_start_y_spinbox_->value(); + settings.crop_end_x = ui_->crop_end_x_spinbox_->value(); + settings.crop_end_y = ui_->crop_end_y_spinbox_->value(); + } + else + { + // Set to full dimensions when cropping is disabled + settings.crop_start_x = 0; + settings.crop_start_y = 0; + settings.crop_end_x = file_width_; + settings.crop_end_y = file_height_; + } + + return settings; +} \ No newline at end of file diff --git a/src/metadataimportsettingsdialog.h b/src/metadataimportsettingsdialog.h new file mode 100644 index 0000000..547af22 --- /dev/null +++ b/src/metadataimportsettingsdialog.h @@ -0,0 +1,64 @@ +#ifndef METADATAIMPORTSETTINGSDIALOG_H +#define METADATAIMPORTSETTINGSDIALOG_H + +#include +#include "metadataimporter.h" + +namespace Ui +{ +class MetadataImportSettingsDialog; +} + +/** + * @brief Dialog for configuring metadata import settings + * + * Allows users to configure data range, out-of-range handling, and + * optional crop region before importing numerical metadata files + * as grayscale images. + */ +class MetadataImportSettingsDialog : public QDialog +{ + Q_OBJECT + + public: + explicit MetadataImportSettingsDialog(const QString& filepath, + int width, int height, + QWidget* parent = nullptr); + ~MetadataImportSettingsDialog(); + + /** + * @brief Get configured import settings + * @return ImportSettings configured by user + */ + MetadataImporter::ImportSettings GetSettings() const; + + private slots: + /** + * @brief Handle crop enabled/disabled state change + * @param enabled Whether cropping is enabled + */ + void OnCropEnabledChanged(bool enabled); + + /** + * @brief Validate user settings before accepting dialog + */ + void ValidateSettings(); + + private: + /** + * @brief Initialize UI controls with default values + */ + void InitializeControls(); + + /** + * @brief Update crop control maximum values based on file dimensions + */ + void UpdateCropLimits(); + + Ui::MetadataImportSettingsDialog* ui_; + QString filepath_; + int file_width_; + int file_height_; +}; + +#endif // METADATAIMPORTSETTINGSDIALOG_H \ No newline at end of file diff --git a/src/metadataimportsettingsdialog.ui b/src/metadataimportsettingsdialog.ui new file mode 100644 index 0000000..2400b2a --- /dev/null +++ b/src/metadataimportsettingsdialog.ui @@ -0,0 +1,350 @@ + + + MetadataImportSettingsDialog + + + + 0 + 0 + 450 + 550 + + + + + 450 + 0 + + + + Metadata Import Settings + + + + + + File Information + + + + + + File: + + + + + + + /path/to/data.txt + + + true + + + + + + + Dimensions: + + + + + + + 1024 x 768 + + + + + + + + + + Data Range Settings + + + + + + + + Minimum value: + + + + + + + -999999.000000000000000 + + + 999999.000000000000000 + + + 6 + + + + + + + Maximum value: + + + + + + + -999999.000000000000000 + + + 999999.000000000000000 + + + 6 + + + 100.000000000000000 + + + + + + + + + Out-of-range handling: + + + + + + + Clamp to bounds + + + true + + + + + + + Set to zero (minimum) + + + + + + + Set to maximum + + + + + + + + + + Crop Region + + + + + + Enable cropping + + + + + + + + + Start (top-left): + + + + + + + + + X: + + + + + + + 0 + + + 99999 + + + + + + + Y: + + + + + + + 0 + + + 99999 + + + + + + + + + End (bottom-right): + + + + + + + + + X: + + + + + + + 1 + + + 99999 + + + 100 + + + + + + + Y: + + + + + + + 1 + + + 99999 + + + 100 + + + + + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + Import + + + true + + + + + + + Cancel + + + + + + + + + + + import_button_ + clicked() + MetadataImportSettingsDialog + accept() + + + 325 + 517 + + + 225 + 275 + + + + + cancel_button_ + clicked() + MetadataImportSettingsDialog + reject() + + + 400 + 517 + + + 225 + 275 + + + + + \ No newline at end of file diff --git a/tests/main_test.cpp b/tests/main_test.cpp index 4b22658..cecc92e 100644 --- a/tests/main_test.cpp +++ b/tests/main_test.cpp @@ -3,10 +3,14 @@ #include #include #include +#include +#include +#include // Include headers from the main application #include "projectconfig.h" #include "polygoncanvas.h" +#include "metadataimporter.h" // Test fixture for PolySeg tests class PolySegTest : public ::testing::Test { @@ -111,6 +115,159 @@ TEST_F(PolySegTest, CoordinateNormalization) { EXPECT_NEAR(normalize(300, imageHeight), 0.5, 0.001); } +// MetadataImporter Tests +TEST_F(PolySegTest, MetadataImporter_ValidHeaderParsing) { + int width, height; + MetadataImporter::ImportError error; + + bool result = MetadataImporter::ParseHeaderWithError("test_data_4x3.txt", width, height, error); + + EXPECT_TRUE(result); + EXPECT_EQ(error.type, MetadataImporter::ImportError::NO_ERROR); + EXPECT_EQ(width, 4); + EXPECT_EQ(height, 3); +} + +TEST_F(PolySegTest, MetadataImporter_InvalidHeaderFormat) { + int width, height; + MetadataImporter::ImportError error; + + bool result = MetadataImporter::ParseHeaderWithError("test_invalid_header.txt", width, height, error); + + EXPECT_FALSE(result); + EXPECT_EQ(error.type, MetadataImporter::ImportError::INVALID_HEADER_FORMAT); + EXPECT_FALSE(error.message.isEmpty()); +} + +TEST_F(PolySegTest, MetadataImporter_FileNotFound) { + int width, height; + MetadataImporter::ImportError error; + + bool result = MetadataImporter::ParseHeaderWithError("nonexistent_file.txt", width, height, error); + + EXPECT_FALSE(result); + EXPECT_EQ(error.type, MetadataImporter::ImportError::FILE_NOT_FOUND); + EXPECT_FALSE(error.message.isEmpty()); +} + +TEST_F(PolySegTest, MetadataImporter_ValidDataImport) { + MetadataImporter::ImportSettings settings; + settings.range_min = 0.0; + settings.range_max = 100.0; + settings.out_of_range_handling = MetadataImporter::ImportSettings::CLAMP_TO_BOUNDS; + settings.enable_cropping = false; + + MetadataImporter::ImportError error; + + QImage image = MetadataImporter::ImportMetadataFileWithError("test_data_4x3.txt", settings, error); + + EXPECT_FALSE(image.isNull()); + EXPECT_EQ(error.type, MetadataImporter::ImportError::NO_ERROR); + EXPECT_EQ(image.width(), 4); + EXPECT_EQ(image.height(), 3); + EXPECT_EQ(image.format(), QImage::Format_Grayscale8); +} + +TEST_F(PolySegTest, MetadataImporter_WrongDimensions) { + MetadataImporter::ImportSettings settings; + settings.range_min = 0.0; + settings.range_max = 100.0; + settings.out_of_range_handling = MetadataImporter::ImportSettings::CLAMP_TO_BOUNDS; + settings.enable_cropping = false; + + MetadataImporter::ImportError error; + + QImage image = MetadataImporter::ImportMetadataFileWithError("test_wrong_dimensions.txt", settings, error); + + EXPECT_TRUE(image.isNull()); + EXPECT_EQ(error.type, MetadataImporter::ImportError::DATA_MISMATCH); + EXPECT_GT(error.row_number, 0); +} + +TEST_F(PolySegTest, MetadataImporter_NonNumericData) { + MetadataImporter::ImportSettings settings; + settings.range_min = 0.0; + settings.range_max = 100.0; + settings.out_of_range_handling = MetadataImporter::ImportSettings::CLAMP_TO_BOUNDS; + settings.enable_cropping = false; + + MetadataImporter::ImportError error; + + QImage image = MetadataImporter::ImportMetadataFileWithError("test_non_numeric.txt", settings, error); + + EXPECT_TRUE(image.isNull()); + EXPECT_EQ(error.type, MetadataImporter::ImportError::INVALID_NUMERIC_DATA); + EXPECT_GT(error.row_number, 0); + EXPECT_FALSE(error.invalid_value.isEmpty()); +} + +TEST_F(PolySegTest, MetadataImporter_CroppingFunctionality) { + MetadataImporter::ImportSettings settings; + settings.range_min = 0.0; + settings.range_max = 100.0; + settings.out_of_range_handling = MetadataImporter::ImportSettings::CLAMP_TO_BOUNDS; + settings.enable_cropping = true; + settings.crop_start_x = 1; + settings.crop_start_y = 1; + settings.crop_end_x = 3; + settings.crop_end_y = 2; + + MetadataImporter::ImportError error; + + QImage image = MetadataImporter::ImportMetadataFileWithError("test_data_4x3.txt", settings, error); + + EXPECT_FALSE(image.isNull()); + EXPECT_EQ(error.type, MetadataImporter::ImportError::NO_ERROR); + EXPECT_EQ(image.width(), 2); // crop_end_x - crop_start_x = 3 - 1 = 2 + EXPECT_EQ(image.height(), 1); // crop_end_y - crop_start_y = 2 - 1 = 1 +} + +TEST_F(PolySegTest, MetadataImporter_CropBoundaryError) { + MetadataImporter::ImportSettings settings; + settings.range_min = 0.0; + settings.range_max = 100.0; + settings.out_of_range_handling = MetadataImporter::ImportSettings::CLAMP_TO_BOUNDS; + settings.enable_cropping = true; + settings.crop_start_x = 0; + settings.crop_start_y = 0; + settings.crop_end_x = 10; // Beyond data boundary (width=4) + settings.crop_end_y = 10; // Beyond data boundary (height=3) + + MetadataImporter::ImportError error; + + QImage image = MetadataImporter::ImportMetadataFileWithError("test_data_4x3.txt", settings, error); + + EXPECT_TRUE(image.isNull()); + EXPECT_EQ(error.type, MetadataImporter::ImportError::CROP_BOUNDARY_ERROR); + EXPECT_FALSE(error.message.isEmpty()); +} + +TEST_F(PolySegTest, MetadataImporter_RangeProcessing) { + // Test with different range handling options + MetadataImporter::ImportSettings settings; + settings.range_min = 20.0; + settings.range_max = 80.0; + settings.out_of_range_handling = MetadataImporter::ImportSettings::CLAMP_TO_BOUNDS; + settings.enable_cropping = false; + + MetadataImporter::ImportError error; + + QImage image = MetadataImporter::ImportMetadataFileWithError("test_data_4x3.txt", settings, error); + + EXPECT_FALSE(image.isNull()); + EXPECT_EQ(error.type, MetadataImporter::ImportError::NO_ERROR); + EXPECT_EQ(image.width(), 4); + EXPECT_EQ(image.height(), 3); + + // Test with zero handling + settings.out_of_range_handling = MetadataImporter::ImportSettings::SET_TO_ZERO; + + QImage image2 = MetadataImporter::ImportMetadataFileWithError("test_data_4x3.txt", settings, error); + + EXPECT_FALSE(image2.isNull()); + EXPECT_EQ(error.type, MetadataImporter::ImportError::NO_ERROR); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); From 3fd0e3e41bcfff61209fd65c16a3b05cfa9b70fb Mon Sep 17 00:00:00 2001 From: Lukasz Stachowicz Date: Mon, 4 May 2026 21:03:40 +0200 Subject: [PATCH 2/8] refactor(libs): extract and standardize core library modules Extract core image processing and annotation functionality into separate, reusable library modules (imgproc, segcore) with proper separation from UI/application code. This enables framework-agnostic library usage and improves code organization. Changes: - Create imgproc library: header-only image processing components (Frame, Kernel, Convolution) with POLYSEG_IMGPROC_* header guards - Create segcore library: annotation/project management core (AnnotationSet, Project, Segment, Geometry, Display, Serialization) with POLYSEG_SEGCORE_* header guards - Standardize all intra-library includes to angle-bracket format (#include instead of "segcore/...") - Update all consumers (src/, tests/) to use angle-bracket includes - Add target_include_directories to PolySeg_lib for proper CMake include path propagation - Decouple core business logic from Qt/UI dependencies for independent library reusability This enables downstream projects to link against segcore/imgproc without pulling in Qt dependencies or UI code. --- CMakeLists.txt | 48 +- libs/imgproc/CMakeLists.txt | 2 + libs/imgproc/convolution.h | 173 +++ libs/imgproc/convolution_kernels.h | 71 ++ libs/imgproc/frame.h | 46 + libs/imgproc/kernel.h | 31 + libs/segcore/CMakeLists.txt | 19 + libs/segcore/include/segcore/annotation_set.h | 60 + libs/segcore/include/segcore/artifact.h | 36 + libs/segcore/include/segcore/display.h | 14 + libs/segcore/include/segcore/geometry.h | 23 + libs/segcore/include/segcore/history.h | 33 + .../segcore/include/segcore/iannotation_set.h | 48 + .../segcore/normalized_format_serializer.h | 15 + libs/segcore/include/segcore/project.h | 31 + libs/segcore/include/segcore/segment.h | 19 + libs/segcore/include/segcore/types.h | 39 + libs/segcore/src/annotation_set.cpp | 373 ++++++ libs/segcore/src/artifact.cpp | 39 + libs/segcore/src/display.cpp | 42 + libs/segcore/src/geometry.cpp | 107 ++ libs/segcore/src/history.cpp | 56 + .../src/normalized_format_serializer.cpp | 66 ++ libs/segcore/src/project.cpp | 50 + src/edgedetector.cpp | 302 +++++ src/edgedetector.h | 25 + src/mainwindow.cpp | 175 ++- src/mainwindow.h | 9 + src/mainwindow.ui | 31 + src/modelcomparisondialog.cpp | 7 +- src/modelcomparisondialog.h | 3 + src/polygoncanvas.cpp | 1053 +++++++---------- src/polygoncanvas.h | 86 +- src/qtadapter.cpp | 23 + src/qtadapter.h | 31 + tests/CMakeLists.txt | 76 ++ ...ain_test.cpp => metadataimporter_test.cpp} | 219 ++-- tests/polygoncanvas_test.cpp | 178 +++ tests/projectconfig_test.cpp | 98 ++ tests/segcore/annotation_set_test.cpp | 285 +++++ tests/segcore/display_test.cpp | 77 ++ tests/segcore/geometry_test.cpp | 74 ++ tests/segcore/history_test.cpp | 81 ++ .../normalized_format_serializer_test.cpp | 74 ++ tests/segcore/project_test.cpp | 75 ++ tests/segcore/types_test.cpp | 55 + 46 files changed, 3589 insertions(+), 889 deletions(-) create mode 100644 libs/imgproc/CMakeLists.txt create mode 100644 libs/imgproc/convolution.h create mode 100644 libs/imgproc/convolution_kernels.h create mode 100644 libs/imgproc/frame.h create mode 100644 libs/imgproc/kernel.h create mode 100644 libs/segcore/CMakeLists.txt create mode 100644 libs/segcore/include/segcore/annotation_set.h create mode 100644 libs/segcore/include/segcore/artifact.h create mode 100644 libs/segcore/include/segcore/display.h create mode 100644 libs/segcore/include/segcore/geometry.h create mode 100644 libs/segcore/include/segcore/history.h create mode 100644 libs/segcore/include/segcore/iannotation_set.h create mode 100644 libs/segcore/include/segcore/normalized_format_serializer.h create mode 100644 libs/segcore/include/segcore/project.h create mode 100644 libs/segcore/include/segcore/segment.h create mode 100644 libs/segcore/include/segcore/types.h create mode 100644 libs/segcore/src/annotation_set.cpp create mode 100644 libs/segcore/src/artifact.cpp create mode 100644 libs/segcore/src/display.cpp create mode 100644 libs/segcore/src/geometry.cpp create mode 100644 libs/segcore/src/history.cpp create mode 100644 libs/segcore/src/normalized_format_serializer.cpp create mode 100644 libs/segcore/src/project.cpp create mode 100644 src/edgedetector.cpp create mode 100644 src/edgedetector.h create mode 100644 src/qtadapter.cpp create mode 100644 src/qtadapter.h create mode 100644 tests/CMakeLists.txt rename tests/{main_test.cpp => metadataimporter_test.cpp} (51%) create mode 100644 tests/polygoncanvas_test.cpp create mode 100644 tests/projectconfig_test.cpp create mode 100644 tests/segcore/annotation_set_test.cpp create mode 100644 tests/segcore/display_test.cpp create mode 100644 tests/segcore/geometry_test.cpp create mode 100644 tests/segcore/history_test.cpp create mode 100644 tests/segcore/normalized_format_serializer_test.cpp create mode 100644 tests/segcore/project_test.cpp create mode 100644 tests/segcore/types_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 34b190a..ae9b8d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,6 +68,7 @@ set(POLYSEG_SOURCES src/modeldownloadmanager.cpp src/modelregistrationdialog.cpp src/polygoncanvas.cpp + src/qtadapter.cpp src/projectconfig.cpp src/pythonenvironmentmanager.cpp src/settingstabbase.cpp @@ -79,6 +80,7 @@ set(POLYSEG_SOURCES src/shortcutssettingstab.cpp src/aipluginmanager.cpp src/pluginwizard.cpp + src/edgedetector.cpp src/metadataimporter.cpp src/metadataimportsettingsdialog.cpp src/wizardpages/welcomepage.cpp @@ -114,6 +116,7 @@ set(POLYSEG_HEADERS src/shortcutssettingstab.h src/aipluginmanager.h src/pluginwizard.h + src/edgedetector.h src/metadataimporter.h src/metadataimportsettingsdialog.h src/wizardpages/welcomepage.h @@ -179,12 +182,20 @@ add_library(PolySeg_lib ${POLYSEG_RESOURCES} ) +# Include directories +target_include_directories(PolySeg_lib PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/src + ${CMAKE_SOURCE_DIR}/libs +) + # Link Qt libraries to the library -target_link_libraries(PolySeg_lib PRIVATE +target_link_libraries(PolySeg_lib PUBLIC Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Network + imgproc + segcore ) # Create the main PolySeg executable @@ -295,39 +306,12 @@ endif() enable_testing() # Add Google Test from external directory +add_subdirectory(libs/imgproc) +add_subdirectory(libs/segcore) add_subdirectory(external/googletest) -# Create unit test executable -add_executable(PolySeg_tests - tests/main_test.cpp - # Add additional test files here as you create them -) - -# Link Google Test libraries and PolySeg library to test executable -target_link_libraries(PolySeg_tests PRIVATE - gtest - gtest_main - PolySeg_lib - Qt6::Core - Qt6::Gui - Qt6::Widgets - Qt6::Network -) - -# Apply coverage flags to test executable if enabled -if(ENABLE_COVERAGE AND (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")) - target_compile_options(PolySeg_tests PRIVATE ${COVERAGE_FLAGS}) - target_link_libraries(PolySeg_tests PRIVATE ${COVERAGE_FLAGS}) -endif() - -# Include test directories -target_include_directories(PolySeg_tests PRIVATE - src - external/googletest/googletest/include -) - -# Add tests to CTest -add_test(NAME PolySeg_unit_tests COMMAND PolySeg_tests) +# Add unit tests +add_subdirectory(tests) # Coverage target for easy usage if(ENABLE_COVERAGE AND (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")) diff --git a/libs/imgproc/CMakeLists.txt b/libs/imgproc/CMakeLists.txt new file mode 100644 index 0000000..d246d8c --- /dev/null +++ b/libs/imgproc/CMakeLists.txt @@ -0,0 +1,2 @@ +add_library(imgproc INTERFACE) +target_include_directories(imgproc INTERFACE ${CMAKE_SOURCE_DIR}/libs) diff --git a/libs/imgproc/convolution.h b/libs/imgproc/convolution.h new file mode 100644 index 0000000..c35b6bd --- /dev/null +++ b/libs/imgproc/convolution.h @@ -0,0 +1,173 @@ +#ifndef POLYSEG_IMGPROC_CONVOLUTION_H +#define POLYSEG_IMGPROC_CONVOLUTION_H + +#include +#include +#include +#include + +#include +#include + +namespace polyseg { + +class Convolution +{ + public: + template + static auto apply(Frame& output, const Frame& input, const Kernel& kernel) -> void + { + assert(output.width() == input.width()); + assert(output.height() == input.height()); + detail::apply(output, input, kernel); + } + + private: + template + struct detail + { + static auto apply(Frame& output, const Frame& input, const Kernel& kernel) -> void + { + const auto* k = kernel.data().data(); + const auto* src = input.data(); + const int width = input.width(); + const int height = input.height(); + const double factor = kernel.factor(); + const int border = N / 2; + + for (int y = border; y < height - border; ++y) + { + for (int x = border; x < width - border; ++x) + { + double sum = 0.0; + for (int ky = 0; ky < N; ++ky) + { + for (int kx = 0; kx < N; ++kx) + { + sum += k[ky * N + kx] * src[(y + ky - border) * width + (x + kx - border)]; + } + } + sum = std::max(0.0, std::min(static_cast(std::numeric_limits::max()), + sum * factor)); + output.data()[y * width + x] = static_cast(sum); + } + } + } + }; +}; + +// Specialization for 3x3 kernels + +template +struct Convolution::detail<3, T> +{ + static auto apply(Frame& output, const Frame& input, const Kernel<3>& kernel) -> void + { + const int width = input.width(); + const int height = input.height(); + + const auto& kdata = kernel.data(); + const float factor = kernel.factor(); + const float kf[9] = { + kdata[0] * factor, kdata[1] * factor, kdata[2] * factor, + kdata[3] * factor, kdata[4] * factor, kdata[5] * factor, + kdata[6] * factor, kdata[7] * factor, kdata[8] * factor, + }; + + const auto* r0 = input.data(); + const auto* r1 = input.data() + width; + const auto* r2 = input.data() + width * 2; + T* dst = output.data() + width; + + for (int i = 1; i < height - 1; ++i) + { + ++r0; + ++r1; + ++r2; + ++dst; + for (int j = 1; j < width - 1; ++j) + { + float value = kf[0] * r0[-1] + kf[1] * r0[0] + kf[2] * r0[1] + kf[3] * r1[-1] + + kf[4] * r1[0] + kf[5] * r1[1] + kf[6] * r2[-1] + kf[7] * r2[0] + + kf[8] * r2[1]; + value = std::max(0.0f, std::min(static_cast(std::numeric_limits::max()), value)); + *dst++ = static_cast(value); + ++r0; + ++r1; + ++r2; + } + ++r0; + ++r1; + ++r2; + ++dst; + } + } +}; + +// Specialization for 5x5 kernels + +template +struct Convolution::detail<5, T> +{ + static auto apply(Frame& output, const Frame& input, const Kernel<5>& kernel) -> void + { + const int width = input.width(); + const int height = input.height(); + + const auto& kdata = kernel.data(); + const float factor = kernel.factor(); + const float kf[25] = { + kdata[0] * factor, kdata[1] * factor, kdata[2] * factor, kdata[3] * factor, + kdata[4] * factor, kdata[5] * factor, kdata[6] * factor, kdata[7] * factor, + kdata[8] * factor, kdata[9] * factor, kdata[10] * factor, kdata[11] * factor, + kdata[12] * factor, kdata[13] * factor, kdata[14] * factor, kdata[15] * factor, + kdata[16] * factor, kdata[17] * factor, kdata[18] * factor, kdata[19] * factor, + kdata[20] * factor, kdata[21] * factor, kdata[22] * factor, kdata[23] * factor, + kdata[24] * factor, + }; + + const auto* r0 = input.data(); + const auto* r1 = input.data() + width; + const auto* r2 = input.data() + width * 2; + const auto* r3 = input.data() + width * 3; + const auto* r4 = input.data() + width * 4; + T* dst = output.data() + width * 2; + + for (int i = 2; i < height - 2; ++i) + { + r0 += 2; + r1 += 2; + r2 += 2; + r3 += 2; + r4 += 2; + dst += 2; + for (int j = 2; j < width - 2; ++j) + { + float value = + kf[0] * r0[-2] + kf[1] * r0[-1] + kf[2] * r0[0] + kf[3] * r0[1] + kf[4] * r0[2] + + kf[5] * r1[-2] + kf[6] * r1[-1] + kf[7] * r1[0] + kf[8] * r1[1] + kf[9] * r1[2] + + kf[10] * r2[-2] + kf[11] * r2[-1] + kf[12] * r2[0] + kf[13] * r2[1] + + kf[14] * r2[2] + kf[15] * r3[-2] + kf[16] * r3[-1] + kf[17] * r3[0] + + kf[18] * r3[1] + kf[19] * r3[2] + kf[20] * r4[-2] + kf[21] * r4[-1] + + kf[22] * r4[0] + kf[23] * r4[1] + kf[24] * r4[2]; + value = std::max(0.0f, std::min(static_cast(std::numeric_limits::max()), value)); + *dst++ = static_cast(value); + ++r0; + ++r1; + ++r2; + ++r3; + ++r4; + } + r0 += 2; + r1 += 2; + r2 += 2; + r3 += 2; + r4 += 2; + dst += 2; + } + } +}; + +} // namespace polyseg + +#endif // POLYSEG_IMGPROC_CONVOLUTION_H diff --git a/libs/imgproc/convolution_kernels.h b/libs/imgproc/convolution_kernels.h new file mode 100644 index 0000000..7e844d4 --- /dev/null +++ b/libs/imgproc/convolution_kernels.h @@ -0,0 +1,71 @@ +#ifndef POLYSEG_IMGPROC_CONVOLUTION_KERNELS_H +#define POLYSEG_IMGPROC_CONVOLUTION_KERNELS_H + +#include + +#include + +namespace polyseg { + +// Base for Laplacian-of-Gaussian kernels +template +class LaplacianKernel : public Kernel +{ + public: + LaplacianKernel(const std::array& data, float factor = 1.0f) + : Kernel(data, factor) + { + } +}; + +// 3x3 discrete Laplacian +class LaplacianKernel3 : public LaplacianKernel<3> +{ + public: + LaplacianKernel3() + : LaplacianKernel<3>({ + 0.0f, -1.0f, 0.0f, + -1.0f, 4.0f, -1.0f, + 0.0f, -1.0f, 0.0f, + }) + { + } +}; + +// 5x5 Laplacian-of-Gaussian approximation +class LaplacianKernel5 : public LaplacianKernel<5> +{ + public: + LaplacianKernel5() + : LaplacianKernel<5>({ + 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, + 0.0f, -1.0f, -2.0f, -1.0f, 0.0f, + -1.0f, -2.0f, 16.0f, -2.0f, -1.0f, + 0.0f, -1.0f, -2.0f, -1.0f, 0.0f, + 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, + }) + { + } +}; + +// 5x5 Gaussian blur (sigma ~1.0, sum = 159) +class GaussianKernel5 : public Kernel<5> +{ + public: + GaussianKernel5() + : Kernel<5>( + { + 2.0f, 4.0f, 5.0f, 4.0f, 2.0f, + 4.0f, 9.0f, 12.0f, 9.0f, 4.0f, + 5.0f, 12.0f, 15.0f, 12.0f, 5.0f, + 4.0f, 9.0f, 12.0f, 9.0f, 4.0f, + 2.0f, 4.0f, 5.0f, 4.0f, 2.0f, + }, + 1.0f / 159.0f) + { + } +}; + +} // namespace polyseg + +#endif // POLYSEG_IMGPROC_CONVOLUTION_KERNELS_H diff --git a/libs/imgproc/frame.h b/libs/imgproc/frame.h new file mode 100644 index 0000000..7223e5f --- /dev/null +++ b/libs/imgproc/frame.h @@ -0,0 +1,46 @@ +#ifndef POLYSEG_IMGPROC_FRAME_H +#define POLYSEG_IMGPROC_FRAME_H + +#include +#include +#include + +namespace polyseg { + +template +class Frame +{ + public: + Frame() : width_(0), height_(0) {} + + Frame(uint16_t width, uint16_t height) + : width_(width), height_(height), data_(width * height) + { + } + + Frame(uint16_t width, uint16_t height, const T* data) + : width_(width), height_(height), data_(data, data + width * height) + { + } + + Frame(uint16_t width, uint16_t height, T fill) + : width_(width), height_(height), data_(width * height, fill) + { + } + + [[nodiscard]] auto width() const -> int { return width_; } + [[nodiscard]] auto height() const -> int { return height_; } + [[nodiscard]] auto size() const -> size_t { return data_.size(); } + + auto data() -> T* { return data_.data(); } + [[nodiscard]] auto data() const -> const T* { return data_.data(); } + + private: + uint16_t width_; + uint16_t height_; + std::vector data_; +}; + +} // namespace polyseg + +#endif // POLYSEG_IMGPROC_FRAME_H diff --git a/libs/imgproc/kernel.h b/libs/imgproc/kernel.h new file mode 100644 index 0000000..bd13a1d --- /dev/null +++ b/libs/imgproc/kernel.h @@ -0,0 +1,31 @@ +#ifndef POLYSEG_IMGPROC_KERNEL_H +#define POLYSEG_IMGPROC_KERNEL_H + +#include + +namespace polyseg { + +template +class Kernel +{ + public: + Kernel(const std::array& data, float factor = 1.0f) + : data_(data), factor_(factor) + { + } + + [[nodiscard]] constexpr int size() const { return N; } + [[nodiscard]] constexpr int width() const { return N; } + [[nodiscard]] constexpr int height() const { return N; } + + [[nodiscard]] const std::array& data() const { return data_; } + [[nodiscard]] float factor() const { return factor_; } + + protected: + std::array data_; + float factor_; +}; + +} // namespace polyseg + +#endif // POLYSEG_IMGPROC_KERNEL_H diff --git a/libs/segcore/CMakeLists.txt b/libs/segcore/CMakeLists.txt new file mode 100644 index 0000000..d282dfb --- /dev/null +++ b/libs/segcore/CMakeLists.txt @@ -0,0 +1,19 @@ +add_library(segcore STATIC + src/artifact.cpp + src/history.cpp + src/geometry.cpp + src/normalized_format_serializer.cpp + src/display.cpp + src/annotation_set.cpp + src/project.cpp +) + +target_include_directories(segcore PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include) +target_link_libraries(segcore PUBLIC imgproc) +set_target_properties(segcore PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + AUTOMOC OFF + AUTOUIC OFF + AUTORCC OFF +) diff --git a/libs/segcore/include/segcore/annotation_set.h b/libs/segcore/include/segcore/annotation_set.h new file mode 100644 index 0000000..71cb757 --- /dev/null +++ b/libs/segcore/include/segcore/annotation_set.h @@ -0,0 +1,60 @@ +#ifndef POLYSEG_SEGCORE_ANNOTATION_SET_H +#define POLYSEG_SEGCORE_ANNOTATION_SET_H + +#include +#include +#include +#include +#include +#include + +namespace segcore { + +class AnnotationSet : public IAnnotationSet +{ + public: + SegmentId BeginSegment(int class_id) override; + void AddPoint(SegmentId id, Point2D p) override; + void InsertPoint(SegmentId id, int index, Point2D p) override; + void MovePoint(SegmentId id, int index, Point2D new_pos) override; + void RemovePoint(SegmentId id, int index) override; + bool CommitSegment(SegmentId id) override; + void DeleteSegment(SegmentId id) override; + void ClearAll() override; + + const Segment* GetSegment(SegmentId id) const override; + const std::vector& GetSegments() const override; + const Segment* GetInProgress() const override; + + void SelectSegment(SegmentId id) override; + void DeselectAll() override; + SegmentId GetSelectedSegmentId() const override; + + void CopySegment(SegmentId id) override; + SegmentId PasteSegment() override; + bool HasClipboard() const override; + const Segment* GetClipboard() const; + void SetClipboard(const Segment* segment); + + void CancelInProgress() override; + + void Undo() override; + void Redo() override; + bool CanUndo() const override; + bool CanRedo() const override; + + private: + Segment* FindSegment(SegmentId id); + + std::vector segments_; + Segment in_progress_; + bool has_in_progress_ = false; + SegmentId next_id_ = 1; + SegmentId selected_id_ = kInvalidSegmentId; + std::optional clipboard_; + UndoStack undo_stack_; +}; + +} // namespace segcore + +#endif // POLYSEG_SEGCORE_ANNOTATION_SET_H diff --git a/libs/segcore/include/segcore/artifact.h b/libs/segcore/include/segcore/artifact.h new file mode 100644 index 0000000..dd77d34 --- /dev/null +++ b/libs/segcore/include/segcore/artifact.h @@ -0,0 +1,36 @@ +#ifndef POLYSEG_SEGCORE_ARTIFACT_H +#define POLYSEG_SEGCORE_ARTIFACT_H + +#include +#include +#include +#include + +namespace segcore { + +struct ImageArtifact +{ + polyseg::Frame pixels; + std::string source_path; +}; + +struct FloatArtifact +{ + polyseg::Frame data; + float range_min = 0.0f; + float range_max = 1.0f; + std::string source_path; +}; + +struct Artifact +{ + ArtifactId id = kInvalidArtifactId; + std::variant payload; + + int width() const; + int height() const; +}; + +} // namespace segcore + +#endif // POLYSEG_SEGCORE_ARTIFACT_H diff --git a/libs/segcore/include/segcore/display.h b/libs/segcore/include/segcore/display.h new file mode 100644 index 0000000..606c521 --- /dev/null +++ b/libs/segcore/include/segcore/display.h @@ -0,0 +1,14 @@ +#ifndef POLYSEG_SEGCORE_DISPLAY_H +#define POLYSEG_SEGCORE_DISPLAY_H + +#include +#include +#include + +namespace segcore { + +polyseg::Frame NormaliseForDisplay(const Artifact& artifact); + +} // namespace segcore + +#endif // POLYSEG_SEGCORE_DISPLAY_H diff --git a/libs/segcore/include/segcore/geometry.h b/libs/segcore/include/segcore/geometry.h new file mode 100644 index 0000000..2e5c3c8 --- /dev/null +++ b/libs/segcore/include/segcore/geometry.h @@ -0,0 +1,23 @@ +#ifndef POLYSEG_SEGCORE_GEOMETRY_H +#define POLYSEG_SEGCORE_GEOMETRY_H + +#include +#include +#include + +namespace segcore { + +struct PointHit +{ + SegmentId segment_id = kInvalidSegmentId; + int point_index = -1; +}; + +PointHit HitTestPoint(const std::vector& segments, Point2D pos, int tolerance); +SegmentId HitTestSegment(const std::vector& segments, Point2D pos); +int InsertionIndex(const Segment& seg, Point2D pos); +bool PointInPolygon(const std::vector& pts, Point2D pos); + +} // namespace segcore + +#endif // POLYSEG_SEGCORE_GEOMETRY_H diff --git a/libs/segcore/include/segcore/history.h b/libs/segcore/include/segcore/history.h new file mode 100644 index 0000000..60c96f8 --- /dev/null +++ b/libs/segcore/include/segcore/history.h @@ -0,0 +1,33 @@ +#ifndef POLYSEG_SEGCORE_HISTORY_H +#define POLYSEG_SEGCORE_HISTORY_H + +#include +#include + +namespace segcore { + +struct Command +{ + std::function undo; + std::function redo; +}; + +class UndoStack +{ + public: + void Push(Command cmd); + void Undo(); + void Redo(); + bool CanUndo() const; + bool CanRedo() const; + void Clear(); + + private: + static constexpr int kMaxHistory = 50; + std::deque undo_; + std::deque redo_; +}; + +} // namespace segcore + +#endif // POLYSEG_SEGCORE_HISTORY_H diff --git a/libs/segcore/include/segcore/iannotation_set.h b/libs/segcore/include/segcore/iannotation_set.h new file mode 100644 index 0000000..c60d23d --- /dev/null +++ b/libs/segcore/include/segcore/iannotation_set.h @@ -0,0 +1,48 @@ +#ifndef POLYSEG_SEGCORE_IANNOTATION_SET_H +#define POLYSEG_SEGCORE_IANNOTATION_SET_H + +#include + +#include +#include + +namespace segcore +{ + +class IAnnotationSet +{ + public: + virtual ~IAnnotationSet() = default; + + virtual SegmentId BeginSegment(int class_id) = 0; + virtual void AddPoint(SegmentId id, Point2D p) = 0; + virtual void InsertPoint(SegmentId id, int index, Point2D p) = 0; + virtual void MovePoint(SegmentId id, int index, Point2D new_pos) = 0; + virtual void RemovePoint(SegmentId id, int index) = 0; + virtual bool CommitSegment(SegmentId id) = 0; + virtual void DeleteSegment(SegmentId id) = 0; + virtual void ClearAll() = 0; + + virtual const Segment* GetSegment(SegmentId id) const = 0; + virtual const std::vector& GetSegments() const = 0; + virtual const Segment* GetInProgress() const = 0; + + virtual void SelectSegment(SegmentId id) = 0; + virtual void DeselectAll() = 0; + virtual SegmentId GetSelectedSegmentId() const = 0; + + virtual void CopySegment(SegmentId id) = 0; + virtual SegmentId PasteSegment() = 0; + virtual bool HasClipboard() const = 0; + + virtual void CancelInProgress() = 0; + + virtual void Undo() = 0; + virtual void Redo() = 0; + virtual bool CanUndo() const = 0; + virtual bool CanRedo() const = 0; +}; + +} // namespace segcore + +#endif // POLYSEG_SEGCORE_IANNOTATION_SET_H diff --git a/libs/segcore/include/segcore/normalized_format_serializer.h b/libs/segcore/include/segcore/normalized_format_serializer.h new file mode 100644 index 0000000..40e187d --- /dev/null +++ b/libs/segcore/include/segcore/normalized_format_serializer.h @@ -0,0 +1,15 @@ +#ifndef POLYSEG_SEGCORE_NORMALIZED_FORMAT_SERIALIZER_H +#define POLYSEG_SEGCORE_NORMALIZED_FORMAT_SERIALIZER_H + +#include +#include +#include + +namespace segcore { + +std::string SegmentsToNormalizedFormat(const std::vector& segs, int w, int h); +std::vector NormalizedFormatToSegments(const std::string& text, int w, int h); + +} // namespace segcore + +#endif // POLYSEG_SEGCORE_NORMALIZED_FORMAT_SERIALIZER_H diff --git a/libs/segcore/include/segcore/project.h b/libs/segcore/include/segcore/project.h new file mode 100644 index 0000000..988c4ab --- /dev/null +++ b/libs/segcore/include/segcore/project.h @@ -0,0 +1,31 @@ +#ifndef POLYSEG_SEGCORE_PROJECT_H +#define POLYSEG_SEGCORE_PROJECT_H + +#include +#include +#include +#include + +namespace segcore { + +class Project +{ + public: + ArtifactId AddArtifact(Artifact artifact); + void RemoveArtifact(ArtifactId id); + const Artifact* GetArtifact(ArtifactId id) const; + AnnotationSet& GetAnnotations(ArtifactId id); + const AnnotationSet& GetAnnotations(ArtifactId id) const; + ArtifactId GetCurrentArtifactId() const; + void SetCurrentArtifact(ArtifactId id); + + private: + ArtifactId next_id_ = 1; + ArtifactId current_id_ = kInvalidArtifactId; + std::unordered_map artifacts_; + std::unordered_map annotations_; +}; + +} // namespace segcore + +#endif // POLYSEG_SEGCORE_PROJECT_H diff --git a/libs/segcore/include/segcore/segment.h b/libs/segcore/include/segcore/segment.h new file mode 100644 index 0000000..0694bc7 --- /dev/null +++ b/libs/segcore/include/segcore/segment.h @@ -0,0 +1,19 @@ +#ifndef POLYSEG_SEGCORE_SEGMENT_H +#define POLYSEG_SEGCORE_SEGMENT_H + +#include +#include + +namespace segcore { + +struct Segment +{ + SegmentId id = kInvalidSegmentId; + int class_id = 0; + bool selected = false; + std::vector points; +}; + +} // namespace segcore + +#endif // POLYSEG_SEGCORE_SEGMENT_H diff --git a/libs/segcore/include/segcore/types.h b/libs/segcore/include/segcore/types.h new file mode 100644 index 0000000..940cb5a --- /dev/null +++ b/libs/segcore/include/segcore/types.h @@ -0,0 +1,39 @@ +#ifndef POLYSEG_SEGCORE_TYPES_H +#define POLYSEG_SEGCORE_TYPES_H + +#include + +namespace segcore { + +struct Point2D +{ + int x = 0; + int y = 0; + + bool operator==(const Point2D& other) const + { + return x == other.x && y == other.y; + } +}; + +struct Rgb24 +{ + uint8_t r = 0; + uint8_t g = 0; + uint8_t b = 0; + + bool operator==(const Rgb24& other) const + { + return r == other.r && g == other.g && b == other.b; + } +}; + +using ArtifactId = uint32_t; +using SegmentId = uint32_t; + +constexpr ArtifactId kInvalidArtifactId = 0; +constexpr SegmentId kInvalidSegmentId = 0; + +} // namespace segcore + +#endif // POLYSEG_SEGCORE_TYPES_H diff --git a/libs/segcore/src/annotation_set.cpp b/libs/segcore/src/annotation_set.cpp new file mode 100644 index 0000000..414b1b2 --- /dev/null +++ b/libs/segcore/src/annotation_set.cpp @@ -0,0 +1,373 @@ +#include + +#include + +namespace segcore { + +Segment* AnnotationSet::FindSegment(SegmentId id) +{ + auto it = std::find_if(segments_.begin(), segments_.end(), + [id](const Segment& s) { return s.id == id; }); + return it != segments_.end() ? &(*it) : nullptr; +} + +SegmentId AnnotationSet::BeginSegment(int class_id) +{ + SegmentId id = next_id_++; + in_progress_ = Segment{}; + in_progress_.id = id; + in_progress_.class_id = class_id; + has_in_progress_ = true; + return id; +} + +void AnnotationSet::AddPoint(SegmentId id, Point2D p) +{ + if (has_in_progress_ && in_progress_.id == id) + { + undo_stack_.Push( + {[this, id]() + { + if (has_in_progress_ && in_progress_.id == id && !in_progress_.points.empty()) + { + in_progress_.points.pop_back(); + } + }, + [this, id, p]() + { + if (has_in_progress_ && in_progress_.id == id) + { + in_progress_.points.push_back(p); + } + }}); + return; + } + Segment* seg = FindSegment(id); + if (!seg) + { + return; + } + undo_stack_.Push({[this, id]() + { + Segment* s = FindSegment(id); + if (s && !s->points.empty()) + { + s->points.pop_back(); + } + }, + [this, id, p]() + { + Segment* s = FindSegment(id); + if (s) + { + s->points.push_back(p); + } + }}); +} + +void AnnotationSet::InsertPoint(SegmentId id, int index, Point2D p) +{ + Segment* seg = FindSegment(id); + if (!seg || index < 0 || index > static_cast(seg->points.size())) + { + return; + } + undo_stack_.Push({[this, id, index]() + { + Segment* s = FindSegment(id); + if (s && index < static_cast(s->points.size())) + { + s->points.erase(s->points.begin() + index); + } + }, + [this, id, index, p]() + { + Segment* s = FindSegment(id); + if (s && index <= static_cast(s->points.size())) + { + s->points.insert(s->points.begin() + index, p); + } + }}); +} + +void AnnotationSet::MovePoint(SegmentId id, int index, Point2D new_pos) +{ + if (has_in_progress_ && in_progress_.id == id) + { + if (index < 0 || index >= static_cast(in_progress_.points.size())) + { + return; + } + undo_stack_.Push({[this, id, index, old_pos = in_progress_.points[index]]() + { + if (has_in_progress_ && in_progress_.id == id && + index < static_cast(in_progress_.points.size())) + { + in_progress_.points[index] = old_pos; + } + }, + [this, id, index, new_pos]() + { + if (has_in_progress_ && in_progress_.id == id && + index < static_cast(in_progress_.points.size())) + { + in_progress_.points[index] = new_pos; + } + }}); + return; + } + Segment* seg = FindSegment(id); + if (!seg || index < 0 || index >= static_cast(seg->points.size())) + { + return; + } + Point2D old_pos = seg->points[index]; + undo_stack_.Push({[this, id, index, old_pos]() + { + Segment* s = FindSegment(id); + if (s && index < static_cast(s->points.size())) + { + s->points[index] = old_pos; + } + }, + [this, id, index, new_pos]() + { + Segment* s = FindSegment(id); + if (s && index < static_cast(s->points.size())) + { + s->points[index] = new_pos; + } + }}); +} + +void AnnotationSet::CancelInProgress() +{ + if (has_in_progress_) + { + in_progress_ = Segment{}; + has_in_progress_ = false; + } +} + +void AnnotationSet::RemovePoint(SegmentId id, int index) +{ + Segment* seg = FindSegment(id); + if (!seg || index < 0 || index >= static_cast(seg->points.size())) + { + return; + } + Point2D removed = seg->points[index]; + undo_stack_.Push({[this, id, index, removed]() + { + Segment* s = FindSegment(id); + if (s && index <= static_cast(s->points.size())) + { + s->points.insert(s->points.begin() + index, removed); + } + }, + [this, id, index]() + { + Segment* s = FindSegment(id); + if (s && index < static_cast(s->points.size())) + { + s->points.erase(s->points.begin() + index); + } + }}); +} + +bool AnnotationSet::CommitSegment(SegmentId id) +{ + if (!has_in_progress_ || in_progress_.id != id) + { + return false; + } + if (static_cast(in_progress_.points.size()) < 3) + { + return false; + } + Segment to_commit = in_progress_; + undo_stack_.Push( + {[this, to_commit]() + { + segments_.erase( + std::remove_if(segments_.begin(), segments_.end(), + [&](const Segment& s) { return s.id == to_commit.id; }), + segments_.end()); + in_progress_ = to_commit; + has_in_progress_ = true; + }, + [this, to_commit]() + { + has_in_progress_ = false; + in_progress_ = Segment{}; + auto it = std::find_if(segments_.begin(), segments_.end(), + [&](const Segment& s) { return s.id == to_commit.id; }); + if (it == segments_.end()) + { + segments_.push_back(to_commit); + } + }}); + return true; +} + +void AnnotationSet::DeleteSegment(SegmentId id) +{ + auto it = std::find_if(segments_.begin(), segments_.end(), + [id](const Segment& s) { return s.id == id; }); + if (it == segments_.end()) + { + return; + } + Segment saved = *it; + int saved_index = static_cast(std::distance(segments_.begin(), it)); + if (selected_id_ == id) + { + selected_id_ = kInvalidSegmentId; + } + undo_stack_.Push( + {[this, saved, saved_index]() + { + int insert_at = std::min(saved_index, static_cast(segments_.size())); + segments_.insert(segments_.begin() + insert_at, saved); + }, + [this, id]() + { + segments_.erase( + std::remove_if(segments_.begin(), segments_.end(), + [id](const Segment& s) { return s.id == id; }), + segments_.end()); + }}); +} + +void AnnotationSet::ClearAll() +{ + segments_.clear(); + in_progress_ = Segment{}; + has_in_progress_ = false; + selected_id_ = kInvalidSegmentId; + undo_stack_.Clear(); +} + +const Segment* AnnotationSet::GetSegment(SegmentId id) const +{ + auto it = std::find_if(segments_.begin(), segments_.end(), + [id](const Segment& s) { return s.id == id; }); + return it != segments_.end() ? &(*it) : nullptr; +} + +const std::vector& AnnotationSet::GetSegments() const +{ + return segments_; +} + +const Segment* AnnotationSet::GetInProgress() const +{ + return has_in_progress_ ? &in_progress_ : nullptr; +} + +void AnnotationSet::SelectSegment(SegmentId id) +{ + for (auto& seg : segments_) + { + seg.selected = (seg.id == id); + } + selected_id_ = id; +} + +void AnnotationSet::DeselectAll() +{ + for (auto& seg : segments_) + { + seg.selected = false; + } + selected_id_ = kInvalidSegmentId; +} + +SegmentId AnnotationSet::GetSelectedSegmentId() const +{ + return selected_id_; +} + +void AnnotationSet::CopySegment(SegmentId id) +{ + const Segment* seg = GetSegment(id); + if (seg) + { + clipboard_ = *seg; + } +} + +SegmentId AnnotationSet::PasteSegment() +{ + if (!clipboard_.has_value()) + { + return kInvalidSegmentId; + } + Segment pasted = clipboard_.value(); + pasted.id = next_id_++; + pasted.selected = false; + undo_stack_.Push( + {[this, id = pasted.id]() + { + segments_.erase( + std::remove_if(segments_.begin(), segments_.end(), + [id](const Segment& s) { return s.id == id; }), + segments_.end()); + }, + [this, pasted]() + { + auto it = std::find_if(segments_.begin(), segments_.end(), + [&](const Segment& s) { return s.id == pasted.id; }); + if (it == segments_.end()) + segments_.push_back(pasted); + }}); + return pasted.id; +} + +bool AnnotationSet::HasClipboard() const +{ + return clipboard_.has_value(); +} + +const Segment* AnnotationSet::GetClipboard() const +{ + if (clipboard_.has_value()) + { + return &clipboard_.value(); + } + return nullptr; +} + +void AnnotationSet::SetClipboard(const Segment* segment) +{ + if (segment != nullptr) + { + clipboard_ = *segment; + } + else + { + clipboard_.reset(); + } +} + +void AnnotationSet::Undo() +{ + undo_stack_.Undo(); +} + +void AnnotationSet::Redo() +{ + undo_stack_.Redo(); +} + +bool AnnotationSet::CanUndo() const +{ + return undo_stack_.CanUndo(); +} + +bool AnnotationSet::CanRedo() const +{ + return undo_stack_.CanRedo(); +} + +} // namespace segcore diff --git a/libs/segcore/src/artifact.cpp b/libs/segcore/src/artifact.cpp new file mode 100644 index 0000000..c3efd1a --- /dev/null +++ b/libs/segcore/src/artifact.cpp @@ -0,0 +1,39 @@ +#include + +namespace segcore { + +int Artifact::width() const +{ + return std::visit( + [](const auto& v) -> int + { + if constexpr (std::is_same_v, ImageArtifact>) + { + return v.pixels.width(); + } + else + { + return v.data.width(); + } + }, + payload); +} + +int Artifact::height() const +{ + return std::visit( + [](const auto& v) -> int + { + if constexpr (std::is_same_v, ImageArtifact>) + { + return v.pixels.height(); + } + else + { + return v.data.height(); + } + }, + payload); +} + +} // namespace segcore diff --git a/libs/segcore/src/display.cpp b/libs/segcore/src/display.cpp new file mode 100644 index 0000000..6775eb7 --- /dev/null +++ b/libs/segcore/src/display.cpp @@ -0,0 +1,42 @@ +#include + +#include +#include + +namespace segcore { + +polyseg::Frame NormaliseForDisplay(const Artifact& artifact) +{ + return std::visit( + [](const auto& v) -> polyseg::Frame + { + if constexpr (std::is_same_v, ImageArtifact>) + { + return v.pixels; + } + else + { + int w = v.data.width(); + int h = v.data.height(); + polyseg::Frame out(static_cast(w), static_cast(h)); + const float* src = v.data.data(); + Rgb24* dst = out.data(); + float range = v.range_max - v.range_min; + if (range < 1e-10f) + { + range = 1.0f; + } + for (int i = 0; i < w * h; ++i) + { + float normalized = (src[i] - v.range_min) / range; + normalized = std::clamp(normalized, 0.0f, 1.0f); + uint8_t byte = static_cast(normalized * 255.0f); + dst[i] = {byte, byte, byte}; + } + return out; + } + }, + artifact.payload); +} + +} // namespace segcore diff --git a/libs/segcore/src/geometry.cpp b/libs/segcore/src/geometry.cpp new file mode 100644 index 0000000..adf23d9 --- /dev/null +++ b/libs/segcore/src/geometry.cpp @@ -0,0 +1,107 @@ +#include + +#include +#include + +namespace segcore { + +namespace { + +double Distance(Point2D a, Point2D b) +{ + double dx = a.x - b.x; + double dy = a.y - b.y; + return std::sqrt(dx * dx + dy * dy); +} + +double DistanceFromPointToSegment(Point2D p, Point2D a, Point2D b) +{ + double dx = b.x - a.x; + double dy = b.y - a.y; + double len_sq = dx * dx + dy * dy; + if (len_sq < 1e-10) + { + return Distance(p, a); + } + double t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / len_sq; + t = std::max(0.0, std::min(1.0, t)); + Point2D proj{static_cast(a.x + t * dx), static_cast(a.y + t * dy)}; + return Distance(p, proj); +} + +} // namespace + +PointHit HitTestPoint(const std::vector& segments, Point2D pos, int tolerance) +{ + for (const auto& seg : segments) + { + for (int i = 0; i < static_cast(seg.points.size()); ++i) + { + if (Distance(seg.points[i], pos) <= static_cast(tolerance)) + { + return {seg.id, i}; + } + } + } + return {kInvalidSegmentId, -1}; +} + +SegmentId HitTestSegment(const std::vector& segments, Point2D pos) +{ + for (const auto& seg : segments) + { + if (PointInPolygon(seg.points, pos)) + { + return seg.id; + } + } + return kInvalidSegmentId; +} + +int InsertionIndex(const Segment& seg, Point2D pos) +{ + if (seg.points.size() < 2) + { + return static_cast(seg.points.size()); + } + int best_index = 1; + double best_dist = std::numeric_limits::max(); + int n = static_cast(seg.points.size()); + for (int i = 0; i < n; ++i) + { + int j = (i + 1) % n; + double d = DistanceFromPointToSegment(pos, seg.points[i], seg.points[j]); + if (d < best_dist) + { + best_dist = d; + best_index = j == 0 ? n : j; + } + } + return best_index; +} + +bool PointInPolygon(const std::vector& pts, Point2D pos) +{ + int n = static_cast(pts.size()); + if (n < 3) + { + return false; + } + bool inside = false; + for (int i = 0, j = n - 1; i < n; j = i++) + { + int xi = pts[i].x; + int yi = pts[i].y; + int xj = pts[j].x; + int yj = pts[j].y; + bool intersects = ((yi > pos.y) != (yj > pos.y)) && + (pos.x < (xj - xi) * (pos.y - yi) / (yj - yi) + xi); + if (intersects) + { + inside = !inside; + } + } + return inside; +} + +} // namespace segcore diff --git a/libs/segcore/src/history.cpp b/libs/segcore/src/history.cpp new file mode 100644 index 0000000..1d0d467 --- /dev/null +++ b/libs/segcore/src/history.cpp @@ -0,0 +1,56 @@ +#include + +namespace segcore { + +void UndoStack::Push(Command cmd) +{ + cmd.redo(); + redo_.clear(); + undo_.push_back(std::move(cmd)); + if (static_cast(undo_.size()) > kMaxHistory) + { + undo_.pop_front(); + } +} + +void UndoStack::Undo() +{ + if (!CanUndo()) + { + return; + } + Command cmd = std::move(undo_.back()); + undo_.pop_back(); + cmd.undo(); + redo_.push_back(std::move(cmd)); +} + +void UndoStack::Redo() +{ + if (!CanRedo()) + { + return; + } + Command cmd = std::move(redo_.back()); + redo_.pop_back(); + cmd.redo(); + undo_.push_back(std::move(cmd)); +} + +bool UndoStack::CanUndo() const +{ + return !undo_.empty(); +} + +bool UndoStack::CanRedo() const +{ + return !redo_.empty(); +} + +void UndoStack::Clear() +{ + undo_.clear(); + redo_.clear(); +} + +} // namespace segcore diff --git a/libs/segcore/src/normalized_format_serializer.cpp b/libs/segcore/src/normalized_format_serializer.cpp new file mode 100644 index 0000000..7dd773b --- /dev/null +++ b/libs/segcore/src/normalized_format_serializer.cpp @@ -0,0 +1,66 @@ +#include + +#include +#include + +namespace segcore { + +std::string SegmentsToNormalizedFormat(const std::vector& segs, int w, int h) +{ + std::ostringstream out; + out << std::fixed << std::setprecision(3); + for (const auto& seg : segs) + { + if (seg.points.size() < 3) + { + continue; + } + out << seg.class_id; + for (const auto& p : seg.points) + { + out << ' ' << static_cast(p.x) / w + << ' ' << static_cast(p.y) / h; + } + out << '\n'; + } + return out.str(); +} + +std::vector NormalizedFormatToSegments(const std::string& text, int w, int h) +{ + std::vector result; + std::istringstream stream(text); + std::string line; + SegmentId next_id = 1; + while (std::getline(stream, line)) + { + if (line.empty()) + { + continue; + } + std::istringstream ls(line); + std::vector tokens; + std::string token; + while (ls >> token) + { + tokens.push_back(token); + } + if (tokens.size() < 7) + { + continue; + } + Segment seg; + seg.id = next_id++; + seg.class_id = std::stoi(tokens[0]); + for (size_t i = 1; i + 1 < tokens.size(); i += 2) + { + double nx = std::stod(tokens[i]); + double ny = std::stod(tokens[i + 1]); + seg.points.push_back({static_cast(nx * w), static_cast(ny * h)}); + } + result.push_back(std::move(seg)); + } + return result; +} + +} // namespace segcore diff --git a/libs/segcore/src/project.cpp b/libs/segcore/src/project.cpp new file mode 100644 index 0000000..90a233e --- /dev/null +++ b/libs/segcore/src/project.cpp @@ -0,0 +1,50 @@ +#include + +namespace segcore { + +ArtifactId Project::AddArtifact(Artifact artifact) +{ + ArtifactId id = next_id_++; + artifact.id = id; + artifacts_.emplace(id, std::move(artifact)); + annotations_.emplace(id, AnnotationSet{}); + return id; +} + +void Project::RemoveArtifact(ArtifactId id) +{ + artifacts_.erase(id); + annotations_.erase(id); + if (current_id_ == id) + { + current_id_ = kInvalidArtifactId; + } +} + +const Artifact* Project::GetArtifact(ArtifactId id) const +{ + auto it = artifacts_.find(id); + return it != artifacts_.end() ? &it->second : nullptr; +} + +AnnotationSet& Project::GetAnnotations(ArtifactId id) +{ + return annotations_.at(id); +} + +const AnnotationSet& Project::GetAnnotations(ArtifactId id) const +{ + return annotations_.at(id); +} + +ArtifactId Project::GetCurrentArtifactId() const +{ + return current_id_; +} + +void Project::SetCurrentArtifact(ArtifactId id) +{ + current_id_ = id; +} + +} // namespace segcore diff --git a/src/edgedetector.cpp b/src/edgedetector.cpp new file mode 100644 index 0000000..82d495b --- /dev/null +++ b/src/edgedetector.cpp @@ -0,0 +1,302 @@ +#include "edgedetector.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace { + +polyseg::Frame ToGrayscaleFrame(const QImage& image) +{ + QImage gray = image.convertToFormat(QImage::Format_Grayscale8); + const auto w = static_cast(gray.width()); + const auto h = static_cast(gray.height()); + polyseg::Frame frame(w, h); + for (int y = 0; y < gray.height(); ++y) + { + const uchar* row = gray.constScanLine(y); + for (int x = 0; x < gray.width(); ++x) + { + frame.data()[y * w + x] = row[x]; + } + } + return frame; +} + +// Applies LoG kernel without clamping negatives; returns absolute response. +polyseg::Frame ApplyLogAbs(const polyseg::Frame& input) +{ + const polyseg::LaplacianKernel5 kernel; + const auto& kdata = kernel.data(); + const int w = input.width(); + const int h = input.height(); + polyseg::Frame output(static_cast(w), static_cast(h), 0.0f); + + for (int y = 2; y < h - 2; ++y) + { + for (int x = 2; x < w - 2; ++x) + { + float sum = 0.0f; + for (int ky = 0; ky < 5; ++ky) + { + for (int kx = 0; kx < 5; ++kx) + { + sum += kdata[ky * 5 + kx] * + static_cast(input.data()[(y + ky - 2) * w + (x + kx - 2)]); + } + } + output.data()[y * w + x] = std::abs(sum); + } + } + return output; +} + +uint8_t OtsuThreshold(const uint8_t* data, size_t n) +{ + std::array hist = {}; + for (size_t i = 0; i < n; ++i) ++hist[data[i]]; + + double total_sum = 0.0; + for (int i = 0; i < 256; ++i) total_sum += i * hist[i]; + + double sum_bg = 0.0; + int w_bg = 0; + double best_var = 0.0; + int best_t = 0; + const int N = static_cast(n); + + for (int t = 0; t < 256; ++t) + { + w_bg += hist[t]; + if (w_bg == 0 || w_bg == N) + { + sum_bg += t * hist[t]; + continue; + } + sum_bg += t * hist[t]; + double mean_bg = sum_bg / w_bg; + double mean_fg = (total_sum - sum_bg) / (N - w_bg); + double diff = mean_bg - mean_fg; + double var = static_cast(w_bg) * static_cast(N - w_bg) * diff * diff; + if (var > best_var) + { + best_var = var; + best_t = t; + } + } + return static_cast(best_t); +} + +std::vector Threshold(const polyseg::Frame& log_abs) +{ + const size_t n = log_abs.size(); + const float* src = log_abs.data(); + + float max_val = 0.0f; + for (size_t i = 0; i < n; ++i) max_val = std::max(max_val, src[i]); + + std::vector norm(n, 0); + if (max_val < 1e-6f) return std::vector(n, 0); + + const float scale = 255.0f / max_val; + for (size_t i = 0; i < n; ++i) + norm[i] = static_cast(std::min(255.0f, src[i] * scale)); + + const uint8_t otsu = OtsuThreshold(norm.data(), n); + const uint8_t t = std::max(static_cast(otsu * 65 / 100), uint8_t{8}); + std::cout << "[EdgeDetect] max_log=" << max_val << " otsu=" << (int)otsu + << " threshold=" << (int)t << std::endl; + + std::vector edges(n, 0); + for (size_t i = 0; i < n; ++i) edges[i] = (norm[i] >= t) ? 1 : 0; + return edges; +} + +// Removes edge pixels with fewer than 1 edge neighbor (isolated single pixels). +void Denoise(std::vector& edges, int w, int h) +{ + std::vector result = edges; + for (int y = 1; y < h - 1; ++y) + { + for (int x = 1; x < w - 1; ++x) + { + if (!edges[y * w + x]) continue; + int count = 0; + for (int dy = -1; dy <= 1; ++dy) + for (int dx = -1; dx <= 1; ++dx) + if ((dy || dx) && edges[(y + dy) * w + (x + dx)]) ++count; + if (count < 1) result[y * w + x] = 0; + } + } + edges = std::move(result); +} + +// Zhang-Suen thinning: reduces edge map to 1-pixel skeleton. +void ThinZhangSuen(std::vector& img, int w, int h) +{ + auto get = [&](int x, int y) -> int { + if (x < 0 || x >= w || y < 0 || y >= h) return 0; + return img[y * w + x] ? 1 : 0; + }; + + bool changed = true; + std::vector to_remove; + to_remove.reserve(4096); + + while (changed) + { + changed = false; + + // Sub-iteration 1 + for (int y = 1; y < h - 1; ++y) + { + for (int x = 1; x < w - 1; ++x) + { + if (!img[y * w + x]) continue; + int p2 = get(x, y - 1), p3 = get(x + 1, y - 1); + int p4 = get(x + 1, y), p5 = get(x + 1, y + 1); + int p6 = get(x, y + 1), p7 = get(x - 1, y + 1); + int p8 = get(x - 1, y), p9 = get(x - 1, y - 1); + int B = p2 + p3 + p4 + p5 + p6 + p7 + p8 + p9; + if (B < 2 || B > 6) continue; + int seq[8] = {p2, p3, p4, p5, p6, p7, p8, p9}; + int A = 0; + for (int i = 0; i < 8; ++i) + if (seq[i] == 0 && seq[(i + 1) % 8] == 1) ++A; + if (A != 1) continue; + if (p2 * p4 * p6 != 0) continue; + if (p4 * p6 * p8 != 0) continue; + to_remove.push_back(y * w + x); + } + } + for (int idx : to_remove) + { + img[idx] = 0; + changed = true; + } + to_remove.clear(); + + // Sub-iteration 2 + for (int y = 1; y < h - 1; ++y) + { + for (int x = 1; x < w - 1; ++x) + { + if (!img[y * w + x]) continue; + int p2 = get(x, y - 1), p3 = get(x + 1, y - 1); + int p4 = get(x + 1, y), p5 = get(x + 1, y + 1); + int p6 = get(x, y + 1), p7 = get(x - 1, y + 1); + int p8 = get(x - 1, y), p9 = get(x - 1, y - 1); + int B = p2 + p3 + p4 + p5 + p6 + p7 + p8 + p9; + if (B < 2 || B > 6) continue; + int seq[8] = {p2, p3, p4, p5, p6, p7, p8, p9}; + int A = 0; + for (int i = 0; i < 8; ++i) + if (seq[i] == 0 && seq[(i + 1) % 8] == 1) ++A; + if (A != 1) continue; + if (p2 * p4 * p8 != 0) continue; + if (p2 * p6 * p8 != 0) continue; + to_remove.push_back(y * w + x); + } + } + for (int idx : to_remove) + { + img[idx] = 0; + changed = true; + } + to_remove.clear(); + } +} + +QImage CreateOverlay(const std::vector& edges, int w, int h) +{ + QImage overlay(w, h, QImage::Format_ARGB32); + overlay.fill(Qt::transparent); + for (int y = 0; y < h; ++y) + { + QRgb* line = reinterpret_cast(overlay.scanLine(y)); + for (int x = 0; x < w; ++x) + { + if (edges[y * w + x]) line[x] = qRgba(255, 255, 0, 230); + } + } + return overlay; +} + +} // namespace + +EdgeDetector::Result EdgeDetector::Detect(const QImage& image) +{ + if (image.isNull()) return {}; + + // Grayscale conversion + polyseg::Frame gray = ToGrayscaleFrame(image); + const int w = gray.width(); + const int h = gray.height(); + + // Gaussian 5x5 blur (denoising before LoG) + polyseg::Frame blurred(static_cast(w), static_cast(h), uint8_t{0}); + polyseg::Convolution::apply(blurred, gray, polyseg::GaussianKernel5{}); + + // Replicate border pixels: Gaussian skips 2px border, leaving it at 0. + // Those zeros would produce fake strong LoG responses at image boundary. + for (int y = 0; y < h; ++y) + { + for (int x = 0; x < w; ++x) + { + if (x >= 2 && x < w - 2 && y >= 2 && y < h - 2) continue; + const int sx = std::clamp(x, 2, w - 3); + const int sy = std::clamp(y, 2, h - 3); + blurred.data()[y * w + x] = blurred.data()[sy * w + sx]; + } + } + + // LoG 5x5 (absolute response) + polyseg::Frame log_response = ApplyLogAbs(blurred); + + // Threshold via Otsu on normalized response + std::vector edges = Threshold(log_response); + + // Denoise: remove isolated edge pixels + Denoise(edges, w, h); + + // Zhang-Suen thinning to 1-pixel skeleton + ThinZhangSuen(edges, w, h); + + Result result; + result.width = w; + result.height = h; + result.edge_map = std::move(edges); + result.overlay = CreateOverlay(result.edge_map, w, h); + return result; +} + +QPoint EdgeDetector::SnapToEdge(const QPoint& img_pos, const Result& edges, int radius) +{ + if (!edges.isValid()) return img_pos; + + int best_dist_sq = radius * radius + 1; + QPoint best = img_pos; + + for (int dy = -radius; dy <= radius; ++dy) + { + for (int dx = -radius; dx <= radius; ++dx) + { + const int nx = img_pos.x() + dx; + const int ny = img_pos.y() + dy; + if (nx < 0 || nx >= edges.width || ny < 0 || ny >= edges.height) continue; + if (!edges.edge_map[ny * edges.width + nx]) continue; + const int dist_sq = dx * dx + dy * dy; + if (dist_sq < best_dist_sq) + { + best_dist_sq = dist_sq; + best = QPoint(nx, ny); + } + } + } + return best; +} diff --git a/src/edgedetector.h b/src/edgedetector.h new file mode 100644 index 0000000..fae55a5 --- /dev/null +++ b/src/edgedetector.h @@ -0,0 +1,25 @@ +#ifndef EDGEDETECTOR_H +#define EDGEDETECTOR_H + +#include +#include +#include +#include + +class EdgeDetector +{ + public: + struct Result + { + QImage overlay; + std::vector edge_map; + int width = 0; + int height = 0; + bool isValid() const { return !edge_map.empty(); } + }; + + static Result Detect(const QImage& image); + static QPoint SnapToEdge(const QPoint& img_pos, const Result& edges, int radius = 15); +}; + +#endif // EDGEDETECTOR_H diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 6e7bffe..7faeb3c 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -46,6 +46,25 @@ #include "settingsdialog.h" #include "ui_mainwindow.h" +static std::string ReadFileAsString(const QString& path) +{ + QFile f(path); + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return {}; + return f.readAll().toStdString(); +} + +static void WriteStringToFile(const QString& path, const std::string& text) +{ + if (text.empty()) + { + QFile::remove(path); + return; + } + QFile f(path); + if (f.open(QIODevice::WriteOnly | QIODevice::Text)) + f.write(QByteArray::fromStdString(text)); +} + MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent), ui(new Ui::MainWindow), @@ -102,6 +121,11 @@ MainWindow::MainWindow(QWidget* parent) connect(ui->actionZoomOut, &QAction::triggered, this, &MainWindow::Decrease); connect(ui->actionResetZoom, &QAction::triggered, this, &MainWindow::ResetZoom); + connect(ui->actionSnapToEdges, &QAction::toggled, ui->label, + &PolygonCanvas::SetSnapToEdges); + connect(ui->actionEdgeMapOnly, &QAction::toggled, ui->label, + &PolygonCanvas::SetEdgeMapOnly); + // Class navigation shortcuts (Ctrl+] and Ctrl+[) QShortcut* next_class_shortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_BracketRight), this); connect(next_class_shortcut, &QShortcut::activated, this, &MainWindow::NextClass); @@ -147,6 +171,12 @@ MainWindow::MainWindow(QWidget* parent) connect(ui->actionEditShortcuts, &QAction::triggered, this, &MainWindow::EditShortcuts); connect(ui->actionAbout, &QAction::triggered, this, &MainWindow::ShowAboutDialog); + // Install event filter on canvas and scroll area to intercept arrow key navigation + // regardless of which widget has focus (QScrollArea would otherwise consume these keys) + ui->label->installEventFilter(this); + ui->scrollArea->installEventFilter(this); + ui->scrollArea->viewport()->installEventFilter(this); + // Load keyboard shortcuts LoadShortcuts(); ApplyShortcuts(); @@ -198,6 +228,28 @@ void MainWindow::keyPressEvent(QKeyEvent* event) QMainWindow::keyPressEvent(event); } +bool MainWindow::eventFilter(QObject* obj, QEvent* event) +{ + if (event->type() == QEvent::KeyPress) + { + QKeyEvent* key = static_cast(event); + if (key->modifiers() == Qt::NoModifier) + { + if (key->key() == Qt::Key_Right) + { + NextImage(); + return true; + } + if (key->key() == Qt::Key_Left) + { + PreviousImage(); + return true; + } + } + } + return QMainWindow::eventFilter(obj, event); +} + void MainWindow::Load() { const auto home = qgetenv("HOME"); @@ -460,8 +512,7 @@ void MainWindow::Save() return; } - auto polygons = ui->label->GetPolygons(); - if (polygons.isEmpty()) + if (!ui->label->HasAnnotations()) { QMessageBox::warning(this, "No Annotations", "Please create polygon annotations first.\n\n" @@ -502,38 +553,37 @@ void MainWindow::SaveProjectConfig() void MainWindow::AutoSaveCurrentImage() { - if (current_image_path_.isEmpty() || project_directory_.isEmpty()) - { - return; - } + if (current_artifact_id_ == segcore::kInvalidArtifactId) return; + if (current_image_path_.isEmpty() || project_directory_.isEmpty()) return; - auto polygons = ui->label->GetPolygons(); + auto& anns = project_core_.GetAnnotations(current_artifact_id_); + const auto* art = project_core_.GetArtifact(current_artifact_id_); + if (!art) return; - // Get filename without extension QFileInfo fileInfo(current_image_path_); QString labelsDir = project_directory_ + "/labels"; QString labelPath = labelsDir + "/" + fileInfo.completeBaseName() + ".txt"; - if (polygons.isEmpty()) - { - // Remove label file if no polygons - if (QFile::exists(labelPath)) - { - QFile::remove(labelPath); - } - } - else + std::string text = segcore::SegmentsToNormalizedFormat(anns.GetSegments(), art->width(), art->height()); + if (!text.empty()) { - // Ensure labels directory exists QDir dir; if (!dir.exists(labelsDir)) - { dir.mkpath(labelsDir); - } - - // Save annotations - ui->label->ExportAnnotations(labelPath, 0); } + WriteStringToFile(labelPath, text); +} + +QVector MainWindow::BuildClassColorTable() const +{ + QVector colors; + for (const auto& cls : project_config_.GetClasses()) + { + while (colors.size() <= cls.index) + colors.append(Qt::red); + colors[cls.index] = cls.color; + } + return colors; } void MainWindow::LoadImageAtIndex(int index) @@ -550,55 +600,76 @@ void MainWindow::LoadImageAtIndex(int index) return; } - // Auto-save current image before switching AutoSaveCurrentImage(); - // Load new image + // Save clipboard from current image before switching + const segcore::Segment* clipboard_backup = nullptr; + if (ui->label->GetAnnotationSet() != nullptr) + { + auto* current_set = dynamic_cast(ui->label->GetAnnotationSet()); + if (current_set != nullptr) + { + clipboard_backup = current_set->GetClipboard(); + } + } + current_image_index_ = index; QString imagePath = project_directory_ + "/images/" + image_list_[index]; - QPixmap pixmap(imagePath); - if (pixmap.isNull()) + QImage qimg(imagePath); + if (qimg.isNull()) { QMessageBox::critical(this, "Error", "Failed to load image:\n" + imagePath); return; } current_image_path_ = imagePath; - ui->label->setPixmap(pixmap); - // Don't reset zoom - keep current zoom level - // ui->label->ResetZoom(); - // Load existing annotations if they exist + segcore::Artifact artifact; + artifact.payload = segcore::ImageArtifact{qt_adapter::QImageToFrame(qimg), imagePath.toStdString()}; + current_artifact_id_ = project_core_.AddArtifact(std::move(artifact)); + project_core_.SetCurrentArtifact(current_artifact_id_); + + ui->label->LoadArtifact(*project_core_.GetArtifact(current_artifact_id_)); + ui->label->SetAnnotationSet(&project_core_.GetAnnotations(current_artifact_id_)); + + // Restore clipboard to new image + if (clipboard_backup != nullptr) + { + auto* new_set = dynamic_cast(ui->label->GetAnnotationSet()); + if (new_set != nullptr) + { + new_set->SetClipboard(clipboard_backup); + } + } + + ui->label->SetClassColors(BuildClassColorTable()); + QFileInfo fileInfo(imagePath); QString labelPath = project_directory_ + "/labels/" + fileInfo.completeBaseName() + ".txt"; - // Temporarily disconnect auto-save signal during loading disconnect(ui->label, &PolygonCanvas::PolygonsChanged, this, &MainWindow::AutoSaveCurrentImage); - if (QFile::exists(labelPath)) + std::string text = ReadFileAsString(labelPath); + if (!text.empty()) { - // Get class colors from project config - QVector class_colors; - for (const auto& cls : project_config_.GetClasses()) + auto segs = segcore::NormalizedFormatToSegments(text, qimg.width(), qimg.height()); + auto& anns = project_core_.GetAnnotations(current_artifact_id_); + for (auto& s : segs) { - class_colors.append(cls.color); + auto id = anns.BeginSegment(s.class_id); + for (auto& p : s.points) + anns.AddPoint(id, p); + anns.CommitSegment(id); } - - ui->label->LoadAnnotations(labelPath, class_colors); - } - else - { - ui->label->ClearAllPolygons(); } - // Reconnect auto-save signal connect(ui->label, &PolygonCanvas::PolygonsChanged, this, &MainWindow::AutoSaveCurrentImage); + ui->label->update(); UpdateWindowTitle(); UpdateStatusBar(); - // Set focus to canvas for keyboard shortcuts ui->label->setFocus(); } @@ -1179,7 +1250,7 @@ void MainWindow::ShowProjectStatistics() void MainWindow::UpdateStatusBar() { // Left: Current action (with split info if enabled) - int polygon_count = ui->label->GetPolygons().size(); + int polygon_count = ui->label->GetAnnotationCount(); QString left_text; if (!image_list_.isEmpty() && current_image_index_ >= 0 && project_config_.IsSplitEnabled()) @@ -2017,9 +2088,15 @@ void MainWindow::ImportDataAsImage() else { // Load image directly if no project - auto pixmap = QPixmap(temp_path); - ui->label->setPixmap(pixmap); - ui->label->setFixedSize(pixmap.size()); + segcore::Artifact art; + art.payload = segcore::ImageArtifact{qt_adapter::QImageToFrame(image), temp_path.toStdString()}; + current_artifact_id_ = project_core_.AddArtifact(std::move(art)); + project_core_.SetCurrentArtifact(current_artifact_id_); + const auto* stored_art = project_core_.GetArtifact(current_artifact_id_); + ui->label->LoadArtifact(*stored_art); + ui->label->setFixedSize(QSize(stored_art->width(), stored_art->height())); + ui->label->SetAnnotationSet(&project_core_.GetAnnotations(current_artifact_id_)); + ui->label->SetClassColors(BuildClassColorTable()); current_image_path_ = temp_path; current_image_index_ = -1; diff --git a/src/mainwindow.h b/src/mainwindow.h index 29cf69c..6c26b80 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -4,6 +4,10 @@ #include #include "projectconfig.h" +#include +#include +#include +#include "qtadapter.h" QT_BEGIN_NAMESPACE namespace Ui @@ -84,6 +88,7 @@ class MainWindow : public QMainWindow protected: void keyPressEvent(QKeyEvent* event) override; + bool eventFilter(QObject* obj, QEvent* event) override; private: void SaveProjectConfig(); @@ -97,6 +102,7 @@ class MainWindow : public QMainWindow void UpdateRecentProjectsMenu(); void LoadLastProject(); QString GetProjectStatistics() const; + QVector BuildClassColorTable() const; Ui::MainWindow* ui; QString current_image_path_; @@ -120,5 +126,8 @@ class MainWindow : public QMainWindow // AI Plugin Manager AIPluginManager* ai_plugin_manager_; + + segcore::Project project_core_; + segcore::ArtifactId current_artifact_id_ = segcore::kInvalidArtifactId; }; #endif // MAINWINDOW_H diff --git a/src/mainwindow.ui b/src/mainwindow.ui index e326b14..b3b3b64 100644 --- a/src/mainwindow.ui +++ b/src/mainwindow.ui @@ -71,6 +71,9 @@ + + + @@ -167,6 +170,34 @@ Ctrl+Q + + + true + + + Edge Map Only + + + Show only detected edges (white on black) + + + Ctrl+Shift+E + + + + + true + + + Snap to Edges + + + Snap polygon points to detected edges (Ctrl+E) + + + Ctrl+E + + Zoom In diff --git a/src/modelcomparisondialog.cpp b/src/modelcomparisondialog.cpp index 900b801..779ddfe 100644 --- a/src/modelcomparisondialog.cpp +++ b/src/modelcomparisondialog.cpp @@ -41,6 +41,9 @@ void ModelComparisonDialog::SetupUI() ui_->model_a_combo_->setCurrentIndex(0); ui_->model_b_combo_->setCurrentIndex(1); } + + ui_->canvas_a_->SetAnnotationSet(&annotation_set_a_); + ui_->canvas_b_->SetAnnotationSet(&annotation_set_b_); } void ModelComparisonDialog::ConnectSignals() @@ -166,8 +169,8 @@ void ModelComparisonDialog::RunComparison() RunDetectionOnModel(model_b_path, ui_->canvas_b_); // Update stats (placeholder for now) - int count_a = ui_->canvas_a_->GetPolygons().size(); - int count_b = ui_->canvas_b_->GetPolygons().size(); + int count_a = ui_->canvas_a_->GetAnnotationCount(); + int count_b = ui_->canvas_b_->GetAnnotationCount(); ui_->stats_a_->setText(QString("Detections: %1").arg(count_a)); ui_->stats_b_->setText(QString("Detections: %1").arg(count_b)); diff --git a/src/modelcomparisondialog.h b/src/modelcomparisondialog.h index 7c612c6..d995c9c 100644 --- a/src/modelcomparisondialog.h +++ b/src/modelcomparisondialog.h @@ -4,6 +4,7 @@ #include #include "projectconfig.h" +#include class PolygonCanvas; @@ -42,6 +43,8 @@ class ModelComparisonDialog : public QDialog QString project_dir_; QStringList test_images_; int current_image_index_; + segcore::AnnotationSet annotation_set_a_; + segcore::AnnotationSet annotation_set_b_; }; #endif // MODELCOMPARISONDIALOG_H diff --git a/src/polygoncanvas.cpp b/src/polygoncanvas.cpp index f8d941b..e34fdb2 100644 --- a/src/polygoncanvas.cpp +++ b/src/polygoncanvas.cpp @@ -5,132 +5,156 @@ #include #include #include -#include -#include +#include #include -#include -inline int Distance(const QPoint& p1, const QPoint& p2) +#include "edgedetector.h" +#include "qtadapter.h" +#include +#include + +namespace { + +QPoint ClampToImageBounds(const QPoint& point, const QSize& image_size) { - auto result = sqrt(pow(p1.x() - p2.x(), 2) + pow(p1.y() - p2.y(), 2)); - return result; + int x = qBound(0, point.x(), image_size.width() - 1); + int y = qBound(0, point.y(), image_size.height() - 1); + return QPoint(x, y); } -// Oblicza odległość punktu od segmentu linii -inline float DistanceFromPointToSegment(const QPoint& point, const QPoint& lineStart, - const QPoint& lineEnd) +} // namespace + +PolygonCanvas::PolygonCanvas(QWidget* parent) : QLabel(parent) { - // Wektor od lineStart do lineEnd - float dx = lineEnd.x() - lineStart.x(); - float dy = lineEnd.y() - lineStart.y(); + setFocusPolicy(Qt::StrongFocus); + setMouseTracking(true); +} - // Jeśli segment ma zerową długość, zwróć odległość do punktu - float segmentLengthSquared = dx * dx + dy * dy; - if (segmentLengthSquared == 0.0f) - { - return Distance(point, lineStart); - } +void PolygonCanvas::setPixmap(const QPixmap& pm) +{ + QLabel::setPixmap(pm); + edges_ = EdgeDetector::Result{}; + std::cout << "[EdgeSnap] setPixmap called, size=" << pm.width() << "x" << pm.height() + << ", snap=" << snap_to_edges_ << std::endl; + if (snap_to_edges_) ComputeEdges(); +} - // Parametr t określa gdzie projekcja punktu pada na linię (0 = start, 1 = end) - float t = - ((point.x() - lineStart.x()) * dx + (point.y() - lineStart.y()) * dy) / segmentLengthSquared; +void PolygonCanvas::SetAnnotationSet(segcore::IAnnotationSet* set) +{ + annotation_set_ = set; +} - // Ogranicz t do zakresu [0, 1] - projekcja musi być na segmencie - t = qBound(0.0f, t, 1.0f); +void PolygonCanvas::LoadArtifact(const segcore::Artifact& artifact) +{ + display_frame_ = segcore::NormaliseForDisplay(artifact); + QLabel::setPixmap( + QPixmap::fromImage(qt_adapter::FrameToQImage(display_frame_))); + edges_ = EdgeDetector::Result{}; + if (snap_to_edges_) ComputeEdges(); +} - // Znajdź najbliższy punkt na segmencie - QPointF closestPoint(lineStart.x() + t * dx, lineStart.y() + t * dy); +void PolygonCanvas::SetClassColors(const QVector& colors) +{ + class_colors_ = colors; +} - // Oblicz odległość od punktu do najbliższego punktu na segmencie - float distX = point.x() - closestPoint.x(); - float distY = point.y() - closestPoint.y(); +void PolygonCanvas::SetDrawingMode(int class_id) +{ + drawing_class_id_ = class_id; +} - return sqrt(distX * distX + distY * distY); +void PolygonCanvas::SetSnapToEdges(bool enabled) +{ + snap_to_edges_ = enabled; + std::cout << "[EdgeSnap] SetSnapToEdges(" << enabled + << "), pixmap null=" << pixmap().isNull() + << ", edges valid=" << edges_.isValid() << std::endl; + if (enabled && !edges_.isValid()) ComputeEdges(); + repaint(); } -// Clamp point to image bounds -inline QPoint ClampToImageBounds(const QPoint& point, const QSize& imageSize) +void PolygonCanvas::SetEdgeMapOnly(bool enabled) { - int x = qBound(0, point.x(), imageSize.width() - 1); - int y = qBound(0, point.y(), imageSize.height() - 1); - return QPoint(x, y); + edge_map_only_ = enabled; + if (enabled && !edges_.isValid()) ComputeEdges(); + repaint(); } -PolygonCanvas::PolygonCanvas(QWidget* parent) : QLabel(parent) +void PolygonCanvas::ComputeEdges() { - // Initialize with default color for first polygon - current_polygon_.class_id = 0; - current_polygon_.color = Qt::red; + if (pixmap().isNull()) + { + std::cout << "[EdgeSnap] ComputeEdges: pixmap is null, skipping" << std::endl; + return; + } + std::cout << "[EdgeSnap] ComputeEdges: detecting..." << std::endl; + edges_ = EdgeDetector::Detect(pixmap().toImage()); + const long edge_count = + std::count(edges_.edge_map.begin(), edges_.edge_map.end(), uint8_t{1}); + std::cout << "[EdgeSnap] ComputeEdges: found " << edge_count << " edge pixels (" + << edges_.width << "x" << edges_.height << ")" << std::endl; + repaint(); +} - // Enable keyboard focus to receive key events - setFocusPolicy(Qt::StrongFocus); +QPoint PolygonCanvas::ApplySnap(const QPoint& pos) const +{ + if (!snap_to_edges_) return pos; + const QPoint snapped = EdgeDetector::SnapToEdge(pos, edges_); + return snapped; } void PolygonCanvas::Increase() { scalar_ = scalar_ + 1.0f; - QSize size = pixmap().size(); - size.setWidth(static_cast(size.width() * scalar_)); - size.setHeight(static_cast(size.height() * scalar_)); - setFixedSize(size); + setFixedSize(static_cast(size.width() * scalar_), + static_cast(size.height() * scalar_)); } void PolygonCanvas::Decrease() { - auto new_scalar_ = scalar_ - 1.0f; - if (new_scalar_ > 0) - { - scalar_ = new_scalar_; - } - + float new_scalar = scalar_ - 1.0f; + if (new_scalar > 0.0f) scalar_ = new_scalar; QSize size = pixmap().size(); - size.setWidth(static_cast(size.width() * scalar_)); - size.setHeight(static_cast(size.height() * scalar_)); - setFixedSize(size); + setFixedSize(static_cast(size.width() * scalar_), + static_cast(size.height() * scalar_)); } void PolygonCanvas::ResetZoom() { - scalar_ = 1.0; + scalar_ = 1.0f; QSize size = pixmap().size(); - size.setWidth(static_cast(size.width() * scalar_)); - size.setHeight(static_cast(size.height() * scalar_)); - setFixedSize(size); + setFixedSize(static_cast(size.width() * scalar_), + static_cast(size.height() * scalar_)); std::cout << "Zoom reset to 100%" << std::endl; } void PolygonCanvas::StartNewPolygon(int class_id, QColor color) { - current_polygon_.class_id = class_id; - current_polygon_.color = color; - current_polygon_.points.clear(); - current_polygon_.is_selected = false; + if (class_id >= class_colors_.size()) + { + class_colors_.resize(class_id + 1, Qt::red); + } + class_colors_[class_id] = color; + SetDrawingMode(class_id); std::cout << "Started new polygon with class_id: " << class_id << std::endl; } void PolygonCanvas::FinishCurrentPolygon() { - if (current_polygon_.points.size() >= 3) + if (annotation_set_ == nullptr) return; + const segcore::Segment* ip = annotation_set_->GetInProgress(); + if (ip != nullptr && static_cast(ip->points.size()) >= 3) { - SaveState(); // Save state before adding polygon - polygons_.push_back(current_polygon_); - - // Keep class_id and color for next polygon, only clear points - int saved_class_id = current_polygon_.class_id; - QColor saved_color = current_polygon_.color; - current_polygon_.points.clear(); - current_polygon_.class_id = saved_class_id; - current_polygon_.color = saved_color; - current_polygon_.is_selected = true; - - emit PolygonsChanged(); - current_polygon_.class_id = -1; // Exit drawing mode + segcore::SegmentId committed_id = ip->id; + annotation_set_->CommitSegment(committed_id); + annotation_set_->SelectSegment(committed_id); + drawing_class_id_ = -1; emit CurrentClassChanged(-1); + emit PolygonsChanged(); repaint(); - std::cout << "Polygon finished and saved. Click to start next polygon or press Esc to stop." - << std::endl; + std::cout << "Polygon finished and saved." << std::endl; } else { @@ -140,8 +164,9 @@ void PolygonCanvas::FinishCurrentPolygon() void PolygonCanvas::ClearCurrentPolygon() { - current_polygon_.points.clear(); - current_polygon_.class_id = -1; // Exit drawing mode + if (annotation_set_ == nullptr) return; + annotation_set_->CancelInProgress(); + drawing_class_id_ = -1; emit CurrentClassChanged(-1); repaint(); std::cout << "Drawing cancelled" << std::endl; @@ -149,50 +174,52 @@ void PolygonCanvas::ClearCurrentPolygon() void PolygonCanvas::mouseMoveEvent(QMouseEvent* ev) { - auto pos = ev->pos() / scalar_; - - // Clamp position to image bounds + cursor_pos_ = ev->pos() / scalar_; QPixmap pix = pixmap(); - if (!pix.isNull()) - { - pos = ClampToImageBounds(pos, pix.size()); - } - - active_point_pos_ = pos; + if (!pix.isNull()) cursor_pos_ = ClampToImageBounds(cursor_pos_, pix.size()); + repaint(); } void PolygonCanvas::mousePressEvent(QMouseEvent* ev) { QPoint pos = ev->pos() / scalar_; - - // Clamp position to image bounds QPixmap pix = pixmap(); - if (!pix.isNull()) - { - pos = ClampToImageBounds(pos, pix.size()); - } + if (!pix.isNull()) pos = ClampToImageBounds(pos, pix.size()); - // Check if editing current polygon being drawn - for (const auto& point : current_polygon_.points) + if (annotation_set_ == nullptr) return; + + float tolerance = static_cast(POINT_SELECT_TOLERANCE) / scalar_; + + // Check in-progress segment for drag + const auto* ip = annotation_set_->GetInProgress(); + if (ip != nullptr) { - if (IsPointNearPosition(point, pos, POINT_SELECT_TOLERANCE)) + for (int i = 0; i < static_cast(ip->points.size()); ++i) { - active_point_ = point; - active_point_pos_ = point; - return; + if (IsPointNearPosition(qt_adapter::ToQPoint(ip->points[i]), pos, tolerance)) + { + drag_segment_id_ = ip->id; + drag_point_index_ = i; + return; + } } } - // Check if editing selected polygon - if (selected_polygon_index_ >= 0 && selected_polygon_index_ < polygons_.size()) + // Check selected segment for drag + auto sel_id = annotation_set_->GetSelectedSegmentId(); + if (sel_id != segcore::kInvalidSegmentId) { - for (const auto& point : polygons_[selected_polygon_index_].points) + const auto* seg = annotation_set_->GetSegment(sel_id); + if (seg != nullptr) { - if (IsPointNearPosition(point, pos, POINT_SELECT_TOLERANCE)) + for (int i = 0; i < static_cast(seg->points.size()); ++i) { - active_point_ = point; - active_point_pos_ = point; - return; + if (IsPointNearPosition(qt_adapter::ToQPoint(seg->points[i]), pos, tolerance)) + { + drag_segment_id_ = sel_id; + drag_point_index_ = i; + return; + } } } } @@ -201,75 +228,89 @@ void PolygonCanvas::mousePressEvent(QMouseEvent* ev) void PolygonCanvas::mouseReleaseEvent(QMouseEvent* ev) { QPoint pos = ev->pos() / scalar_; - - // Clamp position to image bounds QPixmap pix = pixmap(); - if (!pix.isNull()) - { - pos = ClampToImageBounds(pos, pix.size()); - } + if (!pix.isNull()) pos = ClampToImageBounds(pos, pix.size()); + pos = ApplySnap(pos); - // Right click finishes current polygon - if (ev->button() == Qt::RightButton) + if (annotation_set_ == nullptr) return; + + // Handle drag end + if (drag_segment_id_ != segcore::kInvalidSegmentId) { - if (!current_polygon_.points.isEmpty()) + bool ctrl = QGuiApplication::keyboardModifiers().testFlag(Qt::ControlModifier); + if (ctrl) { - FinishCurrentPolygon(); - return; + annotation_set_->RemovePoint(drag_segment_id_, drag_point_index_); } - } - - if (!active_point_.isNull()) - { - // Save state before modifying polygon - if (selected_polygon_index_ >= 0 && selected_polygon_index_ < polygons_.size()) + else { - SaveState(); + annotation_set_->MovePoint(drag_segment_id_, drag_point_index_, + qt_adapter::FromQPoint(pos)); } - HandlePointDrag(pos); + drag_segment_id_ = segcore::kInvalidSegmentId; + drag_point_index_ = -1; + emit PolygonsChanged(); + repaint(); + return; } - else - { - bool ctrl_pressed = QGuiApplication::keyboardModifiers().testFlag(Qt::ControlModifier); - // If we have a selected polygon and not pressing Ctrl, add point to end - if (selected_polygon_index_ >= 0 && selected_polygon_index_ < polygons_.size() && !ctrl_pressed) + // Right click: commit in-progress if >= 3 points + if (ev->button() == Qt::RightButton) + { + const segcore::Segment* ip = annotation_set_->GetInProgress(); + if (ip != nullptr && static_cast(ip->points.size()) >= 3) { - SaveState(); - polygons_[selected_polygon_index_].points.push_back(pos); + segcore::SegmentId committed_id = ip->id; + annotation_set_->CommitSegment(committed_id); + annotation_set_->SelectSegment(committed_id); + drawing_class_id_ = -1; + emit CurrentClassChanged(-1); emit PolygonsChanged(); repaint(); - std::cout << "✓ Added point to selected polygon (total: " - << polygons_[selected_polygon_index_].points.size() << " points)" << std::endl; } - // If Ctrl is pressed with selected polygon, insert point on edge - else if (selected_polygon_index_ >= 0 && selected_polygon_index_ < polygons_.size() && - ctrl_pressed) - { - HandlePointInsertion(pos); - } - // If currently drawing, add point to current polygon - else if (!current_polygon_.points.isEmpty()) + return; + } + + bool ctrl = QGuiApplication::keyboardModifiers().testFlag(Qt::ControlModifier); + + // Ctrl+Click on selected segment: insert point on nearest edge + if (ctrl) + { + auto sel_id = annotation_set_->GetSelectedSegmentId(); + if (sel_id != segcore::kInvalidSegmentId) { - current_polygon_.points.push_back(pos); - repaint(); + const segcore::Segment* seg = annotation_set_->GetSegment(sel_id); + if (seg != nullptr && seg->points.size() >= 2) + { + int idx = segcore::InsertionIndex(*seg, qt_adapter::FromQPoint(pos)); + annotation_set_->InsertPoint(sel_id, idx, qt_adapter::FromQPoint(pos)); + emit PolygonsChanged(); + repaint(); + } } - // If polygon mode is active (class_id set) but no points yet, add first point - else if (current_polygon_.class_id >= 0) + return; + } + + // Drawing mode: add point to in-progress + if (drawing_class_id_ >= 0) + { + const segcore::Segment* ip = annotation_set_->GetInProgress(); + segcore::SegmentId id; + if (ip == nullptr) { - current_polygon_.points.push_back(pos); - std::cout << "Added first point to new polygon" << std::endl; + id = annotation_set_->BeginSegment(drawing_class_id_); } else { - // Not drawing and no active polygon - try to select a polygon - SelectPolygon(pos); + id = ip->id; } + annotation_set_->AddPoint(id, qt_adapter::FromQPoint(pos)); + repaint(); + return; } - active_point_ = QPoint(); - active_point_pos_ = QPoint(); - repaint(); + // Not drawing: try to select a polygon + SelectPolygon(pos); } void PolygonCanvas::keyPressEvent(QKeyEvent* ev) @@ -280,7 +321,7 @@ void PolygonCanvas::keyPressEvent(QKeyEvent* ev) } else if (ev->key() == Qt::Key_Escape) { - if (!current_polygon_.points.isEmpty()) + if (annotation_set_ != nullptr && annotation_set_->GetInProgress() != nullptr) { ClearCurrentPolygon(); } @@ -293,19 +334,19 @@ void PolygonCanvas::keyPressEvent(QKeyEvent* ev) { DeleteSelectedPolygon(); } - else if (ev->matches(QKeySequence::Undo)) // Ctrl+Z + else if (ev->matches(QKeySequence::Undo)) { Undo(); } - else if (ev->matches(QKeySequence::Redo)) // Ctrl+Y or Ctrl+Shift+Z + else if (ev->matches(QKeySequence::Redo)) { Redo(); } - else if (ev->matches(QKeySequence::Copy)) // Ctrl+C + else if (ev->matches(QKeySequence::Copy)) { CopySelectedPolygon(); } - else if (ev->matches(QKeySequence::Paste)) // Ctrl+V + else if (ev->matches(QKeySequence::Paste)) { PastePolygon(); } @@ -318,112 +359,48 @@ void PolygonCanvas::keyPressEvent(QKeyEvent* ev) void PolygonCanvas::paintEvent(QPaintEvent*) { QPainter painter(this); - DrawImage(painter); - - // Draw all completed polygons - for (const auto& polygon : polygons_) - { - if (polygon.points.size() < 2) - continue; - - // Visual feedback for selection - QColor drawColor = polygon.color; - int lineWidth = LINE_WIDTH; - - if (polygon.is_selected) - { - lineWidth = 2; - drawColor = drawColor.lighter(120); // Brighter color - } - else - { - drawColor.setAlpha(180); // Semi-transparent for unselected - } - - QPen pen(drawColor, lineWidth); - painter.setPen(pen); - - // Draw points - for (const auto& point : polygon.points) - { - QPoint scaledPoint = point * scalar_; - painter.fillRect(scaledPoint.x() - POINT_DRAW_SIZE / 2, scaledPoint.y() - POINT_DRAW_SIZE / 2, - POINT_DRAW_SIZE, POINT_DRAW_SIZE, drawColor); - } - - // Draw segments - for (int i = 1; i < polygon.points.size(); ++i) - { - painter.drawLine(polygon.points[i - 1] * scalar_, polygon.points[i] * scalar_); - } - - // Draw closing segment - painter.setPen(QPen(drawColor.darker(120), lineWidth)); - painter.drawLine(polygon.points[0] * scalar_, - polygon.points[polygon.points.size() - 1] * scalar_); - } - - // Draw current polygon being edited - DrawPoints(painter); - DrawSegments(painter); - DrawClosingSegment(painter); + DrawEdgeOverlay(painter); + DrawCompletedSegments(painter); + DrawInProgressSegment(painter); } QSize PolygonCanvas::GetOriginalImageSize() const { - if (!pixmap().isNull()) - { - return pixmap().size(); - } + if (!pixmap().isNull()) return pixmap().size(); return QSize(0, 0); } -void PolygonCanvas::ExportAnnotations(const QString& filename, int class_id) +void PolygonCanvas::ExportAnnotations(const QString& filename, int) { - (void)class_id; - QFile file(filename); - if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) - { - std::cerr << "Cannot open file for writing: " << filename.toStdString() << std::endl; - return; - } - - QTextStream out(&file); + if (annotation_set_ == nullptr) return; QSize img_size = GetOriginalImageSize(); - if (img_size.width() == 0 || img_size.height() == 0) - { - std::cerr << "Invalid image size" << std::endl; - return; - } + if (img_size.width() == 0 || img_size.height() == 0) return; - float img_width = static_cast(img_size.width()); - float img_height = static_cast(img_size.height()); + std::string text = segcore::SegmentsToNormalizedFormat( + annotation_set_->GetSegments(), img_size.width(), img_size.height()); - // Export all polygons - for (const auto& polygon : polygons_) + QFile file(filename); + if (file.open(QIODevice::WriteOnly | QIODevice::Text)) { - out << polygon.class_id; - - for (const auto& point : polygon.points) - { - float normalized_x = qBound(0.0f, point.x() / img_width, 1.0f); - float normalized_y = qBound(0.0f, point.y() / img_height, 1.0f); - out << " " << normalized_x << " " << normalized_y; - } - - out << "\n"; + file.write(QByteArray::fromStdString(text)); + } + else + { + std::cerr << "Cannot open file for writing: " << filename.toStdString() << std::endl; } - - file.close(); std::cout << "Annotations exported to: " << filename.toStdString() << std::endl; - std::cout << "Polygons: " << polygons_.size() << std::endl; } void PolygonCanvas::LoadAnnotations(const QString& filepath, const QVector& class_colors) { + if (annotation_set_ == nullptr) return; + + QSize img_size = GetOriginalImageSize(); + if (img_size.width() == 0 || img_size.height() == 0) return; + QFile file(filepath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { @@ -431,497 +408,291 @@ void PolygonCanvas::LoadAnnotations(const QString& filepath, const QVector(img_size.width()); - float img_height = static_cast(img_size.height()); + class_colors_ = class_colors; + annotation_set_->ClearAll(); - polygons_.clear(); - QTextStream in(&file); + std::string text = file.readAll().toStdString(); + auto segments = segcore::NormalizedFormatToSegments(text, img_size.width(), img_size.height()); - while (!in.atEnd()) + for (const auto& seg : segments) { - QString line = in.readLine().trimmed(); - if (line.isEmpty()) - { - continue; - } - - QStringList parts = line.split(' ', Qt::SkipEmptyParts); - if (parts.size() < 7) // class_id + at least 3 points (6 coordinates) + auto id = annotation_set_->BeginSegment(seg.class_id); + for (const auto& p : seg.points) { - std::cerr << "Invalid line: " << line.toStdString() << std::endl; - continue; - } - - bool ok; - int class_id = parts[0].toInt(&ok); - if (!ok) - { - std::cerr << "Invalid class_id: " << parts[0].toStdString() << std::endl; - continue; - } - - Polygon polygon; - polygon.class_id = class_id; - polygon.color = (class_id < class_colors.size()) ? class_colors[class_id] : Qt::red; - polygon.is_selected = false; - - // Parse coordinate pairs - for (int i = 1; i < parts.size() - 1; i += 2) - { - float x_norm = parts[i].toFloat(&ok); - if (!ok) - continue; - - float y_norm = parts[i + 1].toFloat(&ok); - if (!ok) - continue; - - // Denormalize coordinates - int x_pixel = static_cast(x_norm * img_width); - int y_pixel = static_cast(y_norm * img_height); - - polygon.points.append(QPoint(x_pixel, y_pixel)); - } - - if (polygon.points.size() >= 3) - { - polygons_.append(polygon); + annotation_set_->AddPoint(id, p); } + annotation_set_->CommitSegment(id); } - file.close(); update(); - - std::cout << "Loaded " << polygons_.size() << " polygons from: " << filepath.toStdString() + std::cout << "Loaded " << segments.size() << " polygons from: " << filepath.toStdString() << std::endl; } void PolygonCanvas::ClearAllPolygons() { - if (!polygons_.isEmpty()) - { - SaveState(); // Save state before clearing - } - polygons_.clear(); - current_polygon_.points.clear(); - selected_polygon_index_ = -1; + if (annotation_set_ != nullptr) annotation_set_->ClearAll(); + drawing_class_id_ = -1; emit PolygonsChanged(); update(); } void PolygonCanvas::AddPolygonFromPlugin(const QVector& points, int class_id, - const QColor& color) + const QColor&) { - if (points.size() < 3) + if (points.size() < 3 || annotation_set_ == nullptr) return; + auto id = annotation_set_->BeginSegment(class_id); + for (const auto& p : points) { - return; // Invalid polygon + annotation_set_->AddPoint(id, qt_adapter::FromQPoint(p)); } - - Polygon polygon; - polygon.class_id = class_id; - polygon.points = points; - polygon.color = color; - polygon.is_selected = false; - - polygons_.append(polygon); + annotation_set_->CommitSegment(id); emit PolygonsChanged(); - update(); - - std::cout << "Added plugin polygon with " << points.size() << " points (class_id=" << class_id - << ")" << std::endl; + repaint(); + std::cout << "Added plugin polygon with " << points.size() + << " points (class_id=" << class_id << ")" << std::endl; } void PolygonCanvas::SelectPolygon(const QPoint& pos) { - // Check from last to first (top to bottom in Z-order) - for (int i = polygons_.size() - 1; i >= 0; --i) + if (annotation_set_ == nullptr) return; + auto seg_id = + segcore::HitTestSegment(annotation_set_->GetSegments(), qt_adapter::FromQPoint(pos)); + annotation_set_->DeselectAll(); + if (seg_id != segcore::kInvalidSegmentId) { - const auto& polygon = polygons_[i]; - if (polygon.points.size() < 3) - continue; - - // Point-in-polygon algorithm (ray casting) - bool inside = false; - int j = polygon.points.size() - 1; - - for (int k = 0; k < polygon.points.size(); ++k) - { - const QPoint& vi = polygon.points[k]; - const QPoint& vj = polygon.points[j]; - - if (((vi.y() > pos.y()) != (vj.y() > pos.y())) && - (pos.x() < (vj.x() - vi.x()) * (pos.y() - vi.y()) / (vj.y() - vi.y()) + vi.x())) - { - inside = !inside; - } - j = k; - } - - if (inside) - { - // Deselect all first - for (auto& p : polygons_) - { - p.is_selected = false; - } - - // Select this polygon - polygons_[i].is_selected = true; - selected_polygon_index_ = i; - update(); - std::cout << "Selected polygon " << i << " (class_id=" << polygons_[i].class_id << ")" - << std::endl; - return; - } + annotation_set_->SelectSegment(seg_id); + std::cout << "Selected segment " << seg_id << std::endl; } - - // No polygon selected - deselect all - DeselectAll(); + else + { + std::cout << "Deselected all" << std::endl; + } + repaint(); } void PolygonCanvas::DeselectAll() { - for (auto& polygon : polygons_) - { - polygon.is_selected = false; - } - selected_polygon_index_ = -1; - update(); - std::cout << "Deselected all polygons" << std::endl; + if (annotation_set_ != nullptr) annotation_set_->DeselectAll(); + repaint(); } void PolygonCanvas::DeleteSelectedPolygon() { - if (selected_polygon_index_ >= 0 && selected_polygon_index_ < polygons_.size()) + if (annotation_set_ == nullptr) return; + auto sel_id = annotation_set_->GetSelectedSegmentId(); + if (sel_id != segcore::kInvalidSegmentId) { - SaveState(); // Save state before deleting - std::cout << "Deleting polygon " << selected_polygon_index_ << std::endl; - polygons_.removeAt(selected_polygon_index_); - selected_polygon_index_ = -1; + annotation_set_->DeleteSegment(sel_id); emit PolygonsChanged(); update(); } } -// Private helper methods - -bool PolygonCanvas::IsPointNearPosition(const QPoint& point, const QPoint& position, - int tolerance) const +int PolygonCanvas::GetSelectedPolygonIndex() const { - return qAbs(point.x() - position.x()) <= tolerance && qAbs(point.y() - position.y()) <= tolerance; + if (annotation_set_ == nullptr) return -1; + return (annotation_set_->GetSelectedSegmentId() != segcore::kInvalidSegmentId) ? 0 : -1; } -int PolygonCanvas::FindNearestSegmentIndex(const QPoint& position) const +bool PolygonCanvas::HasAnnotations() const { - float min_distance = std::numeric_limits::max(); - int insert_index = -1; - - for (int i = 0; i < current_polygon_.points.size(); ++i) - { - int next_i = (i + 1) % current_polygon_.points.size(); - float distance = DistanceFromPointToSegment(position, current_polygon_.points[i], - current_polygon_.points[next_i]); - - if (distance < min_distance) - { - min_distance = distance; - insert_index = next_i; - } - } - - return insert_index; + return annotation_set_ != nullptr && !annotation_set_->GetSegments().empty(); } -void PolygonCanvas::HandlePointDrag(const QPoint& position) +int PolygonCanvas::GetAnnotationCount() const { - QPoint clamped_pos = position; - - // Clamp position to image bounds - QPixmap pix = pixmap(); - if (!pix.isNull()) - { - clamped_pos = ClampToImageBounds(position, pix.size()); - } - - // Try editing current polygon first - for (int i = 0; i < current_polygon_.points.size(); ++i) - { - if (current_polygon_.points[i] == active_point_) - { - if (QGuiApplication::keyboardModifiers().testFlag(Qt::ControlModifier)) - { - current_polygon_.points.removeAt(i); - } - else - { - current_polygon_.points[i] = position; - } - return; - } - } - - // Try editing selected polygon - if (selected_polygon_index_ >= 0 && selected_polygon_index_ < polygons_.size()) - { - auto& polygon = polygons_[selected_polygon_index_]; - for (int i = 0; i < polygon.points.size(); ++i) - { - if (polygon.points[i] == active_point_) - { - if (QGuiApplication::keyboardModifiers().testFlag(Qt::ControlModifier)) - { - polygon.points.removeAt(i); - std::cout << "Removed point from selected polygon" << std::endl; - } - else - { - polygon.points[i] = position; - } - emit PolygonsChanged(); - return; - } - } - } + if (annotation_set_ == nullptr) return 0; + return static_cast(annotation_set_->GetSegments().size()); } -void PolygonCanvas::HandlePointInsertion(const QPoint& position) +void PolygonCanvas::Undo() { - QPoint clamped_pos = position; - - // Clamp position to image bounds - QPixmap pix = pixmap(); - if (!pix.isNull()) - { - clamped_pos = ClampToImageBounds(position, pix.size()); - } - - bool ctrl_pressed = QGuiApplication::keyboardModifiers().testFlag(Qt::ControlModifier); - - // If Ctrl is pressed and we have a selected polygon, try to insert point - if (ctrl_pressed && selected_polygon_index_ >= 0 && selected_polygon_index_ < polygons_.size()) - { - auto& polygon = polygons_[selected_polygon_index_]; - if (polygon.points.size() > 1) - { - // Find nearest segment in selected polygon - float min_distance = std::numeric_limits::max(); - int insert_index = -1; - - for (int i = 0; i < polygon.points.size(); ++i) - { - int next_i = (i + 1) % polygon.points.size(); - float distance = - DistanceFromPointToSegment(clamped_pos, polygon.points[i], polygon.points[next_i]); - - if (distance < min_distance) - { - min_distance = distance; - insert_index = next_i; - } - } - - if (insert_index != -1 && min_distance < 10.0f) - { - SaveState(); // Save state before inserting point - polygon.points.insert(polygon.points.begin() + insert_index, clamped_pos); - emit PolygonsChanged(); - repaint(); - std::cout << "✓ Inserted point at index " << insert_index << std::endl; - return; - } - else - { - std::cout << "✗ Click closer to polygon edge to insert point (Ctrl+Click)" << std::endl; - return; - } - } - } - - // Otherwise add to current polygon - if (ctrl_pressed && current_polygon_.points.size() > 1) - { - int insert_index = FindNearestSegmentIndex(clamped_pos); - if (insert_index != -1) - { - current_polygon_.points.insert(current_polygon_.points.begin() + insert_index, clamped_pos); - std::cout << "Inserted at index: " << insert_index << std::endl; - } - } - else - { - current_polygon_.points.push_back(clamped_pos); - } + if (annotation_set_ == nullptr) return; + annotation_set_->Undo(); + emit PolygonsChanged(); + repaint(); } -void PolygonCanvas::DrawImage(QPainter& painter) +void PolygonCanvas::Redo() { - QPixmap pix = pixmap(); - if (!pix.isNull()) - { - pix = pix.scaled(pix.width() * scalar_, pix.height() * scalar_); - painter.drawPixmap(0, 0, pix); - } + if (annotation_set_ == nullptr) return; + annotation_set_->Redo(); + emit PolygonsChanged(); + repaint(); } -void PolygonCanvas::DrawPoints(QPainter& painter) +bool PolygonCanvas::CanUndo() const { - QPen pen(current_polygon_.color, POINT_DRAW_SIZE); - painter.setPen(pen); - - for (const auto& point : current_polygon_.points) - { - QPoint draw_pos = (!active_point_.isNull() && point == active_point_) - ? active_point_pos_ * scalar_ - : point * scalar_; - painter.drawPoint(draw_pos); - } + return annotation_set_ != nullptr && annotation_set_->CanUndo(); } -void PolygonCanvas::DrawSegments(QPainter& painter) +bool PolygonCanvas::CanRedo() const { - if (current_polygon_.points.size() < 2) - return; - - QPen pen(current_polygon_.color, LINE_WIDTH); - painter.setPen(pen); + return annotation_set_ != nullptr && annotation_set_->CanRedo(); +} - for (int i = 1; i < current_polygon_.points.size(); ++i) +void PolygonCanvas::CopySelectedPolygon() +{ + if (annotation_set_ == nullptr) return; + auto sel_id = annotation_set_->GetSelectedSegmentId(); + if (sel_id != segcore::kInvalidSegmentId) { - QPoint prev = current_polygon_.points[i - 1]; - QPoint curr = current_polygon_.points[i]; - - if (curr == active_point_) - curr = active_point_pos_; - if (prev == active_point_) - prev = active_point_pos_; - - painter.drawLine(prev * scalar_, curr * scalar_); + annotation_set_->CopySegment(sel_id); } } -void PolygonCanvas::DrawClosingSegment(QPainter& painter) +void PolygonCanvas::PastePolygon() { - if (current_polygon_.points.size() < 2) - return; - - QPen pen(current_polygon_.color.darker(), LINE_WIDTH); - painter.setPen(pen); - - QPoint first = current_polygon_.points[0]; - QPoint last = current_polygon_.points[current_polygon_.points.size() - 1]; - - if (first == active_point_) - first = active_point_pos_; - if (last == active_point_) - last = active_point_pos_; - - painter.drawLine(first * scalar_, last * scalar_); + if (annotation_set_ == nullptr) return; + segcore::SegmentId pasted_id = annotation_set_->PasteSegment(); + if (pasted_id != segcore::kInvalidSegmentId) + annotation_set_->SelectSegment(pasted_id); + emit PolygonsChanged(); + repaint(); } -// ============================================================================ -// Undo/Redo System -// ============================================================================ - -void PolygonCanvas::SaveState() +bool PolygonCanvas::HasClipboard() const { - // Save current state to undo stack - undo_stack_.push(polygons_); - - // Limit stack size - if (undo_stack_.size() > MAX_UNDO_HISTORY) - { - undo_stack_.removeFirst(); - } - - // Clear redo stack when new action is performed - ClearRedoStack(); + return annotation_set_ != nullptr && annotation_set_->HasClipboard(); } -void PolygonCanvas::ClearRedoStack() +bool PolygonCanvas::IsPointNearPosition(const QPoint& point, const QPoint& position, + float tolerance) const { - redo_stack_.clear(); + float dx = static_cast(point.x() - position.x()); + float dy = static_cast(point.y() - position.y()); + return dx * dx + dy * dy <= tolerance * tolerance; } -void PolygonCanvas::Undo() +void PolygonCanvas::DrawImage(QPainter& painter) { - if (undo_stack_.isEmpty()) + QPixmap pix = pixmap(); + if (pix.isNull()) return; + if (edge_map_only_) { - return; + painter.fillRect(0, 0, static_cast(pix.width() * scalar_), + static_cast(pix.height() * scalar_), Qt::black); + } + else + { + pix = pix.scaled(static_cast(pix.width() * scalar_), + static_cast(pix.height() * scalar_)); + painter.drawPixmap(0, 0, pix); } - - // Save current state to redo stack - redo_stack_.push(polygons_); - - // Restore previous state - polygons_ = undo_stack_.pop(); - - // Clear selection - selected_polygon_index_ = -1; - - emit PolygonsChanged(); - repaint(); } -void PolygonCanvas::Redo() +void PolygonCanvas::DrawEdgeOverlay(QPainter& painter) { - if (redo_stack_.isEmpty()) + if (edges_.overlay.isNull()) return; + + if (edge_map_only_) { + const int w = static_cast(edges_.width * scalar_); + const int h = static_cast(edges_.height * scalar_); + QImage bw(edges_.width, edges_.height, QImage::Format_RGB32); + bw.fill(Qt::black); + for (int y = 0; y < edges_.height; ++y) + { + QRgb* line = reinterpret_cast(bw.scanLine(y)); + for (int x = 0; x < edges_.width; ++x) + { + if (edges_.edge_map[static_cast(y * edges_.width + x)]) + line[x] = qRgb(255, 255, 255); + } + } + painter.drawImage(0, 0, bw.scaled(w, h, Qt::IgnoreAspectRatio, Qt::FastTransformation)); return; } - // Save current state to undo stack - undo_stack_.push(polygons_); - - // Restore next state - polygons_ = redo_stack_.pop(); - - // Clear selection - selected_polygon_index_ = -1; - - emit PolygonsChanged(); - repaint(); + if (!snap_to_edges_) return; + const QImage scaled = edges_.overlay.scaled( + static_cast(edges_.overlay.width() * scalar_), + static_cast(edges_.overlay.height() * scalar_), + Qt::IgnoreAspectRatio, Qt::FastTransformation); + painter.drawImage(0, 0, scaled); } -// ============================================================================ -// Copy/Paste System -// ============================================================================ - -void PolygonCanvas::CopySelectedPolygon() +void PolygonCanvas::DrawCompletedSegments(QPainter& painter) { - if (selected_polygon_index_ >= 0 && selected_polygon_index_ < polygons_.size()) + if (annotation_set_ == nullptr) return; + for (const auto& seg : annotation_set_->GetSegments()) { - clipboard_polygon_ = polygons_[selected_polygon_index_]; - std::cout << "Polygon copied to clipboard (" << clipboard_polygon_.points.size() << " points)" - << std::endl; + if (seg.points.size() < 2) continue; + QColor color = class_colors_.value(seg.class_id, Qt::red); + int line_width = LINE_WIDTH; + if (seg.selected) + { + line_width = 2; + color = color.lighter(120); + } + else + { + color.setAlpha(180); + } + QPen pen(color, line_width); + painter.setPen(pen); + for (const auto& p : seg.points) + { + QPoint sp = qt_adapter::ToQPoint(p) * scalar_; + painter.fillRect(sp.x() - POINT_DRAW_SIZE / 2, sp.y() - POINT_DRAW_SIZE / 2, + POINT_DRAW_SIZE, POINT_DRAW_SIZE, color); + } + for (int i = 1; i < static_cast(seg.points.size()); ++i) + { + painter.drawLine(qt_adapter::ToQPoint(seg.points[static_cast(i - 1)]) * scalar_, + qt_adapter::ToQPoint(seg.points[static_cast(i)]) * scalar_); + } + painter.setPen(QPen(color.darker(120), line_width)); + painter.drawLine(qt_adapter::ToQPoint(seg.points.front()) * scalar_, + qt_adapter::ToQPoint(seg.points.back()) * scalar_); } } -void PolygonCanvas::PastePolygon() +void PolygonCanvas::DrawInProgressSegment(QPainter& painter) { - if (clipboard_polygon_.points.isEmpty()) - { - std::cout << "Clipboard is empty" << std::endl; - return; - } - - // Save state for undo - SaveState(); + if (annotation_set_ == nullptr) return; + const segcore::Segment* ip = annotation_set_->GetInProgress(); + if (ip == nullptr) return; - // Create new polygon from clipboard (exact copy, no offset) - Polygon new_polygon = clipboard_polygon_; - new_polygon.is_selected = false; - - polygons_.push_back(new_polygon); + QColor color = class_colors_.value(ip->class_id, Qt::red); + QPen pen(color, POINT_DRAW_SIZE); + painter.setPen(pen); - std::cout << "Polygon pasted (" << new_polygon.points.size() << " points)" << std::endl; + for (int i = 0; i < static_cast(ip->points.size()); ++i) + { + QPoint draw_pos = + (drag_segment_id_ == ip->id && drag_point_index_ == i) + ? cursor_pos_ * static_cast(scalar_) + : qt_adapter::ToQPoint(ip->points[static_cast(i)]) * scalar_; + painter.drawPoint(draw_pos); + } - emit PolygonsChanged(); - repaint(); + if (ip->points.size() < 2) return; + + painter.setPen(QPen(color, LINE_WIDTH)); + for (int i = 1; i < static_cast(ip->points.size()); ++i) + { + QPoint prev = + (drag_segment_id_ == ip->id && drag_point_index_ == i - 1) + ? cursor_pos_ * static_cast(scalar_) + : qt_adapter::ToQPoint(ip->points[static_cast(i - 1)]) * scalar_; + QPoint curr = + (drag_segment_id_ == ip->id && drag_point_index_ == i) + ? cursor_pos_ * static_cast(scalar_) + : qt_adapter::ToQPoint(ip->points[static_cast(i)]) * scalar_; + painter.drawLine(prev, curr); + } + + painter.setPen(QPen(color.darker(), LINE_WIDTH)); + QPoint first = + (drag_segment_id_ == ip->id && drag_point_index_ == 0) + ? cursor_pos_ * static_cast(scalar_) + : qt_adapter::ToQPoint(ip->points.front()) * scalar_; + QPoint last_pt = + (drag_segment_id_ == ip->id && + drag_point_index_ == static_cast(ip->points.size()) - 1) + ? cursor_pos_ * static_cast(scalar_) + : qt_adapter::ToQPoint(ip->points.back()) * scalar_; + painter.drawLine(first, last_pt); } diff --git a/src/polygoncanvas.h b/src/polygoncanvas.h index 371f792..45f2f5b 100644 --- a/src/polygoncanvas.h +++ b/src/polygoncanvas.h @@ -2,18 +2,16 @@ #define POLYGONCANVAS_H #include +#include #include #include #include -#include -struct Polygon -{ - int class_id = 0; - QVector points; - QColor color = Qt::red; - bool is_selected = false; -}; +#include "edgedetector.h" +#include +#include +#include +#include class PolygonCanvas : public QLabel { @@ -26,7 +24,6 @@ class PolygonCanvas : public QLabel void Decrease(); void ResetZoom(); - QVector GetPolygons() const { return polygons_; } QSize GetOriginalImageSize() const; void ExportAnnotations(const QString& filename, int class_id = 0); void LoadAnnotations(const QString& filepath, const QVector& class_colors); @@ -35,6 +32,7 @@ class PolygonCanvas : public QLabel void StartNewPolygon(int class_id = 0, QColor color = Qt::red); void FinishCurrentPolygon(); void ClearCurrentPolygon(); + void SetDrawingMode(int class_id); // Plugin integration void AddPolygonFromPlugin(const QVector& points, int class_id, const QColor& color); @@ -44,18 +42,34 @@ class PolygonCanvas : public QLabel void SelectPolygon(const QPoint& pos); void DeselectAll(); void DeleteSelectedPolygon(); - int GetSelectedPolygonIndex() const { return selected_polygon_index_; } + int GetSelectedPolygonIndex() const; + + // Annotation set injection + void SetAnnotationSet(segcore::IAnnotationSet* set); + segcore::IAnnotationSet* GetAnnotationSet() const { return annotation_set_; } + void LoadArtifact(const segcore::Artifact& artifact); + void SetClassColors(const QVector& colors); + + // Annotation queries + bool HasAnnotations() const; + int GetAnnotationCount() const; // Undo/Redo void Undo(); void Redo(); - bool CanUndo() const { return !undo_stack_.isEmpty(); } - bool CanRedo() const { return !redo_stack_.isEmpty(); } + bool CanUndo() const; + bool CanRedo() const; // Copy/Paste void CopySelectedPolygon(); void PastePolygon(); - bool HasClipboard() const { return clipboard_polygon_.points.size() > 0; } + bool HasClipboard() const; + + // Edge snap + void SetSnapToEdges(bool enabled); + void SetEdgeMapOnly(bool enabled); + void ComputeEdges(); + void setPixmap(const QPixmap& pixmap); signals: void PolygonsChanged(); @@ -69,40 +83,30 @@ class PolygonCanvas : public QLabel void keyPressEvent(QKeyEvent* ev) override; private: - // Helper methods - bool IsPointNearPosition(const QPoint& point, const QPoint& position, int tolerance) const; - int FindNearestSegmentIndex(const QPoint& position) const; - void HandlePointDrag(const QPoint& position); - void HandlePointInsertion(const QPoint& position); + bool IsPointNearPosition(const QPoint& point, const QPoint& position, float tolerance) const; void DrawImage(QPainter& painter); - void DrawPoints(QPainter& painter); - void DrawSegments(QPainter& painter); - void DrawClosingSegment(QPainter& painter); + void DrawEdgeOverlay(QPainter& painter); + void DrawCompletedSegments(QPainter& painter); + void DrawInProgressSegment(QPainter& painter); + QPoint ApplySnap(const QPoint& pos) const; - // Constants static constexpr int POINT_SELECT_TOLERANCE = 5; static constexpr int POINT_DRAW_SIZE = 5; static constexpr int LINE_WIDTH = 1; - // State management helpers - void SaveState(); - void ClearRedoStack(); - - // Member variables - QVector polygons_; - Polygon current_polygon_; - int selected_polygon_index_ = -1; - QPoint active_point_; - QPoint active_point_pos_; - float scalar_ = 1.0; - - // Undo/Redo stacks - QStack> undo_stack_; - QStack> redo_stack_; - static constexpr int MAX_UNDO_HISTORY = 50; - - // Clipboard - Polygon clipboard_polygon_; + segcore::IAnnotationSet* annotation_set_ = nullptr; + polyseg::Frame display_frame_; + segcore::SegmentId drag_segment_id_ = segcore::kInvalidSegmentId; + int drag_point_index_ = -1; + QVector class_colors_; + int drawing_class_id_ = -1; + + QPoint cursor_pos_; + float scalar_ = 1.0f; + + EdgeDetector::Result edges_; + bool snap_to_edges_ = false; + bool edge_map_only_ = false; }; #endif // POLYGONCANVAS_H diff --git a/src/qtadapter.cpp b/src/qtadapter.cpp new file mode 100644 index 0000000..f55c24f --- /dev/null +++ b/src/qtadapter.cpp @@ -0,0 +1,23 @@ +#include "qtadapter.h" + +namespace qt_adapter { + +polyseg::Frame QImageToFrame(const QImage& img) +{ + QImage rgb = img.convertToFormat(QImage::Format_RGB888); + int w = rgb.width(); + int h = rgb.height(); + polyseg::Frame frame(static_cast(w), static_cast(h)); + segcore::Rgb24* dst = frame.data(); + for (int y = 0; y < h; ++y) + { + const uchar* row = rgb.scanLine(y); + for (int x = 0; x < w; ++x) + { + dst[y * w + x] = {row[x * 3], row[x * 3 + 1], row[x * 3 + 2]}; + } + } + return frame; +} + +} // namespace qt_adapter diff --git a/src/qtadapter.h b/src/qtadapter.h new file mode 100644 index 0000000..1695043 --- /dev/null +++ b/src/qtadapter.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace qt_adapter { + +inline QPoint ToQPoint(segcore::Point2D p) +{ + return {p.x, p.y}; +} + +inline segcore::Point2D FromQPoint(QPoint p) +{ + return {p.x(), p.y()}; +} + +// Zero-copy: QImage borrows Frame's data pointer — Frame must outlive QImage +inline QImage FrameToQImage(const polyseg::Frame& f) +{ + return QImage(reinterpret_cast(f.data()), f.width(), f.height(), f.width() * 3, + QImage::Format_RGB888); +} + +// Copies pixels from QImage into a new Frame +polyseg::Frame QImageToFrame(const QImage& img); + +} // namespace qt_adapter diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..52fff13 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,76 @@ +# Unit tests for PolySeg + +set(POLYSEG_TEST_SOURCES + projectconfig_test.cpp + metadataimporter_test.cpp +) + +add_executable(PolySeg_tests ${POLYSEG_TEST_SOURCES}) + +target_link_libraries(PolySeg_tests PRIVATE + gtest_main + PolySeg_lib + Qt6::Core + Qt6::Gui + Qt6::Widgets + Qt6::Network +) + +target_include_directories(PolySeg_tests PRIVATE + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_SOURCE_DIR}/external/googletest/googletest/include +) + +# Apply coverage flags if enabled +if(ENABLE_COVERAGE AND (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")) + target_compile_options(PolySeg_tests PRIVATE ${COVERAGE_FLAGS}) + target_link_libraries(PolySeg_tests PRIVATE ${COVERAGE_FLAGS}) +endif() + +add_test(NAME PolySeg_unit_tests COMMAND PolySeg_tests) + +add_executable(segcore_tests + segcore/history_test.cpp + segcore/types_test.cpp + segcore/geometry_test.cpp + segcore/normalized_format_serializer_test.cpp + segcore/display_test.cpp + segcore/annotation_set_test.cpp + segcore/project_test.cpp +) + +target_link_libraries(segcore_tests PRIVATE gtest_main segcore) + +target_include_directories(segcore_tests PRIVATE + ${CMAKE_SOURCE_DIR}/external/googletest/googletest/include +) + +add_test(NAME segcore_unit_tests COMMAND segcore_tests) + +find_package(Qt6 COMPONENTS Test REQUIRED) + +add_executable(polygoncanvas_tests + polygoncanvas_test.cpp +) + +target_link_libraries(polygoncanvas_tests PRIVATE + gtest + gmock + PolySeg_lib + Qt6::Core + Qt6::Gui + Qt6::Widgets + Qt6::Test +) + +target_include_directories(polygoncanvas_tests PRIVATE + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_SOURCE_DIR}/external/googletest/googletest/include + ${CMAKE_SOURCE_DIR}/external/googletest/googlemock/include +) + +add_test(NAME polygoncanvas_unit_tests COMMAND polygoncanvas_tests) + +set_tests_properties(polygoncanvas_unit_tests PROPERTIES + ENVIRONMENT "QT_QPA_PLATFORM=offscreen" +) diff --git a/tests/main_test.cpp b/tests/metadataimporter_test.cpp similarity index 51% rename from tests/main_test.cpp rename to tests/metadataimporter_test.cpp index cecc92e..83c5f78 100644 --- a/tests/main_test.cpp +++ b/tests/metadataimporter_test.cpp @@ -1,126 +1,94 @@ #include #include #include -#include -#include #include #include #include +#include -// Include headers from the main application -#include "projectconfig.h" -#include "polygoncanvas.h" #include "metadataimporter.h" -// Test fixture for PolySeg tests -class PolySegTest : public ::testing::Test { +class MetadataImporterTest : public ::testing::Test { protected: void SetUp() override { - // Set up any common test data or objects - argc = 1; - argv = new char*[1]; - argv[0] = const_cast("test"); - app = new QCoreApplication(argc, argv); + argc_ = 1; + argv_ = new char*[1]; + argv_[0] = const_cast("test"); + app_ = new QCoreApplication(argc_, argv_); + + temp_dir_ = new QTemporaryDir(); + ASSERT_TRUE(temp_dir_->isValid()); + + CreateTestDataFiles(); } void TearDown() override { - delete app; - delete[] argv; + delete temp_dir_; + delete app_; + delete[] argv_; } - int argc; - char** argv; - QCoreApplication* app; -}; - -// Basic test to ensure Google Test is working -TEST_F(PolySegTest, BasicTest) { - EXPECT_EQ(2 + 2, 4); - EXPECT_TRUE(true); -} - -// Test ProjectConfig functionality -TEST_F(PolySegTest, ProjectConfigDefaultValues) { - ProjectConfig config; - - // Test default values based on actual ProjectConfig implementation - EXPECT_FALSE(config.GetProjectName().isEmpty()); // Should have default name - EXPECT_FALSE(config.GetVersion().isEmpty()); // Should have version - EXPECT_EQ(config.GetClasses().size(), 0); // No classes by default - EXPECT_EQ(config.GetTotalImages(), 0); // No images by default - EXPECT_EQ(config.GetLabeledImages(), 0); // No labeled images by default -} - -// Test basic Qt functionality -TEST_F(PolySegTest, QtStringOperations) { - QString testString = "PolySeg Test"; - EXPECT_EQ(testString.length(), 12); - EXPECT_TRUE(testString.contains("Test")); - EXPECT_TRUE(testString.startsWith("PolySeg")); -} - -// Test QPoint operations (used in polygon functionality) -TEST_F(PolySegTest, QPointOperations) { - QPoint point1(10, 20); - QPoint point2(30, 40); - - EXPECT_EQ(point1.x(), 10); - EXPECT_EQ(point1.y(), 20); - EXPECT_EQ(point2.x(), 30); - EXPECT_EQ(point2.y(), 40); - - // Test vector operations - QVector points; - points.append(point1); - points.append(point2); - - EXPECT_EQ(points.size(), 2); - EXPECT_EQ(points[0], point1); - EXPECT_EQ(points[1], point2); -} - -// Test polygon-related calculations -TEST_F(PolySegTest, DistanceCalculation) { - // This tests a basic distance calculation similar to what's used in PolygonCanvas - auto distance = [](const QPoint& p1, const QPoint& p2) -> double { - int dx = p2.x() - p1.x(); - int dy = p2.y() - p1.y(); - return std::sqrt(dx * dx + dy * dy); - }; - - QPoint p1(0, 0); - QPoint p2(3, 4); - - double dist = distance(p1, p2); - EXPECT_NEAR(dist, 5.0, 0.001); // 3-4-5 triangle -} - -// Test coordinate normalization (used in annotation export) -TEST_F(PolySegTest, CoordinateNormalization) { - // Test coordinate normalization functionality - auto normalize = [](int coord, int max_size) -> double { - return static_cast(coord) / static_cast(max_size); - }; - - int imageWidth = 800; - int imageHeight = 600; + void CreateTestDataFiles() { + // test_data_4x3.txt - valid 4x3 data file + { + QFile file(temp_dir_->filePath("test_data_4x3.txt")); + file.open(QIODevice::WriteOnly | QIODevice::Text); + QTextStream out(&file); + out << "4 3\n"; + out << "10.5 20.0 -5.0 100.0\n"; + out << "15.0 25.0 0.0 95.0\n"; + out << "12.0 30.0 -10.0 80.0\n"; + } + + // test_invalid_header.txt - invalid header format + { + QFile file(temp_dir_->filePath("test_invalid_header.txt")); + file.open(QIODevice::WriteOnly | QIODevice::Text); + QTextStream out(&file); + out << "abc def\n"; + out << "10.5 20.0 -5.0 100.0\n"; + out << "15.0 25.0 0.0 95.0\n"; + } + + // test_non_numeric.txt - contains non-numeric data + { + QFile file(temp_dir_->filePath("test_non_numeric.txt")); + file.open(QIODevice::WriteOnly | QIODevice::Text); + QTextStream out(&file); + out << "4 3\n"; + out << "10.5 20.0 -5.0 100.0\n"; + out << "15.0 abc 0.0 95.0\n"; + out << "12.0 30.0 -10.0 80.0\n"; + } + + // test_wrong_dimensions.txt - row has wrong number of columns + { + QFile file(temp_dir_->filePath("test_wrong_dimensions.txt")); + file.open(QIODevice::WriteOnly | QIODevice::Text); + QTextStream out(&file); + out << "4 3\n"; + out << "10.5 20.0 -5.0 100.0 50.0\n"; + out << "15.0 25.0 0.0 95.0\n"; + out << "12.0 30.0 -10.0 80.0\n"; + } + } - // Test corner coordinates - EXPECT_NEAR(normalize(0, imageWidth), 0.0, 0.001); - EXPECT_NEAR(normalize(imageWidth, imageWidth), 1.0, 0.001); - EXPECT_NEAR(normalize(400, imageWidth), 0.5, 0.001); + QString GetTestFilePath(const QString& filename) { + return temp_dir_->filePath(filename); + } - EXPECT_NEAR(normalize(0, imageHeight), 0.0, 0.001); - EXPECT_NEAR(normalize(imageHeight, imageHeight), 1.0, 0.001); - EXPECT_NEAR(normalize(300, imageHeight), 0.5, 0.001); -} + int argc_; + char** argv_; + QCoreApplication* app_; + QTemporaryDir* temp_dir_; +}; -// MetadataImporter Tests -TEST_F(PolySegTest, MetadataImporter_ValidHeaderParsing) { +TEST_F(MetadataImporterTest, ValidHeaderParsing) { int width, height; MetadataImporter::ImportError error; - bool result = MetadataImporter::ParseHeaderWithError("test_data_4x3.txt", width, height, error); + bool result = MetadataImporter::ParseHeaderWithError( + GetTestFilePath("test_data_4x3.txt"), width, height, error); EXPECT_TRUE(result); EXPECT_EQ(error.type, MetadataImporter::ImportError::NO_ERROR); @@ -128,29 +96,31 @@ TEST_F(PolySegTest, MetadataImporter_ValidHeaderParsing) { EXPECT_EQ(height, 3); } -TEST_F(PolySegTest, MetadataImporter_InvalidHeaderFormat) { +TEST_F(MetadataImporterTest, InvalidHeaderFormat) { int width, height; MetadataImporter::ImportError error; - bool result = MetadataImporter::ParseHeaderWithError("test_invalid_header.txt", width, height, error); + bool result = MetadataImporter::ParseHeaderWithError( + GetTestFilePath("test_invalid_header.txt"), width, height, error); EXPECT_FALSE(result); EXPECT_EQ(error.type, MetadataImporter::ImportError::INVALID_HEADER_FORMAT); EXPECT_FALSE(error.message.isEmpty()); } -TEST_F(PolySegTest, MetadataImporter_FileNotFound) { +TEST_F(MetadataImporterTest, FileNotFound) { int width, height; MetadataImporter::ImportError error; - bool result = MetadataImporter::ParseHeaderWithError("nonexistent_file.txt", width, height, error); + bool result = MetadataImporter::ParseHeaderWithError( + "nonexistent_file.txt", width, height, error); EXPECT_FALSE(result); EXPECT_EQ(error.type, MetadataImporter::ImportError::FILE_NOT_FOUND); EXPECT_FALSE(error.message.isEmpty()); } -TEST_F(PolySegTest, MetadataImporter_ValidDataImport) { +TEST_F(MetadataImporterTest, ValidDataImport) { MetadataImporter::ImportSettings settings; settings.range_min = 0.0; settings.range_max = 100.0; @@ -159,7 +129,8 @@ TEST_F(PolySegTest, MetadataImporter_ValidDataImport) { MetadataImporter::ImportError error; - QImage image = MetadataImporter::ImportMetadataFileWithError("test_data_4x3.txt", settings, error); + QImage image = MetadataImporter::ImportMetadataFileWithError( + GetTestFilePath("test_data_4x3.txt"), settings, error); EXPECT_FALSE(image.isNull()); EXPECT_EQ(error.type, MetadataImporter::ImportError::NO_ERROR); @@ -168,7 +139,7 @@ TEST_F(PolySegTest, MetadataImporter_ValidDataImport) { EXPECT_EQ(image.format(), QImage::Format_Grayscale8); } -TEST_F(PolySegTest, MetadataImporter_WrongDimensions) { +TEST_F(MetadataImporterTest, WrongDimensions) { MetadataImporter::ImportSettings settings; settings.range_min = 0.0; settings.range_max = 100.0; @@ -177,14 +148,15 @@ TEST_F(PolySegTest, MetadataImporter_WrongDimensions) { MetadataImporter::ImportError error; - QImage image = MetadataImporter::ImportMetadataFileWithError("test_wrong_dimensions.txt", settings, error); + QImage image = MetadataImporter::ImportMetadataFileWithError( + GetTestFilePath("test_wrong_dimensions.txt"), settings, error); EXPECT_TRUE(image.isNull()); EXPECT_EQ(error.type, MetadataImporter::ImportError::DATA_MISMATCH); EXPECT_GT(error.row_number, 0); } -TEST_F(PolySegTest, MetadataImporter_NonNumericData) { +TEST_F(MetadataImporterTest, NonNumericData) { MetadataImporter::ImportSettings settings; settings.range_min = 0.0; settings.range_max = 100.0; @@ -193,7 +165,8 @@ TEST_F(PolySegTest, MetadataImporter_NonNumericData) { MetadataImporter::ImportError error; - QImage image = MetadataImporter::ImportMetadataFileWithError("test_non_numeric.txt", settings, error); + QImage image = MetadataImporter::ImportMetadataFileWithError( + GetTestFilePath("test_non_numeric.txt"), settings, error); EXPECT_TRUE(image.isNull()); EXPECT_EQ(error.type, MetadataImporter::ImportError::INVALID_NUMERIC_DATA); @@ -201,7 +174,7 @@ TEST_F(PolySegTest, MetadataImporter_NonNumericData) { EXPECT_FALSE(error.invalid_value.isEmpty()); } -TEST_F(PolySegTest, MetadataImporter_CroppingFunctionality) { +TEST_F(MetadataImporterTest, CroppingFunctionality) { MetadataImporter::ImportSettings settings; settings.range_min = 0.0; settings.range_max = 100.0; @@ -214,7 +187,8 @@ TEST_F(PolySegTest, MetadataImporter_CroppingFunctionality) { MetadataImporter::ImportError error; - QImage image = MetadataImporter::ImportMetadataFileWithError("test_data_4x3.txt", settings, error); + QImage image = MetadataImporter::ImportMetadataFileWithError( + GetTestFilePath("test_data_4x3.txt"), settings, error); EXPECT_FALSE(image.isNull()); EXPECT_EQ(error.type, MetadataImporter::ImportError::NO_ERROR); @@ -222,7 +196,7 @@ TEST_F(PolySegTest, MetadataImporter_CroppingFunctionality) { EXPECT_EQ(image.height(), 1); // crop_end_y - crop_start_y = 2 - 1 = 1 } -TEST_F(PolySegTest, MetadataImporter_CropBoundaryError) { +TEST_F(MetadataImporterTest, CropBoundaryError) { MetadataImporter::ImportSettings settings; settings.range_min = 0.0; settings.range_max = 100.0; @@ -235,15 +209,15 @@ TEST_F(PolySegTest, MetadataImporter_CropBoundaryError) { MetadataImporter::ImportError error; - QImage image = MetadataImporter::ImportMetadataFileWithError("test_data_4x3.txt", settings, error); + QImage image = MetadataImporter::ImportMetadataFileWithError( + GetTestFilePath("test_data_4x3.txt"), settings, error); EXPECT_TRUE(image.isNull()); EXPECT_EQ(error.type, MetadataImporter::ImportError::CROP_BOUNDARY_ERROR); EXPECT_FALSE(error.message.isEmpty()); } -TEST_F(PolySegTest, MetadataImporter_RangeProcessing) { - // Test with different range handling options +TEST_F(MetadataImporterTest, RangeProcessing) { MetadataImporter::ImportSettings settings; settings.range_min = 20.0; settings.range_max = 80.0; @@ -252,7 +226,8 @@ TEST_F(PolySegTest, MetadataImporter_RangeProcessing) { MetadataImporter::ImportError error; - QImage image = MetadataImporter::ImportMetadataFileWithError("test_data_4x3.txt", settings, error); + QImage image = MetadataImporter::ImportMetadataFileWithError( + GetTestFilePath("test_data_4x3.txt"), settings, error); EXPECT_FALSE(image.isNull()); EXPECT_EQ(error.type, MetadataImporter::ImportError::NO_ERROR); @@ -262,13 +237,9 @@ TEST_F(PolySegTest, MetadataImporter_RangeProcessing) { // Test with zero handling settings.out_of_range_handling = MetadataImporter::ImportSettings::SET_TO_ZERO; - QImage image2 = MetadataImporter::ImportMetadataFileWithError("test_data_4x3.txt", settings, error); + QImage image2 = MetadataImporter::ImportMetadataFileWithError( + GetTestFilePath("test_data_4x3.txt"), settings, error); EXPECT_FALSE(image2.isNull()); EXPECT_EQ(error.type, MetadataImporter::ImportError::NO_ERROR); } - -int main(int argc, char **argv) { - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} \ No newline at end of file diff --git a/tests/polygoncanvas_test.cpp b/tests/polygoncanvas_test.cpp new file mode 100644 index 0000000..578ccaa --- /dev/null +++ b/tests/polygoncanvas_test.cpp @@ -0,0 +1,178 @@ +#include +#include + +#include +#include + +#include "polygoncanvas.h" +#include +#include +#include + +// Suppress gmock warnings from -Wshadow and -Wold-style-cast in generated code +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wshadow" +#pragma GCC diagnostic ignored "-Wold-style-cast" + +class MockAnnotationSet : public segcore::IAnnotationSet +{ + public: + MOCK_METHOD(segcore::SegmentId, BeginSegment, (int), (override)); + MOCK_METHOD(void, AddPoint, (segcore::SegmentId, segcore::Point2D), (override)); + MOCK_METHOD(void, InsertPoint, (segcore::SegmentId, int, segcore::Point2D), (override)); + MOCK_METHOD(void, MovePoint, (segcore::SegmentId, int, segcore::Point2D), (override)); + MOCK_METHOD(void, RemovePoint, (segcore::SegmentId, int), (override)); + MOCK_METHOD(bool, CommitSegment, (segcore::SegmentId), (override)); + MOCK_METHOD(void, DeleteSegment, (segcore::SegmentId), (override)); + MOCK_METHOD(void, ClearAll, (), (override)); + MOCK_METHOD(const segcore::Segment*, GetSegment, (segcore::SegmentId), (const, override)); + MOCK_METHOD(const std::vector&, GetSegments, (), (const, override)); + MOCK_METHOD(const segcore::Segment*, GetInProgress, (), (const, override)); + MOCK_METHOD(void, SelectSegment, (segcore::SegmentId), (override)); + MOCK_METHOD(void, DeselectAll, (), (override)); + MOCK_METHOD(segcore::SegmentId, GetSelectedSegmentId, (), (const, override)); + MOCK_METHOD(void, CopySegment, (segcore::SegmentId), (override)); + MOCK_METHOD(segcore::SegmentId, PasteSegment, (), (override)); + MOCK_METHOD(bool, HasClipboard, (), (const, override)); + MOCK_METHOD(void, CancelInProgress, (), (override)); + MOCK_METHOD(void, Undo, (), (override)); + MOCK_METHOD(void, Redo, (), (override)); + MOCK_METHOD(bool, CanUndo, (), (const, override)); + MOCK_METHOD(bool, CanRedo, (), (const, override)); +}; + +#pragma GCC diagnostic pop + +class PolygonCanvasTest : public testing::Test +{ + protected: + void SetUp() override + { + canvas_ = new PolygonCanvas(nullptr); + canvas_->resize(200, 200); + canvas_->show(); + + ON_CALL(mock_, GetSegments()).WillByDefault(testing::ReturnRef(empty_segments_)); + ON_CALL(mock_, GetInProgress()).WillByDefault(testing::Return(nullptr)); + ON_CALL(mock_, GetSelectedSegmentId()) + .WillByDefault(testing::Return(segcore::kInvalidSegmentId)); + ON_CALL(mock_, CanUndo()).WillByDefault(testing::Return(false)); + ON_CALL(mock_, CanRedo()).WillByDefault(testing::Return(false)); + ON_CALL(mock_, HasClipboard()).WillByDefault(testing::Return(false)); + } + + void TearDown() override { delete canvas_; } + + PolygonCanvas* canvas_; + testing::NiceMock mock_; + std::vector empty_segments_; +}; + +TEST_F(PolygonCanvasTest, SetAnnotationSetDelegatesToMock) +{ + canvas_->SetAnnotationSet(&mock_); + EXPECT_CALL(mock_, CanUndo()).WillOnce(testing::Return(true)); + EXPECT_TRUE(canvas_->CanUndo()); +} + +TEST_F(PolygonCanvasTest, MouseReleaseInDrawingModeCallsAddPoint) +{ + canvas_->SetAnnotationSet(&mock_); + canvas_->SetDrawingMode(0); + + EXPECT_CALL(mock_, BeginSegment(0)).WillOnce(testing::Return(segcore::SegmentId{1})); + EXPECT_CALL(mock_, AddPoint(segcore::SegmentId{1}, segcore::Point2D{10, 20})); + + QTest::mouseClick(canvas_, Qt::LeftButton, Qt::NoModifier, QPoint(10, 20)); +} + +TEST_F(PolygonCanvasTest, MouseReleaseRightButtonCallsCommitSegment) +{ + canvas_->SetAnnotationSet(&mock_); + canvas_->SetDrawingMode(0); + + segcore::Segment ip; + ip.id = 1; + ip.points = {{0, 0}, {10, 0}, {5, 10}}; + + ON_CALL(mock_, GetInProgress()).WillByDefault(testing::Return(&ip)); + EXPECT_CALL(mock_, CommitSegment(segcore::SegmentId{1})).WillOnce(testing::Return(true)); + + QTest::mouseClick(canvas_, Qt::RightButton, Qt::NoModifier, QPoint(50, 50)); +} + +TEST_F(PolygonCanvasTest, DragMoveSetsStateThenCallsMovePoint) +{ + canvas_->SetAnnotationSet(&mock_); + + segcore::Segment ip; + ip.id = 1; + ip.points = {{10, 20}, {50, 20}, {30, 50}}; + + ON_CALL(mock_, GetInProgress()).WillByDefault(testing::Return(&ip)); + EXPECT_CALL(mock_, MovePoint(segcore::SegmentId{1}, 0, segcore::Point2D{15, 25})); + + QTest::mousePress(canvas_, Qt::LeftButton, Qt::NoModifier, QPoint(10, 20)); + QTest::mouseRelease(canvas_, Qt::LeftButton, Qt::NoModifier, QPoint(15, 25)); +} + +TEST_F(PolygonCanvasTest, KeyPressCtrlZCallsUndo) +{ + canvas_->SetAnnotationSet(&mock_); + canvas_->setFocus(); + EXPECT_CALL(mock_, Undo()); + QTest::keyClick(canvas_, Qt::Key_Z, Qt::ControlModifier); +} + +TEST_F(PolygonCanvasTest, KeyPressDeleteCallsDeleteSegment) +{ + canvas_->SetAnnotationSet(&mock_); + canvas_->setFocus(); + + ON_CALL(mock_, GetSelectedSegmentId()).WillByDefault(testing::Return(segcore::SegmentId{42})); + EXPECT_CALL(mock_, DeleteSegment(segcore::SegmentId{42})); + + QTest::keyClick(canvas_, Qt::Key_Delete); +} + +TEST_F(PolygonCanvasTest, KeyPressCtrlCCallsCopySegment) +{ + canvas_->SetAnnotationSet(&mock_); + canvas_->setFocus(); + + ON_CALL(mock_, GetSelectedSegmentId()).WillByDefault(testing::Return(segcore::SegmentId{5})); + EXPECT_CALL(mock_, CopySegment(segcore::SegmentId{5})); + + QTest::keyClick(canvas_, Qt::Key_C, Qt::ControlModifier); +} + +TEST_F(PolygonCanvasTest, KeyPressCtrlVCallsPasteSegment) +{ + canvas_->SetAnnotationSet(&mock_); + canvas_->setFocus(); + + EXPECT_CALL(mock_, PasteSegment()).WillOnce(testing::Return(segcore::kInvalidSegmentId)); + + QTest::keyClick(canvas_, Qt::Key_V, Qt::ControlModifier); +} + +TEST_F(PolygonCanvasTest, CanUndoDelegatesToMock) +{ + canvas_->SetAnnotationSet(&mock_); + EXPECT_CALL(mock_, CanUndo()).WillOnce(testing::Return(true)); + EXPECT_TRUE(canvas_->CanUndo()); +} + +TEST_F(PolygonCanvasTest, CanRedoDelegatesToMock) +{ + canvas_->SetAnnotationSet(&mock_); + EXPECT_CALL(mock_, CanRedo()).WillOnce(testing::Return(false)); + EXPECT_FALSE(canvas_->CanRedo()); +} + +int main(int argc, char** argv) +{ + QApplication app(argc, argv); + testing::InitGoogleMock(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/projectconfig_test.cpp b/tests/projectconfig_test.cpp new file mode 100644 index 0000000..e36ebaf --- /dev/null +++ b/tests/projectconfig_test.cpp @@ -0,0 +1,98 @@ +#include +#include +#include +#include +#include +#include + +#include "projectconfig.h" + +class ProjectConfigTest : public ::testing::Test { +protected: + void SetUp() override { + argc_ = 1; + argv_ = new char*[1]; + argv_[0] = const_cast("test"); + app_ = new QCoreApplication(argc_, argv_); + } + + void TearDown() override { + delete app_; + delete[] argv_; + } + + int argc_; + char** argv_; + QCoreApplication* app_; +}; + +TEST_F(ProjectConfigTest, BasicTest) { + EXPECT_EQ(2 + 2, 4); + EXPECT_TRUE(true); +} + +TEST_F(ProjectConfigTest, ProjectConfigDefaultValues) { + ProjectConfig config; + + EXPECT_FALSE(config.GetProjectName().isEmpty()); + EXPECT_FALSE(config.GetVersion().isEmpty()); + EXPECT_EQ(config.GetClasses().size(), 0); + EXPECT_EQ(config.GetTotalImages(), 0); + EXPECT_EQ(config.GetLabeledImages(), 0); +} + +TEST_F(ProjectConfigTest, QtStringOperations) { + QString testString = "PolySeg Test"; + EXPECT_EQ(testString.length(), 12); + EXPECT_TRUE(testString.contains("Test")); + EXPECT_TRUE(testString.startsWith("PolySeg")); +} + +TEST_F(ProjectConfigTest, QPointOperations) { + QPoint point1(10, 20); + QPoint point2(30, 40); + + EXPECT_EQ(point1.x(), 10); + EXPECT_EQ(point1.y(), 20); + EXPECT_EQ(point2.x(), 30); + EXPECT_EQ(point2.y(), 40); + + QVector points; + points.append(point1); + points.append(point2); + + EXPECT_EQ(points.size(), 2); + EXPECT_EQ(points[0], point1); + EXPECT_EQ(points[1], point2); +} + +TEST_F(ProjectConfigTest, DistanceCalculation) { + auto distance = [](const QPoint& p1, const QPoint& p2) -> double { + int dx = p2.x() - p1.x(); + int dy = p2.y() - p1.y(); + return std::sqrt(dx * dx + dy * dy); + }; + + QPoint p1(0, 0); + QPoint p2(3, 4); + + double dist = distance(p1, p2); + EXPECT_NEAR(dist, 5.0, 0.001); // 3-4-5 triangle +} + +TEST_F(ProjectConfigTest, CoordinateNormalization) { + auto normalize = [](int coord, int max_size) -> double { + return static_cast(coord) / static_cast(max_size); + }; + + int imageWidth = 800; + int imageHeight = 600; + + EXPECT_NEAR(normalize(0, imageWidth), 0.0, 0.001); + EXPECT_NEAR(normalize(imageWidth, imageWidth), 1.0, 0.001); + EXPECT_NEAR(normalize(400, imageWidth), 0.5, 0.001); + + EXPECT_NEAR(normalize(0, imageHeight), 0.0, 0.001); + EXPECT_NEAR(normalize(imageHeight, imageHeight), 1.0, 0.001); + EXPECT_NEAR(normalize(300, imageHeight), 0.5, 0.001); +} diff --git a/tests/segcore/annotation_set_test.cpp b/tests/segcore/annotation_set_test.cpp new file mode 100644 index 0000000..38fd973 --- /dev/null +++ b/tests/segcore/annotation_set_test.cpp @@ -0,0 +1,285 @@ +#include +#include + +using namespace segcore; + +class AnnotationSetTest : public ::testing::Test +{ + protected: + AnnotationSet set_; +}; + +// --- Lifecycle --- + +TEST_F(AnnotationSetTest, BeginSegmentReturnsValidId) +{ + SegmentId id = set_.BeginSegment(0); + EXPECT_NE(id, kInvalidSegmentId); +} + +TEST_F(AnnotationSetTest, AddPointGrowsInProgress) +{ + SegmentId id = set_.BeginSegment(0); + set_.AddPoint(id, {10, 20}); + set_.AddPoint(id, {30, 40}); + const Segment* ip = set_.GetInProgress(); + ASSERT_NE(ip, nullptr); + EXPECT_EQ(ip->points.size(), 2u); +} + +TEST_F(AnnotationSetTest, CommitSegmentWithEnoughPoints) +{ + SegmentId id = set_.BeginSegment(0); + set_.AddPoint(id, {0, 0}); + set_.AddPoint(id, {10, 0}); + set_.AddPoint(id, {5, 10}); + EXPECT_TRUE(set_.CommitSegment(id)); + EXPECT_EQ(set_.GetSegments().size(), 1u); + EXPECT_EQ(set_.GetInProgress(), nullptr); +} + +TEST_F(AnnotationSetTest, CommitSegmentWithTooFewPoints) +{ + SegmentId id = set_.BeginSegment(0); + set_.AddPoint(id, {0, 0}); + set_.AddPoint(id, {10, 0}); + EXPECT_FALSE(set_.CommitSegment(id)); + EXPECT_TRUE(set_.GetSegments().empty()); +} + +TEST_F(AnnotationSetTest, DeleteSegmentRemovesIt) +{ + SegmentId id = set_.BeginSegment(0); + set_.AddPoint(id, {0, 0}); + set_.AddPoint(id, {10, 0}); + set_.AddPoint(id, {5, 10}); + set_.CommitSegment(id); + set_.DeleteSegment(id); + EXPECT_TRUE(set_.GetSegments().empty()); +} + +TEST_F(AnnotationSetTest, ClearAllEmptiesEverything) +{ + SegmentId id = set_.BeginSegment(0); + set_.AddPoint(id, {0, 0}); + set_.AddPoint(id, {10, 0}); + set_.AddPoint(id, {5, 10}); + set_.CommitSegment(id); + set_.ClearAll(); + EXPECT_TRUE(set_.GetSegments().empty()); + EXPECT_EQ(set_.GetInProgress(), nullptr); +} + +// --- Point editing --- + +TEST_F(AnnotationSetTest, MovePointUpdatesPosition) +{ + SegmentId id = set_.BeginSegment(0); + set_.AddPoint(id, {0, 0}); + set_.AddPoint(id, {10, 0}); + set_.AddPoint(id, {5, 10}); + set_.CommitSegment(id); + set_.MovePoint(id, 0, {99, 99}); + const Segment* seg = set_.GetSegment(id); + ASSERT_NE(seg, nullptr); + EXPECT_EQ(seg->points[0].x, 99); + EXPECT_EQ(seg->points[0].y, 99); +} + +TEST_F(AnnotationSetTest, RemovePointShrinks) +{ + SegmentId id = set_.BeginSegment(0); + set_.AddPoint(id, {0, 0}); + set_.AddPoint(id, {10, 0}); + set_.AddPoint(id, {5, 10}); + set_.AddPoint(id, {5, 20}); + set_.CommitSegment(id); + set_.RemovePoint(id, 0); + const Segment* seg = set_.GetSegment(id); + ASSERT_NE(seg, nullptr); + EXPECT_EQ(seg->points.size(), 3u); +} + +TEST_F(AnnotationSetTest, InsertPointInsertsAtIndex) +{ + SegmentId id = set_.BeginSegment(0); + set_.AddPoint(id, {0, 0}); + set_.AddPoint(id, {10, 0}); + set_.AddPoint(id, {5, 10}); + set_.CommitSegment(id); + set_.InsertPoint(id, 1, {99, 99}); + const Segment* seg = set_.GetSegment(id); + ASSERT_NE(seg, nullptr); + EXPECT_EQ(seg->points[1].x, 99); + EXPECT_EQ(seg->points.size(), 4u); +} + +// --- Selection --- + +TEST_F(AnnotationSetTest, SelectSegment) +{ + SegmentId id = set_.BeginSegment(0); + set_.AddPoint(id, {0, 0}); + set_.AddPoint(id, {10, 0}); + set_.AddPoint(id, {5, 10}); + set_.CommitSegment(id); + set_.SelectSegment(id); + EXPECT_EQ(set_.GetSelectedSegmentId(), id); + const Segment* seg = set_.GetSegment(id); + ASSERT_NE(seg, nullptr); + EXPECT_TRUE(seg->selected); +} + +TEST_F(AnnotationSetTest, DeselectAll) +{ + SegmentId id = set_.BeginSegment(0); + set_.AddPoint(id, {0, 0}); + set_.AddPoint(id, {10, 0}); + set_.AddPoint(id, {5, 10}); + set_.CommitSegment(id); + set_.SelectSegment(id); + set_.DeselectAll(); + EXPECT_EQ(set_.GetSelectedSegmentId(), kInvalidSegmentId); +} + +// --- Copy/Paste --- + +TEST_F(AnnotationSetTest, HasClipboardFalseInitially) +{ + EXPECT_FALSE(set_.HasClipboard()); +} + +TEST_F(AnnotationSetTest, CopyAndPaste) +{ + SegmentId id = set_.BeginSegment(2); + set_.AddPoint(id, {0, 0}); + set_.AddPoint(id, {10, 0}); + set_.AddPoint(id, {5, 10}); + set_.CommitSegment(id); + set_.CopySegment(id); + EXPECT_TRUE(set_.HasClipboard()); + SegmentId pasted_id = set_.PasteSegment(); + EXPECT_NE(pasted_id, kInvalidSegmentId); + EXPECT_NE(pasted_id, id); + EXPECT_EQ(set_.GetSegments().size(), 2u); + const Segment* pasted = set_.GetSegment(pasted_id); + ASSERT_NE(pasted, nullptr); + EXPECT_EQ(pasted->class_id, 2); + EXPECT_EQ(pasted->points.size(), 3u); +} + +// --- Undo/Redo --- + +TEST_F(AnnotationSetTest, UndoAddPoint) +{ + SegmentId id = set_.BeginSegment(0); + set_.AddPoint(id, {0, 0}); + set_.AddPoint(id, {10, 0}); + EXPECT_TRUE(set_.CanUndo()); + set_.Undo(); + const Segment* ip = set_.GetInProgress(); + ASSERT_NE(ip, nullptr); + EXPECT_EQ(ip->points.size(), 1u); +} + +TEST_F(AnnotationSetTest, UndoCommitRestoresInProgress) +{ + SegmentId id = set_.BeginSegment(0); + set_.AddPoint(id, {0, 0}); + set_.AddPoint(id, {10, 0}); + set_.AddPoint(id, {5, 10}); + set_.CommitSegment(id); + EXPECT_EQ(set_.GetSegments().size(), 1u); + set_.Undo(); + EXPECT_TRUE(set_.GetSegments().empty()); + EXPECT_NE(set_.GetInProgress(), nullptr); +} + +TEST_F(AnnotationSetTest, UndoDelete) +{ + SegmentId id = set_.BeginSegment(0); + set_.AddPoint(id, {0, 0}); + set_.AddPoint(id, {10, 0}); + set_.AddPoint(id, {5, 10}); + set_.CommitSegment(id); + set_.DeleteSegment(id); + EXPECT_TRUE(set_.GetSegments().empty()); + set_.Undo(); + EXPECT_EQ(set_.GetSegments().size(), 1u); +} + +TEST_F(AnnotationSetTest, UndoThenRedo) +{ + SegmentId id = set_.BeginSegment(0); + set_.AddPoint(id, {0, 0}); + set_.AddPoint(id, {10, 0}); + set_.AddPoint(id, {5, 10}); + set_.CommitSegment(id); + set_.Undo(); + EXPECT_TRUE(set_.GetSegments().empty()); + set_.Redo(); + EXPECT_EQ(set_.GetSegments().size(), 1u); +} + +TEST_F(AnnotationSetTest, PasteSegmentCanBeUndone) +{ + SegmentId id = set_.BeginSegment(0); + set_.AddPoint(id, {0, 0}); + set_.AddPoint(id, {10, 0}); + set_.AddPoint(id, {5, 10}); + set_.CommitSegment(id); + set_.CopySegment(id); + set_.PasteSegment(); + EXPECT_EQ(set_.GetSegments().size(), 2u); + set_.Undo(); + EXPECT_EQ(set_.GetSegments().size(), 1u); +} + +TEST_F(AnnotationSetTest, GetSetClipboard) +{ + SegmentId id = set_.BeginSegment(1); + set_.AddPoint(id, {5, 5}); + set_.AddPoint(id, {15, 5}); + set_.AddPoint(id, {10, 15}); + set_.CommitSegment(id); + + // Copy segment to clipboard + set_.CopySegment(id); + EXPECT_TRUE(set_.HasClipboard()); + + // Get clipboard and verify it matches original + const Segment* clipboard = set_.GetClipboard(); + ASSERT_NE(clipboard, nullptr); + EXPECT_EQ(clipboard->class_id, 1); + EXPECT_EQ(clipboard->points.size(), 3u); + + // Create new annotation set and transfer clipboard + AnnotationSet other_set; + other_set.SetClipboard(clipboard); + EXPECT_TRUE(other_set.HasClipboard()); + + // Paste should work in new set + SegmentId pasted = other_set.PasteSegment(); + EXPECT_NE(pasted, kInvalidSegmentId); + const Segment* pasted_seg = other_set.GetSegment(pasted); + ASSERT_NE(pasted_seg, nullptr); + EXPECT_EQ(pasted_seg->class_id, 1); + EXPECT_EQ(pasted_seg->points.size(), 3u); +} + +TEST_F(AnnotationSetTest, ClearClipboardWithSetNull) +{ + SegmentId id = set_.BeginSegment(0); + set_.AddPoint(id, {0, 0}); + set_.AddPoint(id, {10, 0}); + set_.AddPoint(id, {5, 10}); + set_.CommitSegment(id); + + // Copy to clipboard + set_.CopySegment(id); + EXPECT_TRUE(set_.HasClipboard()); + + // Clear clipboard by setting to nullptr + set_.SetClipboard(nullptr); + EXPECT_FALSE(set_.HasClipboard()); +} diff --git a/tests/segcore/display_test.cpp b/tests/segcore/display_test.cpp new file mode 100644 index 0000000..5f03250 --- /dev/null +++ b/tests/segcore/display_test.cpp @@ -0,0 +1,77 @@ +#include +#include + +using namespace segcore; + +TEST(DisplayTest, ImageArtifactPassthrough) +{ + Artifact a; + ImageArtifact img; + img.pixels = polyseg::Frame(2, 3, Rgb24{10, 20, 30}); + a.payload = img; + + auto out = NormaliseForDisplay(a); + EXPECT_EQ(out.width(), 2); + EXPECT_EQ(out.height(), 3); + EXPECT_EQ(out.data()[0].r, 10); + EXPECT_EQ(out.data()[0].g, 20); + EXPECT_EQ(out.data()[0].b, 30); +} + +TEST(DisplayTest, FloatArtifactMidpoint) +{ + Artifact a; + FloatArtifact fa; + fa.data = polyseg::Frame(1, 1, 0.5f); + fa.range_min = 0.0f; + fa.range_max = 1.0f; + a.payload = fa; + + auto out = NormaliseForDisplay(a); + EXPECT_EQ(out.width(), 1); + EXPECT_EQ(out.height(), 1); + // 0.5 * 255 = 127 (truncation) + EXPECT_EQ(out.data()[0].r, 127); + EXPECT_EQ(out.data()[0].g, 127); + EXPECT_EQ(out.data()[0].b, 127); +} + +TEST(DisplayTest, FloatArtifactBelowMin) +{ + Artifact a; + FloatArtifact fa; + fa.data = polyseg::Frame(1, 1, -1.0f); + fa.range_min = 0.0f; + fa.range_max = 1.0f; + a.payload = fa; + + auto out = NormaliseForDisplay(a); + EXPECT_EQ(out.data()[0].r, 0); +} + +TEST(DisplayTest, FloatArtifactAboveMax) +{ + Artifact a; + FloatArtifact fa; + fa.data = polyseg::Frame(1, 1, 2.0f); + fa.range_min = 0.0f; + fa.range_max = 1.0f; + a.payload = fa; + + auto out = NormaliseForDisplay(a); + EXPECT_EQ(out.data()[0].r, 255); +} + +TEST(DisplayTest, OutputDimensionsMatchInput) +{ + Artifact a; + FloatArtifact fa; + fa.data = polyseg::Frame(10, 7, 0.5f); + fa.range_min = 0.0f; + fa.range_max = 1.0f; + a.payload = fa; + + auto out = NormaliseForDisplay(a); + EXPECT_EQ(out.width(), 10); + EXPECT_EQ(out.height(), 7); +} diff --git a/tests/segcore/geometry_test.cpp b/tests/segcore/geometry_test.cpp new file mode 100644 index 0000000..44c3640 --- /dev/null +++ b/tests/segcore/geometry_test.cpp @@ -0,0 +1,74 @@ +#include +#include + +using namespace segcore; + +static Segment MakeSquare(SegmentId id) +{ + Segment s; + s.id = id; + s.points = {{0, 0}, {100, 0}, {100, 100}, {0, 100}}; + return s; +} + +TEST(PointInPolygonTest, InsideSquare) +{ + auto square = MakeSquare(1); + EXPECT_TRUE(PointInPolygon(square.points, {50, 50})); +} + +TEST(PointInPolygonTest, OutsideSquare) +{ + auto square = MakeSquare(1); + EXPECT_FALSE(PointInPolygon(square.points, {200, 200})); +} + +TEST(PointInPolygonTest, TooFewPoints) +{ + std::vector pts = {{0, 0}, {10, 0}}; + EXPECT_FALSE(PointInPolygon(pts, {5, 0})); +} + +TEST(HitTestPointTest, HitsVertex) +{ + std::vector segs = {MakeSquare(1)}; + PointHit hit = HitTestPoint(segs, {2, 2}, 5); + EXPECT_EQ(hit.segment_id, 1u); + EXPECT_EQ(hit.point_index, 0); +} + +TEST(HitTestPointTest, MissesVertex) +{ + std::vector segs = {MakeSquare(1)}; + PointHit hit = HitTestPoint(segs, {50, 50}, 5); + EXPECT_EQ(hit.segment_id, kInvalidSegmentId); + EXPECT_EQ(hit.point_index, -1); +} + +TEST(HitTestSegmentTest, InsidePolygon) +{ + std::vector segs = {MakeSquare(1)}; + EXPECT_EQ(HitTestSegment(segs, {50, 50}), 1u); +} + +TEST(HitTestSegmentTest, OutsidePolygon) +{ + std::vector segs = {MakeSquare(1)}; + EXPECT_EQ(HitTestSegment(segs, {200, 200}), kInvalidSegmentId); +} + +TEST(InsertionIndexTest, ReturnsValidIndex) +{ + Segment s = MakeSquare(1); + int idx = InsertionIndex(s, {50, 0}); + EXPECT_GE(idx, 0); + EXPECT_LE(idx, static_cast(s.points.size())); +} + +TEST(InsertionIndexTest, TopEdgeInsertsAt1) +{ + Segment s = MakeSquare(1); + // Point on top edge (between points[0]={0,0} and points[1]={100,0}) + int idx = InsertionIndex(s, {50, 0}); + EXPECT_EQ(idx, 1); +} diff --git a/tests/segcore/history_test.cpp b/tests/segcore/history_test.cpp new file mode 100644 index 0000000..df54707 --- /dev/null +++ b/tests/segcore/history_test.cpp @@ -0,0 +1,81 @@ +#include +#include + +using segcore::Command; +using segcore::UndoStack; + +TEST(UndoStackTest, EmptyStackCannotUndoOrRedo) +{ + UndoStack stack; + EXPECT_FALSE(stack.CanUndo()); + EXPECT_FALSE(stack.CanRedo()); +} + +TEST(UndoStackTest, PushEnablesUndo) +{ + UndoStack stack; + bool redo_called = false; + stack.Push({[] {}, [&] { redo_called = true; }}); + EXPECT_TRUE(redo_called); + EXPECT_TRUE(stack.CanUndo()); + EXPECT_FALSE(stack.CanRedo()); +} + +TEST(UndoStackTest, UndoFiresUndoCallback) +{ + UndoStack stack; + bool undo_called = false; + stack.Push({[&] { undo_called = true; }, [] {}}); + stack.Undo(); + EXPECT_TRUE(undo_called); + EXPECT_FALSE(stack.CanUndo()); + EXPECT_TRUE(stack.CanRedo()); +} + +TEST(UndoStackTest, RedoFiresRedoCallback) +{ + UndoStack stack; + bool redo_called = false; + stack.Push({[] {}, [&] { redo_called = true; }}); + redo_called = false; + stack.Undo(); + stack.Redo(); + EXPECT_TRUE(redo_called); + EXPECT_TRUE(stack.CanUndo()); + EXPECT_FALSE(stack.CanRedo()); +} + +TEST(UndoStackTest, PushDropsRedoHistory) +{ + UndoStack stack; + stack.Push({[] {}, [] {}}); + stack.Undo(); + EXPECT_TRUE(stack.CanRedo()); + stack.Push({[] {}, [] {}}); + EXPECT_FALSE(stack.CanRedo()); +} + +TEST(UndoStackTest, Push51DropsOldest) +{ + UndoStack stack; + int undo_count = 0; + for (int i = 0; i < 51; ++i) + { + stack.Push({[&] { ++undo_count; }, [] {}}); + } + while (stack.CanUndo()) + { + stack.Undo(); + } + EXPECT_EQ(undo_count, 50); +} + +TEST(UndoStackTest, ClearResetsAll) +{ + UndoStack stack; + stack.Push({[] {}, [] {}}); + stack.Undo(); + stack.Clear(); + EXPECT_FALSE(stack.CanUndo()); + EXPECT_FALSE(stack.CanRedo()); +} diff --git a/tests/segcore/normalized_format_serializer_test.cpp b/tests/segcore/normalized_format_serializer_test.cpp new file mode 100644 index 0000000..84b8d72 --- /dev/null +++ b/tests/segcore/normalized_format_serializer_test.cpp @@ -0,0 +1,74 @@ +#include +#include + +using namespace segcore; + +static Segment MakeTriangle(SegmentId id, int class_id = 0) +{ + Segment s; + s.id = id; + s.class_id = class_id; + s.points = {{100, 200}, {300, 400}, {500, 600}}; + return s; +} + +TEST(YoloSerializerTest, SerializeSingleSegment) +{ + std::vector segs = {MakeTriangle(1)}; + std::string out = SegmentsToNormalizedFormat(segs, 1000, 1000); + EXPECT_EQ(out, "0 0.100 0.200 0.300 0.400 0.500 0.600\n"); +} + +TEST(YoloSerializerTest, SerializeMultipleSegments) +{ + Segment s1 = MakeTriangle(1, 0); + Segment s2 = MakeTriangle(2, 1); + std::string out = SegmentsToNormalizedFormat({s1, s2}, 1000, 1000); + EXPECT_EQ(out, + "0 0.100 0.200 0.300 0.400 0.500 0.600\n" + "1 0.100 0.200 0.300 0.400 0.500 0.600\n"); +} + +TEST(YoloSerializerTest, SkipsSegmentWithFewPoints) +{ + Segment s; + s.id = 1; + s.points = {{10, 20}, {30, 40}}; + std::string out = SegmentsToNormalizedFormat({s}, 1000, 1000); + EXPECT_TRUE(out.empty()); +} + +TEST(YoloDeserializerTest, DeserializeValidLine) +{ + std::string text = "0 0.100 0.200 0.300 0.400 0.500 0.600\n"; + auto segs = NormalizedFormatToSegments(text, 1000, 1000); + ASSERT_EQ(segs.size(), 1u); + EXPECT_EQ(segs[0].class_id, 0); + ASSERT_EQ(segs[0].points.size(), 3u); + EXPECT_EQ(segs[0].points[0].x, 100); + EXPECT_EQ(segs[0].points[0].y, 200); +} + +TEST(YoloDeserializerTest, SkipsLineTooShort) +{ + std::string text = "0 0.1 0.2 0.3 0.4\n"; + auto segs = NormalizedFormatToSegments(text, 1000, 1000); + EXPECT_TRUE(segs.empty()); +} + +TEST(YoloSerializerTest, RoundTrip) +{ + std::vector original = {MakeTriangle(1, 2)}; + int w = 1000; + int h = 1000; + std::string serialized = SegmentsToNormalizedFormat(original, w, h); + auto deserialized = NormalizedFormatToSegments(serialized, w, h); + ASSERT_EQ(deserialized.size(), 1u); + EXPECT_EQ(deserialized[0].class_id, original[0].class_id); + ASSERT_EQ(deserialized[0].points.size(), original[0].points.size()); + for (size_t i = 0; i < original[0].points.size(); ++i) + { + EXPECT_NEAR(deserialized[0].points[i].x, original[0].points[i].x, 1); + EXPECT_NEAR(deserialized[0].points[i].y, original[0].points[i].y, 1); + } +} diff --git a/tests/segcore/project_test.cpp b/tests/segcore/project_test.cpp new file mode 100644 index 0000000..1c21b44 --- /dev/null +++ b/tests/segcore/project_test.cpp @@ -0,0 +1,75 @@ +#include +#include + +using namespace segcore; + +class ProjectTest : public ::testing::Test +{ + protected: + Project project_; + + ArtifactId AddImageArtifact() + { + Artifact a; + ImageArtifact img; + img.pixels = polyseg::Frame(10, 10); + img.source_path = "test.png"; + a.payload = std::move(img); + return project_.AddArtifact(std::move(a)); + } +}; + +TEST_F(ProjectTest, AddArtifactReturnsValidId) +{ + ArtifactId id = AddImageArtifact(); + EXPECT_NE(id, kInvalidArtifactId); +} + +TEST_F(ProjectTest, GetArtifactNonNull) +{ + ArtifactId id = AddImageArtifact(); + EXPECT_NE(project_.GetArtifact(id), nullptr); +} + +TEST_F(ProjectTest, GetAnnotationsEmptyInitially) +{ + ArtifactId id = AddImageArtifact(); + EXPECT_TRUE(project_.GetAnnotations(id).GetSegments().empty()); +} + +TEST_F(ProjectTest, AnnotationsIndependentPerArtifact) +{ + ArtifactId id1 = AddImageArtifact(); + ArtifactId id2 = AddImageArtifact(); + auto& ann1 = project_.GetAnnotations(id1); + SegmentId seg_id = ann1.BeginSegment(0); + ann1.AddPoint(seg_id, {0, 0}); + ann1.AddPoint(seg_id, {10, 0}); + ann1.AddPoint(seg_id, {5, 10}); + ann1.CommitSegment(seg_id); + EXPECT_EQ(project_.GetAnnotations(id1).GetSegments().size(), 1u); + EXPECT_EQ(project_.GetAnnotations(id2).GetSegments().size(), 0u); +} + +TEST_F(ProjectTest, SetAndGetCurrentArtifactId) +{ + ArtifactId id = AddImageArtifact(); + EXPECT_EQ(project_.GetCurrentArtifactId(), kInvalidArtifactId); + project_.SetCurrentArtifact(id); + EXPECT_EQ(project_.GetCurrentArtifactId(), id); +} + +TEST_F(ProjectTest, RemoveArtifactMakesGetArtifactNull) +{ + ArtifactId id = AddImageArtifact(); + project_.RemoveArtifact(id); + EXPECT_EQ(project_.GetArtifact(id), nullptr); +} + +TEST_F(ProjectTest, RemoveCurrentArtifactResetsCurrentId) +{ + ArtifactId id = AddImageArtifact(); + project_.SetCurrentArtifact(id); + project_.RemoveArtifact(id); + EXPECT_EQ(project_.GetCurrentArtifactId(), kInvalidArtifactId); +} diff --git a/tests/segcore/types_test.cpp b/tests/segcore/types_test.cpp new file mode 100644 index 0000000..dca5ae6 --- /dev/null +++ b/tests/segcore/types_test.cpp @@ -0,0 +1,55 @@ +#include +#include +#include + +using namespace segcore; + +TEST(TypesTest, InvalidIds) +{ + EXPECT_EQ(kInvalidArtifactId, 0u); + EXPECT_EQ(kInvalidSegmentId, 0u); +} + +TEST(ArtifactTest, ImageArtifactWidthHeight) +{ + Artifact a; + ImageArtifact img; + img.pixels = polyseg::Frame(100, 80); + a.payload = std::move(img); + EXPECT_EQ(a.width(), 100); + EXPECT_EQ(a.height(), 80); +} + +TEST(ArtifactTest, FloatArtifactWidthHeight) +{ + Artifact a; + FloatArtifact fa; + fa.data = polyseg::Frame(64, 48); + a.payload = std::move(fa); + EXPECT_EQ(a.width(), 64); + EXPECT_EQ(a.height(), 48); +} + +TEST(ArtifactTest, ImageArtifactPixelDataAccessible) +{ + Artifact a; + ImageArtifact img; + img.pixels = polyseg::Frame(2, 2, Rgb24{1, 2, 3}); + a.payload = img; + const auto& pixels = std::get(a.payload).pixels; + EXPECT_EQ(pixels.data()[0].r, 1); + EXPECT_EQ(pixels.data()[0].g, 2); + EXPECT_EQ(pixels.data()[0].b, 3); +} + +TEST(ArtifactTest, FloatArtifactDataAccessible) +{ + Artifact a; + FloatArtifact fa; + fa.data = polyseg::Frame(1, 1, 0.5f); + fa.range_min = 0.0f; + fa.range_max = 1.0f; + a.payload = fa; + const auto& data = std::get(a.payload).data; + EXPECT_FLOAT_EQ(data.data()[0], 0.5f); +} From 1473cd9dd1fbf05a9c7dad0f49d028bc1c117be7 Mon Sep 17 00:00:00 2001 From: Lukasz Stachowicz Date: Mon, 4 May 2026 21:17:05 +0200 Subject: [PATCH 3/8] style(libs): apply clang-format and configure clang-tidy for C++17 - Apply clang-format to all library headers and source files (reorganize includes to match IncludeBlocks: Regroup policy) - Update .clang-tidy configuration: - Add C++17 standard flag for proper namespace syntax recognition - Add include path for libs to resolve module includes - Exclude CMakeLists.txt from analysis (not C++ code) --- .clang-tidy | 6 ++ libs/imgproc/convolution.h | 20 ++-- libs/imgproc/convolution_kernels.h | 34 +++---- libs/imgproc/frame.h | 8 +- libs/imgproc/kernel.h | 6 +- libs/segcore/include/segcore/annotation_set.h | 8 +- libs/segcore/include/segcore/artifact.h | 8 +- libs/segcore/include/segcore/display.h | 3 +- libs/segcore/include/segcore/geometry.h | 6 +- libs/segcore/include/segcore/history.h | 3 +- .../segcore/include/segcore/iannotation_set.h | 4 +- .../segcore/normalized_format_serializer.h | 6 +- libs/segcore/include/segcore/project.h | 6 +- libs/segcore/include/segcore/segment.h | 6 +- libs/segcore/include/segcore/types.h | 13 +-- libs/segcore/src/annotation_set.cpp | 92 +++++++++---------- libs/segcore/src/artifact.cpp | 3 +- libs/segcore/src/display.cpp | 3 +- libs/segcore/src/geometry.cpp | 10 +- libs/segcore/src/history.cpp | 3 +- .../src/normalized_format_serializer.cpp | 6 +- libs/segcore/src/project.cpp | 3 +- 22 files changed, 137 insertions(+), 120 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index 597f58b..38bfb1c 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -24,6 +24,12 @@ Checks: > WarningsAsErrors: '' HeaderFilterRegex: '.*' +ExcludeHeaderFilterRegex: 'CMakeLists\.txt' + +CompileFlags: + Add: + - '-std=c++17' + - '-I${CMAKE_SOURCE_DIR}/libs' CheckOptions: - key: readability-identifier-naming.ClassCase diff --git a/libs/imgproc/convolution.h b/libs/imgproc/convolution.h index c35b6bd..24add76 100644 --- a/libs/imgproc/convolution.h +++ b/libs/imgproc/convolution.h @@ -1,15 +1,16 @@ #ifndef POLYSEG_IMGPROC_CONVOLUTION_H #define POLYSEG_IMGPROC_CONVOLUTION_H +#include +#include + #include #include #include #include -#include -#include - -namespace polyseg { +namespace polyseg +{ class Convolution { @@ -47,8 +48,8 @@ class Convolution sum += k[ky * N + kx] * src[(y + ky - border) * width + (x + kx - border)]; } } - sum = std::max(0.0, std::min(static_cast(std::numeric_limits::max()), - sum * factor)); + sum = std::max( + 0.0, std::min(static_cast(std::numeric_limits::max()), sum * factor)); output.data()[y * width + x] = static_cast(sum); } } @@ -146,10 +147,9 @@ struct Convolution::detail<5, T> float value = kf[0] * r0[-2] + kf[1] * r0[-1] + kf[2] * r0[0] + kf[3] * r0[1] + kf[4] * r0[2] + kf[5] * r1[-2] + kf[6] * r1[-1] + kf[7] * r1[0] + kf[8] * r1[1] + kf[9] * r1[2] + - kf[10] * r2[-2] + kf[11] * r2[-1] + kf[12] * r2[0] + kf[13] * r2[1] + - kf[14] * r2[2] + kf[15] * r3[-2] + kf[16] * r3[-1] + kf[17] * r3[0] + - kf[18] * r3[1] + kf[19] * r3[2] + kf[20] * r4[-2] + kf[21] * r4[-1] + - kf[22] * r4[0] + kf[23] * r4[1] + kf[24] * r4[2]; + kf[10] * r2[-2] + kf[11] * r2[-1] + kf[12] * r2[0] + kf[13] * r2[1] + kf[14] * r2[2] + + kf[15] * r3[-2] + kf[16] * r3[-1] + kf[17] * r3[0] + kf[18] * r3[1] + kf[19] * r3[2] + + kf[20] * r4[-2] + kf[21] * r4[-1] + kf[22] * r4[0] + kf[23] * r4[1] + kf[24] * r4[2]; value = std::max(0.0f, std::min(static_cast(std::numeric_limits::max()), value)); *dst++ = static_cast(value); ++r0; diff --git a/libs/imgproc/convolution_kernels.h b/libs/imgproc/convolution_kernels.h index 7e844d4..1a9a0ae 100644 --- a/libs/imgproc/convolution_kernels.h +++ b/libs/imgproc/convolution_kernels.h @@ -1,11 +1,12 @@ #ifndef POLYSEG_IMGPROC_CONVOLUTION_KERNELS_H #define POLYSEG_IMGPROC_CONVOLUTION_KERNELS_H -#include - #include -namespace polyseg { +#include + +namespace polyseg +{ // Base for Laplacian-of-Gaussian kernels template @@ -24,9 +25,15 @@ class LaplacianKernel3 : public LaplacianKernel<3> public: LaplacianKernel3() : LaplacianKernel<3>({ - 0.0f, -1.0f, 0.0f, - -1.0f, 4.0f, -1.0f, - 0.0f, -1.0f, 0.0f, + 0.0f, + -1.0f, + 0.0f, + -1.0f, + 4.0f, + -1.0f, + 0.0f, + -1.0f, + 0.0f, }) { } @@ -38,11 +45,9 @@ class LaplacianKernel5 : public LaplacianKernel<5> public: LaplacianKernel5() : LaplacianKernel<5>({ - 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, - 0.0f, -1.0f, -2.0f, -1.0f, 0.0f, - -1.0f, -2.0f, 16.0f, -2.0f, -1.0f, - 0.0f, -1.0f, -2.0f, -1.0f, 0.0f, - 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, + 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, 0.0f, -1.0f, -2.0f, -1.0f, + 0.0f, -1.0f, -2.0f, 16.0f, -2.0f, -1.0f, 0.0f, -1.0f, -2.0f, + -1.0f, 0.0f, 0.0f, 0.0f, -1.0f, 0.0f, 0.0f, }) { } @@ -55,11 +60,8 @@ class GaussianKernel5 : public Kernel<5> GaussianKernel5() : Kernel<5>( { - 2.0f, 4.0f, 5.0f, 4.0f, 2.0f, - 4.0f, 9.0f, 12.0f, 9.0f, 4.0f, - 5.0f, 12.0f, 15.0f, 12.0f, 5.0f, - 4.0f, 9.0f, 12.0f, 9.0f, 4.0f, - 2.0f, 4.0f, 5.0f, 4.0f, 2.0f, + 2.0f, 4.0f, 5.0f, 4.0f, 2.0f, 4.0f, 9.0f, 12.0f, 9.0f, 4.0f, 5.0f, 12.0f, 15.0f, + 12.0f, 5.0f, 4.0f, 9.0f, 12.0f, 9.0f, 4.0f, 2.0f, 4.0f, 5.0f, 4.0f, 2.0f, }, 1.0f / 159.0f) { diff --git a/libs/imgproc/frame.h b/libs/imgproc/frame.h index 7223e5f..25cb5cf 100644 --- a/libs/imgproc/frame.h +++ b/libs/imgproc/frame.h @@ -5,7 +5,8 @@ #include #include -namespace polyseg { +namespace polyseg +{ template class Frame @@ -13,10 +14,7 @@ class Frame public: Frame() : width_(0), height_(0) {} - Frame(uint16_t width, uint16_t height) - : width_(width), height_(height), data_(width * height) - { - } + Frame(uint16_t width, uint16_t height) : width_(width), height_(height), data_(width * height) {} Frame(uint16_t width, uint16_t height, const T* data) : width_(width), height_(height), data_(data, data + width * height) diff --git a/libs/imgproc/kernel.h b/libs/imgproc/kernel.h index bd13a1d..bb885d6 100644 --- a/libs/imgproc/kernel.h +++ b/libs/imgproc/kernel.h @@ -3,14 +3,14 @@ #include -namespace polyseg { +namespace polyseg +{ template class Kernel { public: - Kernel(const std::array& data, float factor = 1.0f) - : data_(data), factor_(factor) + Kernel(const std::array& data, float factor = 1.0f) : data_(data), factor_(factor) { } diff --git a/libs/segcore/include/segcore/annotation_set.h b/libs/segcore/include/segcore/annotation_set.h index 71cb757..bd63e84 100644 --- a/libs/segcore/include/segcore/annotation_set.h +++ b/libs/segcore/include/segcore/annotation_set.h @@ -1,14 +1,16 @@ #ifndef POLYSEG_SEGCORE_ANNOTATION_SET_H #define POLYSEG_SEGCORE_ANNOTATION_SET_H -#include -#include #include #include #include #include -namespace segcore { +#include +#include + +namespace segcore +{ class AnnotationSet : public IAnnotationSet { diff --git a/libs/segcore/include/segcore/artifact.h b/libs/segcore/include/segcore/artifact.h index dd77d34..d0475f7 100644 --- a/libs/segcore/include/segcore/artifact.h +++ b/libs/segcore/include/segcore/artifact.h @@ -1,12 +1,14 @@ #ifndef POLYSEG_SEGCORE_ARTIFACT_H #define POLYSEG_SEGCORE_ARTIFACT_H -#include -#include #include #include -namespace segcore { +#include +#include + +namespace segcore +{ struct ImageArtifact { diff --git a/libs/segcore/include/segcore/display.h b/libs/segcore/include/segcore/display.h index 606c521..3e91419 100644 --- a/libs/segcore/include/segcore/display.h +++ b/libs/segcore/include/segcore/display.h @@ -5,7 +5,8 @@ #include #include -namespace segcore { +namespace segcore +{ polyseg::Frame NormaliseForDisplay(const Artifact& artifact); diff --git a/libs/segcore/include/segcore/geometry.h b/libs/segcore/include/segcore/geometry.h index 2e5c3c8..123023d 100644 --- a/libs/segcore/include/segcore/geometry.h +++ b/libs/segcore/include/segcore/geometry.h @@ -1,11 +1,13 @@ #ifndef POLYSEG_SEGCORE_GEOMETRY_H #define POLYSEG_SEGCORE_GEOMETRY_H -#include #include #include -namespace segcore { +#include + +namespace segcore +{ struct PointHit { diff --git a/libs/segcore/include/segcore/history.h b/libs/segcore/include/segcore/history.h index 60c96f8..37a3653 100644 --- a/libs/segcore/include/segcore/history.h +++ b/libs/segcore/include/segcore/history.h @@ -4,7 +4,8 @@ #include #include -namespace segcore { +namespace segcore +{ struct Command { diff --git a/libs/segcore/include/segcore/iannotation_set.h b/libs/segcore/include/segcore/iannotation_set.h index c60d23d..58f356f 100644 --- a/libs/segcore/include/segcore/iannotation_set.h +++ b/libs/segcore/include/segcore/iannotation_set.h @@ -1,11 +1,11 @@ #ifndef POLYSEG_SEGCORE_IANNOTATION_SET_H #define POLYSEG_SEGCORE_IANNOTATION_SET_H -#include - #include #include +#include + namespace segcore { diff --git a/libs/segcore/include/segcore/normalized_format_serializer.h b/libs/segcore/include/segcore/normalized_format_serializer.h index 40e187d..3ce6a19 100644 --- a/libs/segcore/include/segcore/normalized_format_serializer.h +++ b/libs/segcore/include/segcore/normalized_format_serializer.h @@ -1,11 +1,13 @@ #ifndef POLYSEG_SEGCORE_NORMALIZED_FORMAT_SERIALIZER_H #define POLYSEG_SEGCORE_NORMALIZED_FORMAT_SERIALIZER_H +#include + #include #include -#include -namespace segcore { +namespace segcore +{ std::string SegmentsToNormalizedFormat(const std::vector& segs, int w, int h); std::vector NormalizedFormatToSegments(const std::string& text, int w, int h); diff --git a/libs/segcore/include/segcore/project.h b/libs/segcore/include/segcore/project.h index 988c4ab..7d9d594 100644 --- a/libs/segcore/include/segcore/project.h +++ b/libs/segcore/include/segcore/project.h @@ -1,12 +1,14 @@ #ifndef POLYSEG_SEGCORE_PROJECT_H #define POLYSEG_SEGCORE_PROJECT_H -#include #include #include #include -namespace segcore { +#include + +namespace segcore +{ class Project { diff --git a/libs/segcore/include/segcore/segment.h b/libs/segcore/include/segcore/segment.h index 0694bc7..9a4c35e 100644 --- a/libs/segcore/include/segcore/segment.h +++ b/libs/segcore/include/segcore/segment.h @@ -1,10 +1,12 @@ #ifndef POLYSEG_SEGCORE_SEGMENT_H #define POLYSEG_SEGCORE_SEGMENT_H -#include #include -namespace segcore { +#include + +namespace segcore +{ struct Segment { diff --git a/libs/segcore/include/segcore/types.h b/libs/segcore/include/segcore/types.h index 940cb5a..7bc0f20 100644 --- a/libs/segcore/include/segcore/types.h +++ b/libs/segcore/include/segcore/types.h @@ -3,17 +3,15 @@ #include -namespace segcore { +namespace segcore +{ struct Point2D { int x = 0; int y = 0; - bool operator==(const Point2D& other) const - { - return x == other.x && y == other.y; - } + bool operator==(const Point2D& other) const { return x == other.x && y == other.y; } }; struct Rgb24 @@ -22,10 +20,7 @@ struct Rgb24 uint8_t g = 0; uint8_t b = 0; - bool operator==(const Rgb24& other) const - { - return r == other.r && g == other.g && b == other.b; - } + bool operator==(const Rgb24& other) const { return r == other.r && g == other.g && b == other.b; } }; using ArtifactId = uint32_t; diff --git a/libs/segcore/src/annotation_set.cpp b/libs/segcore/src/annotation_set.cpp index 414b1b2..969f47a 100644 --- a/libs/segcore/src/annotation_set.cpp +++ b/libs/segcore/src/annotation_set.cpp @@ -2,7 +2,8 @@ #include -namespace segcore { +namespace segcore +{ Segment* AnnotationSet::FindSegment(SegmentId id) { @@ -25,21 +26,21 @@ void AnnotationSet::AddPoint(SegmentId id, Point2D p) { if (has_in_progress_ && in_progress_.id == id) { - undo_stack_.Push( - {[this, id]() - { - if (has_in_progress_ && in_progress_.id == id && !in_progress_.points.empty()) - { - in_progress_.points.pop_back(); - } - }, - [this, id, p]() - { - if (has_in_progress_ && in_progress_.id == id) - { - in_progress_.points.push_back(p); - } - }}); + undo_stack_.Push({[this, id]() + { + if (has_in_progress_ && in_progress_.id == id && + !in_progress_.points.empty()) + { + in_progress_.points.pop_back(); + } + }, + [this, id, p]() + { + if (has_in_progress_ && in_progress_.id == id) + { + in_progress_.points.push_back(p); + } + }}); return; } Segment* seg = FindSegment(id); @@ -189,10 +190,9 @@ bool AnnotationSet::CommitSegment(SegmentId id) undo_stack_.Push( {[this, to_commit]() { - segments_.erase( - std::remove_if(segments_.begin(), segments_.end(), - [&](const Segment& s) { return s.id == to_commit.id; }), - segments_.end()); + segments_.erase(std::remove_if(segments_.begin(), segments_.end(), + [&](const Segment& s) { return s.id == to_commit.id; }), + segments_.end()); in_progress_ = to_commit; has_in_progress_ = true; }, @@ -224,19 +224,17 @@ void AnnotationSet::DeleteSegment(SegmentId id) { selected_id_ = kInvalidSegmentId; } - undo_stack_.Push( - {[this, saved, saved_index]() - { - int insert_at = std::min(saved_index, static_cast(segments_.size())); - segments_.insert(segments_.begin() + insert_at, saved); - }, - [this, id]() - { - segments_.erase( - std::remove_if(segments_.begin(), segments_.end(), - [id](const Segment& s) { return s.id == id; }), - segments_.end()); - }}); + undo_stack_.Push({[this, saved, saved_index]() + { + int insert_at = std::min(saved_index, static_cast(segments_.size())); + segments_.insert(segments_.begin() + insert_at, saved); + }, + [this, id]() + { + segments_.erase(std::remove_if(segments_.begin(), segments_.end(), + [id](const Segment& s) { return s.id == id; }), + segments_.end()); + }}); } void AnnotationSet::ClearAll() @@ -306,21 +304,19 @@ SegmentId AnnotationSet::PasteSegment() Segment pasted = clipboard_.value(); pasted.id = next_id_++; pasted.selected = false; - undo_stack_.Push( - {[this, id = pasted.id]() - { - segments_.erase( - std::remove_if(segments_.begin(), segments_.end(), - [id](const Segment& s) { return s.id == id; }), - segments_.end()); - }, - [this, pasted]() - { - auto it = std::find_if(segments_.begin(), segments_.end(), - [&](const Segment& s) { return s.id == pasted.id; }); - if (it == segments_.end()) - segments_.push_back(pasted); - }}); + undo_stack_.Push({[this, id = pasted.id]() + { + segments_.erase(std::remove_if(segments_.begin(), segments_.end(), + [id](const Segment& s) { return s.id == id; }), + segments_.end()); + }, + [this, pasted]() + { + auto it = std::find_if(segments_.begin(), segments_.end(), + [&](const Segment& s) { return s.id == pasted.id; }); + if (it == segments_.end()) + segments_.push_back(pasted); + }}); return pasted.id; } diff --git a/libs/segcore/src/artifact.cpp b/libs/segcore/src/artifact.cpp index c3efd1a..5c684ca 100644 --- a/libs/segcore/src/artifact.cpp +++ b/libs/segcore/src/artifact.cpp @@ -1,6 +1,7 @@ #include -namespace segcore { +namespace segcore +{ int Artifact::width() const { diff --git a/libs/segcore/src/display.cpp b/libs/segcore/src/display.cpp index 6775eb7..a61ff41 100644 --- a/libs/segcore/src/display.cpp +++ b/libs/segcore/src/display.cpp @@ -3,7 +3,8 @@ #include #include -namespace segcore { +namespace segcore +{ polyseg::Frame NormaliseForDisplay(const Artifact& artifact) { diff --git a/libs/segcore/src/geometry.cpp b/libs/segcore/src/geometry.cpp index adf23d9..fc9e5aa 100644 --- a/libs/segcore/src/geometry.cpp +++ b/libs/segcore/src/geometry.cpp @@ -3,9 +3,11 @@ #include #include -namespace segcore { +namespace segcore +{ -namespace { +namespace +{ double Distance(Point2D a, Point2D b) { @@ -94,8 +96,8 @@ bool PointInPolygon(const std::vector& pts, Point2D pos) int yi = pts[i].y; int xj = pts[j].x; int yj = pts[j].y; - bool intersects = ((yi > pos.y) != (yj > pos.y)) && - (pos.x < (xj - xi) * (pos.y - yi) / (yj - yi) + xi); + bool intersects = + ((yi > pos.y) != (yj > pos.y)) && (pos.x < (xj - xi) * (pos.y - yi) / (yj - yi) + xi); if (intersects) { inside = !inside; diff --git a/libs/segcore/src/history.cpp b/libs/segcore/src/history.cpp index 1d0d467..eab887d 100644 --- a/libs/segcore/src/history.cpp +++ b/libs/segcore/src/history.cpp @@ -1,6 +1,7 @@ #include -namespace segcore { +namespace segcore +{ void UndoStack::Push(Command cmd) { diff --git a/libs/segcore/src/normalized_format_serializer.cpp b/libs/segcore/src/normalized_format_serializer.cpp index 7dd773b..fad4233 100644 --- a/libs/segcore/src/normalized_format_serializer.cpp +++ b/libs/segcore/src/normalized_format_serializer.cpp @@ -3,7 +3,8 @@ #include #include -namespace segcore { +namespace segcore +{ std::string SegmentsToNormalizedFormat(const std::vector& segs, int w, int h) { @@ -18,8 +19,7 @@ std::string SegmentsToNormalizedFormat(const std::vector& segs, int w, out << seg.class_id; for (const auto& p : seg.points) { - out << ' ' << static_cast(p.x) / w - << ' ' << static_cast(p.y) / h; + out << ' ' << static_cast(p.x) / w << ' ' << static_cast(p.y) / h; } out << '\n'; } diff --git a/libs/segcore/src/project.cpp b/libs/segcore/src/project.cpp index 90a233e..be04fc3 100644 --- a/libs/segcore/src/project.cpp +++ b/libs/segcore/src/project.cpp @@ -1,6 +1,7 @@ #include -namespace segcore { +namespace segcore +{ ArtifactId Project::AddArtifact(Artifact artifact) { From 69f8c5d5f7c4c625e5f0290538c1de13dfe92ae4 Mon Sep 17 00:00:00 2001 From: Lukasz Stachowicz Date: Mon, 4 May 2026 21:17:54 +0200 Subject: [PATCH 4/8] chore: configure cppcheck for C++17 and exclude non-code files - Add .cppcheck configuration with C++17 standard and include paths - Add .cppcheckignore to exclude CMakeLists.txt and external dependencies - Ensures proper static analysis without false positives on build files --- .cppcheck | 17 +++++++++++++++++ .cppcheckignore | 8 ++++++++ 2 files changed, 25 insertions(+) create mode 100644 .cppcheck create mode 100644 .cppcheckignore diff --git a/.cppcheck b/.cppcheck new file mode 100644 index 0000000..8077881 --- /dev/null +++ b/.cppcheck @@ -0,0 +1,17 @@ + + + + + + libs + src + + + + + + + + + + diff --git a/.cppcheckignore b/.cppcheckignore new file mode 100644 index 0000000..2d1a23e --- /dev/null +++ b/.cppcheckignore @@ -0,0 +1,8 @@ +# CMake files - not C++ code +CMakeLists.txt +*/CMakeLists.txt + +# External dependencies +external/* +build/* +cmake/* From 07a108933dfd93bf5f5fee8870e30b5eed6612ec Mon Sep 17 00:00:00 2001 From: Lukasz Stachowicz Date: Mon, 4 May 2026 21:19:27 +0200 Subject: [PATCH 5/8] docs: add development guide with static analysis configuration - Document cppcheck, clang-format, clang-tidy usage - Provide IDE-specific instructions (CLion, VSCode) - Important: cppcheck requires --language=c++ flag for C++17 code - Include build, test, and commit message guidelines --- DEVELOPMENT.md | 136 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 DEVELOPMENT.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..3f49f5c --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,136 @@ +# Development Guide + +## Code Style & Analysis Tools + +### clang-format +Automatically format code according to Google C++ Style Guide: + +```bash +# Format all C++ files in libs/ and src/ +find libs src -name "*.h" -o -name "*.cpp" | xargs clang-format -i --style=file +``` + +Configuration: `.clang-format` + +### clang-tidy +Static analysis for code quality: + +```bash +# Analyze C++ files (requires C++17) +clang-tidy -checks=* libs/imgproc/convolution.h -- -std=c++17 -Ilibs +``` + +Configuration: `.clang-tidy` + +### cppcheck +Static code analysis tool: + +```bash +# Run cppcheck with C++17 standard +cppcheck --std=c++17 --language=c++ \ + -Ilibs -Isrc \ + --exclude-dir=external \ + --exclude-dir=build \ + --exclude-dir=test123 \ + libs/ src/ tests/ +``` + +**Important:** cppcheck requires `--language=c++` flag to properly analyze C++17 namespace syntax. + +Configuration files: +- `.cppcheck` - Project-level configuration (for IDE integration) +- `.cppcheckignore` - Paths to exclude from analysis + +### IDE Configuration + +#### CLion / JetBrains IDEs +1. Go to Settings → Languages & Frameworks → C/C++ → Cppcheck +2. Enable Cppcheck +3. Set options: + - Language: C++ + - Standard: C++17 + - Include paths: `-Ilibs -Isrc` + - Exclude directories: external, build, test123 + +#### VSCode +Install cppcheck extension and configure in `.vscode/settings.json`: + +```json +{ + "cppcheck.cppcheckArgs": ["--std=c++17", "--language=c++"], + "cppcheck.includePaths": ["libs", "src"], + "cppcheck.excludedPaths": ["external", "build", "test123"] +} +``` + +## Build Instructions + +```bash +# Create build directory +mkdir build && cd build + +# Configure with CMake +cmake .. -DCMAKE_BUILD_TYPE=Release + +# Build +make -j$(nproc) + +# Run tests +ctest --output-on-failure +``` + +## Library Modules + +### imgproc (Header-only) +Image processing components: +- `Frame` - Generic frame/image container +- `Kernel` - Convolution kernel +- `Convolution` - Convolution filter application + +Pure C++ without Qt dependencies - can be used independently. + +### segcore (Static library) +Annotation and project management: +- `AnnotationSet` - Manages polygon annotations with undo/redo +- `Artifact` - Image data container (supports multiple formats) +- `Segment` - Single polygon definition +- `Geometry` - Point-in-polygon, hit testing utilities +- `Project` - Multi-image annotation project management +- `NormalizedFormatSerializer` - Format export/import + +Pure C++ without Qt dependencies - can be used independently. + +## Testing + +```bash +# Run all tests +ctest -j$(nproc) --output-on-failure + +# Run specific test +ctest -R segcore_unit_tests --output-on-failure + +# Run with verbose output +ctest -VV +``` + +## Commit Message Format + +Follow Conventional Commits: + +``` +type(scope): subject + +body explaining why + +footer with references +``` + +Types: feat, fix, refactor, docs, test, chore, style + +Example: +``` +refactor(libs): extract image processing to imgproc module + +Separate image manipulation code into header-only imgproc library +to enable reuse without Qt dependencies. +``` From 079236f9ab25363b74afa0b5b90dd2d67e1474c7 Mon Sep 17 00:00:00 2001 From: Lukasz Stachowicz Date: Mon, 4 May 2026 21:20:47 +0200 Subject: [PATCH 6/8] config: add VSCode cppcheck configuration - Enable cppcheck with C++17 standard and proper language setting - Configure include paths for libs/ and src/ modules - Exclude external dependencies and test directories - Enable auto-format with clang-format on save - Update .gitignore to track .vscode/settings.json --- .gitignore | 3 ++- .vscode/settings.json | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index dd1ac0a..7727317 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,8 @@ debug/ release/ # IDE -.vscode/ +.vscode/* +!.vscode/settings.json .idea/ *.swp *.swo diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4a7e303 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,35 @@ +{ + // Cppcheck configuration + "cppcheck.enable": true, + "cppcheck.cppcheckPath": "cppcheck", + "cppcheck.standard": ["c++17"], + "cppcheck.language": "c++", + "cppcheck.includePaths": ["${workspaceFolder}/libs", "${workspaceFolder}/src"], + "cppcheck.excludedPaths": ["${workspaceFolder}/external", "${workspaceFolder}/build", "${workspaceFolder}/test123"], + "cppcheck.cppcheckArgs": ["--std=c++17", "--language=c++"], + + // C/C++ IntelliSense settings + "C_Cpp.default.standard": "c++17", + "C_Cpp.default.includePath": ["${workspaceFolder}/libs", "${workspaceFolder}/src"], + "C_Cpp.formatting": "clangFormat", + "C_Cpp.clang_format_style": "file", + + // Files to exclude from analysis + "files.exclude": { + "**/CMakeLists.txt": true, + "external/**": true, + "build/**": true, + "test123/**": true + }, + + // Editor settings + "editor.formatOnSave": true, + "[cpp]": { + "editor.defaultFormatter": "xaver.clang-format", + "editor.formatOnSave": true + }, + "[h]": { + "editor.defaultFormatter": "xaver.clang-format", + "editor.formatOnSave": true + } +} From 26ee9a8666bd89b67ca362218797723be7634dec Mon Sep 17 00:00:00 2001 From: Lukasz Stachowicz Date: Mon, 4 May 2026 21:21:54 +0200 Subject: [PATCH 7/8] style(edgedetector): replace old-style casts with static_cast Fix compiler warnings by using static_cast() instead of (int) for type conversions in debug output. --- src/edgedetector.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/edgedetector.cpp b/src/edgedetector.cpp index 82d495b..44cf920 100644 --- a/src/edgedetector.cpp +++ b/src/edgedetector.cpp @@ -109,8 +109,8 @@ std::vector Threshold(const polyseg::Frame& log_abs) const uint8_t otsu = OtsuThreshold(norm.data(), n); const uint8_t t = std::max(static_cast(otsu * 65 / 100), uint8_t{8}); - std::cout << "[EdgeDetect] max_log=" << max_val << " otsu=" << (int)otsu - << " threshold=" << (int)t << std::endl; + std::cout << "[EdgeDetect] max_log=" << max_val << " otsu=" << static_cast(otsu) + << " threshold=" << static_cast(t) << std::endl; std::vector edges(n, 0); for (size_t i = 0; i < n; ++i) edges[i] = (norm[i] >= t) ? 1 : 0; From bcdd709c52f5d9579b52a3602167800574ff2693 Mon Sep 17 00:00:00 2001 From: Lukasz Stachowicz Date: Tue, 5 May 2026 07:04:08 +0200 Subject: [PATCH 8/8] Include logger / fixes from code review --- CMakeLists.txt | 19 ++- THIRD-PARTY-LICENSES.md | 38 +++++- libs/imgproc/frame.h | 9 +- libs/segcore/include/segcore/project.h | 3 +- .../src/normalized_format_serializer.cpp | 23 ++-- libs/segcore/src/project.cpp | 6 +- src/aipluginmanager.cpp | 120 +++++++++--------- src/edgedetector.cpp | 7 +- src/logger.h | 20 +++ src/main.cpp | 25 ++++ src/mainwindow.cpp | 44 ++++--- src/metadataimporter.cpp | 33 ++--- src/polygoncanvas.cpp | 45 +++---- src/projectconfig.cpp | 49 ++++--- tests/segcore/project_test.cpp | 56 ++++++++ 15 files changed, 331 insertions(+), 166 deletions(-) create mode 100644 src/logger.h diff --git a/CMakeLists.txt b/CMakeLists.txt index ae9b8d6..0d5a6e1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -171,6 +171,16 @@ set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) +# Fetch spdlog for structured logging (statically compiled into PolySeg_lib only) +include(FetchContent) +FetchContent_Declare( + spdlog + GIT_REPOSITORY https://github.com/gabime/spdlog.git + GIT_TAG v1.15.3 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(spdlog) + # Create a library with core functionality (excluding main.cpp for testability) set(POLYSEG_LIB_SOURCES ${POLYSEG_SOURCES}) list(REMOVE_ITEM POLYSEG_LIB_SOURCES src/main.cpp) @@ -196,6 +206,7 @@ target_link_libraries(PolySeg_lib PUBLIC Qt6::Network imgproc segcore + spdlog::spdlog ) # Create the main PolySeg executable @@ -342,4 +353,10 @@ if(UNIX AND NOT ANDROID) install(TARGETS PolySeg RUNTIME DESTINATION bin ) -endif() \ No newline at end of file +endif() + +# Install spdlog license for distribution compliance (statically linked) +install(FILES ${spdlog_SOURCE_DIR}/LICENSE + DESTINATION share/doc/PolySeg/licenses + RENAME spdlog-LICENSE.txt +) \ No newline at end of file diff --git a/THIRD-PARTY-LICENSES.md b/THIRD-PARTY-LICENSES.md index 6829928..ee7bdff 100644 --- a/THIRD-PARTY-LICENSES.md +++ b/THIRD-PARTY-LICENSES.md @@ -33,6 +33,42 @@ All Qt libraries are dynamically linked (`.so` files on Linux, `.dll` on Windows --- +## spdlog + +**License:** MIT License +**Copyright:** Copyright (c) 2016 Gabi Melman and spdlog contributors. +**Website:** https://github.com/gabime/spdlog +**Version:** v1.15.3 + +spdlog is statically compiled into this application. The MIT license requires +that this copyright notice and license text be included with all distributions. + +### MIT License Full Text + +MIT License + +Copyright (c) 2016 Gabi Melman and spdlog contributors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + ## Standard C++ Library **License:** Part of the compiler toolchain (typically GPL with GCC Runtime Library Exception) @@ -85,4 +121,4 @@ Plugins are separate processes (subprocess communication) and do not affect Poly --- -**Last Updated:** January 12, 2026 +**Last Updated:** May 5, 2026 diff --git a/libs/imgproc/frame.h b/libs/imgproc/frame.h index 25cb5cf..b791ef8 100644 --- a/libs/imgproc/frame.h +++ b/libs/imgproc/frame.h @@ -14,15 +14,18 @@ class Frame public: Frame() : width_(0), height_(0) {} - Frame(uint16_t width, uint16_t height) : width_(width), height_(height), data_(width * height) {} + Frame(uint16_t width, uint16_t height) + : width_(width), height_(height), data_(static_cast(width) * height) + { + } Frame(uint16_t width, uint16_t height, const T* data) - : width_(width), height_(height), data_(data, data + width * height) + : width_(width), height_(height), data_(data, data + static_cast(width) * height) { } Frame(uint16_t width, uint16_t height, T fill) - : width_(width), height_(height), data_(width * height, fill) + : width_(width), height_(height), data_(static_cast(width) * height, fill) { } diff --git a/libs/segcore/include/segcore/project.h b/libs/segcore/include/segcore/project.h index 7d9d594..dba46cf 100644 --- a/libs/segcore/include/segcore/project.h +++ b/libs/segcore/include/segcore/project.h @@ -5,6 +5,7 @@ #include #include +#include #include namespace segcore @@ -25,7 +26,7 @@ class Project ArtifactId next_id_ = 1; ArtifactId current_id_ = kInvalidArtifactId; std::unordered_map artifacts_; - std::unordered_map annotations_; + std::unordered_map> annotations_; }; } // namespace segcore diff --git a/libs/segcore/src/normalized_format_serializer.cpp b/libs/segcore/src/normalized_format_serializer.cpp index fad4233..87fb152 100644 --- a/libs/segcore/src/normalized_format_serializer.cpp +++ b/libs/segcore/src/normalized_format_serializer.cpp @@ -49,16 +49,23 @@ std::vector NormalizedFormatToSegments(const std::string& text, int w, { continue; } - Segment seg; - seg.id = next_id++; - seg.class_id = std::stoi(tokens[0]); - for (size_t i = 1; i + 1 < tokens.size(); i += 2) + try { - double nx = std::stod(tokens[i]); - double ny = std::stod(tokens[i + 1]); - seg.points.push_back({static_cast(nx * w), static_cast(ny * h)}); + Segment seg; + seg.id = next_id++; + seg.class_id = std::stoi(tokens[0]); + for (size_t i = 1; i + 1 < tokens.size(); i += 2) + { + double nx = std::stod(tokens[i]); + double ny = std::stod(tokens[i + 1]); + seg.points.push_back({static_cast(nx * w), static_cast(ny * h)}); + } + result.push_back(std::move(seg)); + } + catch (const std::exception&) + { + continue; } - result.push_back(std::move(seg)); } return result; } diff --git a/libs/segcore/src/project.cpp b/libs/segcore/src/project.cpp index be04fc3..c28da3e 100644 --- a/libs/segcore/src/project.cpp +++ b/libs/segcore/src/project.cpp @@ -8,7 +8,7 @@ ArtifactId Project::AddArtifact(Artifact artifact) ArtifactId id = next_id_++; artifact.id = id; artifacts_.emplace(id, std::move(artifact)); - annotations_.emplace(id, AnnotationSet{}); + annotations_.emplace(id, std::make_unique()); return id; } @@ -30,12 +30,12 @@ const Artifact* Project::GetArtifact(ArtifactId id) const AnnotationSet& Project::GetAnnotations(ArtifactId id) { - return annotations_.at(id); + return *annotations_.at(id); } const AnnotationSet& Project::GetAnnotations(ArtifactId id) const { - return annotations_.at(id); + return *annotations_.at(id); } ArtifactId Project::GetCurrentArtifactId() const diff --git a/src/aipluginmanager.cpp b/src/aipluginmanager.cpp index 2dfe4bb..24c509d 100644 --- a/src/aipluginmanager.cpp +++ b/src/aipluginmanager.cpp @@ -12,8 +12,7 @@ #include #include -#include - +#include "logger.h" #include "modelregistrationdialog.h" #include "polygoncanvas.h" #include "projectconfig.h" @@ -118,7 +117,7 @@ void AIPluginManager::ExecutePluginCommand(const QString& command, const QString if (!project_directory_.isEmpty()) { process.setWorkingDirectory(project_directory_); - std::cout << "Working directory: " << project_directory_.toStdString() << std::endl; + spdlog::info("Working directory: {}", project_directory_.toStdString()); } QString full_command = command; @@ -137,17 +136,16 @@ void AIPluginManager::ExecutePluginCommand(const QString& command, const QString full_command = "bash"; full_args = QStringList() << "-c" << shell_command; - std::cout << "Executing with env setup: bash -c \"" << shell_command.toStdString() << "\"" - << std::endl; + spdlog::info("Executing with env setup: bash -c \"{}\"", shell_command.toStdString()); } else { - std::cout << "Executing: " << command.toStdString(); + QString cmd_str = command; for (const QString& arg : args) { - std::cout << " " << arg.toStdString(); + cmd_str += " " + arg; } - std::cout << std::endl; + spdlog::info("Executing: {}", cmd_str.toStdString()); } process.start(full_command, full_args); @@ -170,8 +168,8 @@ void AIPluginManager::ExecutePluginCommand(const QString& command, const QString QString output = process.readAll(); int exitCode = process.exitCode(); - std::cout << "Plugin exit code: " << exitCode << std::endl; - std::cout << "Plugin output:\n" << output.toStdString() << std::endl; + spdlog::info("Plugin exit code: {}", exitCode); + spdlog::info("Plugin output:\n{}", output.toStdString()); if (exitCode != 0) { @@ -275,9 +273,8 @@ void AIPluginManager::ParseDetectionResults(const QString& json_output) class_id = classes[external_class_id].id; class_color = classes[external_class_id].color; resolved_class_name = classes[external_class_id].name; - std::cout << "Mapped external class_id " << external_class_id - << " to project class: " << classes[external_class_id].name.toStdString() - << std::endl; + spdlog::info("Mapped external class_id {} to project class: {}", external_class_id, + classes[external_class_id].name.toStdString()); } } @@ -298,7 +295,7 @@ void AIPluginManager::ParseDetectionResults(const QString& json_output) class_color = color; resolved_class_name = new_class_name; emit ClassesUpdated(); - std::cout << "Auto-created class: " << new_class_name.toStdString() << std::endl; + spdlog::info("Auto-created class: {}", new_class_name.toStdString()); } // Convert normalized coordinates to pixel coordinates @@ -463,7 +460,7 @@ void AIPluginManager::RunTrainModel() { if (QFile::remove(cache_file)) { - std::cout << "Removed old cache: " << cache_file.toStdString() << std::endl; + spdlog::info("Removed old cache: {}", cache_file.toStdString()); } } } @@ -492,7 +489,7 @@ void AIPluginManager::RunTrainModel() out << "nc: " << classes.size() << "\n"; data_yaml.close(); - std::cout << "Generated data.yaml with " << classes.size() << " classes" << std::endl; + spdlog::info("Generated data.yaml with {} classes", classes.size()); } else { @@ -568,8 +565,8 @@ void AIPluginManager::RunTrainModel() // Set working directory to project directory training_process_->setWorkingDirectory(project_directory_); - std::cout << "Training working directory: " << project_directory_.toStdString() << std::endl; - std::cout << "Training logs: " << log_file_path.toStdString() << std::endl; + spdlog::info("Training working directory: {}", project_directory_.toStdString()); + spdlog::info("Training logs: {}", log_file_path.toStdString()); // Open log files for writing QFile* log_file = new QFile(log_file_path, training_process_); @@ -577,14 +574,14 @@ void AIPluginManager::RunTrainModel() if (!log_file->open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) { - std::cerr << "Warning: Could not open log file for writing" << std::endl; + spdlog::warn("Could not open log file for writing"); delete log_file; log_file = nullptr; } if (!err_file->open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Append)) { - std::cerr << "Warning: Could not open error log file for writing" << std::endl; + spdlog::warn("Could not open error log file for writing"); delete err_file; err_file = nullptr; } @@ -592,7 +589,7 @@ void AIPluginManager::RunTrainModel() // Connect to read output in real-time and write to both terminal and log file connect(training_process_, &QProcess::readyReadStandardOutput, this, [this, log_file]() { QString output = training_process_->readAllStandardOutput(); - std::cout << output.toStdString() << std::flush; + spdlog::info("{}", output.toStdString()); if (log_file && log_file->isOpen()) { @@ -603,7 +600,7 @@ void AIPluginManager::RunTrainModel() connect(training_process_, &QProcess::readyReadStandardError, this, [this, err_file]() { QString error_output = training_process_->readAllStandardError(); - std::cerr << error_output.toStdString() << std::flush; + spdlog::error("{}", error_output.toStdString()); if (err_file && err_file->isOpen()) { @@ -627,21 +624,20 @@ void AIPluginManager::RunTrainModel() if (exitStatus == QProcess::NormalExit && exitCode == 0) { - std::cout << "\n=== Training completed successfully ===\n" << std::endl; + spdlog::info("=== Training completed successfully ==="); // Training succeeded - prompt for model registration PromptModelRegistration(); emit TrainingComplete(true); } else if (exitStatus == QProcess::CrashExit) { - std::cerr << "\n=== Training process crashed ===\n" << std::endl; + spdlog::error("=== Training process crashed ==="); QMessageBox::critical(nullptr, "Training Failed", "Training process crashed."); emit TrainingComplete(false); } else { - std::cerr << "\n=== Training failed with exit code " << exitCode << " ===\n" - << std::endl; + spdlog::error("=== Training failed with exit code {} ===", exitCode); QMessageBox::critical( nullptr, "Training Failed", QString("Training exited with error code %1\n\nCheck terminal for details.") @@ -670,17 +666,17 @@ void AIPluginManager::RunTrainModel() full_command = "bash"; full_args = QStringList() << "-c" << shell_command; - std::cout << "Executing training with env setup: bash -c \"" << shell_command.toStdString() - << "\"" << std::endl; + spdlog::info("Executing training with env setup: bash -c \"{}\"", + shell_command.toStdString()); } else { - std::cout << "Executing: " << plugin.command.toStdString(); + QString cmd_str = plugin.command; for (const QString& arg : args) { - std::cout << " " << arg.toStdString(); + cmd_str += " " + arg; } - std::cout << std::endl; + spdlog::info("Executing: {}", cmd_str.toStdString()); } training_process_->start(full_command, full_args); @@ -694,7 +690,7 @@ void AIPluginManager::RunTrainModel() return; } - std::cout << "\n=== Training started ===\n" << std::endl; + spdlog::info("=== Training started ==="); } void AIPluginManager::RunBatchDetect() @@ -751,7 +747,7 @@ void AIPluginManager::RunBatchDetect() if (HasApprovedFile(image_path)) { skipped++; - std::cout << "Skipping (already approved): " << image_file.toStdString() << std::endl; + spdlog::info("Skipping (already approved): {}", image_file.toStdString()); continue; } @@ -828,7 +824,7 @@ void AIPluginManager::BatchDetectOnImage(const QString& image_path) QProcess process; process.setProcessChannelMode(QProcess::MergedChannels); - std::cout << "Batch detect: " << image_path.toStdString() << std::endl; + spdlog::info("Batch detect: {}", image_path.toStdString()); QString full_command = plugin.command; QStringList full_args = args; @@ -851,13 +847,13 @@ void AIPluginManager::BatchDetectOnImage(const QString& image_path) if (!process.waitForStarted()) { - std::cerr << "Failed to start plugin for: " << image_path.toStdString() << std::endl; + spdlog::error("Failed to start plugin for: {}", image_path.toStdString()); return; } if (!process.waitForFinished(30000)) { - std::cerr << "Plugin timeout for: " << image_path.toStdString() << std::endl; + spdlog::warn("Plugin timeout for: {}", image_path.toStdString()); process.kill(); return; } @@ -867,8 +863,7 @@ void AIPluginManager::BatchDetectOnImage(const QString& image_path) if (exitCode != 0) { - std::cerr << "Plugin error for: " << image_path.toStdString() << " (exit code: " << exitCode - << ")" << std::endl; + spdlog::error("Plugin error for: {} (exit code: {})", image_path.toStdString(), exitCode); return; } @@ -888,7 +883,7 @@ void AIPluginManager::BatchDetectOnImage(const QString& image_path) if (doc.isNull() || !doc.isObject()) { - std::cerr << "Invalid JSON from plugin for: " << image_path.toStdString() << std::endl; + spdlog::error("Invalid JSON from plugin for: {}", image_path.toStdString()); return; } @@ -898,14 +893,14 @@ void AIPluginManager::BatchDetectOnImage(const QString& image_path) if (root.contains("success") && !root["success"].toBool()) { QString error_msg = root.contains("error") ? root["error"].toString() : "Unknown error"; - std::cerr << "Plugin error for " << image_path.toStdString() << ": " - << error_msg.toStdString() << std::endl; + spdlog::error("Plugin error for {}: {}", image_path.toStdString(), + error_msg.toStdString()); return; } if (!root.contains("detections") || !root["detections"].isArray()) { - std::cerr << "Missing detections array for: " << image_path.toStdString() << std::endl; + spdlog::error("Missing detections array for: {}", image_path.toStdString()); return; } @@ -913,7 +908,7 @@ void AIPluginManager::BatchDetectOnImage(const QString& image_path) if (detections.isEmpty()) { - std::cout << "No detections for: " << image_path.toStdString() << std::endl; + spdlog::info("No detections for: {}", image_path.toStdString()); return; } @@ -924,7 +919,7 @@ void AIPluginManager::BatchDetectOnImage(const QString& image_path) QFile meta_file(meta_path); if (!meta_file.open(QIODevice::WriteOnly | QIODevice::Text)) { - std::cerr << "Failed to create meta file: " << meta_path.toStdString() << std::endl; + spdlog::error("Failed to create meta file: {}", meta_path.toStdString()); return; } @@ -934,7 +929,7 @@ void AIPluginManager::BatchDetectOnImage(const QString& image_path) QPixmap pixmap(image_path); if (pixmap.isNull()) { - std::cerr << "Failed to load image: " << image_path.toStdString() << std::endl; + spdlog::error("Failed to load image: {}", image_path.toStdString()); return; } @@ -1011,8 +1006,7 @@ void AIPluginManager::BatchDetectOnImage(const QString& image_path) } meta_file.close(); - std::cout << "Saved " << detections.size() << " detections to: " << meta_path.toStdString() - << std::endl; + spdlog::info("Saved {} detections to: {}", detections.size(), meta_path.toStdString()); } void AIPluginManager::SaveToMetaFile(const QString& image_path) @@ -1022,7 +1016,7 @@ void AIPluginManager::SaveToMetaFile(const QString& image_path) // Use existing ExportAnnotations logic but to .meta file canvas_->ExportAnnotations(meta_path, 0); - std::cout << "Saved to meta file: " << meta_path.toStdString() << std::endl; + spdlog::info("Saved to meta file: {}", meta_path.toStdString()); } void AIPluginManager::LoadFromMetaFile(const QString& image_path) @@ -1044,7 +1038,7 @@ void AIPluginManager::LoadFromMetaFile(const QString& image_path) // Load from .meta file canvas_->LoadAnnotations(meta_path, class_colors); - std::cout << "Loaded from meta file: " << meta_path.toStdString() << std::endl; + spdlog::info("Loaded from meta file: {}", meta_path.toStdString()); } bool AIPluginManager::HasMetaFile(const QString& image_path) const @@ -1081,11 +1075,11 @@ void AIPluginManager::PromoteMetaToApproved(const QString& image_path) // Rename .meta -> .txt (promote to approved) if (QFile::rename(meta_path, label_path)) { - std::cout << "Approved: " << fileInfo.baseName().toStdString() << std::endl; + spdlog::info("Approved: {}", fileInfo.baseName().toStdString()); } else { - std::cerr << "Failed to approve: " << fileInfo.baseName().toStdString() << std::endl; + spdlog::error("Failed to approve: {}", fileInfo.baseName().toStdString()); } } @@ -1097,7 +1091,7 @@ void AIPluginManager::DeleteMetaFile(const QString& image_path) if (QFile::exists(meta_path)) { QFile::remove(meta_path); - std::cout << "Rejected meta file: " << fileInfo.baseName().toStdString() << std::endl; + spdlog::info("Rejected meta file: {}", fileInfo.baseName().toStdString()); } } @@ -1160,11 +1154,11 @@ void AIPluginManager::PromptModelRegistration() if (!trained_model_path.isEmpty()) { - std::cout << "Found trained model: " << trained_model_path.toStdString() << std::endl; + spdlog::info("Found trained model: {}", trained_model_path.toStdString()); } else { - std::cout << "No best.pt found in: " << runs_dir.toStdString() << std::endl; + spdlog::info("No best.pt found in: {}", runs_dir.toStdString()); } } @@ -1183,11 +1177,11 @@ void AIPluginManager::PromptModelRegistration() // Copy new model if (QFile::copy(trained_model_path, dest_path)) { - std::cout << "Copied model to: " << dest_path.toStdString() << std::endl; + spdlog::info("Copied model to: {}", dest_path.toStdString()); } else { - std::cerr << "Failed to copy model to: " << dest_path.toStdString() << std::endl; + spdlog::error("Failed to copy model to: {}", dest_path.toStdString()); } } @@ -1246,16 +1240,16 @@ void AIPluginManager::RegisterModelManually() model.training_images_count = labeled_count; model.notes = notes; - std::cout << "=== Registering Model ===" << std::endl; - std::cout << "Name: " << name.toStdString() << std::endl; - std::cout << "Path: " << path.toStdString() << std::endl; - std::cout << "Training images: " << labeled_count << std::endl; - std::cout << "Notes: " << notes.toStdString() << std::endl; + spdlog::info("=== Registering Model ==="); + spdlog::info("Name: {}", name.toStdString()); + spdlog::info("Path: {}", path.toStdString()); + spdlog::info("Training images: {}", labeled_count); + spdlog::info("Notes: {}", notes.toStdString()); // Add to config project_config_->AddModelVersion(model); - std::cout << "Total models after adding: " << project_config_->GetModelVersions().size() - << std::endl; + spdlog::info("Total models after adding: {}", + project_config_->GetModelVersions().size()); // Automatically update plugin settings to use this newly registered model for detection PluginConfig plugin = project_config_->GetPluginConfig(); diff --git a/src/edgedetector.cpp b/src/edgedetector.cpp index 44cf920..0cf5071 100644 --- a/src/edgedetector.cpp +++ b/src/edgedetector.cpp @@ -6,9 +6,10 @@ #include #include #include -#include #include +#include "logger.h" + namespace { polyseg::Frame ToGrayscaleFrame(const QImage& image) @@ -109,8 +110,8 @@ std::vector Threshold(const polyseg::Frame& log_abs) const uint8_t otsu = OtsuThreshold(norm.data(), n); const uint8_t t = std::max(static_cast(otsu * 65 / 100), uint8_t{8}); - std::cout << "[EdgeDetect] max_log=" << max_val << " otsu=" << static_cast(otsu) - << " threshold=" << static_cast(t) << std::endl; + spdlog::debug("[EdgeDetect] max_log={} otsu={} threshold={}", max_val, static_cast(otsu), + static_cast(t)); std::vector edges(n, 0); for (size_t i = 0; i < n; ++i) edges[i] = (norm[i] >= t) ? 1 : 0; diff --git a/src/logger.h b/src/logger.h new file mode 100644 index 0000000..5f44822 --- /dev/null +++ b/src/logger.h @@ -0,0 +1,20 @@ +#pragma once +#include +#include + +namespace Logger { + +inline void Init() +{ + auto logger = spdlog::stdout_color_mt("polyseg"); + logger->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%^%l%$] %v"); + spdlog::set_default_logger(logger); + spdlog::flush_on(spdlog::level::trace); +#ifdef NDEBUG + spdlog::set_level(spdlog::level::info); +#else + spdlog::set_level(spdlog::level::debug); +#endif +} + +} // namespace Logger diff --git a/src/main.cpp b/src/main.cpp index 40986b1..dc813ce 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,9 +1,34 @@ #include +#include +#include "logger.h" #include "mainwindow.h" +static void SpdlogQtMessageHandler(QtMsgType type, const QMessageLogContext&, const QString& msg) +{ + const std::string s = msg.toStdString(); + switch (type) + { + case QtWarningMsg: + spdlog::warn("{}", s); + break; + case QtCriticalMsg: + spdlog::error("{}", s); + break; + case QtFatalMsg: + spdlog::critical("{}", s); + break; + default: + spdlog::info("{}", s); + break; + } +} + int main(int argc, char* argv[]) { + Logger::Init(); + qInstallMessageHandler(SpdlogQtMessageHandler); + #ifdef Q_OS_LINUX // Set Wayland decoration style if running on Wayland and not already set // This ensures window frames are displayed on Wayland compositors diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 7faeb3c..e805947 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -36,9 +36,8 @@ #include #include -#include - #include "aipluginmanager.h" +#include "logger.h" #include "metadataimporter.h" #include "metadataimportsettingsdialog.h" #include "pluginwizard.h" @@ -258,7 +257,7 @@ void MainWindow::Load() if (filename.isEmpty()) { - std::cout << "Active image is not selected" << std::endl; + spdlog::info("Active image is not selected"); return; } @@ -596,23 +595,28 @@ void MainWindow::LoadImageAtIndex(int index) if (index < 0 || index >= image_list_.size()) { - std::cerr << "Invalid image index: " << index << std::endl; + spdlog::error("Invalid image index: {}", index); return; } AutoSaveCurrentImage(); - // Save clipboard from current image before switching - const segcore::Segment* clipboard_backup = nullptr; + // Save clipboard from current image before switching (deep copy) + std::optional clipboard_backup; if (ui->label->GetAnnotationSet() != nullptr) { auto* current_set = dynamic_cast(ui->label->GetAnnotationSet()); if (current_set != nullptr) { - clipboard_backup = current_set->GetClipboard(); + if (const segcore::Segment* cb = current_set->GetClipboard(); cb != nullptr) + { + clipboard_backup = *cb; + } } } + segcore::ArtifactId previous_artifact_id = current_artifact_id_; + current_image_index_ = index; QString imagePath = project_directory_ + "/images/" + image_list_[index]; @@ -634,15 +638,20 @@ void MainWindow::LoadImageAtIndex(int index) ui->label->SetAnnotationSet(&project_core_.GetAnnotations(current_artifact_id_)); // Restore clipboard to new image - if (clipboard_backup != nullptr) + if (clipboard_backup.has_value()) { auto* new_set = dynamic_cast(ui->label->GetAnnotationSet()); if (new_set != nullptr) { - new_set->SetClipboard(clipboard_backup); + new_set->SetClipboard(&clipboard_backup.value()); } } + if (previous_artifact_id != segcore::kInvalidArtifactId) + { + project_core_.RemoveArtifact(previous_artifact_id); + } + ui->label->SetClassColors(BuildClassColorTable()); QFileInfo fileInfo(imagePath); @@ -995,14 +1004,13 @@ void MainWindow::ScanProjectImages() int totalPolygons = 0; // TODO: count from label files project_config_.UpdateStatistics(image_list_.size(), labeled, totalPolygons); - std::cout << "Found " << image_list_.size() << " images, " << labeled << " labeled" << std::endl; + spdlog::info("Found {} images, {} labeled", image_list_.size(), labeled); // Show split statistics if enabled if (project_config_.IsSplitEnabled()) { - std::cout << "Split counts - Train: " << project_config_.GetTrainCount() - << " Val: " << project_config_.GetValCount() - << " Test: " << project_config_.GetTestCount() << std::endl; + spdlog::info("Split counts - Train: {} Val: {} Test: {}", project_config_.GetTrainCount(), + project_config_.GetValCount(), project_config_.GetTestCount()); } // Update AI plugin manager with project info @@ -1868,11 +1876,11 @@ void MainWindow::LoadLastProject() if (lastProject.isEmpty() || !QFile::exists(lastProject)) { - std::cout << "No last project to load" << std::endl; + spdlog::info("No last project to load"); return; } - std::cout << "Loading last project: " << lastProject.toStdString() << std::endl; + spdlog::info("Loading last project: {}", lastProject.toStdString()); QFileInfo fileInfo(lastProject); QString projectDir = fileInfo.dir().path(); @@ -1900,12 +1908,12 @@ void MainWindow::LoadLastProject() LoadImageAtIndex(0); } - std::cout << "Loaded last project: " << project_config_.GetProjectName().toStdString() - << " with " << image_list_.size() << " images" << std::endl; + spdlog::info("Loaded last project: {} with {} images", + project_config_.GetProjectName().toStdString(), image_list_.size()); } else { - std::cout << "Failed to load last project" << std::endl; + spdlog::info("Failed to load last project"); } } diff --git a/src/metadataimporter.cpp b/src/metadataimporter.cpp index df3748b..49e6df2 100644 --- a/src/metadataimporter.cpp +++ b/src/metadataimporter.cpp @@ -1,13 +1,15 @@ #include "metadataimporter.h" +#include #include -#include -#include -#include #include -#include -#include +#include +#include + #include +#include + +#include "logger.h" QImage MetadataImporter::ImportMetadataFile(const QString& filepath, const ImportSettings& settings) @@ -16,7 +18,7 @@ QImage MetadataImporter::ImportMetadataFile(const QString& filepath, int width, height; if (!ParseHeader(filepath, width, height)) { - qWarning() << "Failed to parse header for file:" << filepath; + spdlog::warn("Failed to parse header for file: {}", filepath.toStdString()); return QImage(); } @@ -29,14 +31,14 @@ bool MetadataImporter::ParseHeader(const QString& filepath, int& width, int& hei QFile file(filepath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - qWarning() << "Cannot open file:" << filepath; + spdlog::warn("Cannot open file: {}", filepath.toStdString()); return false; } QTextStream stream(&file); if (stream.atEnd()) { - qWarning() << "Empty file:" << filepath; + spdlog::warn("Empty file: {}", filepath.toStdString()); return false; } @@ -67,7 +69,7 @@ QImage MetadataImporter::ProcessDataStream(const QString& filepath, QFile file(filepath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - qWarning() << "Cannot open file for data processing:" << filepath; + spdlog::warn("Cannot open file for data processing: {}", filepath.toStdString()); return QImage(); } @@ -93,7 +95,7 @@ QImage MetadataImporter::ProcessDataStream(const QString& filepath, settings.crop_start_x >= settings.crop_end_x || settings.crop_start_y >= settings.crop_end_y) { - qWarning() << "Invalid crop boundaries"; + spdlog::warn("Invalid crop boundaries"); return QImage(); } @@ -115,15 +117,14 @@ QImage MetadataImporter::ProcessDataStream(const QString& filepath, QString line = stream.readLine().trimmed(); if (line.isEmpty()) { - qWarning() << "Empty line found at row:" << current_row; + spdlog::warn("Empty line found at row: {}", current_row); return QImage(); } QStringList values = line.split(' ', Qt::SkipEmptyParts); if (values.size() != width) { - qWarning() << "Row" << current_row << "has" << values.size() - << "values, expected" << width; + spdlog::warn("Row {} has {} values, expected {}", current_row, values.size(), width); return QImage(); } @@ -146,8 +147,8 @@ QImage MetadataImporter::ProcessDataStream(const QString& filepath, double value = values[col].toDouble(&ok); if (!ok) { - qWarning() << "Invalid numeric value at row" << current_row - << "col" << col << ":" << values[col]; + spdlog::warn("Invalid numeric value at row {} col {}: {}", current_row, col, + values[col].toStdString()); return QImage(); } @@ -174,7 +175,7 @@ QImage MetadataImporter::ProcessDataStream(const QString& filepath, if (current_row != height) { - qWarning() << "Expected" << height << "data rows, found" << current_row; + spdlog::warn("Expected {} data rows, found {}", height, current_row); return QImage(); } diff --git a/src/polygoncanvas.cpp b/src/polygoncanvas.cpp index e34fdb2..63f45c3 100644 --- a/src/polygoncanvas.cpp +++ b/src/polygoncanvas.cpp @@ -7,9 +7,9 @@ #include #include -#include #include "edgedetector.h" +#include "logger.h" #include "qtadapter.h" #include #include @@ -35,8 +35,8 @@ void PolygonCanvas::setPixmap(const QPixmap& pm) { QLabel::setPixmap(pm); edges_ = EdgeDetector::Result{}; - std::cout << "[EdgeSnap] setPixmap called, size=" << pm.width() << "x" << pm.height() - << ", snap=" << snap_to_edges_ << std::endl; + spdlog::debug("[EdgeSnap] setPixmap called, size={}x{}, snap={}", pm.width(), pm.height(), + snap_to_edges_); if (snap_to_edges_) ComputeEdges(); } @@ -67,9 +67,8 @@ void PolygonCanvas::SetDrawingMode(int class_id) void PolygonCanvas::SetSnapToEdges(bool enabled) { snap_to_edges_ = enabled; - std::cout << "[EdgeSnap] SetSnapToEdges(" << enabled - << "), pixmap null=" << pixmap().isNull() - << ", edges valid=" << edges_.isValid() << std::endl; + spdlog::debug("[EdgeSnap] SetSnapToEdges({}), pixmap null={}, edges valid={}", enabled, + pixmap().isNull(), edges_.isValid()); if (enabled && !edges_.isValid()) ComputeEdges(); repaint(); } @@ -85,15 +84,15 @@ void PolygonCanvas::ComputeEdges() { if (pixmap().isNull()) { - std::cout << "[EdgeSnap] ComputeEdges: pixmap is null, skipping" << std::endl; + spdlog::debug("[EdgeSnap] ComputeEdges: pixmap is null, skipping"); return; } - std::cout << "[EdgeSnap] ComputeEdges: detecting..." << std::endl; + spdlog::debug("[EdgeSnap] ComputeEdges: detecting..."); edges_ = EdgeDetector::Detect(pixmap().toImage()); const long edge_count = std::count(edges_.edge_map.begin(), edges_.edge_map.end(), uint8_t{1}); - std::cout << "[EdgeSnap] ComputeEdges: found " << edge_count << " edge pixels (" - << edges_.width << "x" << edges_.height << ")" << std::endl; + spdlog::debug("[EdgeSnap] ComputeEdges: found {} edge pixels ({}x{})", edge_count, edges_.width, + edges_.height); repaint(); } @@ -127,7 +126,7 @@ void PolygonCanvas::ResetZoom() QSize size = pixmap().size(); setFixedSize(static_cast(size.width() * scalar_), static_cast(size.height() * scalar_)); - std::cout << "Zoom reset to 100%" << std::endl; + spdlog::info("Zoom reset to 100%"); } void PolygonCanvas::StartNewPolygon(int class_id, QColor color) @@ -138,7 +137,7 @@ void PolygonCanvas::StartNewPolygon(int class_id, QColor color) } class_colors_[class_id] = color; SetDrawingMode(class_id); - std::cout << "Started new polygon with class_id: " << class_id << std::endl; + spdlog::info("Started new polygon with class_id: {}", class_id); } void PolygonCanvas::FinishCurrentPolygon() @@ -154,11 +153,11 @@ void PolygonCanvas::FinishCurrentPolygon() emit CurrentClassChanged(-1); emit PolygonsChanged(); repaint(); - std::cout << "Polygon finished and saved." << std::endl; + spdlog::info("Polygon finished and saved."); } else { - std::cout << "Cannot finish polygon: need at least 3 points" << std::endl; + spdlog::info("Cannot finish polygon: need at least 3 points"); } } @@ -169,7 +168,7 @@ void PolygonCanvas::ClearCurrentPolygon() drawing_class_id_ = -1; emit CurrentClassChanged(-1); repaint(); - std::cout << "Drawing cancelled" << std::endl; + spdlog::info("Drawing cancelled"); } void PolygonCanvas::mouseMoveEvent(QMouseEvent* ev) @@ -388,10 +387,10 @@ void PolygonCanvas::ExportAnnotations(const QString& filename, int) } else { - std::cerr << "Cannot open file for writing: " << filename.toStdString() << std::endl; + spdlog::error("Cannot open file for writing: {}", filename.toStdString()); } - std::cout << "Annotations exported to: " << filename.toStdString() << std::endl; + spdlog::info("Annotations exported to: {}", filename.toStdString()); } void PolygonCanvas::LoadAnnotations(const QString& filepath, const QVector& class_colors) @@ -404,7 +403,7 @@ void PolygonCanvas::LoadAnnotations(const QString& filepath, const QVector& points, int clas annotation_set_->CommitSegment(id); emit PolygonsChanged(); repaint(); - std::cout << "Added plugin polygon with " << points.size() - << " points (class_id=" << class_id << ")" << std::endl; + spdlog::info("Added plugin polygon with {} points (class_id={})", points.size(), class_id); } void PolygonCanvas::SelectPolygon(const QPoint& pos) @@ -462,11 +459,11 @@ void PolygonCanvas::SelectPolygon(const QPoint& pos) if (seg_id != segcore::kInvalidSegmentId) { annotation_set_->SelectSegment(seg_id); - std::cout << "Selected segment " << seg_id << std::endl; + spdlog::info("Selected segment {}", seg_id); } else { - std::cout << "Deselected all" << std::endl; + spdlog::info("Deselected all"); } repaint(); } diff --git a/src/projectconfig.cpp b/src/projectconfig.cpp index bb1f074..4df6e0e 100644 --- a/src/projectconfig.cpp +++ b/src/projectconfig.cpp @@ -7,7 +7,7 @@ #include #include -#include +#include "logger.h" PluginConfig::PluginConfig() : enabled(false), @@ -267,7 +267,7 @@ bool ProjectConfig::LoadFromFile(const QString& filepath) QFile file(filepath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { - std::cerr << "Failed to open project file: " << filepath.toStdString() << std::endl; + spdlog::error("Failed to open project file: {}", filepath.toStdString()); return false; } @@ -277,7 +277,7 @@ bool ProjectConfig::LoadFromFile(const QString& filepath) QJsonDocument doc = QJsonDocument::fromJson(data); if (doc.isNull() || !doc.isObject()) { - std::cerr << "Invalid JSON in project file" << std::endl; + spdlog::error("Invalid JSON in project file"); return false; } @@ -290,7 +290,7 @@ bool ProjectConfig::SaveToFile(const QString& filepath) QFile file(filepath); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { - std::cerr << "Failed to save project file: " << filepath.toStdString() << std::endl; + spdlog::error("Failed to save project file: {}", filepath.toStdString()); return false; } @@ -298,7 +298,7 @@ bool ProjectConfig::SaveToFile(const QString& filepath) file.write(doc.toJson(QJsonDocument::Indented)); file.close(); - std::cout << "Project saved to: " << filepath.toStdString() << std::endl; + spdlog::info("Project saved to: {}", filepath.toStdString()); return true; } @@ -311,8 +311,8 @@ void ProjectConfig::AddClass(const QString& name, const QColor& color, int index pc.index = (index >= 0) ? index : classes_.size(); classes_.push_back(pc); - std::cout << "Added class: " << name.toStdString() << " (id=" << pc.id << ", index=" << pc.index - << ", color=" << color.name().toStdString() << ")" << std::endl; + spdlog::info("Added class: {} (id={}, index={}, color={})", name.toStdString(), pc.id, pc.index, + color.name().toStdString()); } void ProjectConfig::RemoveClass(int class_id) @@ -321,7 +321,7 @@ void ProjectConfig::RemoveClass(int class_id) { if (classes_[i].id == class_id) { - std::cout << "Removed class: " << classes_[i].name.toStdString() << std::endl; + spdlog::info("Removed class: {}", classes_[i].name.toStdString()); classes_.removeAt(i); return; } @@ -340,7 +340,7 @@ void ProjectConfig::UpdateClass(int class_id, const QString& name, const QColor& { pc.index = index; } - std::cout << "Updated class id=" << class_id << " to: " << name.toStdString() << std::endl; + spdlog::info("Updated class id={} to: {}", class_id, name.toStdString()); return; } } @@ -451,7 +451,7 @@ QJsonObject ProjectConfig::ToJson() const } obj["model_versions"] = models_array; - std::cout << "ToJson: Serializing " << model_versions_.size() << " model versions" << std::endl; + spdlog::info("ToJson: Serializing {} model versions", model_versions_.size()); return obj; } @@ -484,8 +484,8 @@ void ProjectConfig::FromJson(const QJsonObject& json) if (json.contains("plugin")) { plugin_config_ = PluginConfig::FromJson(json["plugin"].toObject()); - std::cout << "Plugin loaded: " << plugin_config_.name.toStdString() - << " (enabled=" << (plugin_config_.enabled ? "yes" : "no") << ")" << std::endl; + spdlog::info("Plugin loaded: {} (enabled={})", plugin_config_.name.toStdString(), + plugin_config_.enabled ? "yes" : "no"); } // Load statistics @@ -528,10 +528,9 @@ void ProjectConfig::FromJson(const QJsonObject& json) model_versions_.append(ModelVersion::FromJson(model_val.toObject())); } - std::cout << "FromJson: Loaded " << model_versions_.size() << " model versions" << std::endl; - - std::cout << "Loaded project: " << project_name_.toStdString() << " with " << classes_.size() - << " classes" << std::endl; + spdlog::info("FromJson: Loaded {} model versions", model_versions_.size()); + spdlog::info("Loaded project: {} with {} classes", project_name_.toStdString(), + classes_.size()); } // Train/Val/Test Split Management Implementation @@ -603,15 +602,15 @@ void ProjectConfig::UpdateImageSplits(const QStringList& all_images) // Existing images keep their split assignment } - std::cout << "Updated splits: Train=" << GetTrainCount() << " Val=" << GetValCount() - << " Test=" << GetTestCount() << std::endl; + spdlog::info("Updated splits: Train={} Val={} Test={}", GetTrainCount(), GetValCount(), + GetTestCount()); } void ProjectConfig::GenerateSplitFiles(const QString& project_dir) { if (!split_config_.enabled) { - std::cerr << "Splits not enabled" << std::endl; + spdlog::error("Splits not enabled"); return; } @@ -656,7 +655,7 @@ void ProjectConfig::GenerateSplitFiles(const QString& project_dir) train_file.write((img + "\n").toUtf8()); } train_file.close(); - std::cout << "Generated train.txt with " << train_images.size() << " images" << std::endl; + spdlog::info("Generated train.txt with {} images", train_images.size()); } // Write val.txt @@ -668,7 +667,7 @@ void ProjectConfig::GenerateSplitFiles(const QString& project_dir) val_file.write((img + "\n").toUtf8()); } val_file.close(); - std::cout << "Generated val.txt with " << val_images.size() << " images" << std::endl; + spdlog::info("Generated val.txt with {} images", val_images.size()); } // Write test.txt @@ -680,7 +679,7 @@ void ProjectConfig::GenerateSplitFiles(const QString& project_dir) test_file.write((img + "\n").toUtf8()); } test_file.close(); - std::cout << "Generated test.txt with " << test_images.size() << " images" << std::endl; + spdlog::info("Generated test.txt with {} images", test_images.size()); } } @@ -722,9 +721,9 @@ int ProjectConfig::GetTestCount() const void ProjectConfig::AddModelVersion(const ModelVersion& model) { model_versions_.append(model); - std::cout << "Added model version: " << model.name.toStdString() - << " (path=" << model.path.toStdString() << ")" << std::endl; - std::cout << "Total models in config: " << model_versions_.size() << std::endl; + spdlog::info("Added model version: {} (path={})", model.name.toStdString(), + model.path.toStdString()); + spdlog::info("Total models in config: {}", model_versions_.size()); } void ProjectConfig::RemoveModelVersion(int index) diff --git a/tests/segcore/project_test.cpp b/tests/segcore/project_test.cpp index 1c21b44..97649d5 100644 --- a/tests/segcore/project_test.cpp +++ b/tests/segcore/project_test.cpp @@ -73,3 +73,59 @@ TEST_F(ProjectTest, RemoveCurrentArtifactResetsCurrentId) project_.RemoveArtifact(id); EXPECT_EQ(project_.GetCurrentArtifactId(), kInvalidArtifactId); } + +TEST_F(ProjectTest, UndoAfterRehashDoesNotCorruptState) +{ + // Add enough artifacts to guarantee at least one unordered_map rehash before + // and after the annotation operations. + for (int i = 0; i < 8; ++i) + { + AddImageArtifact(); + } + + ArtifactId id = AddImageArtifact(); + auto& ann = project_.GetAnnotations(id); + + SegmentId seg = ann.BeginSegment(0); + ann.AddPoint(seg, {0, 0}); + ann.AddPoint(seg, {10, 0}); + + // More insertions after annotation work forces additional rehashes. With + // by-value storage the undo lambdas' captured `this` would now dangle. + for (int i = 0; i < 8; ++i) + { + AddImageArtifact(); + } + + ASSERT_TRUE(ann.CanUndo()); + ann.Undo(); + + const Segment* in_progress = ann.GetInProgress(); + ASSERT_NE(in_progress, nullptr); + EXPECT_EQ(in_progress->points.size(), 1u); +} + +TEST_F(ProjectTest, ImageNavigationReleasesOldArtifacts) +{ + // Each image switch must remove the previous artifact so pixel data does not + // accumulate across navigations. This is the contract LoadImageAtIndex upholds. + ArtifactId previous = kInvalidArtifactId; + std::vector seen_ids; + + for (int i = 0; i < 5; ++i) + { + ArtifactId current = AddImageArtifact(); + project_.SetCurrentArtifact(current); + seen_ids.push_back(current); + + if (previous != kInvalidArtifactId) + { + project_.RemoveArtifact(previous); + EXPECT_EQ(project_.GetArtifact(previous), nullptr) + << "Artifact from navigation step " << (i - 1) << " should have been freed"; + } + previous = current; + } + + EXPECT_NE(project_.GetArtifact(seen_ids.back()), nullptr); +}