From 251b5388139645655f2f982aafb816108173fb46 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 11:46:23 +0200 Subject: [PATCH 01/20] chore: enforce C++17, document CMake options Set CMAKE_CXX_STANDARD 17 / CXX_STANDARD_REQUIRED / CXX_EXTENSIONS OFF project-wide at the top of CMakeLists.txt (previously only set per-target). Add CMakeOptions.md documenting the FORCE_FETCH_DEPENDENCIES option, the language requirement, compile flags, targets, all third-party dependencies and their fetch versions, and the known pre-existing Array2D.h warnings. Co-Authored-By: Claude Opus 4.8 --- CMakeLists.txt | 7 ++++ CMakeOptions.md | 100 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 CMakeOptions.md diff --git a/CMakeLists.txt b/CMakeLists.txt index 0441a7e..38656f5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,13 @@ CMAKE_MINIMUM_REQUIRED(VERSION 3.14) PROJECT(ncorr_library) +# Enforce C++17 project-wide (required for std::optional and modern features). +# Setting these before any target guarantees every target inherits the standard, +# in addition to the per-target properties set further down. +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + # Include FetchContent fallbacks for systems without root access list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") include(FetchDependencies) diff --git a/CMakeOptions.md b/CMakeOptions.md new file mode 100644 index 0000000..c5be63d --- /dev/null +++ b/CMakeOptions.md @@ -0,0 +1,100 @@ +# CppNCorr CMake Options & Build Reference + +This document describes every CMake option, language requirement, and dependency +handling rule used by the CppNCorr build. It complements `CMakeLists.txt` (the +library) and `test/CMakeLists.txt` (the example/tool executables). + +## Language standard + +CppNCorr requires **C++17** (it relies on `std::optional` and other modern +features). The standard is enforced project-wide in the top of `CMakeLists.txt`: + +```cmake +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) +``` + +and is additionally pinned on the `ncorr` target via `set_property(... CXX_STANDARD 17)`. +`test/CMakeLists.txt` sets the same global variables before defining its targets. + +- **Minimum CMake**: 3.14 (declared via `CMAKE_MINIMUM_REQUIRED`). +- **Compilers**: GCC 10+ / Clang 12+ / MSVC 2019+ (mirrors CPPxDIC). + +## User-configurable options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `FORCE_FETCH_DEPENDENCIES` | BOOL | `OFF` | When `ON`, ignore system-installed packages and build **all** third-party dependencies from source via CMake `FetchContent`. Useful for reproducible builds and on machines without root / package-manager access. When `OFF` (default), each dependency is first searched with `find_package` / `find_library` and only fetched if missing. | + +Pass options on the command line, e.g.: + +```bash +cmake -S . -B build -DFORCE_FETCH_DEPENDENCIES=ON +``` + +## Compile flags applied by the build + +| Flag / definition | Where | Purpose | +|-------------------|-------|---------| +| `-O3` | both | Added when the compiler supports it (probed with `CheckCXXCompilerFlag`). | +| `-Wall -Wextra` | library (`target_compile_options(ncorr ...)`) | Warning hygiene for the core library. | +| `-DNDEBUG` | both (`ADD_DEFINITIONS`) | Disables `assert()` in release builds. | +| `-isystem /opt/homebrew/include` | APPLE only | Silences warnings from Homebrew system headers. | + +## Targets + +| Target | Defined in | Type | Notes | +|--------|------------|------|-------| +| `ncorr` | `CMakeLists.txt` | STATIC library | Core DIC engine → `lib/libncorr.a`. | +| `proxyncorr` | `test/CMakeLists.txt` | executable | The de-facto **main DIC tool**: folder discovery, full CLI (getopt), config file, JSON/binary/video output. Used by the parent CPPxDIC project. | +| `ncorr_test` | `test/CMakeLists.txt` | executable | Minimal end-to-end example on the `ohtcfrp` dataset. | +| `ncorr_test_*`, `*_test` | `test/CMakeLists.txt` | executables | Assorted regression/unit-style harnesses (interpolator, cubic, parity, RGDIC, sequential, parallel, chain seam, ROI reduce, etc.). | + +## Dependencies (resolved by `cmake/FetchDependencies.cmake`) + +Each dependency is found on the system first; if missing (or when +`FORCE_FETCH_DEPENDENCIES=ON`) it is fetched and built from source. The pinned +fetch versions are: + +| Dependency | Role | System lookup | Fetch tag/version | +|------------|------|---------------|-------------------| +| **OpenCV** | image / video I/O, image processing | `find_package(OpenCV)` (+ `opencv4` include hints on Debian/Ubuntu) | 4.9.0 (minimal `core,imgproc,imgcodecs,highgui,videoio` build) | +| **FFTW3** | FFT routines | `find_library(fftw3)` + `fftw3.h` | 3.3.10 | +| **SuiteSparse** | sparse solvers (SPQR, CHOLMOD, AMD, COLAMD, config) | `find_library(spqr/cholmod/...)` | v7.6.0 | +| **BLAS / LAPACK** | dense linear algebra (SuiteSparse backend) | `find_package(BLAS/LAPACK)` | OpenBLAS v0.3.26 (fallback) | +| **nlohmann/json** | JSON output (header-only) | `find_package(nlohmann_json CONFIG)` | v3.11.3 | +| **OpenMP** | parallelism | `find_package(OpenMP)` (Homebrew `libomp` paths on macOS) | system only (not fetched) | + +> The `FORCE`-flagged `set(... CACHE ...)` lines inside `FetchDependencies.cmake` +> (e.g. `WITH_CUDA OFF`, `BUILD_TESTS OFF`) are **internal** build-tuning for the +> fetched dependency sub-builds. They are not meant to be set by users. + +## Quick build + +```bash +# Library only +cmake -S . -B build -DCMAKE_POLICY_VERSION_MINIMUM=3.5 +cmake --build build --target ncorr -j + +# Executables (proxyncorr, ncorr_test, ...) — configure the test/ tree +cmake -S test -B test/build -DCMAKE_POLICY_VERSION_MINIMUM=3.5 +cmake --build test/build -j +``` + +The convenience script `build.sh` performs the library build (clean + configure + +make) and refreshes `lib/libncorr.a`. + +## Known pre-existing warnings + +The header `include/Array2D.h` emits a handful of `-Wall -Wextra` warnings that +predate this work and are intrinsic to its template iterator design: + +- `-Winjected-class-name` on qualified iterator definitions. +- `-Wunused-parameter` for the `type` tag parameter of `eye(...)`. +- `-Wdeprecated-declarations` for `allocator::rebind`. + +These are non-fatal and are left untouched to avoid risky edits to the large +template header; they are tracked with `// FIXME(newversion):` markers where it +is safe to annotate. New code added on this branch compiles cleanly under +`-Wall -Wextra`. From 4bc4ee1ec6061a28096c46acd328be43b92192d3 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 11:53:53 +0200 Subject: [PATCH 02/20] feat(ncorr): audit CLI; stub missing post-DIC output flags The de-facto main DIC executable is proxyncorr (folder discovery + full getopt CLI + config file + JSON/binary/video output). Audited its flags and added the post-DIC output flags required by the spec: - --export-video forces displacement/strain video output (backing exists) - --export-strains guarantees strain fields are written (backing exists) - --change-perspective : eulerian is functional (current default); lagrangian/other modes print 'not yet implemented' and exit cleanly. Documented the new flags in --help under a POST-DIC OUTPUT FLAGS section. Also fixed a pre-existing -Wall build blocker: two calls to the overloaded DIC_analysis_sequential were ambiguous (DIC_analysis_parallel_input is implicitly convertible to DIC_analysis_input). Disambiguated via explicit function-pointer overload selection, marked with FIXME(newversion). Co-Authored-By: Claude Opus 4.8 --- test/src/proxyncorr.cpp | 83 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/test/src/proxyncorr.cpp b/test/src/proxyncorr.cpp index 799f596..8140c61 100644 --- a/test/src/proxyncorr.cpp +++ b/test/src/proxyncorr.cpp @@ -57,6 +57,17 @@ struct ProxyConfig { bool save_json = true; bool save_binary = true; bool save_videos = true; + + // --- Post-DIC output flags (section 2a) --- + // Explicit named toggles requested on the CLI. These complement the existing + // --no-* flags. When the user passes the explicit flag we force the matching + // behaviour on; when the backing operation has no dedicated implementation yet + // we print a "not yet implemented" notice and exit cleanly. + bool export_video = false; // alias/forces displacement-field video export + bool export_strains = false; // alias/forces strain-field export + // change_perspective: empty = use default (perspective_interp); otherwise the + // requested mode. Recognised values: "eulerian". "lagrangian" is not yet wired. + std::string change_perspective_mode = ""; }; // ============================================================================ @@ -414,6 +425,13 @@ void print_usage(const char* prog_name) { << " --no-binary Disable binary output\n" << " --no-videos Disable video output\n" << " --debug Enable debug mode\n" + << "\n" + << "POST-DIC OUTPUT FLAGS:\n" + << " --export-video Render displacement/strain fields as video\n" + << " (forces video output on)\n" + << " --export-strains Write strain fields to the output directory\n" + << " --change-perspective Output perspective: eulerian (default).\n" + << " lagrangian is not yet implemented.\n" << " -h, --help Show this help message\n\n" << "CONFIG FILE FORMAT (config.txt):\n" << " # Comment lines start with #\n" @@ -474,6 +492,11 @@ int main(int argc, char* argv[]) { {"no-binary", no_argument, 0, 1004}, {"no-videos", no_argument, 0, 1005}, {"debug", no_argument, 0, 1006}, + // --- Post-DIC output flags (section 2a). Some are functional wrappers + // over existing behaviour; others are stubs pending dedicated code. --- + {"export-video", no_argument, 0, 1009}, // render displacement field as video + {"export-strains", no_argument, 0, 1010}, // write strain fields to file + {"change-perspective",required_argument, 0, 1011}, // perspective transform of output {"help", no_argument, 0, 'h'}, {0, 0, 0, 0} }; @@ -525,6 +548,10 @@ int main(int argc, char* argv[]) { case 1004: config.save_binary = false; break; case 1005: config.save_videos = false; break; case 1006: config.debug = true; break; + // --- Post-DIC output flags (section 2a) --- + case 1009: config.export_video = true; config.save_videos = true; break; + case 1010: config.export_strains = true; break; + case 1011: config.change_perspective_mode = optarg; break; case 'h': print_usage(argv[0]); return 0; @@ -533,7 +560,46 @@ int main(int argc, char* argv[]) { return 1; } } - + + // ------------------------------------------------------------------------ + // Post-DIC output flags (section 2a): validate / map to existing behaviour. + // Flags whose backing operation already exists are wired to existing config; + // flags (or flag *modes*) without dedicated code print a clear notice and + // exit cleanly per the spec, without touching the working DIC pipeline. + // ------------------------------------------------------------------------ + if (config.export_video) { + // Backing code exists: displacement/strain videos via save_DIC_video / + // save_strain_video. --export-video simply forces video output on. + std::cout << "[--export-video] enabled: displacement/strain fields will " + "be rendered to video." << std::endl; + } + if (config.export_strains) { + // Backing code exists: strain fields are written as part of the JSON and + // binary outputs. --export-strains guarantees at least one is emitted. + if (!config.save_json && !config.save_binary) { + config.save_binary = true; + } + std::cout << "[--export-strains] enabled: strain fields will be written " + "to the output directory." << std::endl; + // TODO(newversion): add a dedicated standalone strain export format + // (e.g. CSV / .mat) instead of relying on the JSON/binary bundle. + } + if (!config.change_perspective_mode.empty()) { + std::string mode = config.change_perspective_mode; + std::transform(mode.begin(), mode.end(), mode.begin(), ::tolower); + if (mode == "eulerian") { + // Functional: this is exactly what the pipeline already does below. + std::cout << "[--change-perspective=eulerian] using Eulerian " + "perspective (default)." << std::endl; + } else { + // FIXME(newversion): wire up Lagrangian / arbitrary perspective + // transforms (see change_perspective_with_inversion in ncorr.h). + std::cout << "[--change-perspective=" << config.change_perspective_mode + << "] not yet implemented" << std::endl; + return 0; + } + } + // Resolve ROI path std::string roi_path = config.roi_path; if (roi_path.empty()) { @@ -669,10 +735,21 @@ int main(int argc, char* argv[]) { std::cout << "..." << std::endl; DIC_analysis_parallel_input parallel_input(DIC_input, config.seeds_by_region, config.seeds_are_optimized); - DIC_output = DIC_analysis_sequential(parallel_input); + // FIXME(newversion): DIC_analysis_sequential is overloaded for both + // DIC_analysis_input and DIC_analysis_parallel_input; the latter is + // implicitly convertible to the former, making a bare call ambiguous. + // Select the parallel-input overload explicitly via a function pointer + // so the user-provided seeds are honoured. + DIC_output = static_cast( + &DIC_analysis_sequential)(parallel_input); } else { std::cout << "[SEQUENTIAL MODE] Performing DIC analysis with auto-generated seeds..." << std::endl; - DIC_output = DIC_analysis_sequential(DIC_input); + // FIXME(newversion): same overload ambiguity as above. Here we want + // the DIC_analysis_input overload (no user seeds); select it + // explicitly via a function pointer. + DIC_output = static_cast&, bool)>( + &DIC_analysis_sequential)(DIC_input, {}, false); } } else if (effective_mode == "parallel") { if (has_seeds) { From 6aafea2b25306741b371e06dd8c77b3fb2851ff8 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 11:58:40 +0200 Subject: [PATCH 03/20] fix(input/images): harden image-folder reader edge cases Harden discover_frames() in proxyncorr: - length-safe image-extension check via has_image_extension() helper (was using raw substr() that could misbehave on short names); unified supported list: .png .tif .tiff .bmp .jpg .jpeg. - guard against empty dirent names; skip nested sub-directories (S_ISDIR). - natural (numeric-aware) sort so unpadded frame names order correctly (frame_2 < frame_10), in addition to zero-padded names. - richer error message including strerror(errno) when the folder cannot be opened. - documented the expected naming convention / reserved roi.png & ref.png and supported formats in a block comment above the function. Co-Authored-By: Claude Opus 4.8 --- test/src/proxyncorr.cpp | 124 ++++++++++++++++++++++++++++++---------- 1 file changed, 94 insertions(+), 30 deletions(-) diff --git a/test/src/proxyncorr.cpp b/test/src/proxyncorr.cpp index 8140c61..fde1f9b 100644 --- a/test/src/proxyncorr.cpp +++ b/test/src/proxyncorr.cpp @@ -7,6 +7,10 @@ #include #include #include +#include +#include +#include +#include using namespace ncorr; using json = nlohmann::json; @@ -234,13 +238,77 @@ void save_as_json(const DIC_analysis_input& dic_input, // ============================================================================ // File discovery functions // ============================================================================ + +// Supported image extensions (lowercase, including the dot). Mirrors the formats +// OpenCV's imread handles in this build. Keep in sync with the user guide. +static const std::vector kImageExtensions = { + ".png", ".tif", ".tiff", ".bmp", ".jpg", ".jpeg" +}; + +// Return true if `lower_name` (already lowercased) ends with a supported image +// extension. Length-safe: never reads out of bounds for short names. +static bool has_image_extension(const std::string& lower_name) { + for (const std::string& ext : kImageExtensions) { + if (lower_name.size() > ext.size() && + lower_name.compare(lower_name.size() - ext.size(), ext.size(), ext) == 0) { + return true; + } + } + return false; +} + +// Natural (human) comparison so unpadded numeric frame names sort correctly, +// e.g. "frame_2.png" < "frame_10.png". Falls back to lexicographic for +// non-digit runs. Zero-padded names also sort correctly under this rule. +static bool natural_less(const std::string& a, const std::string& b) { + size_t i = 0, j = 0; + while (i < a.size() && j < b.size()) { + if (std::isdigit(static_cast(a[i])) && + std::isdigit(static_cast(b[j]))) { + // Compare two runs of digits by numeric value (skip leading zeros). + size_t ai = i, bj = j; + while (ai < a.size() && std::isdigit(static_cast(a[ai]))) ++ai; + while (bj < b.size() && std::isdigit(static_cast(b[bj]))) ++bj; + std::string da = a.substr(i, ai - i); + std::string db = b.substr(j, bj - j); + da.erase(0, da.find_first_not_of('0')); + db.erase(0, db.find_first_not_of('0')); + if (da.size() != db.size()) return da.size() < db.size(); + if (da != db) return da < db; + i = ai; j = bj; + } else { + if (a[i] != b[j]) return a[i] < b[j]; + ++i; ++j; + } + } + return a.size() < b.size(); +} + +// Discover the deformed-frame image files inside `folder`. +// +// NAMING CONVENTION / EXPECTED LAYOUT: +// - The folder contains one image per frame plus (optionally) a ROI mask and a +// dedicated reference image. +// - Frames should be numbered so they sort in acquisition order. Both +// zero-padded ("frame_00.png", "frame_01.png", ...) and unpadded +// ("frame_2.png", "frame_10.png", ...) numbering work; ordering uses a +// natural (numeric-aware) comparison, not raw lexicographic order. +// - Files named "roi.png" and "ref.png" (case-insensitive) are reserved and +// excluded from the frame list, as are any files matching the explicitly +// supplied `roi_path` / `ref_path` basenames. +// - Hidden files (leading '.') and any non-image extension are ignored. +// - Supported extensions: .png .tif .tiff .bmp .jpg .jpeg +// +// Throws std::runtime_error if `folder` cannot be opened. Returns an empty +// vector if the folder simply contains no usable frames (the caller reports it). std::vector discover_frames(const std::string& folder, const std::string& ref_path, const std::string& roi_path) { std::vector frames; DIR* dir = opendir(folder.c_str()); if (!dir) { - throw std::runtime_error("Cannot open folder: " + folder); + throw std::runtime_error("Cannot open folder '" + folder + + "': " + std::strerror(errno)); } - + // Get basenames to exclude std::string roi_basename = ""; std::string ref_basename = ""; @@ -252,45 +320,41 @@ std::vector discover_frames(const std::string& folder, const std::s size_t pos = ref_path.find_last_of("/\\"); ref_basename = (pos != std::string::npos) ? ref_path.substr(pos + 1) : ref_path; } - + struct dirent* entry; while ((entry = readdir(dir)) != nullptr) { std::string name = entry->d_name; - - // Skip hidden files and directories - if (name[0] == '.') continue; - - // Check for image extensions + + // Skip empty names (defensive) and hidden files / "." / ".." entries. + if (name.empty() || name[0] == '.') continue; + + // Skip nested sub-directories: only regular files are valid frames. + std::string full_path = folder + "/" + name; + struct stat st; + if (stat(full_path.c_str(), &st) == 0 && S_ISDIR(st.st_mode)) continue; + + // Check for image extensions (case-insensitive, length-safe). std::string lower_name = name; - std::transform(lower_name.begin(), lower_name.end(), lower_name.begin(), ::tolower); - - bool is_image = (lower_name.length() > 4 && - (lower_name.substr(lower_name.length() - 4) == ".png" || - lower_name.substr(lower_name.length() - 4) == ".jpg" || - lower_name.substr(lower_name.length() - 4) == ".bmp" || - lower_name.substr(lower_name.length() - 5) == ".jpeg" || - lower_name.substr(lower_name.length() - 5) == ".tiff" || - lower_name.substr(lower_name.length() - 4) == ".tif")); - - if (!is_image) continue; - - // Skip roi.png by default + std::transform(lower_name.begin(), lower_name.end(), lower_name.begin(), + [](unsigned char c) { return std::tolower(c); }); + + if (!has_image_extension(lower_name)) continue; + + // Skip reserved roi.png / ref.png by default if (lower_name == "roi.png") continue; - - // Skip ref.png by default if (lower_name == "ref.png") continue; - + // Skip explicitly specified roi and ref files if (!roi_basename.empty() && name == roi_basename) continue; if (!ref_basename.empty() && name == ref_basename) continue; - - frames.push_back(folder + "/" + name); + + frames.push_back(full_path); } closedir(dir); - - // Sort frames naturally (handles numbered files) - std::sort(frames.begin(), frames.end()); - + + // Sort frames naturally (handles both padded and unpadded numbered files). + std::sort(frames.begin(), frames.end(), natural_less); + return frames; } From f241076c7961f68bfcb43d06e56ebe65eeb83375 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 12:26:09 +0200 Subject: [PATCH 04/20] feat(input/memory): add in-memory NcorrSession API (stub) Add include/ncorr/session.h and src/session.cpp defining a dependency-light in-memory DIC interface so callers (e.g. CPPxDIC's proxyncorr) can push image data directly without writing frames to disk: - ImageBuffer: thin non-owning view {const uint8_t* data, width, height, channels} with valid()/size_bytes() helpers. - NcorrSession: set_reference(), optional set_roi(), process_frame() -> DICResult, has_reference(). PIMPL keeps heavy ncorr internals out of the public header. - DICResult: flat row-major u/v (+ optional corrcoef) as std::vector, so callers need no internal ncorr types. - SessionConfig: tuneable params whose names match the upcoming Config. Stub bodies validate inputs and behave gracefully (process_frame returns an explicit 'not yet implemented' DICResult / prints a notice). Wired session.cpp and session.h into the ncorr library target. TODO/FIXME(newversion) markers point to proxyncorr.cpp's file-based pipeline as the model to follow. Verified: compiles clean under -Wall -Wextra and links/runs from an external caller TU. Co-Authored-By: Claude Opus 4.8 --- CMakeLists.txt | 4 +- include/ncorr/session.h | 193 ++++++++++++++++++++++++++++++++++++++++ src/session.cpp | 107 ++++++++++++++++++++++ 3 files changed, 302 insertions(+), 2 deletions(-) create mode 100644 include/ncorr/session.h create mode 100644 src/session.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 38656f5..47d7b93 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,8 +20,8 @@ option(FORCE_FETCH_DEPENDENCIES "Force using FetchContent for all dependencies" # included, since the install directories are searched automatically by g++. # Set files -SET(ncorr_src src/ncorr.cpp src/Strain2D.cpp src/Disp2D.cpp src/Data2D.cpp src/ROI2D.cpp src/Image2D.cpp src/Array2D.cpp) -SET(ncorr_h include/ncorr.h include/Strain2D.h include/Disp2D.h include/Data2D.h include/ROI2D.h include/Image2D.h include/Array2D.h) +SET(ncorr_src src/ncorr.cpp src/Strain2D.cpp src/Disp2D.cpp src/Data2D.cpp src/ROI2D.cpp src/Image2D.cpp src/Array2D.cpp src/session.cpp) +SET(ncorr_h include/ncorr.h include/Strain2D.h include/Disp2D.h include/Data2D.h include/ROI2D.h include/Image2D.h include/Array2D.h include/ncorr/session.h) # Set include directory INCLUDE_DIRECTORIES(include) diff --git a/include/ncorr/session.h b/include/ncorr/session.h new file mode 100644 index 0000000..f6b57a5 --- /dev/null +++ b/include/ncorr/session.h @@ -0,0 +1,193 @@ +#pragma once +/** + * @file session.h + * @brief In-memory DIC session API for CppNCorr. + * + * This header defines a small, dependency-light interface that lets a caller + * (for example CPPxDIC's @c proxyncorr target) push raw image buffers directly + * to the ncorr DIC engine without first writing frames to disk. + * + * Design goals: + * - The public surface depends only on standard-library types and the thin + * @ref ncorr::ImageBuffer struct, so callers need not include the heavy + * internal ncorr headers (Array2D, Disp2D, ...) just to drive a session. + * - The usage pattern mirrors the file-based pipeline: set a reference frame + * once, then process one or more deformed frames. + * + * @note This is a STUB in the @c newversion branch: the interface is final but + * the bodies are not yet wired to the DIC engine. See session.cpp and the + * @c TODO markers. The image-folder reader path in @c proxyncorr.cpp + * (discover_frames + DIC_analysis) is the model implementation to follow. + */ + +#include +#include +#include +#include + +namespace ncorr { + +/** + * @brief Thin, non-owning view over a raw image in memory. + * + * Wraps a contiguous pixel buffer plus its geometry. The buffer is interpreted + * as row-major, with @c channels interleaved per pixel (e.g. BGR for 3-channel + * data, matching OpenCV's default layout). 8-bit unsigned samples are assumed. + * + * The struct does NOT own @c data; the caller must keep the underlying storage + * alive for the duration of any call that receives the ImageBuffer. + */ +struct ImageBuffer { + /// Pointer to the first byte of the (row-major, interleaved) pixel data. + const std::uint8_t* data = nullptr; + /// Image width in pixels. + int width = 0; + /// Image height in pixels. + int height = 0; + /// Number of interleaved channels per pixel (1 = grayscale, 3 = BGR, ...). + int channels = 1; + + /// Default-construct an empty (invalid) buffer. + ImageBuffer() = default; + + /** + * @brief Construct an image buffer view. + * @param data_ Pointer to row-major interleaved 8-bit pixel data. + * @param width_ Width in pixels. + * @param height_ Height in pixels. + * @param channels_ Channels per pixel (default 1 = grayscale). + */ + ImageBuffer(const std::uint8_t* data_, int width_, int height_, int channels_ = 1) + : data(data_), width(width_), height(height_), channels(channels_) {} + + /// @return true if the buffer is non-null and has positive dimensions. + bool valid() const { + return data != nullptr && width > 0 && height > 0 && channels > 0; + } + + /// @return total number of bytes the buffer is expected to span. + std::size_t size_bytes() const { + return static_cast(width) * height * channels; + } +}; + +/** + * @brief Result of running DIC on a single deformed frame. + * + * Displacement fields are returned as flat row-major arrays of length + * @c width * @c height. Points outside the analysed region of interest are set + * to NaN. The fields are deliberately plain @c std::vector so callers do + * not need any internal ncorr types to consume the result. + */ +struct DICResult { + /// Width of the displacement fields (pixels). + int width = 0; + /// Height of the displacement fields (pixels). + int height = 0; + /// Horizontal displacement field (u), row-major, size width*height. NaN outside ROI. + std::vector u; + /// Vertical displacement field (v), row-major, size width*height. NaN outside ROI. + std::vector v; + /// Per-point correlation coefficient, row-major, size width*height (optional; may be empty). + std::vector corrcoef; + /// True if the frame was processed successfully. + bool valid = false; + /// Human-readable status / error message (empty on success). + std::string message; +}; + +/** + * @brief Configuration for an in-memory DIC session. + * + * Mirrors the tuneable parameters of the file-based pipeline. Field names match + * the compiled defaults in @ref Config (see config.h) so the two stay in sync. + */ +struct SessionConfig { + /// Pyramid scale factor (downsampling level for the seed search). + int scalefactor = 3; + /// Subregion (correlation window) radius in pixels. + int subregion_radius = 20; + /// Strain subregion radius in pixels. + int strain_radius = 5; + /// Number of worker threads for parallel analysis. + int num_threads = 4; + /// Enable verbose debug output from the engine. + bool debug = false; +}; + +/** + * @brief Drives an in-memory Digital Image Correlation session. + * + * Typical lifecycle: + * @code + * ncorr::NcorrSession session(cfg); + * session.set_reference(ref_buffer); // once + * auto r1 = session.process_frame(def1); // per deformed frame + * auto r2 = session.process_frame(def2); + * @endcode + * + * The session owns a private implementation (PIMPL) so that this header stays + * free of heavy internal ncorr includes. + * + * @warning STUB: methods are declared and behave gracefully (see session.cpp) + * but DIC is not yet computed. Implement following the file-based path + * in proxyncorr.cpp (Image2D::from_mat -> DIC_analysis_input -> DIC_analysis). + */ +class NcorrSession { +public: + /** + * @brief Construct a session with the given configuration. + * @param config Tuneable DIC parameters (defaults match Config). + */ + explicit NcorrSession(const SessionConfig& config = SessionConfig()); + + /// Destructor (defined in session.cpp because of the PIMPL). + ~NcorrSession(); + + // Movable, non-copyable (owns engine state). + NcorrSession(NcorrSession&&) noexcept; + NcorrSession& operator=(NcorrSession&&) noexcept; + NcorrSession(const NcorrSession&) = delete; + NcorrSession& operator=(const NcorrSession&) = delete; + + /** + * @brief Set the reference (undeformed) frame. Call once before processing. + * + * The pixel data is copied into the session, so @p ref need not outlive the + * call. Calling again replaces the reference and invalidates prior state. + * + * @param ref Reference image buffer. + * @throws std::invalid_argument if @p ref is not valid(). + */ + void set_reference(const ImageBuffer& ref); + + /** + * @brief Optionally supply a region-of-interest mask. + * + * The mask is a single-channel buffer the same size as the reference; any + * non-zero pixel marks a point to analyse. If never called, the whole frame + * is analysed. + * + * @param roi_mask Single-channel ROI mask buffer. + */ + void set_roi(const ImageBuffer& roi_mask); + + /** + * @brief Push a deformed frame and run DIC against the reference. + * + * @param def Deformed image buffer (same geometry as the reference). + * @return A DICResult; on failure @c valid is false and @c message explains why. + * @throws std::logic_error if no reference has been set. + */ + DICResult process_frame(const ImageBuffer& def); + + /// @return true once a valid reference frame has been set. + bool has_reference() const; + +private: + // PIMPL: hides internal ncorr engine types from this public header. + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace ncorr diff --git a/src/session.cpp b/src/session.cpp new file mode 100644 index 0000000..4a7d84c --- /dev/null +++ b/src/session.cpp @@ -0,0 +1,107 @@ +/** + * @file session.cpp + * @brief Stub implementation of the in-memory NcorrSession API. + * + * STUB (newversion branch): the interface is final and behaves gracefully, but + * the DIC computation is NOT yet wired up. Each entry point validates its inputs + * and stores state; process_frame() returns an explicit "not implemented" + * DICResult instead of fabricating numbers. + * + * TODO(newversion): implement the bodies by following the file-based pipeline in + * test/src/proxyncorr.cpp: + * 1. Wrap each ImageBuffer in a cv::Mat (no copy of caller memory needed for + * the wrap) and build an ncorr::Image2D via Image2D::from_mat(...). + * 2. Build a ROI2D from the optional mask (or full-frame default). + * 3. Construct a DIC_analysis_input { ref + def imgs, roi, scalefactor, + * interp, subregion, radius, num_threads, DIC_analysis_config, debug }. + * 4. Run DIC_analysis(...) and copy the resulting Disp2D u/v arrays into the + * flat DICResult::u / DICResult::v vectors (NaN outside the ROI). + */ + +#include "ncorr/session.h" + +#include +#include + +namespace ncorr { + +// --------------------------------------------------------------------------- +// Private implementation (PIMPL). Kept minimal for the stub; will later hold +// ncorr::Image2D / ROI2D / cached engine state. +// --------------------------------------------------------------------------- +struct NcorrSession::Impl { + SessionConfig config; + + bool has_reference = false; + int ref_width = 0; + int ref_height = 0; + + bool has_roi = false; + + explicit Impl(const SessionConfig& cfg) : config(cfg) {} +}; + +NcorrSession::NcorrSession(const SessionConfig& config) + : impl_(std::make_unique(config)) {} + +NcorrSession::~NcorrSession() = default; + +NcorrSession::NcorrSession(NcorrSession&&) noexcept = default; +NcorrSession& NcorrSession::operator=(NcorrSession&&) noexcept = default; + +void NcorrSession::set_reference(const ImageBuffer& ref) { + if (!ref.valid()) { + throw std::invalid_argument( + "NcorrSession::set_reference: invalid ImageBuffer (null data or " + "non-positive dimensions)."); + } + // TODO(newversion): copy/convert ref into an ncorr::Image2D and cache its + // grayscale representation. For now we only record geometry. + impl_->ref_width = ref.width; + impl_->ref_height = ref.height; + impl_->has_reference = true; +} + +void NcorrSession::set_roi(const ImageBuffer& roi_mask) { + if (!roi_mask.valid()) { + throw std::invalid_argument( + "NcorrSession::set_roi: invalid ROI mask ImageBuffer."); + } + // TODO(newversion): convert the mask into an ncorr::ROI2D. + impl_->has_roi = true; +} + +DICResult NcorrSession::process_frame(const ImageBuffer& def) { + if (!impl_->has_reference) { + throw std::logic_error( + "NcorrSession::process_frame: no reference frame set; call " + "set_reference() first."); + } + + DICResult result; + result.valid = false; + + if (!def.valid()) { + result.message = "process_frame: invalid deformed ImageBuffer."; + return result; + } + if (def.width != impl_->ref_width || def.height != impl_->ref_height) { + result.message = + "process_frame: deformed frame geometry does not match reference."; + return result; + } + + // FIXME(newversion): in-memory DIC is not yet implemented. Behave gracefully + // rather than returning fabricated displacement fields. + result.width = impl_->ref_width; + result.height = impl_->ref_height; + result.message = "NcorrSession::process_frame not yet implemented"; + std::cerr << "[NcorrSession] process_frame not yet implemented" << std::endl; + return result; +} + +bool NcorrSession::has_reference() const { + return impl_->has_reference; +} + +} // namespace ncorr From 9c31435c9de25df62b9313d80523c0337e84b67b Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 12:40:13 +0200 Subject: [PATCH 05/20] feat(params): implement CLI > config-file > compiled-defaults override chain Add a config-file system from scratch (no new dependency): - include/ncorr/config.h: struct Config holds every tuneable ncorr parameter with its compiled-in default; the field names are the canonical config keys. - include/ncorr/ini.h: tiny header-only INI parser (key=value, [sections], '#'/';' full-line and inline comments, quoted values, CRLF-safe, typed getters with error messages). Written in-tree because no header-only parser was vendored and to avoid adding TOML/INI libraries. - src/config.cpp: load_config_file() overlays INI values onto a Config, matching keys to fields one-to-one. - config/default.cfg: documents the INI format choice and mirrors every Config field with its default and a one-line comment; keys match Config exactly. Integrate the three-tier chain into proxyncorr: tier 3 compiled defaults (seeded from ncorr::Config) -> tier 2 config file (legacy short keys + canonical INI overlay; falls back to config/default.cfg when present) -> tier 1 CLI args. Malformed config values are reported and abort cleanly. Verified: config overrides defaults, CLI overrides config, bad values error out; library and proxyncorr build clean under -Wall -Wextra. Co-Authored-By: Claude Opus 4.8 --- CMakeLists.txt | 4 +- config/default.cfg | 43 ++++++++++ include/ncorr/config.h | 88 +++++++++++++++++++ include/ncorr/ini.h | 183 ++++++++++++++++++++++++++++++++++++++++ src/config.cpp | 52 ++++++++++++ test/src/proxyncorr.cpp | 86 +++++++++++++++++-- 6 files changed, 448 insertions(+), 8 deletions(-) create mode 100644 config/default.cfg create mode 100644 include/ncorr/config.h create mode 100644 include/ncorr/ini.h create mode 100644 src/config.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 47d7b93..424f147 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,8 +20,8 @@ option(FORCE_FETCH_DEPENDENCIES "Force using FetchContent for all dependencies" # included, since the install directories are searched automatically by g++. # Set files -SET(ncorr_src src/ncorr.cpp src/Strain2D.cpp src/Disp2D.cpp src/Data2D.cpp src/ROI2D.cpp src/Image2D.cpp src/Array2D.cpp src/session.cpp) -SET(ncorr_h include/ncorr.h include/Strain2D.h include/Disp2D.h include/Data2D.h include/ROI2D.h include/Image2D.h include/Array2D.h include/ncorr/session.h) +SET(ncorr_src src/ncorr.cpp src/Strain2D.cpp src/Disp2D.cpp src/Data2D.cpp src/ROI2D.cpp src/Image2D.cpp src/Array2D.cpp src/session.cpp src/config.cpp) +SET(ncorr_h include/ncorr.h include/Strain2D.h include/Disp2D.h include/Data2D.h include/ROI2D.h include/Image2D.h include/Array2D.h include/ncorr/session.h include/ncorr/config.h include/ncorr/ini.h) # Set include directory INCLUDE_DIRECTORIES(include) diff --git a/config/default.cfg b/config/default.cfg new file mode 100644 index 0000000..2308f80 --- /dev/null +++ b/config/default.cfg @@ -0,0 +1,43 @@ +# CppNCorr default configuration +# ------------------------------------------------------------------------------ +# Format: INI (key = value). Chosen over TOML to avoid any third-party +# dependency; parsed by the in-tree header-only parser include/ncorr/ini.h. +# +# Override precedence (highest wins): +# 1. Direct CLI arguments +# 2. This config file +# 3. Compiled defaults (include/ncorr/config.h :: struct Config) +# +# Every key below matches a field of ncorr::Config EXACTLY (case-sensitive) so +# there are no silent mismatches. Comments use '#' (or ';'); inline comments are +# supported. The values shown here mirror the compiled defaults. + +# --- Pyramid / search --------------------------------------------------------- +scalefactor = 3 # pyramid downsampling level for seed search + +# --- Subregion (correlation window) ------------------------------------------ +subregion_type = CIRCLE # CIRCLE or SQUARE +subregion_radius = 20 # correlation window radius (pixels) + +# --- Interpolation ------------------------------------------------------------ +# One of: NEAREST, LINEAR, CUBIC_KEYS, CUBIC_KEYS_PRECOMPUTE, +# QUINTIC_BSPLINE, QUINTIC_BSPLINE_PRECOMPUTE +interp_type = QUINTIC_BSPLINE_PRECOMPUTE + +# --- Strain ------------------------------------------------------------------- +strain_subregion_type = CIRCLE # CIRCLE or SQUARE +strain_radius = 5 # strain window radius (pixels) + +# --- DIC behaviour ------------------------------------------------------------ +dic_config = NO_UPDATE # NO_UPDATE, KEEP_MOST_POINTS, REMOVE_BAD_POINTS +num_threads = 4 # worker threads for parallel analysis +debug = false # verbose engine output + +# --- Perspective / units ------------------------------------------------------ +perspective_interp = CUBIC_KEYS # interpolation used by perspective change +units = mm # displacement units label +units_per_pixel = 0.2 # physical scaling + +# --- Output / video ----------------------------------------------------------- +alpha = 0.5 # video overlay alpha (0..1) +fps = 15.0 # output video frames per second diff --git a/include/ncorr/config.h b/include/ncorr/config.h new file mode 100644 index 0000000..5c3ab68 --- /dev/null +++ b/include/ncorr/config.h @@ -0,0 +1,88 @@ +#pragma once +/** + * @file config.h + * @brief Compiled-default DIC parameters and the three-tier override chain. + * + * CppNCorr resolves tuneable parameters from three sources, highest priority + * first: + * 1. Direct CLI arguments (applied by the caller, e.g. proxyncorr) + * 2. A config file (INI format) (see config/default.cfg) + * 3. Compiled defaults (the initial values of @ref Config below) + * + * The config file uses a minimal **INI** format (chosen over TOML to avoid + * adding any third-party dependency; a tiny in-tree parser handles it — see + * @ref IniFile in ini.h). Keys in the file MUST exactly match the field names of + * @ref Config so there are no silent mismatches. + */ + +#include + +namespace ncorr { + +/** + * @brief All tuneable ncorr parameters with their compiled-in defaults. + * + * The field names here are the canonical config keys. The shipped + * config/default.cfg mirrors every field one-to-one. + */ +struct Config { + // --- Pyramid / search --------------------------------------------------// + /// Pyramid scale factor (downsampling level used for the seed search). + int scalefactor = 3; + + // --- Subregion (correlation window) ------------------------------------// + /// Subregion shape: "CIRCLE" or "SQUARE". + std::string subregion_type = "CIRCLE"; + /// Subregion (correlation window) radius in pixels. + int subregion_radius = 20; + + // --- Interpolation -----------------------------------------------------// + /// Interpolation/order: NEAREST, LINEAR, CUBIC_KEYS[_PRECOMPUTE], + /// QUINTIC_BSPLINE[_PRECOMPUTE]. + std::string interp_type = "QUINTIC_BSPLINE_PRECOMPUTE"; + + // --- Strain ------------------------------------------------------------// + /// Strain subregion shape: "CIRCLE" or "SQUARE". + std::string strain_subregion_type = "CIRCLE"; + /// Strain window (subregion) radius in pixels. + int strain_radius = 5; + + // --- DIC behaviour -----------------------------------------------------// + /// Reference-update policy: NO_UPDATE, KEEP_MOST_POINTS, REMOVE_BAD_POINTS. + std::string dic_config = "NO_UPDATE"; + /// Number of worker threads for parallel analysis. + int num_threads = 4; + /// Enable verbose debug output from the engine. + bool debug = false; + + // --- Perspective / units ----------------------------------------------// + /// Interpolation used by the perspective change step. + std::string perspective_interp = "CUBIC_KEYS"; + /// Physical units label for displacement output. + std::string units = "mm"; + /// Physical units per pixel scaling. + double units_per_pixel = 0.2; + + // --- Output / video ----------------------------------------------------// + /// Video overlay alpha (0..1). + double alpha = 0.5; + /// Output video frames-per-second. + double fps = 15.0; +}; + +/** + * @brief Load compiled defaults, then overlay values from an INI config file. + * + * Only keys present in the file override the corresponding @ref Config field; + * unknown keys are ignored (optionally reported by the parser). Missing file is + * treated as "use defaults". + * + * @param config_path Path to an INI config file (e.g. config/default.cfg). + * @param out Config to update in place (start from compiled defaults). + * @return true if the file was found and parsed; false if it did not exist + * (in which case @p out is left at its incoming/default values). + * @throws std::runtime_error on a malformed value (e.g. non-integer for an int). + */ +bool load_config_file(const std::string& config_path, Config& out); + +} // namespace ncorr diff --git a/include/ncorr/ini.h b/include/ncorr/ini.h new file mode 100644 index 0000000..8627cea --- /dev/null +++ b/include/ncorr/ini.h @@ -0,0 +1,183 @@ +#pragma once +/** + * @file ini.h + * @brief Tiny, dependency-free INI-style configuration parser (header-only). + * + * Written in-tree to avoid adding any external dependency (no TOML/INI library). + * Format (deliberately minimal): + * - One `key = value` pair per line. + * - Blank lines are ignored. + * - Full-line comments start with '#' or ';'. + * - Inline comments: text after an unquoted '#' or ';' is stripped. + * - Optional `[section]` headers are parsed; keys may be addressed as + * "section.key" or, for the default (top) section, just "key". + * - Surrounding whitespace on keys and values is trimmed. + * - Values may be wrapped in matching single or double quotes to preserve + * leading/trailing spaces or embedded comment characters. + * - Keys are case-sensitive (to match C++ field names exactly). + * + * This is intended for small config files (a few dozen keys); it loads the whole + * file into a map. It is not a general-purpose INI implementation. + */ + +#include +#include +#include +#include +#include + +namespace ncorr { + +/** + * @brief In-memory representation of a parsed INI file. + */ +class IniFile { +public: + /// Construct an empty INI store. + IniFile() = default; + + /** + * @brief Parse an INI file from disk. + * @param path Path to the INI file. + * @return true if the file was opened and parsed; false if it did not exist. + * @throws std::runtime_error on a structurally invalid line. + */ + bool load(const std::string& path) { + std::ifstream file(path); + if (!file.is_open()) { + return false; // missing file => caller falls back to defaults + } + values_.clear(); + std::string section; + std::string line; + int line_no = 0; + while (std::getline(file, line)) { + ++line_no; + // Strip a trailing '\r' so CRLF files parse correctly. + if (!line.empty() && line.back() == '\r') line.pop_back(); + + std::string content = strip_inline_comment(line); + content = trim(content); + if (content.empty()) continue; + + // Section header: [name] + if (content.front() == '[') { + if (content.back() != ']') { + throw std::runtime_error("INI parse error at line " + + std::to_string(line_no) + ": malformed section header"); + } + section = trim(content.substr(1, content.size() - 2)); + continue; + } + + // key = value + std::size_t eq = content.find('='); + if (eq == std::string::npos) { + throw std::runtime_error("INI parse error at line " + + std::to_string(line_no) + ": expected 'key = value'"); + } + std::string key = trim(content.substr(0, eq)); + std::string value = unquote(trim(content.substr(eq + 1))); + if (key.empty()) { + throw std::runtime_error("INI parse error at line " + + std::to_string(line_no) + ": empty key"); + } + std::string full_key = section.empty() ? key : (section + "." + key); + values_[full_key] = value; + } + loaded_ = true; + return true; + } + + /// @return true if a value exists for @p key. + bool has(const std::string& key) const { + return values_.find(key) != values_.end(); + } + + /// @return the raw string value for @p key, or @p fallback if absent. + std::string get(const std::string& key, const std::string& fallback = "") const { + auto it = values_.find(key); + return it == values_.end() ? fallback : it->second; + } + + /// @return parsed int for @p key, or @p fallback if absent. Throws on bad value. + int get_int(const std::string& key, int fallback) const { + auto it = values_.find(key); + if (it == values_.end()) return fallback; + try { + return std::stoi(it->second); + } catch (const std::exception&) { + throw std::runtime_error("INI value for '" + key + + "' is not a valid integer: '" + it->second + "'"); + } + } + + /// @return parsed double for @p key, or @p fallback if absent. Throws on bad value. + double get_double(const std::string& key, double fallback) const { + auto it = values_.find(key); + if (it == values_.end()) return fallback; + try { + return std::stod(it->second); + } catch (const std::exception&) { + throw std::runtime_error("INI value for '" + key + + "' is not a valid number: '" + it->second + "'"); + } + } + + /// @return parsed bool for @p key, or @p fallback if absent. + /// Accepts (case-insensitively) true/false, 1/0, yes/no, on/off. + bool get_bool(const std::string& key, bool fallback) const { + auto it = values_.find(key); + if (it == values_.end()) return fallback; + std::string v = to_lower(it->second); + if (v == "true" || v == "1" || v == "yes" || v == "on") return true; + if (v == "false" || v == "0" || v == "no" || v == "off") return false; + throw std::runtime_error("INI value for '" + key + + "' is not a valid boolean: '" + it->second + "'"); + } + + /// @return the full key/value map (for iteration / diagnostics). + const std::map& values() const { return values_; } + +private: + static std::string to_lower(std::string s) { + for (char& c : s) c = static_cast(std::tolower(static_cast(c))); + return s; + } + + static std::string trim(const std::string& s) { + std::size_t b = s.find_first_not_of(" \t"); + if (b == std::string::npos) return ""; + std::size_t e = s.find_last_not_of(" \t"); + return s.substr(b, e - b + 1); + } + + // Remove an inline comment introduced by an unquoted '#' or ';'. + static std::string strip_inline_comment(const std::string& s) { + bool in_single = false, in_double = false; + for (std::size_t i = 0; i < s.size(); ++i) { + char c = s[i]; + if (c == '\'' && !in_double) in_single = !in_single; + else if (c == '"' && !in_single) in_double = !in_double; + else if ((c == '#' || c == ';') && !in_single && !in_double) { + return s.substr(0, i); + } + } + return s; + } + + // Strip a single matching pair of surrounding quotes, if present. + static std::string unquote(const std::string& s) { + if (s.size() >= 2 && + ((s.front() == '"' && s.back() == '"') || + (s.front() == '\'' && s.back() == '\''))) { + return s.substr(1, s.size() - 2); + } + return s; + } + + std::map values_; + bool loaded_ = false; +}; + +} // namespace ncorr diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..5f35ad7 --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,52 @@ +/** + * @file config.cpp + * @brief Implements the config-file tier of the override chain. + * + * load_config_file() starts from the compiled defaults already present in the + * passed-in Config and overlays any keys found in the INI file. Each key name in + * the file matches a Config field exactly (see config/default.cfg). + */ + +#include "ncorr/config.h" +#include "ncorr/ini.h" + +namespace ncorr { + +bool load_config_file(const std::string& config_path, Config& out) { + IniFile ini; + if (!ini.load(config_path)) { + return false; // No file: leave `out` at its incoming/default values. + } + + // Pyramid / search + out.scalefactor = ini.get_int("scalefactor", out.scalefactor); + + // Subregion + out.subregion_type = ini.get("subregion_type", out.subregion_type); + out.subregion_radius = ini.get_int("subregion_radius", out.subregion_radius); + + // Interpolation + out.interp_type = ini.get("interp_type", out.interp_type); + + // Strain + out.strain_subregion_type = ini.get("strain_subregion_type", out.strain_subregion_type); + out.strain_radius = ini.get_int("strain_radius", out.strain_radius); + + // DIC behaviour + out.dic_config = ini.get("dic_config", out.dic_config); + out.num_threads = ini.get_int("num_threads", out.num_threads); + out.debug = ini.get_bool("debug", out.debug); + + // Perspective / units + out.perspective_interp = ini.get("perspective_interp", out.perspective_interp); + out.units = ini.get("units", out.units); + out.units_per_pixel = ini.get_double("units_per_pixel", out.units_per_pixel); + + // Output / video + out.alpha = ini.get_double("alpha", out.alpha); + out.fps = ini.get_double("fps", out.fps); + + return true; +} + +} // namespace ncorr diff --git a/test/src/proxyncorr.cpp b/test/src/proxyncorr.cpp index fde1f9b..408f428 100644 --- a/test/src/proxyncorr.cpp +++ b/test/src/proxyncorr.cpp @@ -1,4 +1,5 @@ #include "ncorr.h" +#include "ncorr/config.h" #include #include #include @@ -100,6 +101,31 @@ DIC_analysis_config parse_dic_config(const std::string& s) { throw std::runtime_error("Unknown DIC config: " + s); } +// ============================================================================ +// Override chain (section 4): compiled defaults -> config file -> CLI args +// ---------------------------------------------------------------------------- +// ProxyConfig carries IO/path/seed fields that the core ncorr::Config does not. +// The tuneable DIC parameters, however, are seeded from ncorr::Config's compiled +// defaults and can be overridden by an INI config file (canonical key names) and +// then by CLI args. apply_core_config() copies the tuneable subset across. +// ============================================================================ +void apply_core_config(const ncorr::Config& core, ProxyConfig& cfg) { + cfg.scalefactor = core.scalefactor; + cfg.subregion_type = core.subregion_type; + cfg.subregion_radius = core.subregion_radius; + cfg.interp_type = core.interp_type; + cfg.strain_subregion_type = core.strain_subregion_type; + cfg.strain_radius = core.strain_radius; + cfg.dic_config = core.dic_config; + cfg.num_threads = core.num_threads; + cfg.debug = core.debug; + cfg.perspective_interp = core.perspective_interp; + cfg.units = core.units; + cfg.units_per_pixel = core.units_per_pixel; + cfg.alpha = core.alpha; + cfg.fps = core.fps; +} + // ============================================================================ // Helper functions for JSON serialization (from ncorr_test_cubic.cpp) // ============================================================================ @@ -530,7 +556,11 @@ void print_usage(const char* prog_name) { // ============================================================================ int main(int argc, char* argv[]) { ProxyConfig config; - + + // Tier 3 (lowest priority): compiled defaults. Seed the tuneable DIC params + // from ncorr::Config so the compiled defaults live in one canonical place. + apply_core_config(ncorr::Config{}, config); + // Long options static struct option long_options[] = { {"folder", required_argument, 0, 'f'}, @@ -577,12 +607,56 @@ int main(int argc, char* argv[]) { } } - // Load config file if specified - if (!config_file.empty()) { - std::cout << "Loading config from: " << config_file << std::endl; - config = parse_config_file(config_file); + // Tier 2: config file. If none is given on the CLI, fall back to the shipped + // config/default.cfg when it exists, so the documented INI defaults apply. + std::string effective_config = config_file; + if (effective_config.empty() && file_exists("config/default.cfg")) { + effective_config = "config/default.cfg"; } - + + if (!effective_config.empty()) { + std::cout << "Loading config from: " << effective_config << std::endl; + // (a) Legacy short-key parser: handles paths, seeds, IO toggles and the + // historical short tuneable keys (radius, interp, threads, ...). + try { + config = parse_config_file(effective_config); + } catch (const std::exception& e) { + // A missing legacy file is non-fatal here; the canonical INI loader + // below still runs (and may itself report a real parse error). + std::cerr << "Note: " << e.what() << std::endl; + } + // (b) Canonical INI overlay (section 4): override the tuneable DIC params + // using the exact ncorr::Config field names. This complements the + // legacy short keys above and is the documented override chain. + // Seed the overlay with the values resolved so far (compiled defaults + // plus any legacy values) so only canonical keys present in the file + // are changed. + ncorr::Config overlay; + overlay.scalefactor = config.scalefactor; + overlay.subregion_type = config.subregion_type; + overlay.subregion_radius = config.subregion_radius; + overlay.interp_type = config.interp_type; + overlay.strain_subregion_type = config.strain_subregion_type; + overlay.strain_radius = config.strain_radius; + overlay.dic_config = config.dic_config; + overlay.num_threads = config.num_threads; + overlay.debug = config.debug; + overlay.perspective_interp = config.perspective_interp; + overlay.units = config.units; + overlay.units_per_pixel = config.units_per_pixel; + overlay.alpha = config.alpha; + overlay.fps = config.fps; + try { + if (ncorr::load_config_file(effective_config, overlay)) { + apply_core_config(overlay, config); + } + } catch (const std::exception& e) { + std::cerr << "Error in config file '" << effective_config + << "': " << e.what() << std::endl; + return 1; + } + } + // Reset getopt optind = 1; From b2bf99c67b647efa03618a064f8f70251dcf13a7 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 12:41:39 +0200 Subject: [PATCH 06/20] =?UTF-8?q?docs(tests):=20add=20TESTS=5FPLAN.md=20?= =?UTF-8?q?=E2=80=94=20awaiting=20confirmation=20before=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan of the source tree and existing ad-hoc harnesses; proposes unit, integration and end-to-end tests (config/INI parser, NcorrSession, folder reader, Image2D, Array2D, interpolation, RGDIC, strain, ROI, units/perspective; load->DIC->strain->export pipeline; ohtcfrp e2e smoke + golden value). Lists open questions requiring confirmation before section 5b: test framework choice (recommend Catch2 v3 via the existing FetchContent pattern, otherwise in-tree asserts + CTest), extracting the folder-reader helpers for testability, and the e2e golden control point/tolerance. No test code written yet. Co-Authored-By: Claude Opus 4.8 --- TESTS_PLAN.md | 182 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 TESTS_PLAN.md diff --git a/TESTS_PLAN.md b/TESTS_PLAN.md new file mode 100644 index 0000000..f449eb8 --- /dev/null +++ b/TESTS_PLAN.md @@ -0,0 +1,182 @@ +# CppNCorr Test Plan (`newversion`) + +> STATUS: **proposal — awaiting confirmation.** This document lists candidate +> tests only. No test framework has been added yet and no test code has been +> written. Per the task spec, implementation (section 5b) must not start until +> this plan is explicitly confirmed. + +## Scope & context + +The source tree contains: + +- **Core library** (`src/`, `include/`): `Array2D`, `Data2D`, `ROI2D`, `Disp2D`, + `Strain2D`, `Image2D`, the DIC engine `ncorr.cpp` (RGDIC, `DIC_analysis*`, + `change_perspective`, `set_units`, `strain_analysis`, interpolators, + nloptimizers), plus the new `session.cpp` (in-memory API) and `config.cpp` + (+ header-only `ini.h`). +- **Existing harnesses** (`test/src/`): ad-hoc executables that already exercise + parts of the engine — `interpolator_test`, `ncorr_interpolator_test`, + `ncorr_cubic_interpolator_test`, `ncorr_nloptimizer_test`, `roi2d_reduce_test`, + `ncorr_test_parity`, `ncorr_test_verif`, `ncorr_test_rgdic_verification`, + `ncorr_test_sequential`, `ncorr_test_parallel`, `ncorr_test_chain_seam`, + `ncorr_test`, `proxyncorr`. These are standalone `main()`s, not a unit-test + framework, and mostly dump JSON for external (MATLAB) comparison. +- **Fixture dataset**: `test/examples/ohtcfrp/` (12 PNG frames + `roi.png`) — the + lightweight end-to-end fixture referenced by the spec. + +### Open question for the reviewer (needed before 5b) + +The spec offers two options: reuse the existing ad-hoc harness style, or add +**GoogleTest / Catch2** (which requires explicit approval as a new dependency). +**Recommendation:** add **Catch2 v3 via the existing FetchContent pattern** +(header/single-lib, no system install, matches how nlohmann_json/OpenCV are +already fetched). It gives real assertions, test discovery, and CTest +integration. If you prefer zero new dependencies, the alternative is a minimal +in-tree assertion macro + a CTest registration of pass/fail executables. +**Please confirm the framework choice before I implement.** + +--- + +## 1. Unit tests (individual functions) + +### 1a. Config parser (new code — highest-value, dependency-free) +- `ini_parse_basic` — `key = value`, whitespace trimming, returns expected map. +- `ini_parse_comments` — full-line `#` and `;` comments are skipped; inline + comments after unquoted `#`/`;` are stripped. +- `ini_parse_quoted_values` — single/double quotes preserve spaces and embedded + `#`/`;`. +- `ini_parse_sections` — `[section]` headers produce `section.key` addressing. +- `ini_parse_crlf` — CRLF line endings parse identically to LF. +- `ini_typed_getters` — `get_int`/`get_double`/`get_bool` parse valid values; + bool accepts true/false/1/0/yes/no/on/off. +- `ini_typed_getters_invalid` — non-numeric int/double and bad bool throw with a + descriptive message. +- `ini_missing_file` — `load()` of a non-existent path returns false (not throw). +- `config_defaults` — a fresh `Config` has the documented compiled defaults. +- `config_overlay` — `load_config_file` overrides only keys present in the file, + leaving others at their incoming values. +- `config_key_field_parity` — every key in `config/default.cfg` maps to a + `Config` field (guards against silent mismatches). + +### 1b. In-memory session API (new code) +- `imagebuffer_valid` — `valid()` / `size_bytes()` for good and degenerate inputs. +- `session_requires_reference` — `process_frame` before `set_reference` throws + `std::logic_error`. +- `session_rejects_invalid_reference` — `set_reference` with a null/empty buffer + throws `std::invalid_argument`. +- `session_geometry_mismatch` — deformed frame of a different size returns an + invalid `DICResult` with a message (no crash). +- `session_stub_contract` — current stub returns `valid == false` and the + "not yet implemented" message (update this test when 3b is implemented). + +### 1c. Image folder reader (`discover_frames` + helpers in proxyncorr) +- `has_image_extension` — accepts png/tif/tiff/bmp/jpg/jpeg (any case), rejects + others and length-edge names. +- `natural_less` — `frame_2 < frame_10`; zero-padded names also order correctly. +- `discover_frames_sorted` — returns frames in natural order from a temp dir. +- `discover_frames_excludes_reserved` — skips `roi.png`, `ref.png`, hidden files, + sub-directories, and explicitly named ref/roi basenames. +- `discover_frames_empty` — empty folder returns an empty vector. +- `discover_frames_missing_folder` — throws `std::runtime_error`. + > Note: these helpers currently live in `proxyncorr.cpp`. Testing them cleanly + > may require extracting them into a small reader unit (e.g. + > `include/ncorr/frame_reader.h`). Flag if you want that refactor in 5b. + +### 1d. Image reader (`Image2D`) +- `image2d_read_grayscale` — `get_gs()` on a known PNG returns values in [0,1] + with expected dimensions. +- `image2d_read_missing` — constructing from / reading a missing file throws + `std::invalid_argument`. +- `image2d_from_mat_roundtrip` — `from_mat` then `get_gs()` matches the source. +- `imageprocessor_mat_array_roundtrip` — `array2d_to_mat` / `mat_to_array2d`. + +### 1e. Array2D core +- `array2d_construct_index` — element access, `height`/`width`, bounds. +- `array2d_arithmetic` — `+ - * /`, scalar ops, equality. +- `array2d_eye` — identity construction. +- (Lower priority: this is heavily templated and large; smoke-level coverage.) + +### 1f. Interpolation (engine) +- `interp_nearest_linear` — exact recovery of node values; linear midpoint. +- `interp_cubic_keys` — reproduces a known cubic polynomial within tolerance. +- `interp_quintic_bspline` — round-trip / partition-of-unity property. + > Existing `interpolator_test` / `ncorr_cubic_interpolator_test` provide + > reference behaviour to port into assertions. + +### 1g. Subregion / RGDIC building blocks +- `subregion_circle_square` — generated subregion point sets for CIRCLE/SQUARE at + a few radii. +- `rgdic_synthetic_shift` — `RGDIC` on a synthetically translated image recovers + the known uniform displacement within tolerance (small fixture). + +### 1h. Strain computation +- `strain_zero_for_rigid_translation` — pure translation yields ~0 strain. +- `strain_uniform_field` — a synthetic linear displacement field yields the + expected constant `exx`/`eyy`/`exy`. + +### 1i. ROI2D +- `roi_from_mask` — threshold mask -> region count and mask round-trip. +- `roi_reduce` — port assertions from existing `roi2d_reduce_test`. +- `roi_update` — `update(ROI, Disp, INTERP)` SKIP_ALL vs SKIP_INVALID behaviour. + +### 1j. Units / perspective +- `set_units_scaling` — `set_units` scales displacement by units/pixel. +- `change_perspective_identity` — converting and (where applicable) inverting + returns a field close to the original on a synthetic case. + +--- + +## 2. Integration tests (pipeline stages) + +- `pipeline_load_to_dic` — load `ohtcfrp` ref + one deformed frame, build + `DIC_analysis_input`, run `DIC_analysis`, assert output dimensions and a + finite displacement inside the ROI. +- `pipeline_dic_to_strain` — feed `DIC_analysis_output` into `strain_analysis`, + assert strain field dimensions and finiteness in-ROI. +- `pipeline_perspective_units` — `DIC_analysis -> change_perspective -> + set_units` chain runs and preserves dimensions. +- `pipeline_sequential_vs_parallel` — `DIC_analysis_sequential` and + `DIC_analysis(parallel)` agree within tolerance on the same small input. +- `pipeline_config_drives_run` — load params from a temp INI, confirm the run + uses them (e.g. radius/scalefactor reflected in `DIC_analysis_input`). +- `pipeline_export_outputs` — JSON and binary save/round-trip (`save`/`load`) of + DIC/strain inputs and outputs to a temp dir. +- `pipeline_session_matches_folder` *(deferred until 3b implemented)* — once + `NcorrSession::process_frame` is real, assert it matches the folder pipeline on + the same frames. + +## 3. End-to-end test + +- `e2e_ohtcfrp_smoke` — run the full pipeline (or the `proxyncorr` executable) on + `test/examples/ohtcfrp` with a reduced frame count, writing to a temp output + dir; assert it exits 0 and produces the expected output files. +- `e2e_ohtcfrp_known_value` — at a chosen in-ROI control point on a specific + frame, assert the displacement magnitude matches a recorded golden value + within tolerance. **Action needed:** capture the golden value from a trusted + run during 5b (or supply one) — please confirm the control point/frame and the + acceptable tolerance. +- Keep CI-friendly: use 2-3 frames, small scalefactor, no video rendering, no + large downloads. + +--- + +## Proposed mechanics (pending confirmation) + +- **Framework**: Catch2 v3 via FetchContent (recommended) **or** in-tree minimal + asserts + CTest — your call. +- **Layout**: `test/unit/`, `test/integration/`, `test/e2e/`, registered with + `enable_testing()` / `add_test` so `ctest` runs everything. +- **Fixtures**: reuse `test/examples/ohtcfrp`; generate synthetic shifted images + in-memory for deterministic unit tests (no new data files). +- **Tolerances**: define a shared `kDispTol` / `kStrainTol`; sub-pixel DIC tests + use loose tolerances (e.g. 0.05 px) to stay robust across platforms. +- **Possible small refactor**: extract `discover_frames` / `has_image_extension` + / `natural_less` into a reusable header so 1c can test them without compiling + all of `proxyncorr.cpp` — flag if acceptable. + +--- + +### Please confirm before I proceed to 5b +1. Test framework: **Catch2 v3 (FetchContent)** or in-tree asserts? +2. OK to extract the folder-reader helpers into a small unit for testability? +3. The e2e golden control point + tolerance (or approve capturing one during 5b). From ea1d73e2a39361242f93c2f26a00271d9d60e7c0 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 13:17:48 +0200 Subject: [PATCH 07/20] refactor(input/images): extract frame-reader helpers into reusable header Move discover_frames, has_image_extension and natural_less out of test/src/proxyncorr.cpp into a new header-only unit include/ncorr/frame_reader.h (namespace ncorr, inline functions) so they can be unit-tested in isolation without compiling the driver. Behaviour is unchanged; proxyncorr.cpp now includes the header and uses the functions via its existing 'using namespace ncorr'. Co-Authored-By: Claude Opus 4.8 --- include/ncorr/frame_reader.h | 181 +++++++++++++++++++++++++++++++++++ test/src/proxyncorr.cpp | 124 +----------------------- 2 files changed, 186 insertions(+), 119 deletions(-) create mode 100644 include/ncorr/frame_reader.h diff --git a/include/ncorr/frame_reader.h b/include/ncorr/frame_reader.h new file mode 100644 index 0000000..42e76f4 --- /dev/null +++ b/include/ncorr/frame_reader.h @@ -0,0 +1,181 @@ +#pragma once +/** + * @file frame_reader.h + * @brief Image-folder frame discovery helpers for the file-based DIC pipeline. + * + * These helpers were extracted out of @c proxyncorr.cpp so they can be reused + * and unit-tested in isolation without compiling the whole driver. The behaviour + * is intentionally identical to the original in-driver implementation. + * + * NAMING CONVENTION / EXPECTED LAYOUT (see @ref discover_frames): + * - One image per frame, plus (optionally) a ROI mask and a dedicated + * reference image. + * - Frames numbered so they sort in acquisition order. Both zero-padded + * ("frame_00.png", "frame_01.png", ...) and unpadded ("frame_2.png", + * "frame_10.png", ...) numbering work; ordering uses a natural + * (numeric-aware) comparison, not raw lexicographic order. + * - Files named "roi.png" and "ref.png" (case-insensitive) are reserved and + * excluded, as are files matching the supplied @c ref_path / @c roi_path + * basenames. + * - Hidden files (leading '.') and non-image extensions are ignored. + * - Supported extensions: .png .tif .tiff .bmp .jpg .jpeg + * + * The functions are @c inline so this header is self-contained: tests can + * include it directly without linking the driver translation unit. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace ncorr { + +/** + * @brief Supported image extensions (lowercase, including the leading dot). + * + * Mirrors the formats OpenCV's imread handles in this build. Keep in sync with + * the user guide. + */ +inline const std::vector& image_extensions() { + static const std::vector kImageExtensions = { + ".png", ".tif", ".tiff", ".bmp", ".jpg", ".jpeg"}; + return kImageExtensions; +} + +/** + * @brief Test whether a (lowercased) filename ends with a supported image extension. + * + * Length-safe: never reads out of bounds for short names. + * + * @param lower_name Filename already converted to lowercase. + * @return true if the name ends with one of @ref image_extensions(). + */ +inline bool has_image_extension(const std::string& lower_name) { + for (const std::string& ext : image_extensions()) { + if (lower_name.size() > ext.size() && + lower_name.compare(lower_name.size() - ext.size(), ext.size(), ext) == 0) { + return true; + } + } + return false; +} + +/** + * @brief Natural (human) ordering comparison for filenames. + * + * Compares runs of digits by numeric value so unpadded numeric frame names sort + * correctly, e.g. "frame_2.png" < "frame_10.png". Falls back to lexicographic + * for non-digit runs. Zero-padded names also sort correctly under this rule. + * + * @param a Left-hand filename. + * @param b Right-hand filename. + * @return true if @p a should sort before @p b. + */ +inline bool natural_less(const std::string& a, const std::string& b) { + size_t i = 0, j = 0; + while (i < a.size() && j < b.size()) { + if (std::isdigit(static_cast(a[i])) && + std::isdigit(static_cast(b[j]))) { + // Compare two runs of digits by numeric value (skip leading zeros). + size_t ai = i, bj = j; + while (ai < a.size() && std::isdigit(static_cast(a[ai]))) ++ai; + while (bj < b.size() && std::isdigit(static_cast(b[bj]))) ++bj; + std::string da = a.substr(i, ai - i); + std::string db = b.substr(j, bj - j); + da.erase(0, da.find_first_not_of('0')); + db.erase(0, db.find_first_not_of('0')); + if (da.size() != db.size()) return da.size() < db.size(); + if (da != db) return da < db; + i = ai; + j = bj; + } else { + if (a[i] != b[j]) return a[i] < b[j]; + ++i; + ++j; + } + } + return a.size() < b.size(); +} + +/** + * @brief Discover the deformed-frame image files inside @p folder. + * + * See the file-level documentation for the expected naming convention. Files + * named "roi.png"/"ref.png" (case-insensitive) and any matching the supplied + * @p roi_path / @p ref_path basenames are excluded; hidden files, sub-directories + * and non-image extensions are skipped. The result is sorted with @ref natural_less. + * + * @param folder Directory to scan. + * @param ref_path Reference image path whose basename should be excluded (may be empty). + * @param roi_path ROI image path whose basename should be excluded (may be empty). + * @return Frame paths (folder + "/" + name) in natural order. Empty if the folder + * has no usable frames (the caller is expected to report that). + * @throws std::runtime_error if @p folder cannot be opened. + */ +inline std::vector discover_frames(const std::string& folder, + const std::string& ref_path, + const std::string& roi_path) { + std::vector frames; + DIR* dir = opendir(folder.c_str()); + if (!dir) { + throw std::runtime_error("Cannot open folder '" + folder + + "': " + std::strerror(errno)); + } + + // Get basenames to exclude. + std::string roi_basename = ""; + std::string ref_basename = ""; + if (!roi_path.empty()) { + size_t pos = roi_path.find_last_of("/\\"); + roi_basename = (pos != std::string::npos) ? roi_path.substr(pos + 1) : roi_path; + } + if (!ref_path.empty()) { + size_t pos = ref_path.find_last_of("/\\"); + ref_basename = (pos != std::string::npos) ? ref_path.substr(pos + 1) : ref_path; + } + + struct dirent* entry; + while ((entry = readdir(dir)) != nullptr) { + std::string name = entry->d_name; + + // Skip empty names (defensive) and hidden files / "." / ".." entries. + if (name.empty() || name[0] == '.') continue; + + // Skip nested sub-directories: only regular files are valid frames. + std::string full_path = folder + "/" + name; + struct stat st; + if (stat(full_path.c_str(), &st) == 0 && S_ISDIR(st.st_mode)) continue; + + // Check for image extensions (case-insensitive, length-safe). + std::string lower_name = name; + std::transform(lower_name.begin(), lower_name.end(), lower_name.begin(), + [](unsigned char c) { return std::tolower(c); }); + + if (!has_image_extension(lower_name)) continue; + + // Skip reserved roi.png / ref.png by default. + if (lower_name == "roi.png") continue; + if (lower_name == "ref.png") continue; + + // Skip explicitly specified roi and ref files. + if (!roi_basename.empty() && name == roi_basename) continue; + if (!ref_basename.empty() && name == ref_basename) continue; + + frames.push_back(full_path); + } + closedir(dir); + + // Sort frames naturally (handles both padded and unpadded numbered files). + std::sort(frames.begin(), frames.end(), natural_less); + + return frames; +} + +} // namespace ncorr diff --git a/test/src/proxyncorr.cpp b/test/src/proxyncorr.cpp index 408f428..ef325b5 100644 --- a/test/src/proxyncorr.cpp +++ b/test/src/proxyncorr.cpp @@ -1,5 +1,6 @@ #include "ncorr.h" #include "ncorr/config.h" +#include "ncorr/frame_reader.h" #include #include #include @@ -263,127 +264,12 @@ void save_as_json(const DIC_analysis_input& dic_input, // ============================================================================ // File discovery functions +// ---------------------------------------------------------------------------- +// discover_frames / has_image_extension / natural_less now live in the reusable +// header include/ncorr/frame_reader.h so they can be unit-tested without +// compiling this driver. They are pulled in via "using namespace ncorr;" above. // ============================================================================ -// Supported image extensions (lowercase, including the dot). Mirrors the formats -// OpenCV's imread handles in this build. Keep in sync with the user guide. -static const std::vector kImageExtensions = { - ".png", ".tif", ".tiff", ".bmp", ".jpg", ".jpeg" -}; - -// Return true if `lower_name` (already lowercased) ends with a supported image -// extension. Length-safe: never reads out of bounds for short names. -static bool has_image_extension(const std::string& lower_name) { - for (const std::string& ext : kImageExtensions) { - if (lower_name.size() > ext.size() && - lower_name.compare(lower_name.size() - ext.size(), ext.size(), ext) == 0) { - return true; - } - } - return false; -} - -// Natural (human) comparison so unpadded numeric frame names sort correctly, -// e.g. "frame_2.png" < "frame_10.png". Falls back to lexicographic for -// non-digit runs. Zero-padded names also sort correctly under this rule. -static bool natural_less(const std::string& a, const std::string& b) { - size_t i = 0, j = 0; - while (i < a.size() && j < b.size()) { - if (std::isdigit(static_cast(a[i])) && - std::isdigit(static_cast(b[j]))) { - // Compare two runs of digits by numeric value (skip leading zeros). - size_t ai = i, bj = j; - while (ai < a.size() && std::isdigit(static_cast(a[ai]))) ++ai; - while (bj < b.size() && std::isdigit(static_cast(b[bj]))) ++bj; - std::string da = a.substr(i, ai - i); - std::string db = b.substr(j, bj - j); - da.erase(0, da.find_first_not_of('0')); - db.erase(0, db.find_first_not_of('0')); - if (da.size() != db.size()) return da.size() < db.size(); - if (da != db) return da < db; - i = ai; j = bj; - } else { - if (a[i] != b[j]) return a[i] < b[j]; - ++i; ++j; - } - } - return a.size() < b.size(); -} - -// Discover the deformed-frame image files inside `folder`. -// -// NAMING CONVENTION / EXPECTED LAYOUT: -// - The folder contains one image per frame plus (optionally) a ROI mask and a -// dedicated reference image. -// - Frames should be numbered so they sort in acquisition order. Both -// zero-padded ("frame_00.png", "frame_01.png", ...) and unpadded -// ("frame_2.png", "frame_10.png", ...) numbering work; ordering uses a -// natural (numeric-aware) comparison, not raw lexicographic order. -// - Files named "roi.png" and "ref.png" (case-insensitive) are reserved and -// excluded from the frame list, as are any files matching the explicitly -// supplied `roi_path` / `ref_path` basenames. -// - Hidden files (leading '.') and any non-image extension are ignored. -// - Supported extensions: .png .tif .tiff .bmp .jpg .jpeg -// -// Throws std::runtime_error if `folder` cannot be opened. Returns an empty -// vector if the folder simply contains no usable frames (the caller reports it). -std::vector discover_frames(const std::string& folder, const std::string& ref_path, const std::string& roi_path) { - std::vector frames; - DIR* dir = opendir(folder.c_str()); - if (!dir) { - throw std::runtime_error("Cannot open folder '" + folder + - "': " + std::strerror(errno)); - } - - // Get basenames to exclude - std::string roi_basename = ""; - std::string ref_basename = ""; - if (!roi_path.empty()) { - size_t pos = roi_path.find_last_of("/\\"); - roi_basename = (pos != std::string::npos) ? roi_path.substr(pos + 1) : roi_path; - } - if (!ref_path.empty()) { - size_t pos = ref_path.find_last_of("/\\"); - ref_basename = (pos != std::string::npos) ? ref_path.substr(pos + 1) : ref_path; - } - - struct dirent* entry; - while ((entry = readdir(dir)) != nullptr) { - std::string name = entry->d_name; - - // Skip empty names (defensive) and hidden files / "." / ".." entries. - if (name.empty() || name[0] == '.') continue; - - // Skip nested sub-directories: only regular files are valid frames. - std::string full_path = folder + "/" + name; - struct stat st; - if (stat(full_path.c_str(), &st) == 0 && S_ISDIR(st.st_mode)) continue; - - // Check for image extensions (case-insensitive, length-safe). - std::string lower_name = name; - std::transform(lower_name.begin(), lower_name.end(), lower_name.begin(), - [](unsigned char c) { return std::tolower(c); }); - - if (!has_image_extension(lower_name)) continue; - - // Skip reserved roi.png / ref.png by default - if (lower_name == "roi.png") continue; - if (lower_name == "ref.png") continue; - - // Skip explicitly specified roi and ref files - if (!roi_basename.empty() && name == roi_basename) continue; - if (!ref_basename.empty() && name == ref_basename) continue; - - frames.push_back(full_path); - } - closedir(dir); - - // Sort frames naturally (handles both padded and unpadded numbered files). - std::sort(frames.begin(), frames.end(), natural_less); - - return frames; -} - bool file_exists(const std::string& path) { struct stat buffer; return (stat(path.c_str(), &buffer) == 0); From 6634af9b49896709612726b0a348cce0506bb730 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 14:34:28 +0200 Subject: [PATCH 08/20] test(unit): add Catch2 v3 suite for ini/config/frame_reader/session Wire up the test infrastructure and the dependency-light unit tests. Framework: Catch2 v3 via FetchContent (matches the repo's existing FetchContent dependency pattern). ALL test infra (Catch2 fetch, enable_testing, test targets, CTest registration) is gated behind PROJECT_IS_TOP_LEVEL AND BUILD_TESTING in the root CMakeLists, with a manual PROJECT_IS_TOP_LEVEL fallback for CMake < 3.21, so it never leaks into the parent CPPxDIC add_subdirectory build. BUILD_TESTING defaults ON only when CppNCorr is top-level. Unit targets (ncorr_unit_tests) compile only the small dependency-free sources (ini.h, config.cpp, session.cpp, frame_reader.h) so they build without OpenCV/FFTW/SuiteSparse/BLAS: - test_ini.cpp: parsing, comments, quotes, sections, CRLF, typed getters (valid + invalid), missing file. - test_config.cpp: compiled defaults, file overlay, missing-file/no-op, invalid value throws, and key/field parity against config/default.cfg. - test_frame_reader.cpp: has_image_extension, natural_less, discover_ frames sorting/exclusions/empty/missing-folder. - test_session.cpp: ImageBuffer validity, reference-required precondition, invalid-reference rejection, geometry mismatch, and the current stub contract (must be updated when in-memory DIC lands). Tests registered with CTest via catch_discover_tests. Catch2 build artifacts are redirected to the build tree (and gitignored) so the source lib/ stays clean. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + CMakeLists.txt | 32 ++++++++ test/tests.cmake | 89 +++++++++++++++++++++ test/unit/test_config.cpp | 135 ++++++++++++++++++++++++++++++++ test/unit/test_frame_reader.cpp | 106 +++++++++++++++++++++++++ test/unit/test_ini.cpp | 133 +++++++++++++++++++++++++++++++ test/unit/test_session.cpp | 90 +++++++++++++++++++++ 7 files changed, 588 insertions(+) create mode 100644 test/tests.cmake create mode 100644 test/unit/test_config.cpp create mode 100644 test/unit/test_frame_reader.cpp create mode 100644 test/unit/test_ini.cpp create mode 100644 test/unit/test_session.cpp diff --git a/.gitignore b/.gitignore index e718491..b30eaed 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ lib/libncorr.a +lib/libCatch2.a +lib/libCatch2Main.a build +build_tests test/bin test/examples/ohtcfrp/save/ test/examples/ohtcfrp/video/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 424f147..979eb6d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -104,3 +104,35 @@ ADD_DEFINITIONS(-DNDEBUG) # Install library INSTALL(TARGETS ncorr DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/lib) INSTALL(FILES ${ncorr_h} DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/include) + +# ============================================================================ +# Tests (section 5b) — Catch2 v3 via FetchContent. +# ---------------------------------------------------------------------------- +# CRITICAL: all test infrastructure (Catch2 FetchContent, enable_testing(), +# test targets, add_test/CTest) is gated so it ONLY activates when CppNCorr is +# the TOP-LEVEL project. The parent CPPxDIC project does +# add_subdirectory(Tools/CppNCorr); without this guard the test/Catch2 logic +# would leak into and break the parent build. +# +# PROJECT_IS_TOP_LEVEL is set automatically by project() on CMake >= 3.21; for +# older CMake we derive it by comparing the source/binary dirs. +# ============================================================================ +if(NOT DEFINED PROJECT_IS_TOP_LEVEL) + if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + set(PROJECT_IS_TOP_LEVEL ON) + else() + set(PROJECT_IS_TOP_LEVEL OFF) + endif() +endif() + +# BUILD_TESTING defaults ON only when CppNCorr is top-level. When pulled in as a +# subdirectory it defaults OFF so the parent is never affected. +option(BUILD_TESTING "Build the CppNCorr test suite (Catch2)" ${PROJECT_IS_TOP_LEVEL}) + +if(PROJECT_IS_TOP_LEVEL AND BUILD_TESTING) + message(STATUS "CppNCorr: top-level build with testing enabled — configuring Catch2 test suite") + enable_testing() + include(${CMAKE_CURRENT_SOURCE_DIR}/test/tests.cmake) +else() + message(STATUS "CppNCorr: tests skipped (PROJECT_IS_TOP_LEVEL=${PROJECT_IS_TOP_LEVEL}, BUILD_TESTING=${BUILD_TESTING})") +endif() diff --git a/test/tests.cmake b/test/tests.cmake new file mode 100644 index 0000000..98481d5 --- /dev/null +++ b/test/tests.cmake @@ -0,0 +1,89 @@ +# ============================================================================ +# tests.cmake — Catch2-based test suite for CppNCorr (section 5b) +# ---------------------------------------------------------------------------- +# This file is included ONLY when CppNCorr is the top-level project and testing +# is enabled (see the guard in the root CMakeLists.txt). It must never run when +# the parent CPPxDIC project does add_subdirectory(Tools/CppNCorr), so that the +# Catch2 FetchContent and CTest targets do not leak into the parent build. +# +# Test tiers: +# - ncorr_unit_tests : dependency-light (config/ini/frame_reader/session). +# - ncorr_engine_tests : links the full ncorr library (integration + e2e). +# ============================================================================ + +include(FetchContent) + +# Keep test-tree build artifacts (Catch2 static libs, test executables) inside +# the build directory instead of the source-tree lib/ that the root CMakeLists +# uses for libncorr.a. This avoids polluting the source tree with Catch2.a etc. +set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) + +# ---- Catch2 v3 via FetchContent (matches the repo's FetchContent pattern) --- +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.5.4 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(Catch2) +list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) +include(Catch) + +# Path constants injected into the tests as compile definitions. +set(NCORR_DEFAULT_CFG_PATH "${CMAKE_CURRENT_SOURCE_DIR}/config/default.cfg") +set(NCORR_FIXTURE_DIR_PATH "${CMAKE_CURRENT_SOURCE_DIR}/test/examples/ohtcfrp/images") + +# ---------------------------------------------------------------------------- +# 1. Lightweight unit tests — no OpenCV/FFTW/SuiteSparse/BLAS required. +# Compiles only the small, dependency-free sources directly. +# ---------------------------------------------------------------------------- +add_executable(ncorr_unit_tests + test/unit/test_ini.cpp + test/unit/test_config.cpp + test/unit/test_frame_reader.cpp + test/unit/test_session.cpp + src/config.cpp + src/session.cpp +) +target_include_directories(ncorr_unit_tests PRIVATE include) +target_compile_definitions(ncorr_unit_tests PRIVATE + NCORR_DEFAULT_CFG="${NCORR_DEFAULT_CFG_PATH}") +target_link_libraries(ncorr_unit_tests PRIVATE Catch2::Catch2WithMain) +catch_discover_tests(ncorr_unit_tests) + +# ---------------------------------------------------------------------------- +# 2. Engine tests (integration + e2e) — link the full ncorr library, which the +# root CMakeLists has already defined as target `ncorr`. The ncorr static +# library does not carry its transitive system deps, so the executable must +# link OpenCV/FFTW/SuiteSparse/BLAS itself (resolved here the same way the +# legacy test/CMakeLists.txt does, honouring the FetchContent fallbacks). +# ---------------------------------------------------------------------------- +find_or_fetch_suitesparse() +find_or_fetch_openblas() + +add_executable(ncorr_engine_tests + test/integration/test_pipeline.cpp + test/e2e/test_e2e.cpp +) +target_include_directories(ncorr_engine_tests PRIVATE include ${OpenCV_INCLUDE_DIRS}) +target_compile_definitions(ncorr_engine_tests PRIVATE + NCORR_FIXTURE_DIR="${NCORR_FIXTURE_DIR_PATH}") +target_link_libraries(ncorr_engine_tests PRIVATE ncorr Catch2::Catch2WithMain ${OpenCV_LIBS}) +if(FFTW_LIBRARY) + target_link_libraries(ncorr_engine_tests PRIVATE ${FFTW_LIBRARY}) +endif() +if(SUITESPARSE_LIBRARIES) + target_link_libraries(ncorr_engine_tests PRIVATE ${SUITESPARSE_LIBRARIES}) +endif() +if(LAPACK_LIBRARIES OR BLAS_LIBRARIES) + target_link_libraries(ncorr_engine_tests PRIVATE ${LAPACK_LIBRARIES} ${BLAS_LIBRARIES}) +endif() +find_package(Threads QUIET) +if(Threads_FOUND) + target_link_libraries(ncorr_engine_tests PRIVATE Threads::Threads) +endif() +if(OpenMP_CXX_FOUND) + target_link_libraries(ncorr_engine_tests PRIVATE OpenMP::OpenMP_CXX) +endif() +catch_discover_tests(ncorr_engine_tests) diff --git a/test/unit/test_config.cpp b/test/unit/test_config.cpp new file mode 100644 index 0000000..847f7ee --- /dev/null +++ b/test/unit/test_config.cpp @@ -0,0 +1,135 @@ +/** + * @file test_config.cpp + * @brief Unit tests for the compiled-default Config and the config-file overlay. + * + * Also verifies that every key shipped in config/default.cfg maps to a real + * Config field (key/field parity) to guard against silent mismatches. + */ + +#include + +#include "ncorr/config.h" +#include "ncorr/ini.h" + +#include +#include +#include +#include + +namespace { + +std::string write_temp(const std::string& content, const char* tag) { + static int counter = 0; + std::string path = std::string(std::tmpnam(nullptr)) + "_ncorr_cfg_" + tag + "_" + + std::to_string(counter++) + ".cfg"; + std::ofstream out(path, std::ios::binary); + out << content; + out.close(); + return path; +} + +} // namespace + +TEST_CASE("config_defaults", "[unit][config]") { + ncorr::Config c; + CHECK(c.scalefactor == 3); + CHECK(c.subregion_type == "CIRCLE"); + CHECK(c.subregion_radius == 20); + CHECK(c.interp_type == "QUINTIC_BSPLINE_PRECOMPUTE"); + CHECK(c.strain_subregion_type == "CIRCLE"); + CHECK(c.strain_radius == 5); + CHECK(c.dic_config == "NO_UPDATE"); + CHECK(c.num_threads == 4); + CHECK(c.debug == false); + CHECK(c.perspective_interp == "CUBIC_KEYS"); + CHECK(c.units == "mm"); + CHECK(c.units_per_pixel == 0.2); + CHECK(c.alpha == 0.5); + CHECK(c.fps == 15.0); +} + +TEST_CASE("config_overlay", "[unit][config]") { + auto path = write_temp( + "scalefactor = 7\n" + "subregion_radius = 33\n" + "units = px\n", + "overlay"); + + ncorr::Config c; // start from compiled defaults + REQUIRE(ncorr::load_config_file(path, c)); + + // Overridden keys take the file value... + CHECK(c.scalefactor == 7); + CHECK(c.subregion_radius == 33); + CHECK(c.units == "px"); + // ...everything else stays at the compiled default. + CHECK(c.subregion_type == "CIRCLE"); + CHECK(c.interp_type == "QUINTIC_BSPLINE_PRECOMPUTE"); + CHECK(c.num_threads == 4); + std::remove(path.c_str()); +} + +TEST_CASE("config_overlay_missing_file_keeps_values", "[unit][config]") { + ncorr::Config c; + c.scalefactor = 99; // pretend a prior tier set this + CHECK_FALSE(ncorr::load_config_file("/no/such/file_98765.cfg", c)); + CHECK(c.scalefactor == 99); // left untouched +} + +TEST_CASE("config_overlay_invalid_value_throws", "[unit][config]") { + auto path = write_temp("scalefactor = not_a_number\n", "bad"); + ncorr::Config c; + CHECK_THROWS_AS(ncorr::load_config_file(path, c), std::runtime_error); + std::remove(path.c_str()); +} + +// Every key in the shipped default.cfg must correspond to a Config field that +// the overlay knows how to set. We verify this indirectly: load default.cfg over +// a Config whose fields are all set to non-default sentinels, then confirm every +// key actually changed at least one field away from its sentinel (i.e. is wired). +TEST_CASE("config_key_field_parity", "[unit][config]") { + // NCORR_DEFAULT_CFG is provided as a compile definition pointing at the + // repository's config/default.cfg. + const char* cfg_path = NCORR_DEFAULT_CFG; + + // 1. The default.cfg must parse cleanly into a Config. + ncorr::Config c; + REQUIRE(ncorr::load_config_file(cfg_path, c)); + + // 2. Collect the set of keys the overlay code recognises by diffing two + // Configs: one loaded from default.cfg and one left at compiled defaults. + // default.cfg mirrors the compiled defaults, so loading it should leave + // a default Config unchanged (proves keys map to the right fields with + // the documented values). + ncorr::Config from_file; + REQUIRE(ncorr::load_config_file(cfg_path, from_file)); + ncorr::Config compiled; // compiled defaults + CHECK(from_file.scalefactor == compiled.scalefactor); + CHECK(from_file.subregion_type == compiled.subregion_type); + CHECK(from_file.subregion_radius == compiled.subregion_radius); + CHECK(from_file.interp_type == compiled.interp_type); + CHECK(from_file.strain_subregion_type == compiled.strain_subregion_type); + CHECK(from_file.strain_radius == compiled.strain_radius); + CHECK(from_file.dic_config == compiled.dic_config); + CHECK(from_file.num_threads == compiled.num_threads); + CHECK(from_file.debug == compiled.debug); + CHECK(from_file.perspective_interp == compiled.perspective_interp); + CHECK(from_file.units == compiled.units); + CHECK(from_file.units_per_pixel == compiled.units_per_pixel); + CHECK(from_file.alpha == compiled.alpha); + CHECK(from_file.fps == compiled.fps); + + // 3. Guard against an unrecognised key silently slipping into default.cfg: + // every non-comment 'key = value' line must be one of the known fields. + static const std::set known_keys = { + "scalefactor", "subregion_type", "subregion_radius", "interp_type", + "strain_subregion_type", "strain_radius", "dic_config", "num_threads", + "debug", "perspective_interp", "units", "units_per_pixel", "alpha", "fps"}; + + ncorr::IniFile ini; + REQUIRE(ini.load(cfg_path)); + for (const auto& kv : ini.values()) { + INFO("default.cfg key: " << kv.first); + CHECK(known_keys.count(kv.first) == 1); + } +} diff --git a/test/unit/test_frame_reader.cpp b/test/unit/test_frame_reader.cpp new file mode 100644 index 0000000..84732ee --- /dev/null +++ b/test/unit/test_frame_reader.cpp @@ -0,0 +1,106 @@ +/** + * @file test_frame_reader.cpp + * @brief Unit tests for the image-folder frame discovery helpers + * (include/ncorr/frame_reader.h). + * + * Uses a temporary directory populated with empty placeholder files so the + * discovery / ordering / exclusion logic can be tested without real images. + */ + +#include + +#include "ncorr/frame_reader.h" + +#include +#include +#include +#include +#include + +namespace { + +// Create a unique temporary directory and return its path (no trailing slash). +std::string make_temp_dir() { + static int counter = 0; + std::string base = std::string(std::tmpnam(nullptr)) + "_ncorr_frames_" + + std::to_string(counter++); + mkdir(base.c_str(), 0755); + return base; +} + +void touch(const std::string& path) { + std::ofstream out(path); + out << "x"; + out.close(); +} + +} // namespace + +TEST_CASE("has_image_extension", "[unit][frame_reader]") { + using ncorr::has_image_extension; + CHECK(has_image_extension("frame.png")); + CHECK(has_image_extension("frame.tif")); + CHECK(has_image_extension("frame.tiff")); + CHECK(has_image_extension("frame.bmp")); + CHECK(has_image_extension("frame.jpg")); + CHECK(has_image_extension("frame.jpeg")); + // Rejections. + CHECK_FALSE(has_image_extension("frame.txt")); + CHECK_FALSE(has_image_extension("frame.pn")); + CHECK_FALSE(has_image_extension("noext")); + // Length-edge: a name equal to just the extension is not a valid image name. + CHECK_FALSE(has_image_extension(".png")); + CHECK_FALSE(has_image_extension("")); +} + +TEST_CASE("natural_less", "[unit][frame_reader]") { + using ncorr::natural_less; + CHECK(natural_less("frame_2.png", "frame_10.png")); + CHECK_FALSE(natural_less("frame_10.png", "frame_2.png")); + // Zero-padded names also order correctly. + CHECK(natural_less("img_00.png", "img_01.png")); + CHECK(natural_less("img_09.png", "img_10.png")); + // Pure lexicographic fallback for non-digit prefixes. + CHECK(natural_less("a.png", "b.png")); +} + +TEST_CASE("discover_frames_sorted", "[unit][frame_reader]") { + std::string dir = make_temp_dir(); + touch(dir + "/frame_10.png"); + touch(dir + "/frame_2.png"); + touch(dir + "/frame_1.png"); + + auto frames = ncorr::discover_frames(dir, "", ""); + REQUIRE(frames.size() == 3); + CHECK(frames[0] == dir + "/frame_1.png"); + CHECK(frames[1] == dir + "/frame_2.png"); + CHECK(frames[2] == dir + "/frame_10.png"); +} + +TEST_CASE("discover_frames_excludes_reserved", "[unit][frame_reader]") { + std::string dir = make_temp_dir(); + touch(dir + "/frame_1.png"); + touch(dir + "/roi.png"); // reserved + touch(dir + "/ref.png"); // reserved + touch(dir + "/.hidden.png"); // hidden + touch(dir + "/notes.txt"); // non-image + touch(dir + "/custom_ref.png"); // excluded via ref_path basename + mkdir((dir + "/subdir").c_str(), 0755); // sub-directory, ignored + + auto frames = + ncorr::discover_frames(dir, dir + "/custom_ref.png", ""); + REQUIRE(frames.size() == 1); + CHECK(frames[0] == dir + "/frame_1.png"); +} + +TEST_CASE("discover_frames_empty", "[unit][frame_reader]") { + std::string dir = make_temp_dir(); + auto frames = ncorr::discover_frames(dir, "", ""); + CHECK(frames.empty()); +} + +TEST_CASE("discover_frames_missing_folder", "[unit][frame_reader]") { + CHECK_THROWS_AS( + ncorr::discover_frames("/no/such/folder_abc_98765", "", ""), + std::runtime_error); +} diff --git a/test/unit/test_ini.cpp b/test/unit/test_ini.cpp new file mode 100644 index 0000000..ce8aab0 --- /dev/null +++ b/test/unit/test_ini.cpp @@ -0,0 +1,133 @@ +/** + * @file test_ini.cpp + * @brief Unit tests for the header-only INI parser (include/ncorr/ini.h). + * + * Dependency-free: exercises parsing rules, typed getters and error handling + * using temporary files written to the system temp directory. + */ + +#include + +#include "ncorr/ini.h" + +#include +#include +#include + +namespace { + +// Write `content` to a unique temp file and return its path. The file is left on +// disk for the duration of the test process (small, harmless); Catch reports +// per-section so collisions are avoided via a counter. +std::string write_temp_ini(const std::string& content) { + static int counter = 0; + std::string path = std::string(std::tmpnam(nullptr)) + "_ncorr_ini_" + + std::to_string(counter++) + ".cfg"; + std::ofstream out(path, std::ios::binary); + out << content; + out.close(); + return path; +} + +} // namespace + +TEST_CASE("ini_parse_basic", "[unit][ini]") { + auto path = write_temp_ini(" key1 = value1 \nkey2=value2\n"); + ncorr::IniFile ini; + REQUIRE(ini.load(path)); + CHECK(ini.get("key1") == "value1"); + CHECK(ini.get("key2") == "value2"); + CHECK(ini.has("key1")); + CHECK_FALSE(ini.has("missing")); + CHECK(ini.get("missing", "fallback") == "fallback"); + std::remove(path.c_str()); +} + +TEST_CASE("ini_parse_comments", "[unit][ini]") { + auto path = write_temp_ini( + "# full line comment\n" + "; semicolon comment\n" + "key = value # inline comment\n" + "key2 = val2 ; also inline\n"); + ncorr::IniFile ini; + REQUIRE(ini.load(path)); + CHECK(ini.get("key") == "value"); + CHECK(ini.get("key2") == "val2"); + CHECK(ini.values().size() == 2); + std::remove(path.c_str()); +} + +TEST_CASE("ini_parse_quoted_values", "[unit][ini]") { + auto path = write_temp_ini( + "a = \" spaced value \"\n" + "b = 'has # hash and ; semi'\n"); + ncorr::IniFile ini; + REQUIRE(ini.load(path)); + CHECK(ini.get("a") == " spaced value "); + CHECK(ini.get("b") == "has # hash and ; semi"); + std::remove(path.c_str()); +} + +TEST_CASE("ini_parse_sections", "[unit][ini]") { + auto path = write_temp_ini( + "top = 1\n" + "[dic]\n" + "radius = 20\n"); + ncorr::IniFile ini; + REQUIRE(ini.load(path)); + CHECK(ini.get("top") == "1"); + CHECK(ini.get("dic.radius") == "20"); + std::remove(path.c_str()); +} + +TEST_CASE("ini_parse_crlf", "[unit][ini]") { + auto path = write_temp_ini("a = 1\r\nb = 2\r\n"); + ncorr::IniFile ini; + REQUIRE(ini.load(path)); + CHECK(ini.get("a") == "1"); + CHECK(ini.get("b") == "2"); + std::remove(path.c_str()); +} + +TEST_CASE("ini_typed_getters", "[unit][ini]") { + auto path = write_temp_ini( + "i = 42\n" + "d = 3.5\n" + "t = true\n" + "f = no\n" + "on = ON\n" + "one = 1\n" + "zero = 0\n"); + ncorr::IniFile ini; + REQUIRE(ini.load(path)); + CHECK(ini.get_int("i", -1) == 42); + CHECK(ini.get_double("d", -1.0) == 3.5); + CHECK(ini.get_bool("t", false) == true); + CHECK(ini.get_bool("f", true) == false); + CHECK(ini.get_bool("on", false) == true); + CHECK(ini.get_bool("one", false) == true); + CHECK(ini.get_bool("zero", true) == false); + // Missing keys return the fallback. + CHECK(ini.get_int("missing", 7) == 7); + CHECK(ini.get_double("missing", 1.25) == 1.25); + CHECK(ini.get_bool("missing", true) == true); + std::remove(path.c_str()); +} + +TEST_CASE("ini_typed_getters_invalid", "[unit][ini]") { + auto path = write_temp_ini( + "i = not_an_int\n" + "d = not_a_double\n" + "b = maybe\n"); + ncorr::IniFile ini; + REQUIRE(ini.load(path)); + CHECK_THROWS_AS(ini.get_int("i", 0), std::runtime_error); + CHECK_THROWS_AS(ini.get_double("d", 0.0), std::runtime_error); + CHECK_THROWS_AS(ini.get_bool("b", false), std::runtime_error); + std::remove(path.c_str()); +} + +TEST_CASE("ini_missing_file", "[unit][ini]") { + ncorr::IniFile ini; + CHECK_FALSE(ini.load("/nonexistent/path/does_not_exist_12345.cfg")); +} diff --git a/test/unit/test_session.cpp b/test/unit/test_session.cpp new file mode 100644 index 0000000..3148906 --- /dev/null +++ b/test/unit/test_session.cpp @@ -0,0 +1,90 @@ +/** + * @file test_session.cpp + * @brief Unit tests for the in-memory NcorrSession API (stub contract). + * + * These tests pin the *contract* of the stubbed API: input validation, the + * reference-required precondition, geometry checks, and the current + * "not yet implemented" return. When section 3b (real in-memory DIC) lands, + * the `session_stub_contract` test must be updated. + */ + +#include + +#include "ncorr/session.h" + +#include +#include + +namespace { + +// Build a small valid grayscale buffer kept alive by the returned vector. +ncorr::ImageBuffer make_buffer(std::vector& storage, int w, int h, + int ch = 1) { + storage.assign(static_cast(w) * h * ch, 0); + return ncorr::ImageBuffer(storage.data(), w, h, ch); +} + +} // namespace + +TEST_CASE("imagebuffer_valid", "[unit][session]") { + std::vector storage; + auto good = make_buffer(storage, 4, 3); + CHECK(good.valid()); + CHECK(good.size_bytes() == 12u); + + ncorr::ImageBuffer empty; + CHECK_FALSE(empty.valid()); + + std::uint8_t one = 0; + ncorr::ImageBuffer zero_dim(&one, 0, 5, 1); + CHECK_FALSE(zero_dim.valid()); + + ncorr::ImageBuffer null_data(nullptr, 4, 4, 1); + CHECK_FALSE(null_data.valid()); +} + +TEST_CASE("session_requires_reference", "[unit][session]") { + ncorr::NcorrSession session; + CHECK_FALSE(session.has_reference()); + std::vector storage; + auto def = make_buffer(storage, 8, 8); + CHECK_THROWS_AS(session.process_frame(def), std::logic_error); +} + +TEST_CASE("session_rejects_invalid_reference", "[unit][session]") { + ncorr::NcorrSession session; + ncorr::ImageBuffer bad; // null / zero dims + CHECK_THROWS_AS(session.set_reference(bad), std::invalid_argument); + CHECK_FALSE(session.has_reference()); +} + +TEST_CASE("session_geometry_mismatch", "[unit][session]") { + ncorr::NcorrSession session; + std::vector ref_storage; + auto ref = make_buffer(ref_storage, 8, 8); + session.set_reference(ref); + CHECK(session.has_reference()); + + std::vector def_storage; + auto def = make_buffer(def_storage, 8, 9); // different height + auto result = session.process_frame(def); + CHECK_FALSE(result.valid); + CHECK_FALSE(result.message.empty()); +} + +// STUB CONTRACT: update this test when in-memory DIC (section 3b) is implemented. +TEST_CASE("session_stub_contract", "[unit][session]") { + ncorr::NcorrSession session; + std::vector ref_storage; + auto ref = make_buffer(ref_storage, 8, 8); + session.set_reference(ref); + + std::vector def_storage; + auto def = make_buffer(def_storage, 8, 8); // matching geometry + auto result = session.process_frame(def); + + CHECK_FALSE(result.valid); + CHECK(result.message == "NcorrSession::process_frame not yet implemented"); + CHECK(result.width == 8); + CHECK(result.height == 8); +} From 7e5f89bdc70b9e2d088d9f6044998eca0aeecf9a Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 14:35:04 +0200 Subject: [PATCH 09/20] test(integration): add DIC pipeline-stage tests on ohtcfrp fixture Add ncorr_engine_tests integration cases that link the full ncorr engine and exercise the pipeline stages on the lightweight ohtcfrp fixture (reference + one deformed frame, scalefactor 1, radius 30, single thread to stay CI-friendly): - pipeline_load_to_dic: load -> DIC_analysis; assert one displacement field with positive dimensions and finite in-ROI displacements. - pipeline_perspective_units: change_perspective + set_units preserve field dimensions and record units/units_per_pixel. - pipeline_dic_to_strain: strain_analysis yields a finite in-ROI strain field of the expected shape. - pipeline_config_drives_run: tuneable params land on DIC_analysis_input. Assertions are structural (dimensions, finiteness) with exact-value regression deferred to the e2e test. The fixture image directory is injected via the NCORR_FIXTURE_DIR compile definition. Co-Authored-By: Claude Opus 4.8 --- test/integration/test_pipeline.cpp | 111 +++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 test/integration/test_pipeline.cpp diff --git a/test/integration/test_pipeline.cpp b/test/integration/test_pipeline.cpp new file mode 100644 index 0000000..c73471e --- /dev/null +++ b/test/integration/test_pipeline.cpp @@ -0,0 +1,111 @@ +/** + * @file test_pipeline.cpp + * @brief Integration tests for the DIC pipeline stages on the ohtcfrp fixture. + * + * These tests link the full ncorr engine. To stay CI-friendly they use the + * reference frame plus a single deformed frame at a modest scale factor and + * larger subregion radius, asserting structural properties (dimensions, + * finiteness inside the ROI) rather than exact values. Exact-value regression + * is covered by the e2e test. + * + * NCORR_FIXTURE_DIR is injected as a compile definition pointing at + * test/examples/ohtcfrp/images. + */ + +#include + +#include "ncorr.h" + +#include +#include +#include + +using namespace ncorr; + +namespace { + +const std::string kFixtureDir = NCORR_FIXTURE_DIR; + +// Build a DIC input from the reference + first deformed frame of the fixture. +DIC_analysis_input make_small_input() { + std::vector imgs; + imgs.push_back(Image2D(kFixtureDir + "/ohtcfrp_00.png")); // reference + imgs.push_back(Image2D(kFixtureDir + "/ohtcfrp_01.png")); // one deformed frame + + ROI2D roi(Image2D(kFixtureDir + "/roi.png").get_gs() > 0.5); + + // scalefactor 1, large radius -> few seeds, fast, deterministic enough. + return DIC_analysis_input(imgs, roi, /*scalefactor=*/1, + INTERP::QUINTIC_BSPLINE_PRECOMPUTE, + SUBREGION::CIRCLE, /*radius=*/30, + /*num_threads=*/1, DIC_analysis_config::NO_UPDATE, + /*debug=*/false); +} + +// Count finite (non-NaN) values inside the displacement array. +int count_finite(const Array2D& a) { + int n = 0; + for (int i = 0; i < a.height(); ++i) + for (int j = 0; j < a.width(); ++j) + if (std::isfinite(a(i, j))) ++n; + return n; +} + +} // namespace + +TEST_CASE("pipeline_load_to_dic", "[integration][pipeline]") { + DIC_analysis_input input = make_small_input(); + DIC_analysis_output output = DIC_analysis(input); + + REQUIRE(output.disps.size() == 1); // one deformed frame analysed + const Disp2D& disp = output.disps.front(); + CHECK(disp.get_u().get_array().height() > 0); + CHECK(disp.get_u().get_array().width() > 0); + // At least some points inside the ROI must have a finite displacement. + CHECK(count_finite(disp.get_u().get_array()) > 0); + CHECK(count_finite(disp.get_v().get_array()) > 0); +} + +TEST_CASE("pipeline_perspective_units", "[integration][pipeline]") { + DIC_analysis_input input = make_small_input(); + DIC_analysis_output output = DIC_analysis(input); + + int w0 = output.disps.front().get_u().get_array().width(); + int h0 = output.disps.front().get_u().get_array().height(); + + output = change_perspective(output, INTERP::CUBIC_KEYS); + output = set_units(output, "mm", 0.2); + + REQUIRE(output.disps.size() == 1); + CHECK(output.units == "mm"); + CHECK(output.units_per_pixel == 0.2); + // Perspective change preserves field dimensions. + CHECK(output.disps.front().get_u().get_array().width() == w0); + CHECK(output.disps.front().get_u().get_array().height() == h0); +} + +TEST_CASE("pipeline_dic_to_strain", "[integration][pipeline]") { + DIC_analysis_input input = make_small_input(); + DIC_analysis_output output = DIC_analysis(input); + output = change_perspective(output, INTERP::CUBIC_KEYS); + output = set_units(output, "mm", 0.2); + + strain_analysis_input strain_in(input, output, SUBREGION::CIRCLE, /*r=*/5); + strain_analysis_output strain_out = strain_analysis(strain_in); + + REQUIRE(strain_out.strains.size() == 1); + const Strain2D& strain = strain_out.strains.front(); + CHECK(strain.get_exx().get_array().height() > 0); + CHECK(strain.get_eyy().get_array().width() > 0); + CHECK(count_finite(strain.get_exx().get_array()) > 0); +} + +TEST_CASE("pipeline_config_drives_run", "[integration][pipeline]") { + // Confirm the tuneable parameters set on DIC_analysis_input are reflected in + // the constructed input (the config tier feeds these fields in the driver). + DIC_analysis_input input = make_small_input(); + CHECK(input.scalefactor == 1); + CHECK(input.subregion_type == SUBREGION::CIRCLE); + CHECK(input.r == 30); + CHECK(input.interp_type == INTERP::QUINTIC_BSPLINE_PRECOMPUTE); +} From 417b89c1fa8b151eb3744e164b0dfed2dd699a02 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 14:35:16 +0200 Subject: [PATCH 10/20] test(e2e): add ohtcfrp end-to-end regression guard with golden baseline Add ncorr_engine_tests e2e cases running the full file-based pipeline (load -> DIC_analysis -> change_perspective -> set_units -> strain_analysis) on the ohtcfrp fixture with a reduced frame count (reference + 2 frames) to stay CI-friendly: - e2e_ohtcfrp_smoke: the run completes and produces one displacement and one strain field per deformed frame; the in-ROI mean displacement magnitude is finite. - e2e_ohtcfrp_known_value: asserts the mean in-ROI |displacement| on the final frame matches a recorded GOLDEN baseline within a tolerance band. Captured baseline (macOS/AppleClang, OpenCV 4.13, single-threaded; identical across 3 consecutive runs): mean in-ROI |displacement| (last frame) = 0.180423 mm tolerance = +/-0.02 mm (~11%), generous on purpose so the guard is robust across platforms/compilers/thread counts while still catching a real behavioural regression. Set NCORR_E2E_CAPTURE=1 to re-capture the value instead of asserting. Co-Authored-By: Claude Opus 4.8 --- test/e2e/test_e2e.cpp | 137 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 test/e2e/test_e2e.cpp diff --git a/test/e2e/test_e2e.cpp b/test/e2e/test_e2e.cpp new file mode 100644 index 0000000..4147703 --- /dev/null +++ b/test/e2e/test_e2e.cpp @@ -0,0 +1,137 @@ +/** + * @file test_e2e.cpp + * @brief End-to-end regression test on the ohtcfrp fixture. + * + * Runs the full file-based pipeline in-process (load -> DIC -> change_perspective + * -> set_units -> strain_analysis) on the reference frame plus a small number of + * deformed frames, then asserts: + * - the run completes and produces one displacement/strain field per frame; + * - a recorded GOLDEN baseline (mean in-ROI |displacement| on the last frame) + * is reproduced within a tolerance band. + * + * The golden value was captured from a known-good run during section 5b on this + * fixture with the parameters below. It is a regression guard, not a physical + * ground truth; the tolerance band is intentionally generous so the test stays + * robust across platforms/compilers. + * + * Set the environment variable NCORR_E2E_CAPTURE=1 to print the freshly computed + * statistics (used to (re)capture the baseline) instead of failing. + * + * NCORR_FIXTURE_DIR is injected as a compile definition. + */ + +#include + +#include "ncorr.h" + +#include +#include +#include +#include +#include + +using namespace ncorr; + +namespace { + +const std::string kFixtureDir = NCORR_FIXTURE_DIR; + +// E2E run parameters (kept small for CI). Reference + two deformed frames. +constexpr int kScaleFactor = 1; +constexpr int kRadius = 30; +constexpr int kStrainRadius = 5; +constexpr double kUnitsPerPixel = 0.2; + +// ---- GOLDEN BASELINE (captured during section 5b) -------------------------// +// Mean in-ROI displacement magnitude (sqrt(u^2 + v^2), in the configured units) +// over the final analysed frame. See NCORR_E2E_CAPTURE above to re-capture. +// Captured 2026-06-16 on macOS/AppleClang, OpenCV 4.13, single-threaded; value +// was identical across 3 consecutive runs. Tolerance is a generous +/-0.02 mm +// (~11%) so the regression guard stays robust across platforms/compilers/thread +// counts while still catching a real behavioural regression. +constexpr double kGoldenMeanDispMag = 0.180423; // mm (units_per_pixel = 0.2) +constexpr double kGoldenTolerance = 0.02; // mm +constexpr bool kGoldenRecorded = true; // baseline baked in + +// Mean magnitude of finite (u,v) inside the ROI for the given displacement field. +double mean_disp_magnitude(const Disp2D& disp) { + const Array2D& u = disp.get_u().get_array(); + const Array2D& v = disp.get_v().get_array(); + double sum = 0.0; + long n = 0; + for (int i = 0; i < u.height(); ++i) { + for (int j = 0; j < u.width(); ++j) { + double uu = u(i, j), vv = v(i, j); + if (std::isfinite(uu) && std::isfinite(vv)) { + sum += std::sqrt(uu * uu + vv * vv); + ++n; + } + } + } + return n > 0 ? sum / static_cast(n) : 0.0; +} + +} // namespace + +TEST_CASE("e2e_ohtcfrp_smoke", "[e2e]") { + std::vector imgs; + imgs.push_back(Image2D(kFixtureDir + "/ohtcfrp_00.png")); // reference + imgs.push_back(Image2D(kFixtureDir + "/ohtcfrp_01.png")); + imgs.push_back(Image2D(kFixtureDir + "/ohtcfrp_02.png")); + + ROI2D roi(Image2D(kFixtureDir + "/roi.png").get_gs() > 0.5); + + DIC_analysis_input input(imgs, roi, kScaleFactor, + INTERP::QUINTIC_BSPLINE_PRECOMPUTE, + SUBREGION::CIRCLE, kRadius, /*num_threads=*/1, + DIC_analysis_config::NO_UPDATE, /*debug=*/false); + + DIC_analysis_output output = DIC_analysis(input); + output = change_perspective(output, INTERP::CUBIC_KEYS); + output = set_units(output, "mm", kUnitsPerPixel); + + strain_analysis_input strain_in(input, output, SUBREGION::CIRCLE, kStrainRadius); + strain_analysis_output strain_out = strain_analysis(strain_in); + + // Two deformed frames -> two displacement and two strain fields. + REQUIRE(output.disps.size() == 2); + REQUIRE(strain_out.strains.size() == 2); + + double mag = mean_disp_magnitude(output.disps.back()); + INFO("mean in-ROI |displacement| (last frame) = " << mag); + CHECK(std::isfinite(mag)); + CHECK(mag >= 0.0); + + if (std::getenv("NCORR_E2E_CAPTURE")) { + std::cout << "[E2E CAPTURE] mean_disp_magnitude(last) = " << mag << std::endl; + } +} + +TEST_CASE("e2e_ohtcfrp_known_value", "[e2e]") { + if (!kGoldenRecorded) { + SUCCEED("golden baseline not yet recorded; run with NCORR_E2E_CAPTURE=1 " + "to capture it (see e2e_ohtcfrp_smoke)."); + return; + } + + std::vector imgs; + imgs.push_back(Image2D(kFixtureDir + "/ohtcfrp_00.png")); + imgs.push_back(Image2D(kFixtureDir + "/ohtcfrp_01.png")); + imgs.push_back(Image2D(kFixtureDir + "/ohtcfrp_02.png")); + + ROI2D roi(Image2D(kFixtureDir + "/roi.png").get_gs() > 0.5); + + DIC_analysis_input input(imgs, roi, kScaleFactor, + INTERP::QUINTIC_BSPLINE_PRECOMPUTE, + SUBREGION::CIRCLE, kRadius, /*num_threads=*/1, + DIC_analysis_config::NO_UPDATE, /*debug=*/false); + + DIC_analysis_output output = DIC_analysis(input); + output = change_perspective(output, INTERP::CUBIC_KEYS); + output = set_units(output, "mm", kUnitsPerPixel); + + double mag = mean_disp_magnitude(output.disps.back()); + INFO("computed = " << mag << ", golden = " << kGoldenMeanDispMag + << ", tol = " << kGoldenTolerance); + CHECK(std::abs(mag - kGoldenMeanDispMag) <= kGoldenTolerance); +} From fb7eb2a9e8bbbb02fe2655885098e993a9451751 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 14:36:14 +0200 Subject: [PATCH 11/20] docs: add quickstart guide Co-Authored-By: Claude Opus 4.8 --- docs/quickstart.md | 118 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/quickstart.md diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..b825a79 --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,118 @@ +# CppNCorr Quick-start + +CppNCorr is a C++17 implementation of the **ncorr** 2D Digital Image Correlation +(DIC) engine. It takes a reference image plus a series of deformed images and +computes 2D displacement and strain fields. It builds as a static library +(`libncorr.a`) and ships a command-line driver, **`proxyncorr`**, that runs a +full DIC analysis on a folder of images. + +> **Note on target names.** `ncorr` is the static *library* target. The actual +> DIC *executable* is `proxyncorr` (defined in `test/CMakeLists.txt`). Throughout +> the docs, "the DIC tool" means `proxyncorr`. + +## Prerequisites + +- CMake >= 3.14 +- A C++17 compiler (GCC 10+ / Clang 12+ / MSVC 2019+) +- OpenCV (image/video I/O), FFTW3, SuiteSparse, BLAS/LAPACK, nlohmann/json, + OpenMP + +Every dependency is searched on the system first and only fetched from source if +missing (or when `-DFORCE_FETCH_DEPENDENCIES=ON`). See +[`../CMakeOptions.md`](../CMakeOptions.md) for the full dependency table. + +On macOS with Homebrew: + +```bash +brew install cmake opencv fftw suite-sparse libomp nlohmann-json +``` + +On Debian/Ubuntu: + +```bash +sudo apt-get install cmake g++ libopencv-dev libfftw3-dev \ + libsuitesparse-dev libopenblas-dev nlohmann-json3-dev libomp-dev +``` + +## Build + +### Library + +```bash +cmake -S . -B build -DCMAKE_POLICY_VERSION_MINIMUM=3.5 +cmake --build build --target ncorr -j +``` + +This produces `lib/libncorr.a`. The convenience script `./build.sh` does the +same (clean + configure + make). + +### The DIC tool (`proxyncorr`) and the example executables + +```bash +cmake -S test -B test/build -DCMAKE_POLICY_VERSION_MINIMUM=3.5 +cmake --build test/build -j +``` + +Executables land in `test/bin/` (e.g. `test/bin/proxyncorr`). + +## Minimal run + +`proxyncorr` works on a **folder of frames** plus a ROI mask. The simplest +invocation points it at an image folder and an output directory: + +```bash +./test/bin/proxyncorr --folder path/to/images --output out/ +``` + +By convention the folder contains numbered frames (`frame_00.png`, +`frame_01.png`, ...) and a `roi.png` mask; the first frame (or `ref.png` if +present) is used as the reference. See the [user guide](user_guide.md) for the +full naming convention and every CLI flag. + +## Try it on the bundled `ohtcfrp` dataset + +The repository ships a small fixture under `test/examples/ohtcfrp/images` +(12 PNG frames + `roi.png`): + +```bash +./test/bin/proxyncorr \ + --folder test/examples/ohtcfrp/images \ + --output test/examples/ohtcfrp \ + --scalefactor 1 --radius 30 --threads 4 +``` + +Outputs are written under the chosen output directory: + +- `save/` — binary serialized DIC/strain inputs and outputs +- `save_json/` — the same data as JSON +- `video/` — rendered displacement/strain field videos (`.avi`) + +To skip the (slower) video rendering, add `--no-videos`. + +## Configuration + +Parameters resolve through a three-tier override chain (highest priority first): + +1. Direct CLI arguments +2. A config file (`--config path.cfg`, INI format; defaults to + `config/default.cfg` when present) +3. Compiled defaults (`include/ncorr/config.h`) + +See [`config/default.cfg`](../config/default.cfg) for every tuneable key. + +## Next steps + +- [User guide](user_guide.md) — image formats, all CLI flags, output layout, + troubleshooting. +- [Developer guide](developer_guide.md) — repository layout, extending the CLI, + the in-memory `NcorrSession` API, config schema, running tests. + +## Reference papers + +CppNCorr is a port of the ncorr 2D DIC algorithm. If you use it, please cite the +original ncorr work: + +- [Ncorr: open-source 2D digital image correlation MATLAB software](DOI_PLACEHOLDER) +- [Investigation of Reliability-Guided Digital Image Correlation](DOI_PLACEHOLDER) + +(Replace `DOI_PLACEHOLDER` with the appropriate DOIs.) From 4af29dbdfbf8059cca5499d3bfc6a6cb806d4377 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 14:38:36 +0200 Subject: [PATCH 12/20] docs: add developer guide Co-Authored-By: Claude Opus 4.8 --- docs/developer_guide.md | 180 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 docs/developer_guide.md diff --git a/docs/developer_guide.md b/docs/developer_guide.md new file mode 100644 index 0000000..5ef221a --- /dev/null +++ b/docs/developer_guide.md @@ -0,0 +1,180 @@ +# CppNCorr Developer Guide + +This guide is for contributors working on CppNCorr itself: the repository layout, +how to extend the CLI, how to finish the in-memory `NcorrSession` API, the +config-file schema, and how to run the tests. + +## Repository layout + +``` +CppNCorr/ +├── CMakeLists.txt # Library target `ncorr`; guarded test include (5b) +├── CMakeOptions.md # Every CMake option / dependency / flag +├── build.sh # Convenience library build +├── cmake/ +│ └── FetchDependencies.cmake # find-or-fetch for OpenCV/FFTW/SuiteSparse/BLAS/json +├── config/ +│ └── default.cfg # Shipped INI config (mirrors compiled defaults) +├── include/ +│ ├── ncorr.h # Public engine API (DIC_analysis, strain_analysis, ...) +│ ├── Array2D.h Data2D.h Disp2D.h Strain2D.h ROI2D.h Image2D.h +│ └── ncorr/ +│ ├── config.h # struct Config + load_config_file (override chain) +│ ├── ini.h # Header-only INI parser +│ ├── session.h # In-memory NcorrSession API (stub) +│ └── frame_reader.h # discover_frames / has_image_extension / natural_less +├── src/ # Engine + config.cpp + session.cpp implementations +├── test/ +│ ├── CMakeLists.txt # Legacy example/tool executables (incl. proxyncorr) +│ ├── tests.cmake # Catch2 test suite (included only when top-level) +│ ├── src/ # Legacy harnesses + proxyncorr.cpp (the DIC tool) +│ ├── unit/ # Catch2 unit tests (ini/config/frame_reader/session) +│ ├── integration/ # Catch2 pipeline-stage tests +│ ├── e2e/ # Catch2 end-to-end test (ohtcfrp) +│ └── examples/ohtcfrp/ # Lightweight fixture dataset +├── deploy/ # Docker / cluster deployment assets +└── docs/ # This guide + quickstart + user guide +``` + +### Targets + +| Target | Type | Notes | +|--------|------|-------| +| `ncorr` | static library | Core DIC engine → `lib/libncorr.a`. | +| `proxyncorr` | executable | The de-facto **main DIC tool** (folder discovery, full CLI, config file, JSON/binary/video output). | +| `ncorr_unit_tests` | executable | Catch2 unit tests; no heavy deps. | +| `ncorr_engine_tests` | executable | Catch2 integration + e2e tests; links `ncorr`. | +| `ncorr_test*`, `*_test` | executables | Legacy regression/example harnesses. | + +See [`../CMakeOptions.md`](../CMakeOptions.md) for the complete options and +dependency reference. + +## How to extend the CLI with a new post-DIC operation + +The CLI lives in `test/src/proxyncorr.cpp` and uses `getopt_long`. To add a new +post-DIC operation (e.g. a new export format): + +1. **Add a field** to `struct ProxyConfig` for the option's state. +2. **Register the long option** in the `long_options[]` array. Use a numeric + value `>= 1000` for options without a short flag. +3. **Parse it** in the second `getopt_long` loop's `switch`. +4. **Document it** in `print_usage()` (and in [`user_guide.md`](user_guide.md)). +5. **Wire the behaviour** after argument parsing. If the backing implementation + does not exist yet, follow the existing stub convention: print + `"[--flag] not yet implemented"` and `return 0` (exit cleanly), and mark the + gap with a `// FIXME(newversion):` comment. See the handling of + `--change-perspective` for an example of a flag with both an implemented mode + and a not-yet-implemented mode. + +If the new parameter is also a tuneable DIC parameter (rather than pure I/O), +add it to `ncorr::Config` (`include/ncorr/config.h`), to `config/default.cfg` +(same key name), to the overlay in `src/config.cpp`, and to `apply_core_config()` +in `proxyncorr.cpp` so it participates in the three-tier override chain. + +## How to implement the in-memory API (`NcorrSession`) + +`include/ncorr/session.h` declares a small, dependency-light API that lets a +caller (e.g. CPPxDIC's `proxyncorr`) push image buffers directly to the engine +without writing frames to disk. The interface is **final**; the bodies in +`src/session.cpp` are currently **stubs** (validate inputs, return a +"not yet implemented" `DICResult`). + +To implement it, follow the file-based pipeline in `test/src/proxyncorr.cpp`: + +1. Wrap each `ImageBuffer` in a `cv::Mat` (row-major, interleaved, 8-bit) and + build an `ncorr::Image2D` via `Image2D::from_mat(...)`. +2. Build a `ROI2D` from the optional mask (or a full-frame default). +3. Construct a `DIC_analysis_input { {ref, def}, roi, scalefactor, interp, + subregion, radius, num_threads, DIC_analysis_config, debug }`. +4. Run `DIC_analysis(...)` and copy the resulting `Disp2D` `u`/`v` arrays + (`disp.get_u().get_array()`, `disp.get_v().get_array()`) into the flat + row-major `DICResult::u` / `DICResult::v` vectors, writing `NaN` outside the + ROI. Set `result.valid = true` on success. + +The `Impl` PIMPL struct in `session.cpp` should cache the converted reference +`Image2D` and the `ROI2D` so repeated `process_frame` calls reuse them. + +When done, **update the unit test** `session_stub_contract` in +`test/unit/test_session.cpp` (which currently pins the stub message) and add the +deferred integration test `pipeline_session_matches_folder` from `TESTS_PLAN.md` +(assert the session matches the folder pipeline on the same frames). + +## Config-file schema reference + +Format: minimal **INI** (`key = value`), parsed by the header-only +`include/ncorr/ini.h`. Full-line and inline `#`/`;` comments are supported; +values may be quoted to preserve spaces or comment characters; CRLF line endings +are handled. + +Keys MUST exactly match the field names of `ncorr::Config` (case-sensitive) so +there are no silent mismatches — this is enforced by the +`config_key_field_parity` unit test. + +| Key | Type | Default | Meaning | +|-----|------|---------|---------| +| `scalefactor` | int | `3` | Pyramid downsampling level for seed search. | +| `subregion_type` | string | `CIRCLE` | `CIRCLE` or `SQUARE`. | +| `subregion_radius` | int | `20` | Correlation window radius (px). | +| `interp_type` | string | `QUINTIC_BSPLINE_PRECOMPUTE` | `NEAREST`, `LINEAR`, `CUBIC_KEYS[_PRECOMPUTE]`, `QUINTIC_BSPLINE[_PRECOMPUTE]`. | +| `strain_subregion_type` | string | `CIRCLE` | `CIRCLE` or `SQUARE`. | +| `strain_radius` | int | `5` | Strain window radius (px). | +| `dic_config` | string | `NO_UPDATE` | `NO_UPDATE`, `KEEP_MOST_POINTS`, `REMOVE_BAD_POINTS`. | +| `num_threads` | int | `4` | Worker threads for parallel analysis. | +| `debug` | bool | `false` | Verbose engine output. | +| `perspective_interp` | string | `CUBIC_KEYS` | Interpolation used by the perspective change. | +| `units` | string | `mm` | Displacement units label. | +| `units_per_pixel` | double | `0.2` | Physical scaling. | +| `alpha` | double | `0.5` | Video overlay alpha (0..1). | +| `fps` | double | `15.0` | Output video FPS. | + +The shipped [`config/default.cfg`](../config/default.cfg) mirrors this table. + +## How to run tests locally + +The Catch2 test suite is configured **only when CppNCorr is the top-level +project** (gated by `PROJECT_IS_TOP_LEVEL AND BUILD_TESTING`), so it never +affects the parent CPPxDIC build. + +```bash +# Configure + build the whole top-level project (incl. tests) +cmake -S . -B build_tests -DCMAKE_POLICY_VERSION_MINIMUM=3.5 +cmake --build build_tests -j + +# Run everything via CTest +cd build_tests && ctest --output-on-failure +``` + +### Test tiers and filtering + +- **`ncorr_unit_tests`** — fast, dependency-light (config/ini/frame_reader/ + session). Run directly: `./build_tests/ncorr_unit_tests`. +- **`ncorr_engine_tests`** — links the full engine; the integration/e2e cases + run real DIC and are slow (tens of seconds each). Filter by Catch2 tag: + + ```bash + ./build_tests/ncorr_engine_tests "[integration]" + ./build_tests/ncorr_engine_tests "[e2e]" + ``` + +To re-capture the e2e golden baseline (after an intentional behavioural change): + +```bash +NCORR_E2E_CAPTURE=1 ./build_tests/ncorr_engine_tests "[e2e]" +``` + +then update `kGoldenMeanDispMag` in `test/e2e/test_e2e.cpp`. + +### Adding a test + +Drop a new `.cpp` under `test/unit/`, `test/integration/`, or `test/e2e/`, add it +to the corresponding `add_executable(...)` list in `test/tests.cmake`, and use +the Catch2 `TEST_CASE("name", "[tier][topic]")` macro. `catch_discover_tests` +registers each `TEST_CASE` with CTest automatically. + +## Coding conventions + +- New public headers: `#pragma once` and Doxygen doc-comments on every public + symbol. +- Conventional-commit messages, one logical unit per commit. +- New code must compile cleanly under `-Wall -Wextra`. Pre-existing template + warnings in `Array2D.h` are documented in `CMakeOptions.md` and left untouched. From 87585d461b49b6d4fc1ad8cf5e5a85fc2cca70c7 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 14:39:46 +0200 Subject: [PATCH 13/20] docs: add user guide Co-Authored-By: Claude Opus 4.8 --- docs/user_guide.md | 221 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 docs/user_guide.md diff --git a/docs/user_guide.md b/docs/user_guide.md new file mode 100644 index 0000000..1edd0cc --- /dev/null +++ b/docs/user_guide.md @@ -0,0 +1,221 @@ +# CppNCorr User Guide + +This guide covers running the CppNCorr DIC tool (`proxyncorr`): supported image +formats and naming conventions, every CLI flag, the config-file format, the +output layout, and troubleshooting. + +## Supported image formats and naming conventions + +### Formats + +`proxyncorr` reads the image formats OpenCV's `imread` supports in this build: + +| Extension | Format | +|-----------|--------| +| `.png` | PNG | +| `.tif`, `.tiff` | TIFF | +| `.bmp` | BMP | +| `.jpg`, `.jpeg` | JPEG | + +Extension matching is **case-insensitive**. + +### Folder layout and naming + +`proxyncorr` discovers frames from a folder. The expected layout: + +- **One image per frame.** Frames are sorted in *natural* (numeric-aware) order, + so both zero-padded (`frame_00.png`, `frame_01.png`, ...) and unpadded + (`frame_2.png`, `frame_10.png`, ...) numbering sort correctly. +- **ROI mask.** A `roi.png` in the folder is used as the region-of-interest mask + by default (any pixel > 0.5 grayscale is inside the ROI). Override with + `--roi`. +- **Reference frame.** If a `ref.png` exists in the folder it is used as the + reference; otherwise the first discovered frame is the reference. Override with + `--ref`. +- **Reserved / ignored.** `roi.png` and `ref.png` (case-insensitive) are excluded + from the frame list, as are any files matching the `--roi` / `--ref` + basenames, hidden files (leading `.`), sub-directories, and non-image + extensions. + +## CLI flags + +Every flag below is accepted by `proxyncorr`. Types and defaults match the +compiled defaults (which themselves come from `ncorr::Config` and the config +file; CLI args have the highest priority). + +### Paths + +| Flag | Short | Type | Default | Example | +|------|-------|------|---------|---------| +| `--folder` | `-f` | path | `images` | `--folder data/run1` | +| `--config` | `-c` | path | (none; falls back to `config/default.cfg` if present) | `--config my.cfg` | +| `--roi` | `-r` | path | `/roi.png` | `--roi masks/roi.png` | +| `--ref` | `-R` | path | first frame (or `/ref.png`) | `--ref data/ref.png` | +| `--output` | `-o` | path | `output` | `--output out/` | + +### DIC parameters + +| Flag | Short | Type | Default | Example | +|------|-------|------|---------|---------| +| `--scalefactor` | `-s` | int | `3` | `--scalefactor 1` | +| `--interp` | `-i` | enum | `QUINTIC_BSPLINE_PRECOMPUTE` | `--interp CUBIC_KEYS` | +| `--subregion` | `-S` | enum | `CIRCLE` | `--subregion SQUARE` | +| `--radius` | `-d` | int | `20` | `--radius 30` | +| `--threads` | `-t` | int | `4` | `--threads 8` | +| `--mode` | `-m` | enum | `auto` | `--mode parallel` | + +`--interp` accepts: `NEAREST`, `LINEAR`, `CUBIC_KEYS`, `CUBIC_KEYS_PRECOMPUTE`, +`QUINTIC_BSPLINE`, `QUINTIC_BSPLINE_PRECOMPUTE`. +`--subregion` accepts: `CIRCLE`, `SQUARE`. +`--mode` accepts: `auto`, `sequential`, `parallel` (`auto` picks `sequential` +when seeds are supplied, otherwise `parallel`). + +### Seeds + +| Flag | Type | Default | Example | +|------|------|---------|---------| +| `--seeds` | path | (none) | `--seeds seeds.json` | +| `--seeds-optimized` | switch | off | `--seeds-optimized` | + +`--seeds` points to a JSON array of seed objects, one per region: + +```json +[ + {"x": 100, "y": 200, "u": 0.0, "v": 0.0}, + {"x": 300, "y": 400, "u": 0.5, "v": 0.3} +] +``` + +`--seeds-optimized` declares the supplied seeds are already optimized (skips the +optimization step). + +### Units and strain + +| Flag | Short | Type | Default | Example | +|------|-------|------|---------|---------| +| `--units` | `-u` | string | `mm` | `--units px` | +| `--units-per-pixel` | `-p` | double | `0.2` | `--units-per-pixel 0.05` | +| `--strain-subregion` | | enum | `CIRCLE` | `--strain-subregion SQUARE` | +| `--strain-radius` | | int | `5` | `--strain-radius 7` | + +### Video / output toggles + +| Flag | Short | Type | Default | Example | +|------|-------|------|---------|---------| +| `--alpha` | `-a` | double | `0.5` | `--alpha 0.3` | +| `--fps` | `-F` | double | `15` | `--fps 30` | +| `--no-json` | | switch | (JSON on) | `--no-json` | +| `--no-binary` | | switch | (binary on) | `--no-binary` | +| `--no-videos` | | switch | (videos on) | `--no-videos` | +| `--debug` | | switch | off | `--debug` | + +### Post-DIC output flags + +| Flag | Type | Default | Behaviour | +|------|------|---------|-----------| +| `--export-video` | switch | off | Forces video output on (renders displacement/strain fields). | +| `--export-strains` | switch | off | Ensures strain fields are written (enables binary output if both JSON and binary were disabled). | +| `--change-perspective` | enum | `eulerian` | `eulerian` is implemented (the default). `lagrangian` and other modes print "not yet implemented" and exit cleanly. | +| `--help` | switch | — | Print usage and exit. | + +### Example + +```bash +./test/bin/proxyncorr \ + --folder test/examples/ohtcfrp/images \ + --output test/examples/ohtcfrp \ + --scalefactor 1 --radius 30 --threads 4 \ + --units mm --units-per-pixel 0.2 \ + --no-videos +``` + +## Config file format and placement + +The config file is INI (`key = value`). It is loaded when passed with +`--config `; if no `--config` is given, `proxyncorr` falls back to +`config/default.cfg` (relative to the working directory) when that file exists. + +```ini +# Comments start with # or ; (inline comments are supported) +scalefactor = 3 +subregion_type = CIRCLE +subregion_radius = 20 +interp_type = QUINTIC_BSPLINE_PRECOMPUTE +strain_subregion_type = CIRCLE +strain_radius = 5 +dic_config = NO_UPDATE +num_threads = 4 +debug = false +perspective_interp = CUBIC_KEYS +units = mm +units_per_pixel = 0.2 +alpha = 0.5 +fps = 15.0 +``` + +Override precedence (highest wins): **CLI args > config file > compiled +defaults**. Key names must match `ncorr::Config` field names exactly. See the +full schema in the [developer guide](developer_guide.md#config-file-schema-reference). + +## Output directory structure + +For `--output out/`, the tool creates: + +``` +out/ +├── save/ # binary serialized data (when binary output enabled) +│ ├── DIC_input.bin +│ ├── DIC_output.bin +│ ├── strain_input.bin +│ └── strain_output.bin +├── save_json/ # same data as JSON (when JSON output enabled) +│ ├── DIC_input.json +│ ├── DIC_output.json +│ ├── strain_input.json +│ └── strain_output.json +└── video/ # rendered field videos (when video output enabled) + ├── u_eulerian.avi + ├── v_eulerian.avi + ├── exx_eulerian.avi + ├── eyy_eulerian.avi + └── exy_eulerian.avi +``` + +The JSON `DIC_output.json` contains per-frame `disps` with `u`/`v` arrays (each +with `rows`/`cols`/`data`), the ROI mask, the scale factor, units, and +units-per-pixel. The strain JSON contains `exx`/`eyy`/`exy` fields per frame. + +## Troubleshooting + +1. **`Error: ROI file not found: /roi.png`** + The tool needs a ROI mask. Put a `roi.png` in the image folder or pass + `--roi path/to/roi.png`. + +2. **`Error: No frames found in folder: `** + The folder has no supported image files (after excluding `roi.png`/`ref.png` + and hidden files/sub-directories). Check the path and that frames use a + supported extension (`.png/.tif/.tiff/.bmp/.jpg/.jpeg`). + +3. **`Error: Cannot open folder '': ...`** + The `--folder` path does not exist or is not readable. Verify the path + (paths are resolved relative to the current working directory). + +4. **Frames analysed in the wrong order.** + Ordering is natural/numeric-aware, but only across the numeric runs in the + names. Use a consistent numbering scheme (e.g. `frame_00`, `frame_01`, ...). + Mixed prefixes can sort unexpectedly; rename to a uniform pattern. + +5. **`Error in config file '...': INI value for '' is not a valid integer/number`** + A config value has the wrong type (e.g. text where a number is expected), or + a key is malformed. Fix the value; key names must match `ncorr::Config` + fields exactly. + +6. **`[--change-perspective=] not yet implemented` and the tool exits.** + Only `eulerian` is currently implemented. Use the default (omit the flag) or + pass `--change-perspective eulerian`. + +7. **Run is very slow / uses one core.** + Increase `--threads`, lower the `--scalefactor`, or reduce the number of + frames. Real DIC is compute-heavy; the bundled `ohtcfrp` fixture is small but + still takes tens of seconds per frame at fine settings. Add `--no-videos` to + skip video rendering. From cfc0640062fcb7b4fad1af5f85ef312e521d6653 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 14:42:04 +0200 Subject: [PATCH 14/20] chore(deploy): organise parallel/cluster/Docker scripts under deploy/ No HPC or Docker assets existed in the repo previously, so introduce a deploy/ directory with the deployment recipes for the main DIC tool (proxyncorr): - deploy/Dockerfile: single-stage Ubuntu 24.04 image that installs all dependencies (OpenCV, FFTW, SuiteSparse, OpenBLAS/LAPACK, nlohmann/json, OpenMP) from the distro and builds the proxyncorr executable (BUILD_TESTING=OFF so the Catch2 suite is excluded). ENTRYPOINT is proxyncorr. Build context is the repo root (docker build -f deploy/Dockerfile .). - .dockerignore (repo root, where Docker reads it): keeps local build trees and caches out of the build context. - deploy/run_slurm.sh: sbatch script for a single-node OpenMP DIC run, binding OMP_NUM_THREADS to the allocated CPUs (OMP_PROC_BIND=spread, OMP_PLACES=cores). - deploy/README.md: explains each asset, its target environment, Docker and SLURM usage, and how to run the image under Apptainer/Singularity. The Dockerfile's native build sequence (library target + proxyncorr via the test tree) was verified to build the executable cleanly; the Docker daemon was unavailable in this environment to run the container build itself. Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 15 +++++++++ deploy/Dockerfile | 58 ++++++++++++++++++++++++++++++++ deploy/README.md | 81 +++++++++++++++++++++++++++++++++++++++++++++ deploy/run_slurm.sh | 49 +++++++++++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 .dockerignore create mode 100644 deploy/Dockerfile create mode 100644 deploy/README.md create mode 100755 deploy/run_slurm.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d070e5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Keep the Docker build context small and reproducible: never ship local build +# trees, fetched dependency caches, or editor/OS cruft into the image. +build +build_tests +test/build +test/bin +lib/libncorr.a +lib/libCatch2.a +lib/libCatch2Main.a +**/.DS_Store +.git +.cache +compile_commands.json +test/examples/ohtcfrp/save/ +test/examples/ohtcfrp/video/ diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..1a307fb --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,58 @@ +# syntax=docker/dockerfile:1 +# +# Dockerfile for CppNCorr — builds the main DIC executable `proxyncorr`. +# +# Builds on Ubuntu using system-installed dependencies (OpenCV, FFTW, +# SuiteSparse, OpenBLAS/LAPACK, nlohmann/json, OpenMP). The CMake build will +# find these via find_package/find_library; FetchContent is only used as a +# fallback when a dependency is missing. +# +# Build (from the repository root): +# docker build -f deploy/Dockerfile -t cppncorr . +# +# Run (mount a data folder containing frames + roi.png): +# docker run --rm -v "$PWD/data:/data" cppncorr \ +# --folder /data/images --output /data/out --no-videos +# +# This is a single-stage build kept deliberately simple for reproducibility. + +FROM ubuntu:24.04 + +ARG DEBIAN_FRONTEND=noninteractive + +# Toolchain + all CppNCorr dependencies from the distro. +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + git \ + ca-certificates \ + libopencv-dev \ + libfftw3-dev \ + libsuitesparse-dev \ + libopenblas-dev \ + liblapack-dev \ + nlohmann-json3-dev \ + libomp-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/cppncorr + +# Copy the whole repository (build context = repo root). +COPY . . + +# Build the static library and the proxyncorr executable. +# - The library target `ncorr` is built from the root CMakeLists. +# - The executables (incl. proxyncorr) are built from the test/ tree, which +# links libncorr.a. +# BUILD_TESTING=OFF keeps the Catch2 test suite out of the image build. +RUN cmake -S . -B build -DCMAKE_POLICY_VERSION_MINIMUM=3.5 -DBUILD_TESTING=OFF \ + && cmake --build build --target ncorr -j "$(nproc)" \ + && cmake -S test -B test/build -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ + && cmake --build test/build --target proxyncorr -j "$(nproc)" + +# Put the DIC tool on PATH. +RUN install -m 0755 test/bin/proxyncorr /usr/local/bin/proxyncorr + +# Default to running the DIC tool; arguments are appended after `docker run img`. +ENTRYPOINT ["proxyncorr"] +CMD ["--help"] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..0fc5aa0 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,81 @@ +# CppNCorr deployment assets + +This directory holds everything needed to build and run the CppNCorr DIC tool +(`proxyncorr`) in containerized and cluster environments. CppNCorr's parallelism +is **OpenMP within a single node**; there is no MPI/multi-node distribution, so +the cluster recipe scales by CPU cores per node, not across nodes. + +## Files + +| File | Target environment | Purpose | +|------|--------------------|---------| +| `Dockerfile` | Any Docker host / CI | Single-stage Ubuntu 24.04 image that installs all dependencies from the distro and builds the `proxyncorr` executable. `ENTRYPOINT` is `proxyncorr`. | +| `run_slurm.sh` | SLURM-managed HPC cluster | `sbatch` script that runs one DIC job on a single node, binding `OMP_NUM_THREADS` to the allocated CPUs. | +| `../.dockerignore` | (repo root) | Keeps local build trees / caches out of the Docker build context. Lives at the repo root because that is the Docker build context. | + +## Docker + +The build context is the **repository root** (so the image can copy the whole +source tree), and the Dockerfile is referenced with `-f`: + +```bash +# from the repository root +docker build -f deploy/Dockerfile -t cppncorr . +``` + +Run it against a local data folder that contains the numbered frames plus a +`roi.png` mask: + +```bash +docker run --rm -v "$PWD/data:/data" cppncorr \ + --folder /data/images --output /data/out --no-videos +``` + +Arguments after the image name are passed straight to `proxyncorr` (see the +[user guide](../docs/user_guide.md) for all flags). `docker run --rm cppncorr` +with no extra args prints `--help`. + +The image builds `proxyncorr` with `BUILD_TESTING=OFF`, so the Catch2 test suite +is not part of the image. + +## SLURM / HPC + +Build CppNCorr on the cluster first (or use the container via Singularity/Apptainer +— see below), then submit a job: + +```bash +# images_dir must contain the frames + roi.png; output_dir is created as needed +sbatch deploy/run_slurm.sh /scratch/$USER/run1/images /scratch/$USER/run1/out +``` + +Tunables (edit the `#SBATCH` directives or export before submitting): + +- `--cpus-per-task` — number of OpenMP threads (the script sets + `OMP_NUM_THREADS=$SLURM_CPUS_PER_TASK`). +- `--mem`, `--time` — scale to your dataset. +- `PROXYNCORR` — path to the executable if it is not on `PATH` + (`export PROXYNCORR=/path/to/proxyncorr`). + +The script sets `OMP_PROC_BIND=spread` and `OMP_PLACES=cores`, matching the +engine's recommended thread affinity. + +### Running the Docker image under Singularity/Apptainer + +Many clusters disallow the Docker daemon but support Apptainer/Singularity: + +```bash +# convert the Docker image to a SIF (after `docker build` + push, or from a local daemon) +apptainer build cppncorr.sif docker-daemon://cppncorr:latest + +# then call it from run_slurm.sh by setting: +# export PROXYNCORR="apptainer run cppncorr.sif" +``` + +## Notes + +- All dependencies (OpenCV, FFTW, SuiteSparse, OpenBLAS/LAPACK, nlohmann/json, + OpenMP) are installed from Ubuntu packages in the Dockerfile. On clusters + without these as modules, configure with `-DFORCE_FETCH_DEPENDENCIES=ON` to + build them from source via FetchContent (slower first build). +- Video rendering (`.avi`) is disabled in the sample commands (`--no-videos`) to + keep batch runs fast and headless; drop the flag to produce field videos. diff --git a/deploy/run_slurm.sh b/deploy/run_slurm.sh new file mode 100755 index 0000000..4df32f6 --- /dev/null +++ b/deploy/run_slurm.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# ============================================================================ +# run_slurm.sh — SLURM batch script for a CppNCorr DIC run on an HPC cluster. +# ---------------------------------------------------------------------------- +# Target environment: a SLURM-managed cluster where CppNCorr has been built +# (the `proxyncorr` executable is on PATH or referenced via PROXYNCORR below). +# +# DIC is OpenMP-parallel within a single node. This script requests one node and +# binds the OpenMP thread count to the allocated CPUs. Adjust the resource +# requests (--cpus-per-task, --mem, --time) to your dataset and cluster limits. +# +# Submit: +# sbatch deploy/run_slurm.sh /path/to/images /path/to/output +# ============================================================================ +#SBATCH --job-name=cppncorr-dic +#SBATCH --nodes=1 +#SBATCH --ntasks=1 +#SBATCH --cpus-per-task=8 +#SBATCH --mem=16G +#SBATCH --time=04:00:00 +#SBATCH --output=cppncorr-%j.out +#SBATCH --error=cppncorr-%j.err + +set -euo pipefail + +IMAGES="${1:?usage: sbatch run_slurm.sh }" +OUTPUT="${2:?usage: sbatch run_slurm.sh }" + +# Path to the DIC executable. Override by exporting PROXYNCORR before submitting. +PROXYNCORR="${PROXYNCORR:-proxyncorr}" + +# Bind OpenMP to the CPUs SLURM allocated to this task. +export OMP_NUM_THREADS="${SLURM_CPUS_PER_TASK:-8}" +# Match the engine's recommended affinity (see recent engine tuning): +export OMP_PROC_BIND=spread +export OMP_PLACES=cores + +echo "Host: $(hostname)" +echo "OMP_NUM_THREADS=${OMP_NUM_THREADS}" +echo "Images: ${IMAGES}" +echo "Output: ${OUTPUT}" + +srun "${PROXYNCORR}" \ + --folder "${IMAGES}" \ + --output "${OUTPUT}" \ + --threads "${OMP_NUM_THREADS}" \ + --no-videos + +echo "Done." From 8273d66a5a9ba9746e55ea8e10dfe64ab9d9c08c Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 14:49:12 +0200 Subject: [PATCH 15/20] ci: add GitHub Actions workflow for build, test, lint Add .github/workflows/ci.yml triggered on push and PR to main and newversion, with three jobs on ubuntu-latest (GCC), all using apt-provided dependencies (no GPU, no source builds, no large downloads): - build: cmake configure + build of the library and test targets. - test: build the test targets, run the fast unit tests (ctest -L unit), then the integration tests on the ohtcfrp fixture (ctest -L integration, generous timeout). The very slow e2e cases are excluded from CI and run locally/on demand. - lint: clang-format --dry-run --Werror on the C/C++ files ADDED on this branch (legacy files are not reformatted wholesale). Supporting changes folded into this CI unit: - .clang-format (Google-based, 100 col, 4-space indent) defining the style the lint job enforces for new code. - test/tests.cmake: label discovered tests (unit/integration/e2e) so CI can select them with ctest -L. - reformat the new test/header sources added on this branch to satisfy the new lint rule (whitespace only; no behavioural change; unit tests still pass). Co-Authored-By: Claude Opus 4.8 --- .clang-format | 18 +++++ .github/workflows/ci.yml | 110 +++++++++++++++++++++++++++++ include/ncorr/frame_reader.h | 7 +- test/e2e/test_e2e.cpp | 15 ++-- test/integration/test_pipeline.cpp | 3 +- test/tests.cmake | 15 +++- test/unit/test_config.cpp | 18 +++-- test/unit/test_frame_reader.cpp | 22 +++--- test/unit/test_ini.cpp | 4 +- test/unit/test_session.cpp | 3 +- 10 files changed, 179 insertions(+), 36 deletions(-) create mode 100644 .clang-format create mode 100644 .github/workflows/ci.yml diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..358fef7 --- /dev/null +++ b/.clang-format @@ -0,0 +1,18 @@ +# clang-format configuration for CppNCorr. +# Applies to new code on the newversion branch; CI lints only changed files so +# the large legacy headers are not reformatted wholesale. +--- +Language: Cpp +BasedOnStyle: Google +ColumnLimit: 100 +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +AccessModifierOffset: -2 +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: WithoutElse +DerivePointerAlignment: false +PointerAlignment: Left +SortIncludes: false +SpacesBeforeTrailingComments: 2 +... diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e668d90 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,110 @@ +name: CI + +# Build, test and lint CppNCorr. Triggered on pushes and pull requests targeting +# main and newversion. Kept deliberately fast: GCC on ubuntu-latest, system +# dependencies from apt (no GPU, no large dataset downloads, no source builds of +# OpenCV etc.). +on: + push: + branches: [main, newversion] + pull_request: + branches: [main, newversion] + +# Cancel superseded runs on the same ref to save CI minutes. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + DEBIAN_FRONTEND: noninteractive + # CMake >= 3.21 on the runner sets PROJECT_IS_TOP_LEVEL automatically. + CMAKE_ARGS: -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + +jobs: + build: + name: build (gcc, ubuntu-latest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake \ + libopencv-dev libfftw3-dev libsuitesparse-dev \ + libopenblas-dev liblapack-dev nlohmann-json3-dev libomp-dev + + - name: Configure (top-level, tests enabled) + run: cmake -S . -B build $CMAKE_ARGS + + - name: Build library + tests + run: cmake --build build -j "$(nproc)" + + test: + name: test (unit + integration via ctest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential cmake \ + libopencv-dev libfftw3-dev libsuitesparse-dev \ + libopenblas-dev liblapack-dev nlohmann-json3-dev libomp-dev + + - name: Configure + run: cmake -S . -B build $CMAKE_ARGS + + - name: Build test targets + run: cmake --build build --target ncorr_unit_tests ncorr_engine_tests -j "$(nproc)" + + - name: Run unit tests (fast) + working-directory: build + run: ctest --output-on-failure -L unit + + - name: Run integration tests (ohtcfrp) + working-directory: build + env: + OMP_NUM_THREADS: "2" + # Integration DIC is compute-heavy; give it a generous timeout. The very + # slow end-to-end cases (label "e2e") are excluded here to keep CI fast — + # they run locally / on demand (see docs/developer_guide.md). + run: ctest --output-on-failure --timeout 1200 -L integration + + lint: + name: lint (clang-format on changed files) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install clang-format + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends clang-format + + - name: clang-format --dry-run on changed C/C++ files + run: | + # Determine the base ref to diff against. + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE="origin/${{ github.base_ref }}" + git fetch origin "${{ github.base_ref }}" || true + else + BASE="${{ github.event.before }}" + fi + # Lint files ADDED on this branch (diff-filter=A). Pre-existing legacy + # files that only received small edits are intentionally not reformatted + # wholesale; the .clang-format style applies to new code on this branch. + CHANGED=$(git diff --name-only --diff-filter=A "$BASE" HEAD 2>/dev/null \ + | grep -E '\.(c|cc|cpp|cxx|h|hpp)$' || true) + if [ -z "$CHANGED" ]; then + echo "No newly added C/C++ files to lint." + exit 0 + fi + echo "Linting:"; echo "$CHANGED" + # --dry-run --Werror fails if any file is not formatted. + clang-format --dry-run --Werror $CHANGED diff --git a/include/ncorr/frame_reader.h b/include/ncorr/frame_reader.h index 42e76f4..018bea7 100644 --- a/include/ncorr/frame_reader.h +++ b/include/ncorr/frame_reader.h @@ -44,8 +44,8 @@ namespace ncorr { * the user guide. */ inline const std::vector& image_extensions() { - static const std::vector kImageExtensions = { - ".png", ".tif", ".tiff", ".bmp", ".jpg", ".jpeg"}; + static const std::vector kImageExtensions = {".png", ".tif", ".tiff", + ".bmp", ".jpg", ".jpeg"}; return kImageExtensions; } @@ -125,8 +125,7 @@ inline std::vector discover_frames(const std::string& folder, std::vector frames; DIR* dir = opendir(folder.c_str()); if (!dir) { - throw std::runtime_error("Cannot open folder '" + folder + - "': " + std::strerror(errno)); + throw std::runtime_error("Cannot open folder '" + folder + "': " + std::strerror(errno)); } // Get basenames to exclude. diff --git a/test/e2e/test_e2e.cpp b/test/e2e/test_e2e.cpp index 4147703..db8bf51 100644 --- a/test/e2e/test_e2e.cpp +++ b/test/e2e/test_e2e.cpp @@ -50,8 +50,8 @@ constexpr double kUnitsPerPixel = 0.2; // (~11%) so the regression guard stays robust across platforms/compilers/thread // counts while still catching a real behavioural regression. constexpr double kGoldenMeanDispMag = 0.180423; // mm (units_per_pixel = 0.2) -constexpr double kGoldenTolerance = 0.02; // mm -constexpr bool kGoldenRecorded = true; // baseline baked in +constexpr double kGoldenTolerance = 0.02; // mm +constexpr bool kGoldenRecorded = true; // baseline baked in // Mean magnitude of finite (u,v) inside the ROI for the given displacement field. double mean_disp_magnitude(const Disp2D& disp) { @@ -81,8 +81,7 @@ TEST_CASE("e2e_ohtcfrp_smoke", "[e2e]") { ROI2D roi(Image2D(kFixtureDir + "/roi.png").get_gs() > 0.5); - DIC_analysis_input input(imgs, roi, kScaleFactor, - INTERP::QUINTIC_BSPLINE_PRECOMPUTE, + DIC_analysis_input input(imgs, roi, kScaleFactor, INTERP::QUINTIC_BSPLINE_PRECOMPUTE, SUBREGION::CIRCLE, kRadius, /*num_threads=*/1, DIC_analysis_config::NO_UPDATE, /*debug=*/false); @@ -109,8 +108,9 @@ TEST_CASE("e2e_ohtcfrp_smoke", "[e2e]") { TEST_CASE("e2e_ohtcfrp_known_value", "[e2e]") { if (!kGoldenRecorded) { - SUCCEED("golden baseline not yet recorded; run with NCORR_E2E_CAPTURE=1 " - "to capture it (see e2e_ohtcfrp_smoke)."); + SUCCEED( + "golden baseline not yet recorded; run with NCORR_E2E_CAPTURE=1 " + "to capture it (see e2e_ohtcfrp_smoke)."); return; } @@ -121,8 +121,7 @@ TEST_CASE("e2e_ohtcfrp_known_value", "[e2e]") { ROI2D roi(Image2D(kFixtureDir + "/roi.png").get_gs() > 0.5); - DIC_analysis_input input(imgs, roi, kScaleFactor, - INTERP::QUINTIC_BSPLINE_PRECOMPUTE, + DIC_analysis_input input(imgs, roi, kScaleFactor, INTERP::QUINTIC_BSPLINE_PRECOMPUTE, SUBREGION::CIRCLE, kRadius, /*num_threads=*/1, DIC_analysis_config::NO_UPDATE, /*debug=*/false); diff --git a/test/integration/test_pipeline.cpp b/test/integration/test_pipeline.cpp index c73471e..7a743d4 100644 --- a/test/integration/test_pipeline.cpp +++ b/test/integration/test_pipeline.cpp @@ -35,8 +35,7 @@ DIC_analysis_input make_small_input() { ROI2D roi(Image2D(kFixtureDir + "/roi.png").get_gs() > 0.5); // scalefactor 1, large radius -> few seeds, fast, deterministic enough. - return DIC_analysis_input(imgs, roi, /*scalefactor=*/1, - INTERP::QUINTIC_BSPLINE_PRECOMPUTE, + return DIC_analysis_input(imgs, roi, /*scalefactor=*/1, INTERP::QUINTIC_BSPLINE_PRECOMPUTE, SUBREGION::CIRCLE, /*radius=*/30, /*num_threads=*/1, DIC_analysis_config::NO_UPDATE, /*debug=*/false); diff --git a/test/tests.cmake b/test/tests.cmake index 98481d5..a7a7bfe 100644 --- a/test/tests.cmake +++ b/test/tests.cmake @@ -50,7 +50,8 @@ target_include_directories(ncorr_unit_tests PRIVATE include) target_compile_definitions(ncorr_unit_tests PRIVATE NCORR_DEFAULT_CFG="${NCORR_DEFAULT_CFG_PATH}") target_link_libraries(ncorr_unit_tests PRIVATE Catch2::Catch2WithMain) -catch_discover_tests(ncorr_unit_tests) +# Label every discovered case "unit" so CI can select it with `ctest -L unit`. +catch_discover_tests(ncorr_unit_tests PROPERTIES LABELS "unit") # ---------------------------------------------------------------------------- # 2. Engine tests (integration + e2e) — link the full ncorr library, which the @@ -86,4 +87,14 @@ endif() if(OpenMP_CXX_FOUND) target_link_libraries(ncorr_engine_tests PRIVATE OpenMP::OpenMP_CXX) endif() -catch_discover_tests(ncorr_engine_tests) +# Register the integration and e2e cases with distinct CTest labels so CI can +# run them selectively (e.g. `ctest -L integration`, `ctest -L e2e`). Each +# invocation filters by Catch2 tag via TEST_SPEC. +catch_discover_tests(ncorr_engine_tests + TEST_SPEC "[integration]" + TEST_PREFIX "integration:" + PROPERTIES LABELS "integration") +catch_discover_tests(ncorr_engine_tests + TEST_SPEC "[e2e]" + TEST_PREFIX "e2e:" + PROPERTIES LABELS "e2e") diff --git a/test/unit/test_config.cpp b/test/unit/test_config.cpp index 847f7ee..56a8e31 100644 --- a/test/unit/test_config.cpp +++ b/test/unit/test_config.cpp @@ -121,10 +121,20 @@ TEST_CASE("config_key_field_parity", "[unit][config]") { // 3. Guard against an unrecognised key silently slipping into default.cfg: // every non-comment 'key = value' line must be one of the known fields. - static const std::set known_keys = { - "scalefactor", "subregion_type", "subregion_radius", "interp_type", - "strain_subregion_type", "strain_radius", "dic_config", "num_threads", - "debug", "perspective_interp", "units", "units_per_pixel", "alpha", "fps"}; + static const std::set known_keys = {"scalefactor", + "subregion_type", + "subregion_radius", + "interp_type", + "strain_subregion_type", + "strain_radius", + "dic_config", + "num_threads", + "debug", + "perspective_interp", + "units", + "units_per_pixel", + "alpha", + "fps"}; ncorr::IniFile ini; REQUIRE(ini.load(cfg_path)); diff --git a/test/unit/test_frame_reader.cpp b/test/unit/test_frame_reader.cpp index 84732ee..62d8614 100644 --- a/test/unit/test_frame_reader.cpp +++ b/test/unit/test_frame_reader.cpp @@ -22,8 +22,8 @@ namespace { // Create a unique temporary directory and return its path (no trailing slash). std::string make_temp_dir() { static int counter = 0; - std::string base = std::string(std::tmpnam(nullptr)) + "_ncorr_frames_" + - std::to_string(counter++); + std::string base = + std::string(std::tmpnam(nullptr)) + "_ncorr_frames_" + std::to_string(counter++); mkdir(base.c_str(), 0755); return base; } @@ -80,15 +80,14 @@ TEST_CASE("discover_frames_sorted", "[unit][frame_reader]") { TEST_CASE("discover_frames_excludes_reserved", "[unit][frame_reader]") { std::string dir = make_temp_dir(); touch(dir + "/frame_1.png"); - touch(dir + "/roi.png"); // reserved - touch(dir + "/ref.png"); // reserved - touch(dir + "/.hidden.png"); // hidden - touch(dir + "/notes.txt"); // non-image - touch(dir + "/custom_ref.png"); // excluded via ref_path basename + touch(dir + "/roi.png"); // reserved + touch(dir + "/ref.png"); // reserved + touch(dir + "/.hidden.png"); // hidden + touch(dir + "/notes.txt"); // non-image + touch(dir + "/custom_ref.png"); // excluded via ref_path basename mkdir((dir + "/subdir").c_str(), 0755); // sub-directory, ignored - auto frames = - ncorr::discover_frames(dir, dir + "/custom_ref.png", ""); + auto frames = ncorr::discover_frames(dir, dir + "/custom_ref.png", ""); REQUIRE(frames.size() == 1); CHECK(frames[0] == dir + "/frame_1.png"); } @@ -100,7 +99,6 @@ TEST_CASE("discover_frames_empty", "[unit][frame_reader]") { } TEST_CASE("discover_frames_missing_folder", "[unit][frame_reader]") { - CHECK_THROWS_AS( - ncorr::discover_frames("/no/such/folder_abc_98765", "", ""), - std::runtime_error); + CHECK_THROWS_AS(ncorr::discover_frames("/no/such/folder_abc_98765", "", ""), + std::runtime_error); } diff --git a/test/unit/test_ini.cpp b/test/unit/test_ini.cpp index ce8aab0..dee7169 100644 --- a/test/unit/test_ini.cpp +++ b/test/unit/test_ini.cpp @@ -21,8 +21,8 @@ namespace { // per-section so collisions are avoided via a counter. std::string write_temp_ini(const std::string& content) { static int counter = 0; - std::string path = std::string(std::tmpnam(nullptr)) + "_ncorr_ini_" + - std::to_string(counter++) + ".cfg"; + std::string path = + std::string(std::tmpnam(nullptr)) + "_ncorr_ini_" + std::to_string(counter++) + ".cfg"; std::ofstream out(path, std::ios::binary); out << content; out.close(); diff --git a/test/unit/test_session.cpp b/test/unit/test_session.cpp index 3148906..79dcf62 100644 --- a/test/unit/test_session.cpp +++ b/test/unit/test_session.cpp @@ -18,8 +18,7 @@ namespace { // Build a small valid grayscale buffer kept alive by the returned vector. -ncorr::ImageBuffer make_buffer(std::vector& storage, int w, int h, - int ch = 1) { +ncorr::ImageBuffer make_buffer(std::vector& storage, int w, int h, int ch = 1) { storage.assign(static_cast(w) * h * ch, 0); return ncorr::ImageBuffer(storage.data(), w, h, ch); } From e2d3de5c07aeae516e04e84c5f9bd16fc1e5b4c6 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 14:51:04 +0200 Subject: [PATCH 16/20] docs: add PR description for newversion (local, not yet opened) Co-Authored-By: Claude Opus 4.8 --- PR_BODY.md | 174 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 PR_BODY.md diff --git a/PR_BODY.md b/PR_BODY.md new file mode 100644 index 0000000..b20ba78 --- /dev/null +++ b/PR_BODY.md @@ -0,0 +1,174 @@ +# feat: CppNCorr newversion — in-memory API, params, tests, docs, CI + +> **Status:** prepared locally on the `newversion` branch. Not yet pushed and no +> PR has been opened (per request). This file is the proposed PR description for +> when the branch is published. + +## Summary + +This branch hardens and extends CppNCorr (the standalone C++ 2D DIC engine that +is also vendored as a submodule of CPPxDIC) without changing the behaviour of the +existing image-folder DIC pipeline. Work spans repository hygiene, CLI auditing, +input handling, a parameter override chain, a real test suite, documentation, +deployment assets, and CI. + +### Section 1 — Repository hygiene +- Enforced **C++17** project-wide and per-target; documented every CMake option, + flag, dependency, and target in `CMakeOptions.md`. +- Commit: `chore: enforce C++17, document CMake options`. + +### Section 2 — Outputs / CLI (the DIC executable is `proxyncorr`) +- Audited the `proxyncorr` CLI and added post-DIC output flags: + `--export-video`, `--export-strains`, `--change-perspective `. Flags whose + backing code exists are wired to existing behaviour; modes without dedicated + code print a clear "not yet implemented" notice and exit cleanly. (`ncorr` is + the static **library** target; `proxyncorr` is the actual DIC tool.) +- Commit: `feat(ncorr): audit CLI; stub missing post-DIC output flags`. + +### Section 3 — Inputs +- **3a (folder reader):** hardened edge cases (sorted/natural filename order, + supported formats, reserved `roi.png`/`ref.png`, hidden files, sub-directories, + empty/missing folders) and documented the naming convention. + - Commit: `fix(input/images): harden image-folder reader edge cases`. + - Follow-up refactor (for testability): extracted `discover_frames`, + `has_image_extension`, `natural_less` into the reusable header-only + `include/ncorr/frame_reader.h`; `proxyncorr.cpp` now includes it. No + behavioural change. + - Commit: `refactor(input/images): extract frame-reader helpers into reusable header`. +- **3b (in-memory input):** added the `NcorrSession` API + (`include/ncorr/session.h` + `src/session.cpp`) with `ImageBuffer`, `DICResult`, + `SessionConfig`, and a PIMPL session class. **Interface is final; bodies are + stubs** (validate inputs, return a graceful "not yet implemented" result). + - Commit: `feat(input/memory): add in-memory NcorrSession API (stub)`. + +### Section 4 — Parameters +- Implemented the three-tier override chain **CLI > config file > compiled + defaults**: `include/ncorr/config.h` (`struct Config` + `load_config_file`), + header-only INI parser `include/ncorr/ini.h`, and `config/default.cfg` whose + keys match `Config` field names exactly. +- Commit: `feat(params): implement CLI > config-file > compiled-defaults override chain`. + +### Section 5 — Tests +- **5a:** `TESTS_PLAN.md` (committed earlier, plan approved). + - Commit: `docs(tests): add TESTS_PLAN.md — awaiting confirmation before implementation`. +- **5b:** implemented the confirmed plan with **Catch2 v3 via FetchContent** + (approved new dependency, matches the repo's existing FetchContent pattern). + - **Unit tests** (`test/unit/`, target `ncorr_unit_tests`, dependency-light): + INI parser, Config + key/field parity, frame-reader helpers, and the + `NcorrSession` stub contract — **24 test cases / 130 assertions**. + - **Integration tests** (`test/integration/`, target `ncorr_engine_tests`): + load→DIC, change_perspective+set_units, DIC→strain, and config-driven input + on the `ohtcfrp` fixture — **4 test cases**. + - **End-to-end tests** (`test/e2e/`): full pipeline on `ohtcfrp` (reference + 2 + frames) with a captured golden baseline — **2 test cases**. + - Registered with CTest (`catch_discover_tests`, labelled `unit` / `integration` + / `e2e`). **All 30 CTest tests pass locally.** + - Commits: `test(unit): ...`, `test(integration): ...`, `test(e2e): ...`. + +### Section 6 — Documentation +- `docs/quickstart.md`, `docs/developer_guide.md`, `docs/user_guide.md`. +- Commits: `docs: add quickstart guide`, `docs: add developer guide`, + `docs: add user guide`. + +### Section 7 — Parallel / cluster / Docker +- New `deploy/` directory (no such assets existed before): `Dockerfile` + (builds `proxyncorr` on Ubuntu 24.04 with apt deps), `run_slurm.sh` + (single-node OpenMP sbatch script), `deploy/README.md`, and a repo-root + `.dockerignore`. The Dockerfile's native build sequence was verified to build + `proxyncorr` cleanly (the Docker daemon was unavailable to run the container + build itself in the prep environment). +- Commit: `chore(deploy): organise parallel/cluster/Docker scripts under deploy/`. + +### Section 8 — CI/CD +- `.github/workflows/ci.yml` (push + PR to `main`/`newversion`): `build`, + `test` (unit + integration on `ohtcfrp` via ctest), and `lint` + (`clang-format --dry-run` on added files). Added a `.clang-format`. Fast: GCC + on ubuntu-latest, apt deps, no GPU/large downloads; slow e2e excluded from CI. +- Commit: `ci: add GitHub Actions workflow for build, test, lint`. + +## Stubs and TODO markers + +- **In-memory DIC** (`src/session.cpp`): `process_frame` returns + `"NcorrSession::process_frame not yet implemented"`. + - `src/session.cpp:10` `TODO(newversion):` implement bodies following the + file-based pipeline. + - `src/session.cpp:58` `TODO(newversion):` convert reference to `Image2D`. + - `src/session.cpp:70` `TODO(newversion):` convert mask to `ROI2D`. + - `src/session.cpp:94` `FIXME(newversion):` in-memory DIC not implemented. + - When implemented, update the `session_stub_contract` unit test and add the + deferred `pipeline_session_matches_folder` integration test from + `TESTS_PLAN.md`. +- **CLI post-DIC stubs** (`test/src/proxyncorr.cpp`): + - `--change-perspective` non-`eulerian` modes print "not yet implemented" + (`FIXME(newversion):` near line 619). + - `--export-strains` relies on the JSON/binary bundle; `TODO(newversion):` + (near line 608) to add a dedicated standalone strain export format. + - Two `FIXME(newversion):` notes document the `DIC_analysis_sequential` + overload-disambiguation already handled explicitly. +- **e2e golden value**: baked in as a regression guard + (`kGoldenMeanDispMag = 0.180423 mm`, tolerance `±0.02 mm`). Re-capture with + `NCORR_E2E_CAPTURE=1`. + +## Items that were confirmed / need awareness + +- **Test framework:** Catch2 v3 via FetchContent — confirmed. +- **Folder-reader extraction** into `frame_reader.h` — confirmed. +- **e2e golden control point + tolerance:** captured during 5b (mean in-ROI + |displacement| on the final frame). Reviewers may wish to confirm the chosen + metric and ±0.02 mm band. + +## Critical: test infra is guarded; parent build is unaffected + +All test infrastructure (Catch2 FetchContent, `enable_testing()`, the test +targets, and CTest registration) is gated behind +`if(PROJECT_IS_TOP_LEVEL AND BUILD_TESTING)` in the root `CMakeLists.txt` +(with a manual `PROJECT_IS_TOP_LEVEL` fallback for CMake < 3.21, and +`BUILD_TESTING` defaulting ON only when top-level). Verified by configuring a +fake parent project that does `add_subdirectory(...)`: CppNCorr printed +`tests skipped (PROJECT_IS_TOP_LEVEL=OFF, BUILD_TESTING=OFF)`, no Catch2 was +fetched, no test targets were generated, and the `ncorr` library still built. + +## How to review + +1. **Read the plan & docs:** `TESTS_PLAN.md`, then `docs/quickstart.md`, + `docs/developer_guide.md`, `docs/user_guide.md`. +2. **Confirm the parent is unaffected:** configure a throwaway parent project + that `add_subdirectory()`s this repo and confirm the "tests skipped" message + and that no Catch2/test targets appear. +3. **Build + test top-level:** + ```bash + cmake -S . -B build -DCMAKE_POLICY_VERSION_MINIMUM=3.5 + cmake --build build -j + cd build && ctest --output-on-failure -L unit # fast + ctest --output-on-failure -L integration --timeout 1200 # slower DIC + ctest --output-on-failure -L e2e --timeout 1200 # slowest + ``` +4. **Spot-check the override chain:** `include/ncorr/config.h`, + `include/ncorr/ini.h`, `config/default.cfg`, and the overlay wiring in + `test/src/proxyncorr.cpp`. +5. **Review the API surface:** `include/ncorr/session.h` (final interface) and + `src/session.cpp` (stub bodies + TODO markers). +6. **Deploy/CI:** `deploy/Dockerfile`, `deploy/run_slurm.sh`, `deploy/README.md`, + `.github/workflows/ci.yml`, `.clang-format`. + +## Commit list (`git log --oneline main..newversion`) + +``` +8273d66 ci: add GitHub Actions workflow for build, test, lint +cfc0640 chore(deploy): organise parallel/cluster/Docker scripts under deploy/ +87585d4 docs: add user guide +4af29db docs: add developer guide +fb7eb2a docs: add quickstart guide +417b89c test(e2e): add ohtcfrp end-to-end regression guard with golden baseline +7e5f89b test(integration): add DIC pipeline-stage tests on ohtcfrp fixture +6634af9 test(unit): add Catch2 v3 suite for ini/config/frame_reader/session +ea1d73e refactor(input/images): extract frame-reader helpers into reusable header +b2bf99c docs(tests): add TESTS_PLAN.md — awaiting confirmation before implementation +9c31435 feat(params): implement CLI > config-file > compiled-defaults override chain +f241076 feat(input/memory): add in-memory NcorrSession API (stub) +6aafea2 fix(input/images): harden image-folder reader edge cases +4bc4ee1 feat(ncorr): audit CLI; stub missing post-DIC output flags +251b538 chore: enforce C++17, document CMake options +``` + +🤖 Generated with [Claude Code](https://claude.com/claude-code) From 43f035b596babe7f92695f061f1b993af0427dd1 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 20:10:45 +0200 Subject: [PATCH 17/20] feat(input/memory): implement in-memory NcorrSession DIC Replace the stub bodies with a real implementation that mirrors the file-based pipeline in proxyncorr.cpp: each ImageBuffer is wrapped in an owning cv::Mat and turned into an ncorr::Image2D; an optional mask becomes a ROI2D (full-frame default); process_frame() builds a DIC_analysis_input {ref, def} and runs DIC_analysis(), copying the resulting Disp2D u/v/cc arrays into the flat DICResult vectors (NaN outside the ROI). Results are the native Lagrangian displacements in pixels on the reduced analysis grid (DICResult.width/height are the array dims, not the image size); the correlation coefficient field is now populated. set_roi() validates geometry against the reference, and a new reference clears any prior ROI. The public session.h interface is unchanged, so CPPxDIC's proxyncorr can call it without modification. Co-Authored-By: Claude Opus 4.8 --- include/ncorr/session.h | 48 ++++++++------ src/session.cpp | 136 ++++++++++++++++++++++++++++++++-------- 2 files changed, 138 insertions(+), 46 deletions(-) diff --git a/include/ncorr/session.h b/include/ncorr/session.h index f6b57a5..80455a4 100644 --- a/include/ncorr/session.h +++ b/include/ncorr/session.h @@ -14,10 +14,10 @@ * - The usage pattern mirrors the file-based pipeline: set a reference frame * once, then process one or more deformed frames. * - * @note This is a STUB in the @c newversion branch: the interface is final but - * the bodies are not yet wired to the DIC engine. See session.cpp and the - * @c TODO markers. The image-folder reader path in @c proxyncorr.cpp - * (discover_frames + DIC_analysis) is the model implementation to follow. + * @note Results are the native Lagrangian displacement fields, in pixels, on + * the reduced DIC analysis grid (see @ref DICResult). The implementation + * drives the same engine path as the image-folder reader in + * @c proxyncorr.cpp (Image2D -> DIC_analysis_input -> DIC_analysis). */ #include @@ -74,21 +74,27 @@ struct ImageBuffer { /** * @brief Result of running DIC on a single deformed frame. * - * Displacement fields are returned as flat row-major arrays of length - * @c width * @c height. Points outside the analysed region of interest are set - * to NaN. The fields are deliberately plain @c std::vector so callers do - * not need any internal ncorr types to consume the result. + * Displacement fields are the native Lagrangian (reference-perspective) @c u / + * @c v in pixels, sampled on the reduced DIC analysis grid. @c width and + * @c height therefore describe the displacement array's own dimensions, which + * are generally smaller than the input image (they depend on scalefactor and + * subregion radius), NOT the reference frame size. + * + * Fields are returned as flat row-major arrays of length @c width * @c height. + * Points outside the analysed region of interest are set to NaN. The fields are + * deliberately plain @c std::vector so callers do not need any internal + * ncorr types to consume the result. */ struct DICResult { - /// Width of the displacement fields (pixels). + /// Width of the displacement fields, in reduced-grid samples. int width = 0; - /// Height of the displacement fields (pixels). + /// Height of the displacement fields, in reduced-grid samples. int height = 0; - /// Horizontal displacement field (u), row-major, size width*height. NaN outside ROI. + /// Horizontal Lagrangian displacement (u), pixels, row-major. NaN outside ROI. std::vector u; - /// Vertical displacement field (v), row-major, size width*height. NaN outside ROI. + /// Vertical Lagrangian displacement (v), pixels, row-major. NaN outside ROI. std::vector v; - /// Per-point correlation coefficient, row-major, size width*height (optional; may be empty). + /// Per-point correlation coefficient, row-major, size width*height. NaN outside ROI. std::vector corrcoef; /// True if the frame was processed successfully. bool valid = false; @@ -129,9 +135,9 @@ struct SessionConfig { * The session owns a private implementation (PIMPL) so that this header stays * free of heavy internal ncorr includes. * - * @warning STUB: methods are declared and behave gracefully (see session.cpp) - * but DIC is not yet computed. Implement following the file-based path - * in proxyncorr.cpp (Image2D::from_mat -> DIC_analysis_input -> DIC_analysis). + * Each call to @ref process_frame runs a full @c DIC_analysis on + * {reference, deformed} and returns the native Lagrangian pixel displacements + * (and correlation coefficient) on the reduced analysis grid. */ class NcorrSession { public: @@ -164,11 +170,13 @@ class NcorrSession { /** * @brief Optionally supply a region-of-interest mask. * - * The mask is a single-channel buffer the same size as the reference; any - * non-zero pixel marks a point to analyse. If never called, the whole frame - * is analysed. + * The mask must match the reference geometry; any pixel whose grayscale + * value exceeds 0.5 marks a point to analyse. If never called, the whole + * frame is analysed. Setting a new reference clears any prior ROI. * - * @param roi_mask Single-channel ROI mask buffer. + * @param roi_mask ROI mask buffer (same width/height as the reference). + * @throws std::invalid_argument if the mask is invalid or, when a reference + * is set, its geometry does not match the reference. */ void set_roi(const ImageBuffer& roi_mask); diff --git a/src/session.cpp b/src/session.cpp index 4a7d84c..471c968 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -1,42 +1,60 @@ /** * @file session.cpp - * @brief Stub implementation of the in-memory NcorrSession API. + * @brief In-memory NcorrSession implementation backed by the DIC engine. * - * STUB (newversion branch): the interface is final and behaves gracefully, but - * the DIC computation is NOT yet wired up. Each entry point validates its inputs - * and stores state; process_frame() returns an explicit "not implemented" - * DICResult instead of fabricating numbers. - * - * TODO(newversion): implement the bodies by following the file-based pipeline in - * test/src/proxyncorr.cpp: - * 1. Wrap each ImageBuffer in a cv::Mat (no copy of caller memory needed for - * the wrap) and build an ncorr::Image2D via Image2D::from_mat(...). - * 2. Build a ROI2D from the optional mask (or full-frame default). + * Mirrors the file-based pipeline in test/src/proxyncorr.cpp but drives the DIC + * engine directly from caller-provided in-memory image buffers: + * 1. Wrap each ImageBuffer in an owning cv::Mat and build an ncorr::Image2D. + * 2. Build a ROI2D from the optional mask (or a full-frame default). * 3. Construct a DIC_analysis_input { ref + def imgs, roi, scalefactor, * interp, subregion, radius, num_threads, DIC_analysis_config, debug }. - * 4. Run DIC_analysis(...) and copy the resulting Disp2D u/v arrays into the - * flat DICResult::u / DICResult::v vectors (NaN outside the ROI). + * 4. Run DIC_analysis(...) and copy the resulting Disp2D u/v/cc arrays into + * the flat DICResult vectors (NaN outside the ROI). + * + * The returned fields are the native Lagrangian displacements in pixels on the + * reduced analysis grid (their own dims, not necessarily the input image size). */ #include "ncorr/session.h" -#include +#include "ncorr.h" + +#include + +#include +#include +#include #include namespace ncorr { +namespace { + +// Wrap a (row-major, interleaved 8-bit) ImageBuffer into an owning cv::Mat. The +// clone() decouples the cv::Mat from the caller's storage so the session can +// outlive the original buffer. +cv::Mat to_owning_mat(const ImageBuffer& b) { + return cv::Mat(b.height, b.width, CV_8UC(b.channels), + const_cast(b.data)) + .clone(); +} + +} // namespace + // --------------------------------------------------------------------------- -// Private implementation (PIMPL). Kept minimal for the stub; will later hold -// ncorr::Image2D / ROI2D / cached engine state. +// Private implementation (PIMPL). Holds the cached reference Image2D, its +// geometry, and the optional ROI2D. // --------------------------------------------------------------------------- struct NcorrSession::Impl { SessionConfig config; bool has_reference = false; + Image2D ref_img; int ref_width = 0; int ref_height = 0; bool has_roi = false; + ROI2D roi; explicit Impl(const SessionConfig& cfg) : config(cfg) {} }; @@ -55,11 +73,12 @@ void NcorrSession::set_reference(const ImageBuffer& ref) { "NcorrSession::set_reference: invalid ImageBuffer (null data or " "non-positive dimensions)."); } - // TODO(newversion): copy/convert ref into an ncorr::Image2D and cache its - // grayscale representation. For now we only record geometry. + impl_->ref_img = Image2D(to_owning_mat(ref)); impl_->ref_width = ref.width; impl_->ref_height = ref.height; impl_->has_reference = true; + // A new reference invalidates any previously set ROI. + impl_->has_roi = false; } void NcorrSession::set_roi(const ImageBuffer& roi_mask) { @@ -67,7 +86,15 @@ void NcorrSession::set_roi(const ImageBuffer& roi_mask) { throw std::invalid_argument( "NcorrSession::set_roi: invalid ROI mask ImageBuffer."); } - // TODO(newversion): convert the mask into an ncorr::ROI2D. + if (impl_->has_reference && + (roi_mask.width != impl_->ref_width || + roi_mask.height != impl_->ref_height)) { + throw std::invalid_argument( + "NcorrSession::set_roi: ROI mask geometry does not match the " + "reference frame."); + } + Array2D gs = Image2D(to_owning_mat(roi_mask)).get_gs(); + impl_->roi = ROI2D(gs > 0.5); impl_->has_roi = true; } @@ -91,13 +118,70 @@ DICResult NcorrSession::process_frame(const ImageBuffer& def) { return result; } - // FIXME(newversion): in-memory DIC is not yet implemented. Behave gracefully - // rather than returning fabricated displacement fields. - result.width = impl_->ref_width; - result.height = impl_->ref_height; - result.message = "NcorrSession::process_frame not yet implemented"; - std::cerr << "[NcorrSession] process_frame not yet implemented" << std::endl; - return result; + try { + Image2D def_img(to_owning_mat(def)); + + ROI2D roi = impl_->has_roi + ? impl_->roi + : ROI2D(Array2D(impl_->ref_height, + impl_->ref_width, true)); + + std::vector imgs{impl_->ref_img, def_img}; + + DIC_analysis_input in( + imgs, roi, impl_->config.scalefactor, + INTERP::QUINTIC_BSPLINE_PRECOMPUTE, SUBREGION::CIRCLE, + impl_->config.subregion_radius, impl_->config.num_threads, + DIC_analysis_config::NO_UPDATE, impl_->config.debug); + + DIC_analysis_output out = DIC_analysis(in); + if (out.disps.empty()) { + result.message = + "process_frame: DIC produced no displacement fields."; + return result; + } + + const Disp2D& disp = out.disps.front(); + const Array2D& u_arr = disp.get_u().get_array(); + const Array2D& v_arr = disp.get_v().get_array(); + const Array2D& cc_arr = disp.get_cc().get_array(); + const Array2D& mask = disp.get_roi().get_mask(); + + const int h = u_arr.height(); + const int w = u_arr.width(); + result.width = w; + result.height = h; + + const double nan = std::numeric_limits::quiet_NaN(); + const std::size_t n = static_cast(w) * h; + result.u.assign(n, nan); + result.v.assign(n, nan); + result.corrcoef.assign(n, nan); + + for (int i = 0; i < h; ++i) { + for (int j = 0; j < w; ++j) { + if (i < mask.height() && j < mask.width() && mask(i, j)) { + const std::size_t idx = + static_cast(i) * w + j; + if (i < u_arr.height() && j < u_arr.width()) + result.u[idx] = u_arr(i, j); + if (i < v_arr.height() && j < v_arr.width()) + result.v[idx] = v_arr(i, j); + if (i < cc_arr.height() && j < cc_arr.width()) + result.corrcoef[idx] = cc_arr(i, j); + } + } + } + + result.valid = true; + result.message.clear(); + return result; + } catch (const std::exception& e) { + result.valid = false; + result.message = + std::string("process_frame: DIC failed: ") + e.what(); + return result; + } } bool NcorrSession::has_reference() const { From b5799aa1c0d629c2aaf6b7e8ee3c163c7e933516 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 20:10:45 +0200 Subject: [PATCH 18/20] test(session): real in-memory DIC parity test; move session tests to engine target session.cpp now links the full DIC engine, so its tests can no longer live in the dependency-light ncorr_unit_tests target. Move test_session.cpp (and drop the standalone src/session.cpp compile) into ncorr_engine_tests, which links libncorr + OpenCV. The contract cases (validation, reference-required, geometry mismatch) are tagged [session] and discovered under the "unit" label; a new [integration] parity test loads the ohtcfrp fixture and asserts NcorrSession::process_frame reproduces the file/Image2D + DIC_analysis path within 1e-6. Replaces the obsolete stub-contract test. Co-Authored-By: Claude Opus 4.8 --- test/tests.cmake | 10 +++- test/unit/test_session.cpp | 116 +++++++++++++++++++++++++++++-------- 2 files changed, 100 insertions(+), 26 deletions(-) diff --git a/test/tests.cmake b/test/tests.cmake index a7a7bfe..b44cd48 100644 --- a/test/tests.cmake +++ b/test/tests.cmake @@ -42,9 +42,7 @@ add_executable(ncorr_unit_tests test/unit/test_ini.cpp test/unit/test_config.cpp test/unit/test_frame_reader.cpp - test/unit/test_session.cpp src/config.cpp - src/session.cpp ) target_include_directories(ncorr_unit_tests PRIVATE include) target_compile_definitions(ncorr_unit_tests PRIVATE @@ -66,6 +64,8 @@ find_or_fetch_openblas() add_executable(ncorr_engine_tests test/integration/test_pipeline.cpp test/e2e/test_e2e.cpp + test/unit/test_session.cpp + src/session.cpp ) target_include_directories(ncorr_engine_tests PRIVATE include ${OpenCV_INCLUDE_DIRS}) target_compile_definitions(ncorr_engine_tests PRIVATE @@ -98,3 +98,9 @@ catch_discover_tests(ncorr_engine_tests TEST_SPEC "[e2e]" TEST_PREFIX "e2e:" PROPERTIES LABELS "e2e") +# Session contract cases (no DIC) are tagged "[session]" and labelled "unit" +# even though they now live in the engine target (session.cpp pulls the engine). +catch_discover_tests(ncorr_engine_tests + TEST_SPEC "[session]" + TEST_PREFIX "session:" + PROPERTIES LABELS "unit") diff --git a/test/unit/test_session.cpp b/test/unit/test_session.cpp index 79dcf62..673f5ad 100644 --- a/test/unit/test_session.cpp +++ b/test/unit/test_session.cpp @@ -1,18 +1,30 @@ /** * @file test_session.cpp - * @brief Unit tests for the in-memory NcorrSession API (stub contract). + * @brief Tests for the in-memory NcorrSession API. * - * These tests pin the *contract* of the stubbed API: input validation, the - * reference-required precondition, geometry checks, and the current - * "not yet implemented" return. When section 3b (real in-memory DIC) lands, - * the `session_stub_contract` test must be updated. + * The contract cases (tagged "[session]") pin input validation, the + * reference-required precondition, and geometry checks; they do not run DIC and + * are labelled "unit" by the engine target's discovery. + * + * The parity case (tagged "[integration]") runs a real in-memory DIC through + * NcorrSession::process_frame and asserts it matches the file/Image2D + + * DIC_analysis path on the same reference/deformed pair, bit-for-bit within a + * tight tolerance (it is the same computation). + * + * This translation unit links the full ncorr engine (see tests.cmake) so it can + * include ncorr.h and OpenCV. */ #include #include "ncorr/session.h" +#include "ncorr.h" + +#include +#include #include +#include #include namespace { @@ -25,7 +37,7 @@ ncorr::ImageBuffer make_buffer(std::vector& storage, int w, int h, } // namespace -TEST_CASE("imagebuffer_valid", "[unit][session]") { +TEST_CASE("imagebuffer_valid", "[session]") { std::vector storage; auto good = make_buffer(storage, 4, 3); CHECK(good.valid()); @@ -42,7 +54,7 @@ TEST_CASE("imagebuffer_valid", "[unit][session]") { CHECK_FALSE(null_data.valid()); } -TEST_CASE("session_requires_reference", "[unit][session]") { +TEST_CASE("session_requires_reference", "[session]") { ncorr::NcorrSession session; CHECK_FALSE(session.has_reference()); std::vector storage; @@ -50,14 +62,14 @@ TEST_CASE("session_requires_reference", "[unit][session]") { CHECK_THROWS_AS(session.process_frame(def), std::logic_error); } -TEST_CASE("session_rejects_invalid_reference", "[unit][session]") { +TEST_CASE("session_rejects_invalid_reference", "[session]") { ncorr::NcorrSession session; ncorr::ImageBuffer bad; // null / zero dims CHECK_THROWS_AS(session.set_reference(bad), std::invalid_argument); CHECK_FALSE(session.has_reference()); } -TEST_CASE("session_geometry_mismatch", "[unit][session]") { +TEST_CASE("session_geometry_mismatch", "[session]") { ncorr::NcorrSession session; std::vector ref_storage; auto ref = make_buffer(ref_storage, 8, 8); @@ -71,19 +83,75 @@ TEST_CASE("session_geometry_mismatch", "[unit][session]") { CHECK_FALSE(result.message.empty()); } -// STUB CONTRACT: update this test when in-memory DIC (section 3b) is implemented. -TEST_CASE("session_stub_contract", "[unit][session]") { - ncorr::NcorrSession session; - std::vector ref_storage; - auto ref = make_buffer(ref_storage, 8, 8); - session.set_reference(ref); - - std::vector def_storage; - auto def = make_buffer(def_storage, 8, 8); // matching geometry - auto result = session.process_frame(def); - - CHECK_FALSE(result.valid); - CHECK(result.message == "NcorrSession::process_frame not yet implemented"); - CHECK(result.width == 8); - CHECK(result.height == 8); +// --------------------------------------------------------------------------- +// Real in-memory DIC parity test: NcorrSession::process_frame must reproduce +// the file/Image2D + DIC_analysis path exactly on the ohtcfrp fixture. +// NCORR_FIXTURE_DIR is injected as a compile definition (see tests.cmake). +// --------------------------------------------------------------------------- +TEST_CASE("session_dic_parity", "[integration]") { + using namespace ncorr; + + const std::string fixture_dir = NCORR_FIXTURE_DIR; + const std::string ref_path = fixture_dir + "/ohtcfrp_00.png"; + const std::string def_path = fixture_dir + "/ohtcfrp_01.png"; + const std::string roi_path = fixture_dir + "/roi.png"; + + // Load both frames and the ROI mask through OpenCV (BGR, as the session + // expects an interleaved 8-bit buffer matching OpenCV's default layout). + cv::Mat ref_bgr = cv::imread(ref_path, cv::IMREAD_COLOR); + cv::Mat def_bgr = cv::imread(def_path, cv::IMREAD_COLOR); + cv::Mat roi_bgr = cv::imread(roi_path, cv::IMREAD_COLOR); + REQUIRE_FALSE(ref_bgr.empty()); + REQUIRE_FALSE(def_bgr.empty()); + REQUIRE_FALSE(roi_bgr.empty()); + REQUIRE(ref_bgr.isContinuous()); + REQUIRE(def_bgr.isContinuous()); + REQUIRE(roi_bgr.isContinuous()); + + // Common config / ROI for both paths. + SessionConfig cfg; // defaults + ROI2D roi(Image2D(roi_path).get_gs() > 0.5); + + // (a) Direct file/Image2D + DIC_analysis path. + std::vector imgs{Image2D(ref_path), Image2D(def_path)}; + DIC_analysis_input in(imgs, roi, cfg.scalefactor, + INTERP::QUINTIC_BSPLINE_PRECOMPUTE, SUBREGION::CIRCLE, + cfg.subregion_radius, cfg.num_threads, + DIC_analysis_config::NO_UPDATE, cfg.debug); + DIC_analysis_output out = DIC_analysis(in); + REQUIRE(out.disps.size() == 1); + const Disp2D& disp = out.disps.front(); + const Array2D& u_arr = disp.get_u().get_array(); + const Array2D& v_arr = disp.get_v().get_array(); + const Array2D& mask = disp.get_roi().get_mask(); + + // (b) In-memory NcorrSession path on the same data. + NcorrSession session(cfg); + session.set_reference(ImageBuffer(ref_bgr.data, ref_bgr.cols, ref_bgr.rows, ref_bgr.channels())); + session.set_roi(ImageBuffer(roi_bgr.data, roi_bgr.cols, roi_bgr.rows, roi_bgr.channels())); + DICResult result = session.process_frame( + ImageBuffer(def_bgr.data, def_bgr.cols, def_bgr.rows, def_bgr.channels())); + + REQUIRE(result.valid); + REQUIRE(result.message.empty()); + REQUIRE(result.width == u_arr.width()); + REQUIRE(result.height == u_arr.height()); + REQUIRE(result.u.size() == static_cast(result.width) * result.height); + + // In-ROI u/v must match the direct DIC_analysis arrays within tolerance. + const double tol = 1e-6; + int checked = 0; + for (int i = 0; i < result.height; ++i) { + for (int j = 0; j < result.width; ++j) { + const size_t idx = static_cast(i) * result.width + j; + if (i < mask.height() && j < mask.width() && mask(i, j)) { + REQUIRE(std::isfinite(result.u[idx])); + REQUIRE(std::isfinite(result.v[idx])); + CHECK(std::abs(result.u[idx] - u_arr(i, j)) <= tol); + CHECK(std::abs(result.v[idx] - v_arr(i, j)) <= tol); + ++checked; + } + } + } + CHECK(checked > 0); // the ROI must actually contain analysed points } From e9f117bd3b3090b05118af59fff51246089f7ec6 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 16 Jun 2026 20:11:28 +0200 Subject: [PATCH 19/20] feat(proxyncorr): lagrangian perspective + strain CSV export; clean up overload Implements the three remaining proxyncorr TODOs: - --change-perspective: wire up "lagrangian" alongside "eulerian" (default). Eulerian keeps the existing change_perspective() conversion; lagrangian leaves the native DIC output untouched. Video filenames now reflect the active perspective. Unknown values error out cleanly. Default behaviour (no flag) is unchanged. - --export-strains: add a dedicated standalone CSV strain export (save_strains_csv): per strain frame, three row-major grid CSVs (exx/eyy/exy), values inside the ROI at 9 sig-digits and "nan" outside, written to /strains_csv. The JSON/binary bundle behaviour is kept. - DIC_analysis_sequential: replace the function-pointer overload-selection workaround with the unambiguous 3-arg overload DIC_analysis_sequential(input, seeds, optimized) for both the seeded and auto-seed sequential paths. Verified by a CLI run on the ohtcfrp fixture (lagrangian + strain CSVs produced, exit 0) and the existing test suite. Co-Authored-By: Claude Opus 4.8 --- test/src/proxyncorr.cpp | 138 +++++++++++++++++++++++++++------------- 1 file changed, 94 insertions(+), 44 deletions(-) diff --git a/test/src/proxyncorr.cpp b/test/src/proxyncorr.cpp index ef325b5..739b016 100644 --- a/test/src/proxyncorr.cpp +++ b/test/src/proxyncorr.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -262,6 +263,49 @@ void save_as_json(const DIC_analysis_input& dic_input, strain_output_file << std::setw(4) << strain_output_json << std::endl; } +// ============================================================================ +// Standalone strain CSV export +// ---------------------------------------------------------------------------- +// Writes, per strain frame k, three grid CSV files (exx / eyy / exy). Each CSV +// is the component's reduced-grid array written row-major: one line per row, +// comma-separated columns. Values inside the strain ROI are printed numerically +// (9 significant digits); points outside the ROI are written as "nan". +// ============================================================================ +void save_strains_csv(const strain_analysis_output& strain_output, + const std::string& dir) { + system(("mkdir -p " + dir).c_str()); + + auto write_component = [&](const Array2D& arr, + const Array2D& mask, + const std::string& path) { + std::ofstream out(path); + out << std::setprecision(9); + for (int i = 0; i < arr.height(); ++i) { + for (int j = 0; j < arr.width(); ++j) { + if (j > 0) out << ","; + if (i < mask.height() && j < mask.width() && mask(i, j)) { + out << arr(i, j); + } else { + out << "nan"; + } + } + out << "\n"; + } + }; + + for (std::size_t k = 0; k < strain_output.strains.size(); ++k) { + const Strain2D& strain = strain_output.strains[k]; + const Array2D& mask = strain.get_roi().get_mask(); + + std::ostringstream prefix; + prefix << dir << "/strain_" << std::setw(4) << std::setfill('0') << k; + + write_component(strain.get_exx().get_array(), mask, prefix.str() + "_exx.csv"); + write_component(strain.get_eyy().get_array(), mask, prefix.str() + "_eyy.csv"); + write_component(strain.get_exy().get_array(), mask, prefix.str() + "_exy.csv"); + } +} + // ============================================================================ // File discovery functions // ---------------------------------------------------------------------------- @@ -406,8 +450,7 @@ void print_usage(const char* prog_name) { << " --export-video Render displacement/strain fields as video\n" << " (forces video output on)\n" << " --export-strains Write strain fields to the output directory\n" - << " --change-perspective Output perspective: eulerian (default).\n" - << " lagrangian is not yet implemented.\n" + << " --change-perspective Output perspective: eulerian (default), lagrangian\n" << " -h, --help Show this help message\n\n" << "CONFIG FILE FORMAT (config.txt):\n" << " # Comment lines start with #\n" @@ -605,22 +648,31 @@ int main(int argc, char* argv[]) { } std::cout << "[--export-strains] enabled: strain fields will be written " "to the output directory." << std::endl; - // TODO(newversion): add a dedicated standalone strain export format - // (e.g. CSV / .mat) instead of relying on the JSON/binary bundle. } - if (!config.change_perspective_mode.empty()) { + // Resolve the output perspective. Native DIC output is Lagrangian; the + // default (no flag) converts to Eulerian to preserve historical behaviour. + bool to_eulerian = true; + std::string persp_label = "eulerian"; + { std::string mode = config.change_perspective_mode; std::transform(mode.begin(), mode.end(), mode.begin(), ::tolower); - if (mode == "eulerian") { - // Functional: this is exactly what the pipeline already does below. - std::cout << "[--change-perspective=eulerian] using Eulerian " - "perspective (default)." << std::endl; + if (mode.empty() || mode == "eulerian") { + to_eulerian = true; + persp_label = "eulerian"; + if (!config.change_perspective_mode.empty()) { + std::cout << "[--change-perspective=eulerian] using Eulerian " + "perspective (default)." << std::endl; + } + } else if (mode == "lagrangian") { + to_eulerian = false; + persp_label = "lagrangian"; + std::cout << "[--change-perspective=lagrangian] keeping native " + "Lagrangian (reference) perspective." << std::endl; } else { - // FIXME(newversion): wire up Lagrangian / arbitrary perspective - // transforms (see change_perspective_with_inversion in ncorr.h). - std::cout << "[--change-perspective=" << config.change_perspective_mode - << "] not yet implemented" << std::endl; - return 0; + std::cerr << "Error: unknown --change-perspective value '" + << config.change_perspective_mode + << "'. Use: eulerian (default) or lagrangian." << std::endl; + return 1; } } @@ -757,23 +809,11 @@ int main(int argc, char* argv[]) { std::cout << " (pre-optimized, skipping optimization step)"; } std::cout << "..." << std::endl; - - DIC_analysis_parallel_input parallel_input(DIC_input, config.seeds_by_region, config.seeds_are_optimized); - // FIXME(newversion): DIC_analysis_sequential is overloaded for both - // DIC_analysis_input and DIC_analysis_parallel_input; the latter is - // implicitly convertible to the former, making a bare call ambiguous. - // Select the parallel-input overload explicitly via a function pointer - // so the user-provided seeds are honoured. - DIC_output = static_cast( - &DIC_analysis_sequential)(parallel_input); + + DIC_output = DIC_analysis_sequential(DIC_input, config.seeds_by_region, config.seeds_are_optimized); } else { std::cout << "[SEQUENTIAL MODE] Performing DIC analysis with auto-generated seeds..." << std::endl; - // FIXME(newversion): same overload ambiguity as above. Here we want - // the DIC_analysis_input overload (no user seeds); select it - // explicitly via a function pointer. - DIC_output = static_cast&, bool)>( - &DIC_analysis_sequential)(DIC_input, {}, false); + DIC_output = DIC_analysis_sequential(DIC_input, {}, false); } } else if (effective_mode == "parallel") { if (has_seeds) { @@ -794,10 +834,14 @@ int main(int argc, char* argv[]) { throw std::runtime_error("Unknown algorithm mode: " + effective_mode + ". Use: auto, sequential, or parallel"); } - // Convert to Eulerian perspective - std::cout << "Converting to Eulerian perspective..." << std::endl; - DIC_output = change_perspective(DIC_output, parse_interp(config.perspective_interp)); - + // Resolve output perspective. Native DIC output is Lagrangian. + if (to_eulerian) { + std::cout << "Converting to Eulerian perspective..." << std::endl; + DIC_output = change_perspective(DIC_output, parse_interp(config.perspective_interp)); + } else { + std::cout << "Keeping Lagrangian (reference) perspective..." << std::endl; + } + // Set units DIC_output = set_units(DIC_output, config.units, config.units_per_pixel); @@ -829,31 +873,37 @@ int main(int argc, char* argv[]) { if (config.save_json) { std::cout << "Saving JSON outputs..." << std::endl; - save_as_json(DIC_input, DIC_output, strain_input, strain_output, + save_as_json(DIC_input, DIC_output, strain_input, strain_output, config.output_dir + "/save_json"); } - + + if (config.export_strains) { + std::string csv_dir = config.output_dir + "/strains_csv"; + std::cout << "Writing standalone strain CSV files to: " << csv_dir << std::endl; + save_strains_csv(strain_output, csv_dir); + } + // Create videos if (config.save_videos) { std::cout << "Creating videos..." << std::endl; - save_DIC_video(config.output_dir + "/video/v_eulerian.avi", + save_DIC_video(config.output_dir + "/video/v_" + persp_label + ".avi", DIC_input, DIC_output, DISP::V, config.alpha, config.fps); - - save_DIC_video(config.output_dir + "/video/u_eulerian.avi", + + save_DIC_video(config.output_dir + "/video/u_" + persp_label + ".avi", DIC_input, DIC_output, DISP::U, config.alpha, config.fps); - - save_strain_video(config.output_dir + "/video/eyy_eulerian.avi", + + save_strain_video(config.output_dir + "/video/eyy_" + persp_label + ".avi", strain_input, strain_output, STRAIN::EYY, config.alpha, config.fps); - - save_strain_video(config.output_dir + "/video/exy_eulerian.avi", + + save_strain_video(config.output_dir + "/video/exy_" + persp_label + ".avi", strain_input, strain_output, STRAIN::EXY, config.alpha, config.fps); - - save_strain_video(config.output_dir + "/video/exx_eulerian.avi", + + save_strain_video(config.output_dir + "/video/exx_" + persp_label + ".avi", strain_input, strain_output, STRAIN::EXX, config.alpha, config.fps); } From 01b6e81263f4a48bc470c8709b4cedd7885e00a8 Mon Sep 17 00:00:00 2001 From: John Aoga Date: Tue, 7 Jul 2026 19:49:45 +0200 Subject: [PATCH 20/20] feat(matlab-dic): fixed-step reference updates (fixed_step_ref option) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add DIC_analysis_parallel_input.fixed_step_ref (default 0 = off, existing behavior unchanged). When > 0, matlab_compute_seed_segment caps every seed segment at N frames, so the segment loop performs a reference change (seed propagation + chain composition back to the global reference in the exact_matlab_* path) every N frames regardless of seed quality — MATLAB ncorr step analysis semantics (stepanalysis.step / step_ref_change). Verified on S09 trial 7 (150 frames, step 10): 15 segments per tracking call, segment boundaries at frames 11, 21, ... matching MATLAB's recorded dispinfo.stepanalysis (enabled=1, type='seed', auto=1, step=10). Co-Authored-By: Claude Fable 5 --- include/ncorr.h | 10 +++++++++- src/ncorr.cpp | 11 ++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/include/ncorr.h b/include/ncorr.h index 8d168aa..3127320 100644 --- a/include/ncorr.h +++ b/include/ncorr.h @@ -451,7 +451,15 @@ struct DIC_analysis_parallel_input final { // Seed-based parallelization parameters double cutoff_max_diffnorm; // Diffnorm threshold for failure prediction double cutoff_max_corrcoef; // Corrcoef threshold for failure prediction - + + // Fixed-step reference updates (MATLAB ncorr step analysis semantics): + // when > 0, every seed segment is capped at this many frames, forcing a + // reference change (with seed propagation and — in the exact_matlab_* + // path — chain composition back to the global reference) every N frames + // regardless of seed quality. 0 (default) = segments end only on + // seed-quality failure (long-standing behavior). + difference_type fixed_step_ref = 0; + // Constructors DIC_analysis_parallel_input() : seeds_are_optimized(false), cutoff_max_diffnorm(0.1), cutoff_max_corrcoef(0.5) { } diff --git a/src/ncorr.cpp b/src/ncorr.cpp index 59db062..63c359f 100644 --- a/src/ncorr.cpp +++ b/src/ncorr.cpp @@ -4901,8 +4901,17 @@ matlab_seed_segment matlab_compute_seed_segment(const DIC_analysis_parallel_inpu segment.seeds_by_frame.reserve(DIC_input.imgs.size() - ref_idx - 1); + // Fixed-step reference updates: cap the segment at fixed_step_ref frames so + // the caller's segment loop performs a reference change (seed propagation + + // chain composition) every N frames — MATLAB ncorr step analysis semantics. + const difference_type last_idx_excl = + input.fixed_step_ref > 0 + ? std::min(difference_type(DIC_input.imgs.size()), + ref_idx + 1 + input.fixed_step_ref) + : difference_type(DIC_input.imgs.size()); + const auto A_ref = DIC_input.imgs[ref_idx].get_gs(); - for (difference_type cur_idx = ref_idx + 1; cur_idx < difference_type(DIC_input.imgs.size()); ++cur_idx) { + for (difference_type cur_idx = ref_idx + 1; cur_idx < last_idx_excl; ++cur_idx) { const auto A_cur = DIC_input.imgs[cur_idx].get_gs(); auto sr_nloptimizer = details::subregion_nloptimizer( A_ref,