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/.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/*
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
+ }
+}
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 6076d2b..0d5a6e1 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,9 @@ 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
src/wizardpages/pluginselectionpage.cpp
src/wizardpages/custompluginpage.cpp
@@ -112,6 +116,9 @@ 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
src/wizardpages/pluginselectionpage.h
src/wizardpages/custompluginpage.h
@@ -138,6 +145,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
@@ -163,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)
@@ -174,12 +192,21 @@ 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
+ spdlog::spdlog
)
# Create the main PolySeg executable
@@ -290,39 +317,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"))
@@ -353,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/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.
+```
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/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..24add76
--- /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..1a9a0ae
--- /dev/null
+++ b/libs/imgproc/convolution_kernels.h
@@ -0,0 +1,73 @@
+#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..b791ef8
--- /dev/null
+++ b/libs/imgproc/frame.h
@@ -0,0 +1,47 @@
+#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_(static_cast(width) * height)
+ {
+ }
+
+ Frame(uint16_t width, uint16_t height, const T* data)
+ : 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_(static_cast(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..bb885d6
--- /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..bd63e84
--- /dev/null
+++ b/libs/segcore/include/segcore/annotation_set.h
@@ -0,0 +1,62 @@
+#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..d0475f7
--- /dev/null
+++ b/libs/segcore/include/segcore/artifact.h
@@ -0,0 +1,38 @@
+#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..3e91419
--- /dev/null
+++ b/libs/segcore/include/segcore/display.h
@@ -0,0 +1,15 @@
+#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..123023d
--- /dev/null
+++ b/libs/segcore/include/segcore/geometry.h
@@ -0,0 +1,25 @@
+#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..37a3653
--- /dev/null
+++ b/libs/segcore/include/segcore/history.h
@@ -0,0 +1,34 @@
+#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..58f356f
--- /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..3ce6a19
--- /dev/null
+++ b/libs/segcore/include/segcore/normalized_format_serializer.h
@@ -0,0 +1,17 @@
+#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..dba46cf
--- /dev/null
+++ b/libs/segcore/include/segcore/project.h
@@ -0,0 +1,34 @@
+#ifndef POLYSEG_SEGCORE_PROJECT_H
+#define POLYSEG_SEGCORE_PROJECT_H
+
+#include
+#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..9a4c35e
--- /dev/null
+++ b/libs/segcore/include/segcore/segment.h
@@ -0,0 +1,21 @@
+#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..7bc0f20
--- /dev/null
+++ b/libs/segcore/include/segcore/types.h
@@ -0,0 +1,34 @@
+#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..969f47a
--- /dev/null
+++ b/libs/segcore/src/annotation_set.cpp
@@ -0,0 +1,369 @@
+#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..5c684ca
--- /dev/null
+++ b/libs/segcore/src/artifact.cpp
@@ -0,0 +1,40 @@
+#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..a61ff41
--- /dev/null
+++ b/libs/segcore/src/display.cpp
@@ -0,0 +1,43 @@
+#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..fc9e5aa
--- /dev/null
+++ b/libs/segcore/src/geometry.cpp
@@ -0,0 +1,109 @@
+#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..eab887d
--- /dev/null
+++ b/libs/segcore/src/history.cpp
@@ -0,0 +1,57 @@
+#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..87fb152
--- /dev/null
+++ b/libs/segcore/src/normalized_format_serializer.cpp
@@ -0,0 +1,73 @@
+#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;
+ }
+ try
+ {
+ 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;
+ }
+ }
+ return result;
+}
+
+} // namespace segcore
diff --git a/libs/segcore/src/project.cpp b/libs/segcore/src/project.cpp
new file mode 100644
index 0000000..c28da3e
--- /dev/null
+++ b/libs/segcore/src/project.cpp
@@ -0,0 +1,51 @@
+#include
+
+namespace segcore
+{
+
+ArtifactId Project::AddArtifact(Artifact artifact)
+{
+ ArtifactId id = next_id_++;
+ artifact.id = id;
+ artifacts_.emplace(id, std::move(artifact));
+ annotations_.emplace(id, std::make_unique());
+ 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/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
new file mode 100644
index 0000000..0cf5071
--- /dev/null
+++ b/src/edgedetector.cpp
@@ -0,0 +1,303 @@
+#include "edgedetector.h"
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+#include "logger.h"
+
+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});
+ 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;
+ 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/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 2d9fd24..e805947 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -36,14 +36,34 @@
#include
#include
-#include
-
#include "aipluginmanager.h"
+#include "logger.h"
+#include "metadataimporter.h"
+#include "metadataimportsettingsdialog.h"
#include "pluginwizard.h"
#include "polygoncanvas.h"
#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),
@@ -91,8 +111,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);
@@ -100,6 +120,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);
@@ -145,6 +170,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();
@@ -196,6 +227,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");
@@ -204,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;
}
@@ -458,8 +511,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"
@@ -500,38 +552,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)
@@ -544,59 +595,90 @@ 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;
}
- // Auto-save current image before switching
AutoSaveCurrentImage();
- // Load new image
+ // 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)
+ {
+ 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];
- 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
- QFileInfo fileInfo(imagePath);
- QString labelPath = project_directory_ + "/labels/" + fileInfo.completeBaseName() + ".txt";
+ 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_);
- // Temporarily disconnect auto-save signal during loading
- disconnect(ui->label, &PolygonCanvas::PolygonsChanged, this, &MainWindow::AutoSaveCurrentImage);
+ ui->label->LoadArtifact(*project_core_.GetArtifact(current_artifact_id_));
+ ui->label->SetAnnotationSet(&project_core_.GetAnnotations(current_artifact_id_));
- if (QFile::exists(labelPath))
+ // Restore clipboard to new image
+ if (clipboard_backup.has_value())
{
- // Get class colors from project config
- QVector class_colors;
- for (const auto& cls : project_config_.GetClasses())
+ auto* new_set = dynamic_cast(ui->label->GetAnnotationSet());
+ if (new_set != nullptr)
{
- class_colors.append(cls.color);
+ new_set->SetClipboard(&clipboard_backup.value());
}
+ }
- ui->label->LoadAnnotations(labelPath, class_colors);
+ if (previous_artifact_id != segcore::kInvalidArtifactId)
+ {
+ project_core_.RemoveArtifact(previous_artifact_id);
}
- else
+
+ ui->label->SetClassColors(BuildClassColorTable());
+
+ QFileInfo fileInfo(imagePath);
+ QString labelPath = project_directory_ + "/labels/" + fileInfo.completeBaseName() + ".txt";
+
+ disconnect(ui->label, &PolygonCanvas::PolygonsChanged, this, &MainWindow::AutoSaveCurrentImage);
+
+ std::string text = ReadFileAsString(labelPath);
+ if (!text.empty())
{
- ui->label->ClearAllPolygons();
+ auto segs = segcore::NormalizedFormatToSegments(text, qimg.width(), qimg.height());
+ auto& anns = project_core_.GetAnnotations(current_artifact_id_);
+ for (auto& s : segs)
+ {
+ auto id = anns.BeginSegment(s.class_id);
+ for (auto& p : s.points)
+ anns.AddPoint(id, p);
+ anns.CommitSegment(id);
+ }
}
- // 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();
}
@@ -922,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
@@ -1177,7 +1258,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())
@@ -1795,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();
@@ -1827,12 +1908,218 @@ 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
+ {
+ spdlog::info("Failed to load last project");
+ }
+}
+
+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
{
- std::cout << "Failed to load last project" << std::endl;
+ // Load image directly if no project
+ 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;
+
+ 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..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
@@ -36,6 +40,7 @@ class MainWindow : public QMainWindow
void PreviousClass();
void SelectClassByNumber(int number);
void AddImagesToProject();
+ void ImportDataAsImage();
// Project Management
void CreateNewProject();
@@ -83,6 +88,7 @@ class MainWindow : public QMainWindow
protected:
void keyPressEvent(QKeyEvent* event) override;
+ bool eventFilter(QObject* obj, QEvent* event) override;
private:
void SaveProjectConfig();
@@ -96,6 +102,7 @@ class MainWindow : public QMainWindow
void UpdateRecentProjectsMenu();
void LoadLastProject();
QString GetProjectStatistics() const;
+ QVector BuildClassColorTable() const;
Ui::MainWindow* ui;
QString current_image_path_;
@@ -119,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 731332c..b3b3b64 100644
--- a/src/mainwindow.ui
+++ b/src/mainwindow.ui
@@ -56,8 +56,8 @@
-
+
@@ -71,6 +71,9 @@
+
+
+
@@ -129,26 +132,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
@@ -170,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/metadataimporter.cpp b/src/metadataimporter.cpp
new file mode 100644
index 0000000..49e6df2
--- /dev/null
+++ b/src/metadataimporter.cpp
@@ -0,0 +1,487 @@
+#include "metadataimporter.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+#include "logger.h"
+
+QImage MetadataImporter::ImportMetadataFile(const QString& filepath,
+ const ImportSettings& settings)
+{
+ // Parse header to get dimensions
+ int width, height;
+ if (!ParseHeader(filepath, width, height))
+ {
+ spdlog::warn("Failed to parse header for file: {}", filepath.toStdString());
+ 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))
+ {
+ spdlog::warn("Cannot open file: {}", filepath.toStdString());
+ return false;
+ }
+
+ QTextStream stream(&file);
+ if (stream.atEnd())
+ {
+ spdlog::warn("Empty file: {}", filepath.toStdString());
+ 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))
+ {
+ spdlog::warn("Cannot open file for data processing: {}", filepath.toStdString());
+ 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)
+ {
+ spdlog::warn("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())
+ {
+ spdlog::warn("Empty line found at row: {}", current_row);
+ return QImage();
+ }
+
+ QStringList values = line.split(' ', Qt::SkipEmptyParts);
+ if (values.size() != width)
+ {
+ spdlog::warn("Row {} has {} values, expected {}", current_row, values.size(), 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)
+ {
+ spdlog::warn("Invalid numeric value at row {} col {}: {}", current_row, col,
+ values[col].toStdString());
+ 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)
+ {
+ spdlog::warn("Expected {} data rows, found {}", height, 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 @@
+
+