diff --git a/.github/workflows/easy-forest-macos.yml b/.github/workflows/easy-forest-macos.yml
new file mode 100644
index 00000000..e2b1b3b0
--- /dev/null
+++ b/.github/workflows/easy-forest-macos.yml
@@ -0,0 +1,137 @@
+# Easy Forest HDMapping release build for macOS (Apple silicon, macOS 13.0 and newer).
+# Separate from the upstream macos.yml: builds only easy_gui + lidar_odometry_step_1 (HDMAPPING_EASY_GUI_ONLY) and
+# packages them as "Easy Forest HDMapping.app" in a DMG. The packaging steps are scripts in
+# apps/easy_gui/packaging/macos, so they can be run and debugged locally the same way.
+name: Easy Forest HDMapping macOS
+
+on:
+ push:
+ tags: ["easy-forest-v*"]
+ workflow_dispatch:
+
+permissions:
+ contents: write # attach the DMG to the release on a tag
+
+env:
+ MIN_MACOS: "13.0"
+ # Picked up by every CMake project (PROJ, the FetchContent raylib) and by clang directly.
+ MACOSX_DEPLOYMENT_TARGET: "13.0"
+
+jobs:
+ build:
+ runs-on: macos-15
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ fetch-depth: 0
+
+ - name: Set version
+ run: |
+ if [[ "$GITHUB_REF" == refs/tags/easy-forest-v* ]]; then
+ echo "VERSION=${GITHUB_REF_NAME#easy-forest-v}" >> "$GITHUB_ENV"
+ # The app shows EASY_FOREST_VERSION from CMake; it must be the tag's version.
+ cmake_version=$(sed -n 's/^set(EASY_FOREST_VERSION "\(.*\)")$/\1/p' apps/easy_gui/CMakeLists.txt)
+ if [[ "$cmake_version" != "${GITHUB_REF_NAME#easy-forest-v}" ]]; then
+ echo "::error::Tag $GITHUB_REF_NAME does not match EASY_FOREST_VERSION $cmake_version in apps/easy_gui/CMakeLists.txt"
+ exit 1
+ fi
+ else
+ echo "VERSION=$(git describe --tags --always)" >> "$GITHUB_ENV"
+ fi
+
+ - uses: lukka/get-cmake@latest
+ with:
+ cmakeVersion: "~4.0.0"
+
+ # Homebrew's PROJ is built for the runner's macOS; build a static one for MIN_MACOS instead (see the script).
+ - name: Cache PROJ
+ id: proj-cache
+ uses: actions/cache@v4
+ with:
+ path: ${{ runner.temp }}/proj-install
+ key: proj-macos-${{ env.MIN_MACOS }}-${{ runner.arch }}-${{ hashFiles('apps/easy_gui/packaging/macos/build_proj.sh') }}
+
+ - name: Build PROJ
+ if: steps.proj-cache.outputs.cache-hit != 'true'
+ run: apps/easy_gui/packaging/macos/build_proj.sh "$RUNNER_TEMP/proj-install" "$RUNNER_TEMP/proj-work"
+
+ # TBB_DISABLE_HWLOC_AUTOMATIC_SEARCH: the bundled oneTBB would otherwise pick up a Homebrew hwloc for tbbbind.
+ - name: Configure
+ run: |
+ cmake -B build \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_OSX_DEPLOYMENT_TARGET="$MIN_MACOS" \
+ -DHDMAPPING_EASY_GUI_ONLY=ON \
+ -DBUILD_TESTING=ON \
+ -DFREEGLUT_COCOA=ON \
+ -DHD_CPU_OPTIMIZATION=ARM \
+ -DTBB_DISABLE_HWLOC_AUTOMATIC_SEARCH=ON \
+ -DPROJ_DIR="$RUNNER_TEMP/proj-install/lib/cmake/proj"
+
+ # A static library built for a newer macOS only shows up as a linker warning (the executables still say
+ # minos 13.0), so treat that warning as an error.
+ - name: Build
+ run: |
+ set -o pipefail
+ cmake --build build --config Release -j"$(sysctl -n hw.ncpu)" 2>&1 | tee build.log
+ if grep -i "built for newer macOS version" build.log; then
+ echo "::error::Something was linked that was built for a newer macOS than $MIN_MACOS"
+ exit 1
+ fi
+
+ - name: Test
+ working-directory: build
+ run: ctest -C Release --output-on-failure
+
+ # Also checks minos <= MIN_MACOS for every bundled binary and fails otherwise.
+ - name: Create app bundle
+ run: python3 apps/easy_gui/packaging/macos/make_app.py build/bin dist --version "$VERSION" --min-macos "$MIN_MACOS"
+
+ - name: Smoke test the bundle
+ run: |
+ set +e
+ "dist/Easy Forest HDMapping.app/Contents/MacOS/easy_gui" --headless > usage.txt 2>&1
+ rc=$?
+ set -e
+ cat usage.txt
+ # Exit code 2 + usage text = dyld loaded every bundled library and main() ran.
+ test "$rc" -eq 2 && grep -q "usage:" usage.txt
+
+ - name: Create DMG
+ run: apps/easy_gui/packaging/macos/make_dmg.sh "dist/Easy Forest HDMapping.app" dist/EasyForestHDMapping-mac.dmg
+
+ - name: Sizes
+ run: |
+ {
+ echo "### Easy Forest HDMapping macOS $VERSION"
+ echo '```'
+ ls -l dist/EasyForestHDMapping-mac.dmg
+ du -sh "dist/Easy Forest HDMapping.app"
+ find "dist/Easy Forest HDMapping.app/Contents" -type f -exec ls -l {} \; | awk '{printf "%10d %s\n", $5, substr($0, index($0, $9))}'
+ echo '```'
+ } | tee -a "$GITHUB_STEP_SUMMARY"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: EasyForestHDMapping-mac-${{ env.VERSION }}
+ path: dist/EasyForestHDMapping-mac.dmg
+ compression-level: 0 # already compressed
+ retention-days: 90
+
+ # The macOS and Windows workflows both run on the same tag: whichever gets here first creates the release.
+ # 0.x versions and versions with a suffix (0.2.0-rc1) are pre-releases. Created with GITHUB_TOKEN, so the
+ # release does not trigger other workflows (upstream windows.yml runs on release: published).
+ - name: Attach to GitHub release
+ if: startsWith(github.ref, 'refs/tags/easy-forest-v')
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ PRERELEASE=""
+ if [[ "$VERSION" == 0.* || "$VERSION" == *-* ]]; then PRERELEASE="--prerelease"; fi
+ gh release view "$GITHUB_REF_NAME" > /dev/null 2>&1 \
+ || gh release create "$GITHUB_REF_NAME" --verify-tag --title "Easy Forest HDMapping $VERSION" $PRERELEASE \
+ --notes "Installation and use: [apps/easy_gui/README.md](https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_REF_NAME}/apps/easy_gui/README.md)" \
+ || gh release view "$GITHUB_REF_NAME" > /dev/null
+ gh release upload "$GITHUB_REF_NAME" dist/EasyForestHDMapping-mac.dmg --clobber
diff --git a/.github/workflows/easy-forest-windows.yml b/.github/workflows/easy-forest-windows.yml
new file mode 100644
index 00000000..a99032b2
--- /dev/null
+++ b/.github/workflows/easy-forest-windows.yml
@@ -0,0 +1,111 @@
+# Easy Forest HDMapping release build for Windows (x64).
+# Separate from the upstream windows.yml: builds only easy_gui + lidar_odometry_step_1 (HDMAPPING_EASY_GUI_ONLY) and
+# packages them with every DLL they need, the MSVC runtime included, as one zip (apps/easy_gui/packaging/windows).
+name: Easy Forest HDMapping Windows
+
+on:
+ push:
+ tags: ["easy-forest-v*"]
+ workflow_dispatch:
+
+permissions:
+ contents: write # attach the zip to the release on a tag
+
+jobs:
+ build:
+ runs-on: windows-latest
+
+ defaults:
+ run:
+ shell: bash
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ fetch-depth: 0
+
+ - name: Set version
+ run: |
+ if [[ "$GITHUB_REF" == refs/tags/easy-forest-v* ]]; then
+ echo "VERSION=${GITHUB_REF_NAME#easy-forest-v}" >> "$GITHUB_ENV"
+ # The app shows EASY_FOREST_VERSION from CMake; it must be the tag's version.
+ cmake_version=$(sed -n 's/^set(EASY_FOREST_VERSION "\(.*\)")$/\1/p' apps/easy_gui/CMakeLists.txt)
+ if [[ "$cmake_version" != "${GITHUB_REF_NAME#easy-forest-v}" ]]; then
+ echo "::error::Tag $GITHUB_REF_NAME does not match EASY_FOREST_VERSION $cmake_version in apps/easy_gui/CMakeLists.txt"
+ exit 1
+ fi
+ else
+ echo "VERSION=$(git describe --tags --always)" >> "$GITHUB_ENV"
+ fi
+
+ # Latest CMake: it has to know the generator of whatever Visual Studio the runner image ships.
+ - uses: lukka/get-cmake@latest
+
+ # HD_CPU_OPTIMIZATION=GENERIC: AUTO adds /arch:AVX2 when the runner's CPU has it, and the release must also run
+ # on PCs without AVX2.
+ - name: Configure
+ run: |
+ cmake -B build -A x64 \
+ -DHDMAPPING_EASY_GUI_ONLY=ON \
+ -DBUILD_TESTING=ON \
+ -DHD_CPU_OPTIMIZATION=GENERIC \
+ -DTBB_DISABLE_HWLOC_AUTOMATIC_SEARCH=ON
+
+ - name: Build
+ run: cmake --build build --config Release --parallel
+
+ - name: Test
+ working-directory: build
+ run: ctest -C Release --output-on-failure
+
+ - name: Create folder and zip
+ run: |
+ python apps/easy_gui/packaging/windows/make_zip.py build/bin/Release \
+ "dist/Easy Forest HDMapping" dist/EasyForestHDMapping-windows.zip \
+ --search-dir build/3rd_binary/PROJ/install_proj/bin
+
+ - name: Smoke test the folder
+ run: |
+ # easy_gui.exe is a windowed program, so check from its own folder that it and lidar_odometry_step_1.exe
+ # load all their DLLs: run with bad arguments and expect the usage exit code 2.
+ set +e
+ "dist/Easy Forest HDMapping/easy_gui.exe" --headless
+ rc=$?
+ set -e
+ echo "easy_gui.exe exit code: $rc"
+ test "$rc" -eq 2
+
+ - name: Sizes
+ run: |
+ {
+ echo "### Easy Forest HDMapping Windows $VERSION"
+ echo '```'
+ ls -l dist/EasyForestHDMapping-windows.zip
+ du -sh "dist/Easy Forest HDMapping"
+ ls -l "dist/Easy Forest HDMapping"
+ echo '```'
+ } | tee -a "$GITHUB_STEP_SUMMARY"
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: EasyForestHDMapping-windows-${{ env.VERSION }}
+ path: dist/EasyForestHDMapping-windows.zip
+ compression-level: 0 # already compressed
+ retention-days: 90
+
+ # The macOS and Windows workflows both run on the same tag: whichever gets here first creates the release.
+ # 0.x versions and versions with a suffix (0.2.0-rc1) are pre-releases. Created with GITHUB_TOKEN, so the
+ # release does not trigger other workflows (upstream windows.yml runs on release: published).
+ - name: Attach to GitHub release
+ if: startsWith(github.ref, 'refs/tags/easy-forest-v')
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ PRERELEASE=""
+ if [[ "$VERSION" == 0.* || "$VERSION" == *-* ]]; then PRERELEASE="--prerelease"; fi
+ gh release view "$GITHUB_REF_NAME" > /dev/null 2>&1 \
+ || gh release create "$GITHUB_REF_NAME" --verify-tag --title "Easy Forest HDMapping $VERSION" $PRERELEASE \
+ --notes "Installation and use: [apps/easy_gui/README.md](https://github.com/${GITHUB_REPOSITORY}/blob/${GITHUB_REF_NAME}/apps/easy_gui/README.md)" \
+ || gh release view "$GITHUB_REF_NAME" > /dev/null
+ gh release upload "$GITHUB_REF_NAME" dist/EasyForestHDMapping-windows.zip --clobber
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 8c0ce6b1..16c369c1 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,5 +1,11 @@
cmake_minimum_required(VERSION 4.0.0)
+# Easy Forest HDMapping release builds (HDMAPPING_EASY_GUI_ONLY) must run on older macOS versions than the
+# build machine's, which is the default minimum. Must be set before project(). Pass -DCMAKE_OSX_DEPLOYMENT_TARGET to override.
+if(HDMAPPING_EASY_GUI_ONLY AND CMAKE_HOST_APPLE AND NOT CMAKE_OSX_DEPLOYMENT_TARGET)
+ set(CMAKE_OSX_DEPLOYMENT_TARGET "13.0" CACHE STRING "Minimum macOS version" FORCE)
+endif()
+
project(hd-mapping VERSION 0.105.0)
# Common settings
@@ -32,6 +38,7 @@ option(BUILD_WITH_BUNDLED_LIBLASZIP "Build with bundled Lib LASZIP" ON)
option(BUILD_WITH_BUNDLED_ONETBB "Build with bundled oneTBB" ON)
option(BUILD_WITH_PYBIND "Enable pybind11 bindings" OFF)
option(HDMAPPING_ASSERT_ALWAYS_ABORT "Make HDMAPPING_ASSERT() abort on failure even in release (NDEBUG) builds" OFF)
+option(HDMAPPING_EASY_GUI_ONLY "Build only easy_gui, lidar_odometry_step_1 and their dependencies (plus easy_gui_tests with BUILD_TESTING)" OFF)
add_definitions(-D_USE_MATH_DEFINES)
@@ -107,37 +114,65 @@ include(cmake/dependencies.cmake)
# unconditionally: apps/multi_view_tls_registration (step2) is raylib-based.
include(cmake/raylib.cmake)
+# HDMAPPING_EASY_GUI_ONLY: the library directories are still added (easy_gui and
+# step 1 link some of their targets), but EXCLUDE_FROM_ALL, so a target in them
+# is only built when easy_gui or lidar_odometry_step_1 needs it. All other apps,
+# tests and optional tool sets are skipped. Off by default: nothing changes.
+if(HDMAPPING_EASY_GUI_ONLY)
+ set(HDMAPPING_EASY_GUI_ONLY_EXCLUDE EXCLUDE_FROM_ALL)
+ foreach(_hd_skipped BUILD_WITH_CLI_TOOLS BUILD_WITH_HD_MAPPER_APPLICATION BUILD_WITH_UTILITY_APPLICATION)
+ if(${_hd_skipped})
+ message(STATUS "HDMAPPING_EASY_GUI_ONLY: ignoring ${_hd_skipped}")
+ endif()
+ endforeach()
+endif()
+
# ============================================================================
# Core Library
# ============================================================================
-add_subdirectory(core)
-add_subdirectory(calib_core)
-add_subdirectory(raylib_widgets)
+add_subdirectory(core ${HDMAPPING_EASY_GUI_ONLY_EXCLUDE})
+add_subdirectory(calib_core ${HDMAPPING_EASY_GUI_ONLY_EXCLUDE})
+add_subdirectory(raylib_widgets ${HDMAPPING_EASY_GUI_ONLY_EXCLUDE})
# ============================================================================
# Unit tests (opt-in: cmake -DBUILD_TESTING=ON)
# ============================================================================
option(BUILD_TESTING "Build HDMapping unit tests" OFF)
-if(BUILD_TESTING)
+if(BUILD_TESTING AND HDMAPPING_EASY_GUI_ONLY)
+ enable_testing()
+ add_subdirectory(apps/easy_gui/tests)
+elseif(BUILD_TESTING)
enable_testing()
add_subdirectory(shared/tests)
add_subdirectory(calib_core/tests)
add_subdirectory(apps/lidar_odometry_step_1/tests)
add_subdirectory(rosbags/tests)
+ add_subdirectory(apps/easy_gui/tests)
endif()
set(CORE_LIBRARIES core)
set(GUI_LIBRARIES imgui imguizmo implot)
-if(BUILD_WITH_CLI_TOOLS)
+if(HDMAPPING_EASY_GUI_ONLY)
+ # The step 1 directory also defines the drag-and-drop precision forestry app.
+ # EXCLUDE_FROM_ALL skips it, together with the directory's install rules, so
+ # lidar_odometry_step_1 is put back into "all" and installed from here.
+ add_subdirectory(apps/lidar_odometry_step_1 EXCLUDE_FROM_ALL)
+ set_target_properties(lidar_odometry_step_1 PROPERTIES EXCLUDE_FROM_ALL OFF)
+ hdmapping_install_app(lidar_odometry_step_1)
+ add_subdirectory(apps/easy_gui)
+endif()
+
+if(BUILD_WITH_CLI_TOOLS AND NOT HDMAPPING_EASY_GUI_ONLY)
add_subdirectory(apps/console_tools)
endif()
-if(BUILD_WITH_HD_MAPPER_APPLICATION)
+if(BUILD_WITH_HD_MAPPER_APPLICATION AND NOT HDMAPPING_EASY_GUI_ONLY)
add_subdirectory(apps/hd_mapper)
endif()
# Applications for typical workflow - see images/workflow.png
+if(NOT HDMAPPING_EASY_GUI_ONLY)
add_subdirectory(apps/mandeye_raw_data_viewer)
add_subdirectory(apps/lidar_odometry_step_1)
add_subdirectory(apps/mandeye_single_session_viewer)
@@ -147,8 +182,10 @@ add_subdirectory(apps/multi_session_registration)
add_subdirectory(apps/multi_session_registration_legacy)
add_subdirectory(apps/session_uncertainty_calculation)
add_subdirectory(apps/session_to_session_rigid_registration)
+add_subdirectory(apps/easy_gui)
+endif()
-if(BUILD_WITH_UTILITY_APPLICATION)
+if(BUILD_WITH_UTILITY_APPLICATION AND NOT HDMAPPING_EASY_GUI_ONLY)
add_subdirectory(apps/split_multi_livox)
add_subdirectory(apps/precision_forestry_tools)
add_subdirectory(apps/compare_trajectories)
diff --git a/README.md b/README.md
index b8e7817f..54542230 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,7 @@
+> **New: Easy Forest HDMapping.** One window that turns a raw Mandeye recording folder into a point cloud (LAZ) and a
+> trajectory (CSV) with the precision forestry settings, no other steps needed. See
+> [apps/easy_gui/README.md](apps/easy_gui/README.md).
+
# Open-source (Windows, Linux and macOS), open hardware hand-held/wearable/mountable mobile mapping system for large-scale surveys:
This is open-source software for large-scale 3D mapping using an open-hardware hand-held/wearable/mountable measurement device available at https://github.com/JanuszBedkowski/mandeye_controller.
We provide an end-to-end mobile mapping framework that does not require any installation, including:
diff --git a/apps/easy_gui/CMakeLists.txt b/apps/easy_gui/CMakeLists.txt
new file mode 100644
index 00000000..885e33c1
--- /dev/null
+++ b/apps/easy_gui/CMakeLists.txt
@@ -0,0 +1,120 @@
+cmake_minimum_required(VERSION 4.0.0)
+
+project(easy_gui)
+
+# Easy Forest HDMapping's own version: shown next to HDMapping's in the window title, README.txt, easy_gui.log and
+# the LAZ header. Keep it equal to the easy-forest-v* release tag.
+set(EASY_FOREST_VERSION "0.2.0")
+
+# easy_gui_core -- everything except the window: pre-flight check, staging, the
+# step 1 child process and its progress parsing, and the pipeline that ties
+# them together. A separate static library so the doctest suite in tests/ can
+# link it without raylib.
+#
+# Lidar odometry itself is NOT compiled in: it runs as a child process
+# (lidar_odometry_step_1 ), for crash isolation
+# and a real cancel -- see src/child_process.h. Only two step 1 sources are
+# compiled here, both by relative path like the other apps do:
+# - lidar_odometry_utils.cpp for MLvxCalib (the pre-flight check mirrors
+# load_data's calibration checks with step 1's own parser),
+# - toml_io.cpp to write the parameter file with step 1's own TomlIO.
+# toml_io.cpp also reads the parameters back from the session for the export.
+# The export (src/export.cpp) loads the step 1 session with core's Session in
+# load_cache_mode (poses + trajectories exactly as step 2 sees them, no points)
+# and streams the points itself, so core_no_gui is linked below.
+# WITH_GUI=0 is PUBLIC because LidarOdometryParams changes layout under
+# WITH_GUI (clear_color), so every TU that sees it must agree.
+add_library(easy_gui_core STATIC
+ src/batch.cpp src/batch.h
+ src/child_process.cpp src/child_process.h
+ src/export.cpp src/export.h
+ src/forestry_preset.cpp src/forestry_preset.h
+ src/las14_writer.cpp src/las14_writer.h
+ src/pipeline.cpp src/pipeline.h
+ src/preflight.cpp src/preflight.h
+ src/progress_parser.cpp src/progress_parser.h
+ src/settings.h
+ src/sleep_blocker.cpp src/sleep_blocker.h
+ src/staging.cpp src/staging.h
+ src/version.h
+ ../lidar_odometry_step_1/lidar_odometry_utils.cpp
+ ../lidar_odometry_step_1/toml_io.cpp
+)
+
+target_compile_definitions(easy_gui_core
+ PUBLIC WITH_GUI=0 EASY_FOREST_VERSION="${EASY_FOREST_VERSION}"
+ PRIVATE HDMAPPING_BINARY_PREFIX="${HDMAPPING_BINARY_PREFIX}" UTL_PROFILER_DISABLE)
+
+target_include_directories(easy_gui_core
+ PUBLIC
+ src
+ ${REPOSITORY_DIRECTORY}/apps/lidar_odometry_step_1
+ ${REPOSITORY_DIRECTORY}/core/include
+ ${THIRDPARTY_DIRECTORY}
+ ${EIGEN3_INCLUDE_DIR}
+ ${THIRDPARTY_DIRECTORY}/json/include
+ ${THIRDPARTY_DIRECTORY}/tomlplusplus/include
+ ${LASZIP_INCLUDE_DIR}/LASzip/include
+ ${THIRDPARTY_DIRECTORY}/observation_equations/codes
+ ${THIRDPARTY_DIRECTORY}/vqf/vqf/cpp
+ ${THIRDPARTY_DIRECTORY}/Fusion/Fusion)
+
+# core_math provides the Core/ndt.h + hash_utils symbols lidar_odometry_utils.cpp
+# needs (the same reason step 1's own tests link core_no_gui); TBB and laszip for
+# its load_point_cloud()/decimate(), which the pre-flight check never calls.
+target_link_libraries(easy_gui_core PUBLIC
+ core_no_gui
+ core_math
+ vqf
+ Fusion
+ unordered_dense::unordered_dense
+ spdlog::spdlog
+ UTL::include
+ ${PLATFORM_LASZIP_LIB}
+ ${PLATFORM_MISCELLANEOUS_LIBS})
+
+# Batch mode keeps the computer awake (src/sleep_blocker.cpp): IOKit power assertions on macOS.
+if(APPLE)
+ target_link_libraries(easy_gui_core PUBLIC "-framework IOKit" "-framework CoreFoundation")
+endif()
+
+if(MSVC)
+ target_compile_definitions(easy_gui_core PUBLIC _USE_MATH_DEFINES NOMINMAX)
+ target_compile_options(easy_gui_core PRIVATE /bigobj)
+endif()
+
+# The window. raylib + rlImGui like step 2; core_pfd for the native folder picker.
+# imgui_stdlib (std::string InputText, for the batch table's output names) is compiled here: imgui_raylib does not
+# include it, and it must come from the same vendored ImGui.
+add_executable(easy_gui src/easy_gui_main.cpp ${THIRDPARTY_DIRECTORY}/imgui/misc/cpp/imgui_stdlib.cpp)
+
+target_link_libraries(easy_gui PRIVATE
+ easy_gui_core
+ core_pfd
+ raylib_widgets
+ raylib
+ imgui_raylib
+ rlimgui)
+
+# The step 1 program is part of Easy GUI at run time (it is started as a child
+# process from the same directory), so building Easy GUI builds it too.
+add_dependencies(easy_gui lidar_odometry_step_1)
+
+if(WIN32)
+ # A windowed program: no console window behind the GUI. main() stays the entry point (the WIN32 subsystem would
+ # otherwise expect WinMain); --headless/--export-only print into the console they were started from
+ # (attachToParentConsole). The manifest makes UTF-8 the process code page, see src/easy_gui.manifest.
+ set_target_properties(easy_gui PROPERTIES WIN32_EXECUTABLE ON)
+ if(MSVC)
+ target_link_options(easy_gui PRIVATE /ENTRY:mainCRTStartup)
+ endif()
+ target_sources(easy_gui PRIVATE src/easy_gui.manifest)
+
+ add_custom_command(
+ TARGET easy_gui
+ POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E copy $ $
+ COMMAND_EXPAND_LISTS)
+endif()
+
+hdmapping_install_app(easy_gui)
diff --git a/apps/easy_gui/README.md b/apps/easy_gui/README.md
new file mode 100644
index 00000000..0635d242
--- /dev/null
+++ b/apps/easy_gui/README.md
@@ -0,0 +1,329 @@
+# Easy Forest HDMapping
+
+Easy Forest HDMapping is a single window that turns the folder a Mandeye scanner records into a 3D point cloud (a `.laz` file)
+and the path you walked (a `.csv` file). It uses the processing settings that HDMapping recommends for forests, so there
+is nothing to tune.
+
+
+
+- [Who it is for](#who-it-is-for)
+- [What your computer needs](#what-your-computer-needs)
+- [Installation](#installation)
+- [How to use it](#how-to-use-it)
+- [Processing many recordings](#processing-many-recordings)
+- [What you get](#what-you-get)
+- [Troubleshooting](#troubleshooting)
+- [Getting help](#getting-help)
+- [Credits and license](#credits-and-license)
+
+## Who it is for
+
+Easy Forest HDMapping is for anyone who walks a forest plot with a Mandeye scanner and wants a point cloud without learning the
+full HDMapping toolchain: forestry partners, students, field crews. It is designed for forests; it works elsewhere, but
+its settings are tuned for forests.
+
+What it does **not** do:
+
+- **One point cloud per walk.** Each recording becomes its own point cloud. It can process many recordings in one go
+ (see [Processing many recordings](#processing-many-recordings)), but it does not merge several walks into one cloud.
+- **No real-world coordinates.** It does not use GNSS or ground control points. Coordinates are in metres, starting at
+ the spot where you started walking, with the vertical axis (Z) pointing up.
+- **No loop closure yet.** If you return to your starting point, small drift is not corrected. (Planned for a later
+ version.)
+- **One lidar only.** Scanners with two or more lidars are refused by the check.
+
+For any of these, use the full HDMapping apps (see the
+[HDMapping wiki](https://github.com/MapsHD/HDMapping/wiki)). Easy Forest HDMapping keeps its intermediate results so that you
+can continue there.
+
+## What your computer needs
+
+| | Mac | Windows |
+|---|---|---|
+| System | macOS 13 (Ventura) or newer | Windows 10 or 11, 64-bit (not yet tested on a real PC) |
+| Processor | Apple silicon (M1 or newer). Intel Macs are not supported in v1. | 64-bit Intel or AMD processor |
+| Memory (RAM) | 16 GB recommended | 16 GB recommended |
+
+**Memory:** a 10-minute walk needs about 9 GB of memory while it is being processed. Longer walks need more. Close
+other large programs while Easy Forest HDMapping is working.
+
+**Disk space, rule of thumb:** while processing, Easy Forest HDMapping needs free space of about **20 times the size of the
+recording's lidar files**, which is roughly **1.5 GB per minute of walking**. About a tenth of that stays as results;
+the rest is freed when processing ends. You do not have to work this out yourself: the check (step 3 below) tells you
+exactly how much is needed and how much is free.
+
+**Time:** on a recent laptop, processing takes about twice as long as the walk (a 10-minute walk takes about
+20-25 minutes).
+
+## Installation
+
+Easy Forest HDMapping is free and needs no account. Your computer will warn you the first time you open it, because the program
+is not signed by a company that Apple or Microsoft knows. The steps below show how to get past the warning; you only
+need to do this once.
+
+### Mac
+
+1. Open the [releases page](https://github.com/MartinMokros/HDMapping-Easy-GUI/releases) and download the newest Easy Forest HDMapping release: the file
+ **`EasyForestHDMapping-mac.dmg`** under *Assets*.
+2. Double-click the `.dmg` file. In the window that opens, drag **Easy Forest HDMapping** onto the **Applications**
+ folder.
+3. Open your **Applications** folder and double-click **Easy Forest HDMapping**.
+4. macOS says **"“Easy Forest HDMapping” Not Opened"** (Apple could not verify it is free of malware). Click
+ **Done**.
+5. Open **System Settings** → **Privacy & Security**. Scroll down to the **Security** section. Next to the line
+ **"“Easy Forest HDMapping” was blocked to protect your Mac."** click **Open Anyway**.
+6. Enter your Mac password (or use Touch ID), then click **Open Anyway** once more in the dialog that appears.
+
+From now on Easy Forest HDMapping opens normally. If macOS ever says the app **"is damaged and can't be opened"**, see
+[Troubleshooting](#troubleshooting).
+
+### Windows (not yet tested on a real PC)
+
+The Windows version is built and its automatic tests pass, but nobody has used it on a real Windows PC yet. If
+something goes wrong, please report it (see [Getting help](#getting-help)).
+
+1. Open the [releases page](https://github.com/MartinMokros/HDMapping-Easy-GUI/releases) and download the newest Easy Forest HDMapping release: the file
+ **`EasyForestHDMapping-windows.zip`** under *Assets*.
+2. Before unpacking: right-click the downloaded `.zip` file → **Properties** → on the **General** tab, tick
+ **Unblock** at the bottom → **OK**. (If there is no Unblock box, skip this step.)
+3. Right-click the `.zip` file → **Extract All…** → choose a folder, for example `Documents\Easy Forest HDMapping`
+ → **Extract**. Keep all files in that folder together: Easy Forest HDMapping needs the other files next to it.
+4. Open the folder and double-click **`easy_gui.exe`**.
+5. If a blue window says **"Windows protected your PC"**: click **More info**, then **Run anyway**.
+
+Tip: right-click `easy_gui.exe` → **Show more options** → **Send to** → **Desktop (create shortcut)** to start it
+from your desktop next time.
+
+## How to use it
+
+At the top of the window, choose **One recording** (described here) or **Several recordings** (see
+[Processing many recordings](#processing-many-recordings)). The window guides you through four steps, top to bottom.
+
+**1. Recording.** Click **Choose recording…** and select the folder the scanner wrote, for example
+`continousScanning_0012`. It contains files named `lidar0000.laz`, `imu0000.csv` and so on. You can also drag the
+folder onto the window. You can work straight from the scanner's USB stick: Easy Forest HDMapping never changes or adds
+anything in the recording folder. Copying the folder to your computer first makes processing a little faster.
+
+**2. Output folder.** Easy Forest HDMapping suggests `Documents/Easy Forest HDMapping/`. To use a
+different place, click **Choose output folder…**. The folder must be new or empty.
+
+**3. Check.** Easy Forest HDMapping checks the recording and the free disk space automatically. Each line is marked:
+
+- **[OK]** (green): all good.
+- **[!]** (amber): worth knowing, but you can go on.
+- **[X]** (red): this must be fixed before you can start. See [Troubleshooting](#troubleshooting).
+
+**4. Process.** Click **Start processing**. Easy Forest HDMapping shows which stage it is in, a progress bar and the time left.
+You can keep using your computer, but it will be busy. **Cancel** stops processing and deletes the unfinished results.
+Closing the window also stops processing.
+
+When it is done, click **Open output folder** to see the results, or **Process another recording** to start again.
+
+**Advanced settings** (you normally do not need these):
+
+- **Closest points kept** (default 0.4 m): points closer to the scanner than this are left out. The default removes
+ the operator's body and the scanner handle. A lower value keeps more of the close surroundings, but may include you.
+- **Place all points by their measurement time (experimental)**: an alternative way of placing the points. Tests showed
+ it slightly less sharp than the default, so leave it off unless you are comparing the two.
+- **Keep intermediate files**: keeps extra working files, useful when reporting a problem.
+
+## Processing many recordings
+
+After a field day you often have many recordings, for example one per plot. Easy Forest HDMapping can process all of
+them one after another, unattended, for example overnight.
+
+
+
+1. At the top of the window, choose **Several recordings**.
+2. **Folder with recordings.** Click **Choose folder…** and select the folder that *contains* the recording folders,
+ for example a copy of the scanner's USB stick. You can also drag it onto the window. Easy Forest HDMapping looks in
+ the folders directly inside it (not deeper). If the folder you chose is itself a recording, it is included too.
+3. **Output folder.** Easy Forest HDMapping suggests `Documents/Easy Forest HDMapping/`. Each
+ recording's results go into a folder of its own inside it.
+4. **Recordings.** A table lists every recording found:
+
+ | Column | Meaning |
+ |---|---|
+ | Include | Untick a recording to leave it out. Recordings that fail the check cannot be ticked. |
+ | Recording | The folder name. Point at it to see the full path. |
+ | Length | How long the walk was. |
+ | Check | **[OK]**, **[!]** (worth knowing, you can go on) or **[X]** (cannot be processed). Point at it for the details; problems are also listed in words below the table. **already done** means this recording was finished in an earlier run and will be skipped. |
+ | Output folder name | The name of this recording's results folder. You can change it. Two recordings cannot use the same name (upper and lower case count as the same). |
+
+ Folders that are not recordings are listed under **folders skipped**, with the reason. Below the table, the disk
+ check tells you how much space the whole batch needs: the temporary space of the largest recording (recordings are
+ processed one at a time, and each frees its temporary space when it is done) plus the results of all of them.
+5. **Process.** Click **Start processing**. You see the progress of the whole batch with the time left, which
+ recording is being processed, and the status of each recording in the table. Your computer will not go to sleep
+ while the batch runs (the screen may still turn off). Leave the power adapter connected.
+
+ 
+
+If a recording fails, it is marked **failed** and the batch goes on with the next one. Point at **failed** in the table
+to see why; the recording's own `easy_gui.log` has the details.
+
+**Stopping.** **Stop after this recording** lets the current recording finish and then stops. **Stop now** stops
+immediately and discards the current recording; recordings already finished are kept.
+
+**Continuing a stopped batch.** Choose the same folder with recordings and the same output folder again. Recordings
+that were finished show **already done** and are skipped; the others are processed, including the ones that failed
+before. This also works after a power cut or a crash. (Each finished recording's folder contains a small file,
+`easy_gui_done.txt`, that marks it as finished. Delete that file to process the recording again.)
+
+**What you get.** The output folder contains one folder per recording, each with the files described in
+[What you get](#what-you-get), plus two files about the whole batch:
+
+| File | What it is |
+|---|---|
+| `batch_summary.csv` | One row per recording: recording folder, output folder name, status (`succeeded`, `failed`, `already_done`, `cancelled`, `not_run`, `excluded`, `skipped`), processing time in seconds, number of raw points, number of points in the point cloud, results folder, and the error message if any. Opens in any spreadsheet program. |
+| `batch.log` | What happened in the batch, with times. |
+
+**Without the window.** The batch can also be started from a terminal, for example on a computer without a screen:
+
+```
+easy_gui --batch
+```
+
+On a Mac the program is `"/Applications/Easy Forest HDMapping.app/Contents/MacOS/easy_gui"`; on Windows,
+`easy_gui.exe` in the folder you extracted. It follows the same rules as the window: every recording that passes the
+check is processed, and finished ones are skipped. Press Ctrl+C once to stop after the current recording, twice to stop
+now. The program ends with exit code 0 if every recording succeeded or was already done, and 1 otherwise.
+
+## What you get
+
+The output folder contains:
+
+| File | What it is |
+|---|---|
+| `point_cloud.laz` | The point cloud. Opens in CloudCompare, QGIS, LAStools, lidR and most other point cloud software. |
+| `trajectory.csv` | The path of the scanner: where it was and which way it pointed, about 200 times a second. Opens in any spreadsheet program. |
+| `README.txt` | A description of both files, with the numbers of this particular run. |
+| `easy_gui.log` | A record of everything that happened during processing. Useful when asking for help. |
+| `work/` | Intermediate results. `work/lio_session/session.mjs` can be opened in the full HDMapping apps to improve the result further or to give it real-world coordinates. |
+
+### The point cloud (`point_cloud.laz`)
+
+Every point has these values:
+
+| Field | Meaning |
+|---|---|
+| X, Y, Z | Position in metres. (0, 0, 0) is where you started walking; Z points up. |
+| intensity | How strongly the surface reflected the laser. Bright, smooth surfaces give high values. |
+| gps_time | When the point was measured, in seconds since 1 January 1970 (UTC). Despite the name, this is not GPS time. |
+| point_source_id | Which roughly 5-second piece of the walk the point belongs to. |
+| user_data | Which lidar measured the point (always the same, since Easy Forest HDMapping handles one lidar). |
+| **range_m** | **Distance from the scanner to the point when it was measured, in metres.** Far points are less precise than near ones, so this is useful for keeping only points measured from close by, for example only points closer than 15 m for measuring stem diameters. |
+| **point_origin** | **Where the point comes from:** |
+| | **0** — the main points, placed by HDMapping's motion tracking (lidar odometry). Most of the cloud. |
+| | **1** — close-range points. HDMapping's motion tracking ignores everything within 1 m of the scanner; Easy Forest HDMapping adds these points back afterwards (from the "Closest points kept" distance outwards), so the ground and stems right next to your path are not missing. |
+| | **2** — filled-in points. HDMapping's motion tracking works in tenth-of-a-second pieces and skips a few milliseconds between them; Easy Forest HDMapping adds the points measured in those short gaps. |
+
+Points 1 and 2 line up with the main points typically within a millimetre, and in 99 % of cases within about 1 cm.
+You can switch them off by filtering on `point_origin`, for example to see exactly what HDMapping alone would have
+produced.
+
+### The trajectory (`trajectory.csv`)
+
+One row per scanner position, with a header row. The most useful columns:
+
+| Column | Meaning |
+|---|---|
+| timestamp_unix_s | Time, in the same seconds as the point cloud's gps_time. |
+| x, y, z | Position of the scanner in metres, in the same coordinates as the point cloud. |
+| qw, qx, qy, qz | Which way the scanner pointed, as a quaternion. |
+| om_deg, fi_deg, ka_deg | The same direction as three angles in degrees. |
+| scan_index | Matches point_source_id in the point cloud. |
+
+`README.txt` describes the remaining columns.
+
+### Colouring the point cloud by a field in CloudCompare
+
+[CloudCompare](https://www.cloudcompare.org) is free and opens `point_cloud.laz` directly.
+
+1. **File → Open**, choose `point_cloud.laz`. In the dialog that lists the fields, keep everything ticked and click
+ **Apply all**.
+2. Click the point cloud in the **DB Tree** panel on the left.
+3. In the **Properties** panel below it, find **Colors** and choose **Scalar field**.
+4. Just below, under **Scalar Fields**, set **Active** to `range_m` or `point_origin` (or `Intensity`).
+5. To keep only some points, for example only the main points: **Edit → Scalar fields → Filter By Value**, enter a
+ range (for example 0 to 0 for `point_origin`), and click **Export**. CloudCompare creates a new cloud with only
+ those points.
+
+## Troubleshooting
+
+The check (step 3) explains problems in plain words. Here is what to do about each one.
+
+**Problems with the recording**
+
+| Message (shortened) | What to do |
+|---|---|
+| "The recording folder … does not exist or is not a folder." | Choose the folder again; the USB stick may have been unplugged. |
+| "This folder does not look like a Mandeye recording: there are no point cloud files / no IMU files." | You chose the wrong folder. Choose the folder that contains `lidar….laz` and `imu….csv` files, for example `continousScanning_0012`, not the folder above or below it. |
+| "There are N point cloud files but M IMU files. The unmatched file(s) at the end will not be processed." | Just information. Usually the scanner stopped between two files; the last few seconds are left out. |
+| "Some file numbers do not match." | A file is probably missing in the middle of the recording (for example after an incomplete copy). Copy the recording from the scanner again. If that does not help, the result may have a gap at that point. |
+| "These point cloud files cannot be read (damaged, or not fully copied from the scanner)." | Copy the recording again. If the damaged files are the last ones, you can also delete them from your copy. |
+| "This recording contains data from more than one lidar." / "…describes more than one lidar." / "…lists more than one lidar." | Easy Forest HDMapping handles single-lidar scanners only. Use the full HDMapping apps. |
+| "The calibration file … could not be read and will be ignored." | Just information, processing continues without it. If your scanner needs a calibration file, ask whoever set up the scanner for a correct one. |
+| "The calibration file … belongs to a different scanner." / "… does not name a valid IMU." | The calibration file in the folder does not match the scanner. Remove it from your copy of the recording, or replace it with the right one. |
+| "The IMU files could not be read." | The motion-sensor files (`imu….csv`) are damaged or empty. Copy the recording again. |
+| "The IMU and point cloud timestamps do not overlap." | The recording is damaged; the two kinds of files do not belong together. Copy it again from the scanner. |
+| "The recording is very short." | Just a warning. Processing needs some walking; recordings of less than about 20 seconds may give poor results. |
+
+**Problems with the output folder**
+
+| Message (shortened) | What to do |
+|---|---|
+| "Choose an output folder." | Click **Choose output folder…**. |
+| "The output folder is inside the recording folder." | Choose a folder somewhere else. Easy Forest HDMapping never writes into the recording. |
+| "The output folder … already contains files." | Choose a new or empty folder, or empty this one first. |
+| "Not enough free disk space …" | Free some space (the message says how much is needed), or choose an output folder on another disk with more space. |
+| "Free disk space is tight." | You can start, but make sure nothing else fills the disk while Easy Forest HDMapping works. |
+| "Could not determine the free disk space." | Usually harmless. Make sure the disk has plenty of space (see the rule of thumb above). |
+| "The output folder holds an unfinished earlier attempt." (several recordings) | Just information: the recording did not finish last time. Its old files are replaced; its log is continued. |
+| "Not enough free disk space for the batch …" (several recordings) | Free some space, choose an output folder on another disk, or untick some recordings and process them later. |
+| "The output folder is inside the recording …" (several recordings) | The folder with recordings is itself a recording, and the output folder is inside it. Choose an output folder somewhere else. |
+| Output folder name shown in red (several recordings) | The name is empty, contains a character a folder name cannot have (such as `/`, `\`, `:`, `*`, `?` or a vertical bar), or another recording uses it. Point at it to see which. |
+
+**Problems during processing**
+
+| Message | What to do |
+|---|---|
+| "Easy Forest HDMapping could not find the lidar odometry program …" | Some files of the installation are missing. Install Easy Forest HDMapping again and keep all its files together. |
+| "Lidar odometry stopped unexpectedly." | Most often the computer ran out of memory. Close other programs and try again. If it happens again, see [Getting help](#getting-help). |
+| "Could not create the output folder …" / "Could not write …" | The disk may be full, or you may not be allowed to write there. Choose another output folder. |
+
+**Mac: "“Easy Forest HDMapping” is damaged and can't be opened."** This sometimes happens with downloaded apps that
+are not signed. Open the **Terminal** app, paste the following line and press Enter, then open Easy Forest HDMapping again:
+
+```
+xattr -dr com.apple.quarantine "/Applications/Easy Forest HDMapping.app"
+```
+
+## Getting help
+
+Open an issue at and attach `easy_gui.log` from the
+output folder. Describe what you did and which message you saw.
+
+## Credits and license
+
+Easy Forest HDMapping is built on [HDMapping](https://github.com/MapsHD/HDMapping) by Janusz Będkowski and the MapsHD
+contributors. All the processing (motion tracking and point placement) is HDMapping's; Easy Forest HDMapping adds the window,
+the checks and the export. Easy Forest HDMapping and HDMapping are released under the [MIT License](../../LICENSE).
+
+If you use the results in your work, please cite HDMapping:
+
+```bibtex
+@article{BEDKOWSKI2024101618,
+ title = {Open source, open hardware hand-held mobile mapping system for large scale surveys},
+ journal = {SoftwareX},
+ volume = {25},
+ pages = {101618},
+ year = {2024},
+ issn = {2352-7110},
+ doi = {https://doi.org/10.1016/j.softx.2023.101618},
+ url = {https://www.sciencedirect.com/science/article/pii/S235271102300314X},
+ author = {Janusz Będkowski}
+}
+```
+
+How Easy Forest HDMapping works and how its results were checked is described in [docs/DESIGN.md](docs/DESIGN.md).
diff --git a/apps/easy_gui/docs/DESIGN.md b/apps/easy_gui/docs/DESIGN.md
new file mode 100644
index 00000000..17bc1ac1
--- /dev/null
+++ b/apps/easy_gui/docs/DESIGN.md
@@ -0,0 +1,552 @@
+# Easy GUI: design notes
+
+This document explains how Easy GUI works, why it is built the way it is, and how its output was validated. It is for
+collaborators and reviewers who know HDMapping. The user guide is [../README.md](../README.md).
+
+Naming: the user-facing name is **Easy Forest HDMapping** (window title, user guide, release packages). Internally,
+the code keeps the name Easy GUI (`apps/easy_gui`, the `easy_gui` binary and targets), and so does this document.
+
+Version: Easy Forest HDMapping has its own version (`EASY_FOREST_VERSION` in `apps/easy_gui/CMakeLists.txt`,
+equal to the `easy-forest-v*` tag). Window title, README.txt and easy_gui.log show both, e.g. "Easy Forest HDMapping
+0.2.0 (HDMapping 0.105.0)". The LAZ header's 32-character fields cannot hold that: generating software is
+"Easy Forest HDMapping 0.2.0", system identifier "HDMapping 0.105.0".
+
+Contents:
+
+1. [Goal and scope](#1-goal-and-scope)
+2. [Architecture](#2-architecture)
+3. [Background: Mandeye raw data](#3-background-mandeye-raw-data)
+4. [Background: what lidar odometry (step 1) does to points](#4-background-what-lidar-odometry-step-1-does-to-points)
+5. [Design decisions](#5-design-decisions)
+6. [The export](#6-the-export)
+7. [Validation](#7-validation)
+8. [Experimental option: place every point by its raw time](#8-experimental-option-place-every-point-by-its-raw-time)
+9. [Resources: time, memory, disk](#9-resources-time-memory-disk)
+10. [Batch mode](#10-batch-mode)
+11. [Status and next steps](#11-status-and-next-steps)
+
+---
+
+## 1. Goal and scope
+
+One window that takes a raw Mandeye recording folder, runs HDMapping's lidar odometry (step 1) with the precision
+forestry preset, and exports two files:
+
+- a LAZ point cloud with every per-point attribute that is available, and
+- a trajectory CSV with timestamp, position, orientation and every other available field.
+
+Constraints:
+
+- Built on the raylib + rlImGui GUI stack (like steps 2 and 3).
+- Reuse HDMapping's code; do not duplicate its algorithms. `core_math` is not modified, and the existing apps behave
+ as before.
+- Show progress and report errors in plain language.
+
+Since 0.2.0 it also processes several recordings in one go (batch mode, §10), each into its own output folder.
+
+Not in scope (v1): step 2 refinement, step 3 (multi-session), georeferencing (GNSS, control points), and rigs with
+more than one lidar. The pre-flight check refuses recordings with more than one lidar id.
+
+Planned for v2: pose-graph loop closure behind a toggle, using `PoseGraphLoopClosure` with automatically detected
+candidates, where an edge is rejected if its ICP fails. `PoseGraphSLAM::optimize` will not be used.
+
+## 2. Architecture
+
+```
+recording folder (read only)
+ │
+ ├─ pre-flight check ────────── file pairing, LAZ/IMU readability, lidar ids, calibration, clocks, disk space
+ │
+ ├─ staging ─────────────────── output/work/staging/: symlinks to the recording's files
+ │
+ ├─ lidar_odometry_step_1 ───── child process:
+ │ (forestry preset) progress parsed from its log
+ │
+ └─ export ──────────────────── point_cloud.laz, trajectory.csv, README.txt
+```
+
+Code in `apps/easy_gui`:
+
+| Part | Role |
+|---|---|
+| `easy_gui_core` (static library, no raylib) | Everything except the window, so the doctest suite can link it. |
+| `src/preflight.*` | Classifies files exactly like step 1's `load_data` does and checks them (see §3). |
+| `src/staging.*` | Builds the staging folder of symlinks (see §5.6). |
+| `src/child_process.*` | Runs step 1: POSIX `posix_spawn` with its own process group; Windows `CreateProcess` with a job object (built and tested on the Windows CI runner). |
+| `src/progress_parser.*` | Turns step 1's log lines into phases and a progress fraction. |
+| `src/forestry_preset.*` | Writes the step 1 parameter file with step 1's own `TomlIO`. |
+| `src/export.*`, `src/las14_writer.*` | The export (§6) and a streaming LAS 1.4 point format 6 LAZ writer with a hand-written extra-bytes VLR. |
+| `src/pipeline.*` | Worker thread that runs the stages above and publishes status for the GUI. |
+| `src/batch.*` | Batch mode (§10): discovery, output names, done markers, batch disk check, and `BatchRunner`, which runs `Pipeline` once per recording. |
+| `src/sleep_blocker.*` | Keeps the computer awake while a batch runs (§10.5). |
+| `src/easy_gui_main.cpp` | The window, plus the command-line modes below. |
+| `tests/` | `easy_gui_tests` (doctest). |
+| `scripts/check_export.py` | Acceptance checks for an export (§7). |
+
+`easy_gui_core` compiles two step 1 sources by path, like the other apps do: `lidar_odometry_utils.cpp` (for the
+calibration parser the pre-flight check uses) and `toml_io.cpp` (to write the parameter file and to read it back from
+the session). It is built with `WITH_GUI=0` and links `core_no_gui` (for `Session`) and `core_math`, not `core`.
+Because of that, `core_no_gui` is now always built; before, it was only built with tests, pybind or the CLI tools.
+
+**Why step 1 runs as a child process.** Crash isolation (step 1 calls `std::exit` on some input errors and opens a
+native message box on `bad_alloc`), and a real cancel: killing the process group stops it immediately and frees its
+~8.5 GB. Building `easy_gui` also builds `lidar_odometry_step_1` (`add_dependencies`). Easy GUI finds it next to its own
+executable, under the plain name or the `HDMAPPING_BINARY_PREFIX` name; `HDMAPPING_STEP1_EXECUTABLE` overrides the
+lookup.
+
+**Building only Easy GUI:** `cmake -B build -DHDMAPPING_EASY_GUI_ONLY=ON` builds and installs only `easy_gui`,
+`lidar_odometry_step_1` and the libraries they link (plus `easy_gui_tests` with `BUILD_TESTING=ON`). The default is
+off, which leaves the upstream build unchanged. On macOS this mode also sets `CMAKE_OSX_DEPLOYMENT_TARGET` to 13.0
+(unless given), the lowest version the code builds for; without it the minimum is the build machine's macOS version.
+
+Quirks of headless step 1 (`lidar_odometry_step_1 `) that Easy GUI handles:
+
+- The exit code is 0 even after a caught exception, so success is judged by the presence of `session.mjs`.
+- `load_data` waits on stdin before `std::exit` when the calibration does not match, so the child gets a closed stdin.
+ The pre-flight check repeats `load_data`'s calibration checks so that this case is refused before step 1 starts.
+- Progress exists only as log lines: `running iterations: i/n` during setup and `finished optimizing … - i / N …` per
+ odometry chunk.
+
+**Command-line modes** of `easy_gui`:
+
+| Mode | Purpose |
+|---|---|
+| `--headless ` | The whole pipeline without a window. Exit code 0 success, 1 failed or refused, 2 usage. |
+| `--batch ` | Batch mode without a window (§10). Exit code 0 when every selected recording succeeded or was already done, 1 otherwise or refused, 2 usage. Ctrl+C once = stop after current, twice = stop now. |
+| `--export-only ` | Only the export, on an existing step 1 session (about 2.5 min instead of about 22). |
+| `--check-band ` (with any of the above) | Test mode for the alignment check, see §7.3. |
+| `--place-by-raw-time` (with any of the above) | The experimental export option of §8. |
+| `--screenshot [recording \| --batch ]` | Renders the window for about 2 s, saves it and exits (for documentation). raylib saves into the working directory. `EASY_GUI_SCREENSHOT_START=1` starts the batch once it is ready and `EASY_GUI_SCREENSHOT_FRAME=` takes the picture at frame n (30 per second), to capture a running or finished batch. |
+
+**Output folder:**
+
+| Path | Content |
+|---|---|
+| `point_cloud.laz` | The point cloud (§6). |
+| `trajectory.csv` | The trajectory (§6.4). |
+| `README.txt` | Field descriptions, time base and near-point rule, generated with the run's own values. |
+| `easy_gui.log` | Everything, including step 1's full output. |
+| `work/easy_gui_step1.toml` | The parameters step 1 ran with. |
+| `work/lio_session/` | The step 1 session (`session.mjs` etc.). Kept, so it can be opened in step 2 for refinement or georeferencing. |
+| `work/staging/` | Removed after success unless "Keep intermediate files" is ticked. |
+| `easy_gui_done.txt` | Batch mode only: written once the result is complete (§10.4). |
+
+After a failure or a cancel, `work/` is removed, except that an export failure keeps `work/lio_session/`, since
+odometry took most of the run.
+
+## 3. Background: Mandeye raw data
+
+Verified on a real recording and in the Mandeye firmware source.
+
+**LAZ files** (`lidar*.laz`): LAS 1.2, point format 1.
+
+- Real fields: X, Y, Z in the sensor frame, intensity (0–160 in practice), gps_time, and user_data = lidar id. There
+ are no extra bytes; every other standard field is 0.
+- gps_time is Unix epoch seconds. The header wrongly flags it as adjusted GPS time.
+- classification is a constant 20 and has no meaning: the firmware never copies the Livox point tag. A planned firmware
+ fix will store the tag there (bits 0–1 adhesion, 2–3 particles, 4–5 other).
+- About 15 % of the records are "no return" points: exactly (0, 0, 0) or gps_time 0.
+
+**IMU files** (`imu*.csv`, 200 Hz): timestamp (ns, the same clock as the LAZ gps_time), gyro XYZ, acc XYZ (g), imuId,
+and timestampUnix (ms, the host computer's clock, not synchronised with the others).
+
+**How step 1's `load_data` picks files** (the pre-flight check mirrors this):
+
+- It sorts all files, then pairs `lidar*.laz` with `imu*.csv` **by position**, and loads `min(#laz, #csv)` pairs. A
+ mismatch of the 4-digit index only prints a warning; surplus trailing files are skipped.
+- `status*` and `cam*` JSON files are ignored. Any other `.json`, or a `.mjc`, is taken as the calibration file. Only
+ the first `.sn` file is used.
+- It writes `cache/` and `preview/` into the folder that holds the input files.
+
+## 4. Background: what lidar odometry (step 1) does to points
+
+Verified in `compute_step_1` and `save_result` (`apps/lidar_odometry_step_1`). These facts drive most decisions in §5.
+
+**Timestamps are not preserved.** Step 1 works in worker chunks of 20 trajectory poses (≈ 0.1 s). In each chunk it
+overwrites every point's timestamp with a uniform ramp `t0 + k·(t_last − t0)/N` ("correct points timestamps" in
+`lidar_odometry.cpp`). Raw gps_time therefore cannot be joined to step 1's output points, with or without a tolerance.
+
+Output formats: gps_time in `scan_lio_N.laz` and the timestamps in `trajectory_lio_N.csv` are seconds × 1e9 (ns stored
+as a double, ≈ 256 ns resolution). `scan_lio_N.laz` holds only XYZ (in the frame of the scan's first trajectory node),
+intensity and gps_time; the lidar id is dropped in `points_to_vector`.
+
+**Distance filters:**
+
+- `filter_threshold_xy_inner` / `_outer` act at load time (`load_point_cloud`), so they remove points from the odometry
+ input *and* from the output. They measure the **horizontal** radius √(x² + y²) in the raw sensor frame.
+- `threshould_output_filter` is **output only** (`points_to_vector` in `save_result`, `fill_session_from_worker_data`;
+ the optimisers never read it) and measures the **3D** norm in the sensor frame.
+- Consequently an output filter below `filter_threshold_xy_inner` has no effect: every surviving point already has a 3D
+ norm ≥ its horizontal radius > inner.
+
+**Other points step 1 drops:** gps_time 0; points before the first `threshold_initial_points`; the gap
+`[t_last, t_next_first)` between consecutive worker chunks (≈ 1 of every 20 IMU intervals, about 5 % of the time); the
+first point of each chunk; chunks with ≤ 1000 points; the trailing poses that do not fill a whole chunk; and the last
+concatenated group if it has ≤ `min_counter_concatenated_trajectory_nodes` nodes.
+
+Voxel decimation (`params.decimation`) only thins the points used for optimisation; `scan_lio_N.laz` is written from
+the undecimated originals.
+
+**Per-point range is recoverable without the raw files:** `index_pose` = the last trajectory node with t_node < t_point
+(step 1's own rule, applied to the ramp time), then `p_sensor = csv_pose[index_pose]⁻¹ · p_laz`, both relative to the
+scan's first node. (`save_index_pose` would record the index directly, but it is not in `TomlIO`'s attribute list, so a
+parameter file cannot enable it.)
+
+## 5. Design decisions
+
+### 5.1 Output format: LAS 1.4, point format 6, extra bytes
+
+Point format 6 has a 64-bit gps_time and room for extra bytes. Two extra-bytes attributes are written:
+
+- `range_m` (float32): 3D distance from the lidar at the moment of measurement.
+- `point_origin` (uint8): where the point comes from (0 = step 1, 1 = near point, 2 = gap point, 3 = test mode only).
+
+The extra-bytes VLR is written by hand because `laszip_add_attribute` sets the scale bit, which makes readers such as
+laspy decode the fields as float64. With the hand-written VLR, CloudCompare 2.x loads both as named scalar fields and
+laspy reads them as float32 / uint8.
+
+`user_data` keeps the lidar id; `point_source_id` is the step 1 scan index (the `scan_lio_N.laz` number, about 5 s of
+recording).
+
+### 5.2 Time base
+
+`gps_time` is Unix epoch seconds, not GPS week time and not adjusted standard GPS time, so global encoding bit 0 (GPS
+time type) is 0. README.txt states this. For the default export, step 1's own points carry step 1's ramp time (§4),
+typically 1–4 ms and at most about 20 ms from the real measurement time; near and gap points carry their raw time.
+
+(Step 2's own `--export` writes gps_time × 1e18, because it multiplies the session's ns-valued times by 1e9 again.
+Easy GUI does not reproduce that.)
+
+### 5.3 The default export is "hybrid"
+
+The point cloud combines:
+
+- `point_origin = 0`: step 1's own points, placed exactly as step 1 placed them;
+- `point_origin = 1`: **near points**, raw points step 1's distance filters removed (§5.4);
+- `point_origin = 2`: **gap points**, raw points inside step 1's distance limits that step 1 skipped between its 0.1 s
+ pieces (§5.5).
+
+Near and gap points come from the raw files and are placed at their real gps_time with step 1's rule: the last
+trajectory node with t_node < t, if it is within 10 ms (step 1's own tolerance), so
+`global = mrp[scan] · csv_pose[node] · calib · p_raw`.
+
+Placing step 1's own points by raw time as well is possible (§8) but measured slightly less sharp, so it is only an
+experimental option.
+
+### 5.4 Near points
+
+Step 1 drops every point whose horizontal range is below 1.0 m, which leaves a hole around the walking path. Easy GUI
+adds those points back at export; odometry never sees them.
+
+Rule: keep raw points with `near_min_range` ≤ 3D range **and** that step 1's distance filters removed. As implemented
+this is the exact complement of step 1's filters: horizontal range ≤ `filter_threshold_xy_inner` (1.0 m) or 3D norm ≤
+`threshould_output_filter`, and horizontal range < `filter_threshold_xy_outer`. No-return points are always dropped.
+
+`near_min_range` defaults to 0.4 m (advanced setting "Closest points kept", 0.1–0.95 m). In measured raw data the
+operator's body and the handle show up only as small blobs inside 0.4 m, and near points beyond that are environment in
+every direction.
+
+**Output filter.** For the near rule to meet step 1's output without a gap, Easy GUI sets
+`threshould_output_filter = filter_threshold_xy_inner` (1.0 m instead of the preset's 1.5 m). Because the output filter
+is output only (§4), the trajectory is unchanged; this was confirmed byte for byte (§7.1). Points with horizontal range
+≥ 1.0 m then all come from step 1, and points below come from the near rule.
+
+### 5.5 Gap points
+
+Step 1 takes `[first node, last node)` of each 0.1 s piece, so the raw points between a piece's last node and the next
+piece's first node are never exported (about 5 % of the recording time). Easy GUI adds them back: a raw point inside
+step 1's distance limits is a gap point when the last trajectory node at or before its time is the last node of a
+piece, i.e. `j % threshold_nr_poses == threshold_nr_poses − 1` for node j within the scan.
+
+### 5.6 Never write into the recording
+
+The recording may be on the scanner's USB stick, so Easy GUI never writes into it. Because `load_data` writes `cache/`
+and `preview/` next to its input files (§3), step 1 runs on a staging folder of symlinks inside the Easy GUI output
+folder. The pre-flight check refuses an output folder inside the recording.
+
+### 5.7 Forestry preset
+
+The preset is `LidarOdometryParams` defaults plus the 18 overrides of "Presets → 2 … precise forestry" in
+`lidar_odometry_gui.cpp`. HDMapping has no shared preset function (the values are inline GUI code, also copied into the
+`precision_forestry` app), so `forestry_preset.cpp` repeats them and writes the TOML through step 1's own `TomlIO`.
+The only deviation is the output filter of §5.4.
+
+## 6. The export
+
+Source: `src/export.cpp`.
+
+### 6.1 Flow and memory
+
+1. Load the session with `Session` in `load_cache_mode`: poses from `session_poses.mrp` plus the trajectories, no
+ points. This is exactly what step 2 uses.
+2. Stream one `scan_lio_N.laz` at a time (step 1's points).
+3. Stream one raw LAZ at a time (near and gap points).
+
+The distance thresholds are read from the session's own `HDMapping_params_*.toml`, i.e. what step 1 actually ran with.
+Peak memory on the full test recording: 106 MB.
+
+### 6.2 Step 1's points
+
+These replicate step 2's `save_all_to_las`: `m_pose · (p − offset) + offset`, gps_time 0 skipped, scale 0.0001. The
+result is integer-identical to `multi_view_tls_registration_step_2 --export` (§7.2).
+
+`range_m` of a step 1 point is `|calib⁻¹ · csv_pose[node]⁻¹ · p_local|`, with the node chosen by step 1's rule on the
+ramp time.
+
+### 6.3 Point counts on the test recording
+
+10-minute forest walk, 115 file pairs, 115.9 M raw points of which 17.1 M are no-return records.
+
+| point_origin | Points |
+|---|---|
+| 0, step 1 | 89,326,756 |
+| 1, near | 4,038,530 |
+| 2, gap | 4,026,788 |
+| total | 97.39 M |
+
+point_cloud.laz 948 MB, trajectory.csv 20 MB (114,940 poses). Export time 143 s.
+
+148 step 1 points (0.0002 %) have a range of 70–99.7 m. These are steep returns (elevation up to 54°): step 1's 70 m
+limit is horizontal.
+
+### 6.4 Trajectory CSV
+
+One row per trajectory node: `timestamp_unix_s`, `x, y, z`, `qw, qx, qy, qz`, `om_deg, fi_deg, ka_deg` (HDMapping
+Tait-Bryan angles), `scan_index` (= `point_source_id`), `host_clock_unix_s` (the IMU file's host clock, ms resolution,
+not synchronised), and `imu_om_deg, imu_fi_deg, imu_ka_deg` (IMU-only orientation before odometry). Timestamps have
+1 µs resolution.
+
+## 7. Validation
+
+`scripts/check_export.py [--step2 ]` automates the checks below (fields, range, alignment, gap
+points, step 2 reference). It needs `laspy[lazrs]`, numpy and scipy. All numbers are from the test recording of §6.3.
+
+### 7.1 Easy GUI's step 1 run is the manual one
+
+Easy GUI (output filter 1.0 m) against a manual step 1 run with preset 2 (1.5 m): the trajectory is **byte-identical**
+(`session_poses.mrp`, `trajectory_lio_*.csv`). So step 1 is deterministic here, and the output filter is confirmed to be
+output only. `scan_lio` has 89.33 M points instead of 84.21 M (+5.11 M, +6.1 %), the points between 1.0 and 1.5 m.
+
+### 7.2 Step 1's points match step 2's export
+
+Against `multi_view_tls_registration_step_2 --export`: 89,326,756 points in both, max |ΔXYZ| = 0 (integer
+coordinates identical), intensity and point_source_id identical. The trajectory CSV matches step 2's `trajectories.csv`
+to 0.05 mm and 5e-10 in the quaternion.
+
+`range_m` of step 1's points, compared with the raw range on coincident pairs: p99.9 difference 0.07 mm.
+
+### 7.3 Near points line up with step 1's points
+
+Test mode `--check-band 1.2` also exports the raw points with horizontal range 1.0–1.2 m, which step 1 exported too,
+placed the near-point way and flagged `point_origin = 3`. Their nearest-neighbour distance to step 1's points:
+
+- median 0.10 mm overall;
+- for the 95.6 % that have a step 1 counterpart: p90 6.4 mm, p99 12.9 mm; 71 % land on the identical pose;
+- the other 4.4 % lie in step 1's inter-chunk gaps, so they have no counterpart.
+
+Rebuilding step 1's ramp from the raw files for scans 40–49 reproduces all 7,595,561 step 1 points (≤ 0.17 mm) and
+shows that the whole difference comes from step 1's ramp time choosing a different node than the real time would:
+|ramp − raw| p50 1.2 ms, p99 11.6 ms, max 17 ms, giving displacements of p90 9 mm, max 56 mm. Displacements of this
+size have a negligible effect on sharpness and stem diameters: placing all points by raw time moves them by the same
+mechanism and changes ground and stem thickness by less than 1 mm and diameters by a median of 0.4 mm (§8).
+
+### 7.4 Gap points sit where a missing slice of step 1's points would
+
+Per scan, against step 1's points of scans s−1 … s+1, gap points have a nearest-neighbour distance of median 19.4 mm,
+p99 187 mm. Control: step 1's own points from the 5 ms before each gap, queried without them: median 19.8 mm, p99
+192 mm. The same holds in every range bin (1–3 m: 10.6 vs 10.9 mm … 20+ m: 68.1 vs 68.2 mm).
+
+277 gap points are within 0.5 µs of a pose time; classifying points from trajectory.csv (1 µs resolution) needs a
+1 µs tolerance for them.
+
+## 8. Experimental option: place every point by its raw time
+
+`place_by_raw_time` (GUI advanced checkbox, `--place-by-raw-time`; default **off**) skips `scan_lio` and places every
+raw point inside step 1's distance limits from the raw files, with the last node before its real gps_time (within
+10 ms), and writes the raw gps_time. The near rule is unchanged.
+
+On the test recording this gives 93,359,291 points inside step 1's limits instead of 89,326,756 (+4,026,808 from step
+1's inter-chunk gaps, plus ~5.7 k first points of chunks).
+
+**Displacement against the default.** Same raw point in both versions (matched via the rebuilt ramp; both placements
+verified against the files to ≤ 0.17 mm): the same pose for 79.3 % of points, median displacement 0 in every range bin.
+p95 is 12 / 20 / 34 / 69 mm at 1–3 / 3–5 / 5–10 / 10–20 m (moved points only: median 8 / 13 / 23 / 47 mm, p95
+19 / 32 / 57 / 118 mm), which is a neighbouring node's rotation (~9 mrad).
+
+**Sharpness** (2026-10-17; the same 89,326,756 matched points in both versions, selections made on the mean of both
+positions, so the comparison is paired):
+
+| Test | Default | Raw time | Paired difference (raw − default) by distance from the path | Raw time better in |
+|---|---|---|---|---|
+| Ground: 5,376 cells of 1 × 1 m within 20 m of the walk, lowest points in a ±0.15 m band, orthogonal plane fit, residual std | median 65.73 mm | 66.14 mm | +0.06 / +0.08 / +0.15 / +0.61 mm at 0–3 / 3–5 / 5–10 / 10–20 m | 37 % of cells (Wilcoxon p ≈ 1e-112) |
+| Ground, ±0.05 m band | | | +0.05 … +0.24 mm | 41 % |
+| Stems: 17 automatically detected, visually checked trunks, circle fit at 1.2–1.4 m above local ground, RMSE | median 19.25 mm | 19.31 mm | +0.05 / +0.43 / +0.03 / +0.17 mm at 1–3 / 3–5 / 5–10 / 10–20 m | 3 of 17 (p = 0.06) |
+
+The ~66 mm ground thickness is mostly forest-floor roughness, which the paired design cancels. Stem diameters do not
+differ systematically: median |ΔD| 0.4 mm (< 2 mm within 10 m; −3 … +7 mm for one-sided arcs at 10–20 m).
+
+**Verdict:** the default (step 1's placement) is slightly sharper, by 0.1–0.6 mm on 20–60 mm thick surfaces. The most
+likely reason is that step 1 optimised the trajectory with its own ramp-time points. Hence the option stays off. (The
+ground and stem analysis scripts are not in the repository.)
+
+## 9. Resources: time, memory, disk
+
+Measured on the test recording (117 M raw points, 10-minute walk):
+
+- Step 1 takes ~21 min with a peak memory footprint of 8.5 GB; the export ~2.5 min (143 s) with ~106 MB.
+- Step 1 needs ~108 B per raw point of temporary disk at its peak (cache 7.5 GB + chunk caches ~4 GB + `scan_lio` for
+ 117 M points).
+- Results are 2.14× the raw LAZ size: session 0.82× + point_cloud.laz 1.29× (with near and gap points) +
+ trajectory.csv 0.03×.
+
+The pre-flight check therefore estimates 120 B per raw point of temporary space plus 2.25× the raw LAZ size for results,
+always reports the breakdown, warns below 1.5× the estimate and refuses below 1×.
+
+## 10. Batch mode
+
+"Several recordings" in the window and `--batch ` process many recordings one after another,
+for example the plots of a field day overnight. Each recording runs through the unchanged `Pipeline` (pre-flight
+re-check, staging, step 1 as a child process, export, clean-up), so its output folder is exactly that of a single run.
+
+```
+/
+ batch.log what happened, with times (flushed per line)
+ batch_summary.csv one row per recording and per skipped folder, rewritten after every recording
+ / one per recording: the files of §2 (+ easy_gui_done.txt once finished)
+```
+
+### 10.1 Finding recordings
+
+`discoverRecordings` looks at the parent's direct subfolders, plus the parent itself (listed first, so a single
+recording folder also works). A recording is a folder in which `classifyRecordingFiles` finds at least one point cloud
+and one IMU file; the pre-flight check judges the rest. It is deliberately not recursive: a recording is the folder the
+scanner wrote, and a deeper search would also find step 1's own `cache/`/`preview/` folders of old manual runs and
+Easy GUI's staging links. Other subfolders are listed with a reason ("No IMU files", "Easy Forest HDMapping results,
+not a recording", "Recordings are only looked for one folder level down"). Hidden and system folders (`.*`, `$*`,
+`System Volume Information`) are left out silently.
+
+### 10.2 Output names
+
+The default is the recording's folder name, made unique ignoring ASCII case (the default file systems of macOS and
+Windows are case-insensitive) by appending `_2`, `_3`, ... A parent that is a recording and contains a recording of
+the same name is the common collision. A name is also passed over when its folder already holds files other than this
+recording's finished result or an unfinished attempt. Edited names are validated for both systems (no
+`/ \ : * ? " < > |` or control characters, no trailing dot or space, not `.`/`..`, not a Windows device name such as
+`CON` or `COM1`) and must be unique among the recordings that will run.
+
+The default output root is `Documents/Easy Forest HDMapping/`. Unlike single mode it is reused when
+it holds an earlier batch (it contains `batch_summary.csv`), so that choosing the same parent folder again continues
+the batch.
+
+The batch refuses an output root inside any recording (`batchOutputProblem`): `batch.log` and the summary would be
+written there. The recordings' own output folders are covered by the per-recording check (§5.6).
+
+### 10.3 Checks before the start
+
+Every recording gets the single-mode pre-flight check against its own output folder, row by row on a background thread
+in the window (the recordings may be on a slow USB stick). A recording that fails cannot be selected; one that is
+already done (§10.4) is shown as such and skipped. The batch disk check needs
+
+ the largest temporary need of any one selected recording + the results of all selected recordings,
+
+because the recordings run one at a time and each frees its temporary space (§9) when it ends. Thresholds are those
+of single mode: refused below the estimate, a warning below 1.5×. Disk space is checked again before every recording
+(the pipeline repeats its pre-flight check), so a disk that fills up during the batch fails the affected recordings
+with the usual message instead of crashing step 1.
+
+### 10.4 Failures, done markers and restarting
+
+- A recording that fails (check, step 1 or export) is marked failed with the pipeline's plain-language message, and the
+ batch continues. Its output folder keeps what single mode keeps after a failure: the log, and the step 1 session if
+ only the export failed.
+- A recording that succeeds gets `easy_gui_done.txt`: version, recording path, a fingerprint, finish time, duration,
+ raw and exported point counts. The fingerprint is the number of file pairs, the total size of the paired files and
+ the first IMU timestamp. It identifies the recording without its path, because a USB stick can be mounted under
+ another name or drive letter after a restart; the IMU timestamp separates equally large recordings.
+- Restarting the same batch (same parent, same output root) skips recordings whose marker matches. Their summary rows
+ say `already_done` and take duration and point counts from the marker.
+- A recording whose output folder holds only Easy GUI's own run files (`easy_gui.log`, `work/`, `point_cloud.laz`,
+ `trajectory.csv`, `README.txt`) but no marker is an unfinished attempt (failed, stopped, power cut). The batch runs
+ the pipeline with `PipelineRequest::resume_unfinished`: the pre-flight check accepts the folder
+ (`runPreflight(..., resume_unfinished)`), and the pipeline removes everything in it except `easy_gui.log`, which it
+ continues. Any other file in the folder makes the check fail ("already contains files"), so nothing a user put
+ there is ever deleted. Single mode keeps refusing non-empty folders.
+- The summary is written to `batch_summary.csv.tmp` and renamed, after every recording, so it is never half-written
+ and is current even if the batch never ends normally. On Windows a summary that is open in Excel cannot be
+ replaced; the batch logs a warning and continues.
+
+Summary columns: `recording, output_name, status, duration_s, raw_points, exported_points, output_path, error`.
+`raw_points` counts all point records of the paired files (including no-return records), as the pre-flight check
+does; `exported_points` is the size of `point_cloud.laz`. Statuses: `succeeded`, `failed`, `already_done`,
+`cancelled` (stopped by "stop now"), `not_run` (the batch stopped before it), `excluded` (not selected, or its check
+failed; the error column says which) and `skipped` (not a recording).
+
+### 10.5 Running
+
+- **Stop after current** lets the running recording finish; the rest become `not_run`. **Stop now** cancels the
+ running recording exactly like single mode's cancel (child process killed, `work/` removed, log kept).
+- **Sleep.** The batch thread holds a `SleepBlocker` for its whole run. On macOS this is an IOKit assertion
+ `kIOPMAssertionTypePreventUserIdleSystemSleep` (shows up in `pmset -g assertions` as "Easy Forest HDMapping is
+ processing recordings"; checked, and released when the batch ends); on Windows
+ `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)`, which applies to the calling thread, so it is set and cleared on the batch thread. Both
+ prevent idle sleep only: the display may turn off, and closing a laptop lid still sleeps. Other platforms: no-op.
+- **Progress and ETA.** Each recording is weighted by its raw point count. Within a recording, odometry counts for
+ 90 % and the export for 10 % (§9: ~21 min against ~2.5 min). The ETA is the processing rate of the recordings that
+ really ran (already-done recordings take no time) applied to the remaining weight.
+- `BatchRunner` drives an abstract `RecordingJob`; the real one wraps `Pipeline`, and the tests inject a fake one.
+
+### 10.6 Tests
+
+Unit tests (`tests/test_batch.cpp`, fake `RecordingJob`): discovery (sorting, nested folders, non-recordings, hidden
+folders, parent that is a recording, with and without a trailing separator), output-name validation and collisions
+(case, invalid characters, foreign / unfinished / finished folders), done-marker round trip and path independence,
+batch disk arithmetic, output root inside a recording, failure-continues with summary and log contents, restart
+skipping finished recordings and retrying an unfinished one, a folder filled after the check, stop after current and
+stop now. `tests/test_preflight.cpp` covers `resume_unfinished` and keeps `holdsUnfinishedOutput` in sync with
+`OutputLayout`.
+
+Integration test (`EASY_GUI_TEST_RECORDING=mini4`): a parent with two copies of the recording (symlinked files), a
+recording that passes the check but on which step 1 fails (one file pair cut to 200 points), and a non-recording
+folder. Both copies succeed with identical point counts (2,566,166), the broken one fails with step 1's message, and a
+restart skips the two copies and retries the broken one. 62 s on an Apple silicon laptop; one `mini4` copy takes
+~30 s.
+
+## 11. Status and next steps
+
+- **M1 (done):** pre-flight check, staging, step 1 as a child process with progress and cancel.
+- **M2 (done):** export (§6).
+- **M3 (in progress):** validation script. `check_export.py` covers fields, range, alignment, gap points and the step 2
+ reference. Still open:
+ - Ramp-time error: for each odometry chunk, rebuild step 1's ramp from the raw points and compare with their real
+ gps_time, reported twice: as step 1 does it (no-return points removed by the load filter *before* the ramp) and
+ with those points kept, to quantify how much their removal contributes.
+ - Near points: counts, range histogram, and sensor-frame density below 1.5 m.
+- **M4 (done, released as 0.2.0):** batch mode (§10). The macOS side was run in the window and with
+ `--batch` on `mini4` copies (stop after current, restart, sleep assertion). The Windows code (`SetThreadExecutionState`,
+ Ctrl+C in `--batch`) compiles and its unit tests pass in CI (run 39163046329); not yet run on a real Windows PC.
+- **v2:** gated pose-graph loop closure (§1).
+- **Release builds:** `.github/workflows/easy-forest-{macos,windows}.yml`, on `easy-forest-v*` tags and manual
+ dispatch; the packaging steps are scripts in `apps/easy_gui/packaging/` so they run locally the same way.
+ - macOS (`macos-15` runner, Apple silicon, minimum 13.0): PROJ is built from source as a static library for 13.0
+ (`build_proj.sh`; core_math links it for GNSS, which Easy GUI never calls; Homebrew's is built for the runner's
+ macOS and drags in libtiff/curl). oneTBB without hwloc (`TBB_DISABLE_HWLOC_AUTOMATIC_SEARCH=ON`). OpenCV is not
+ configured at all in `HDMAPPING_EASY_GUI_ONLY` builds (only the camera apps link it). `make_app.py` builds
+ `Easy Forest HDMapping.app` (`easy_gui` main executable, `lidar_odometry_step_1` next to it in `Contents/MacOS`,
+ `liblaszip`, `libtbb`, `libglut` in `Contents/Frameworks`, rpaths rewritten), re-signs it ad hoc and fails unless
+ every bundled binary has minos <= 13.0 and references nothing outside the bundle, `/usr/lib` and `/System`. The
+ build also fails on the linker's "built for newer macOS version" warning, which a static library would only show
+ there. `make_dmg.sh` adds the Applications shortcut. Local and CI run: app 7.9 MB, DMG 3.6 MB; locally the bundled app
+ processed `mini4` headless.
+ - Windows (`windows-latest`, x64): `easy_gui.exe` is a WIN32-subsystem program (`/ENTRY:mainCRTStartup`, so `main`
+ stays), `--headless`/`--export-only` attach to the parent console. A manifest sets UTF-8 as the process code page,
+ because the file dialog, drag and drop and `ChildProcess` all use UTF-8. `HD_CPU_OPTIMIZATION=GENERIC`: `AUTO`
+ adds `/arch:AVX2` from the runner's CPU. `make_zip.py` collects the DLLs with `dumpbin /dependents`, copies the
+ MSVC runtime app-locally from VS's redist folder and fails on any DLL that is neither bundled nor part of Windows.
+ First CI run: folder 7.1 MB (two exes, `laszip3`, `freeglut`, `tbb12` and the MSVC runtime DLLs), zip 3.3 MB.
+ PROJ's DLL is not needed: nothing Easy GUI calls references it, so the linker drops the import.
+ - `easy_gui_tests` cover `ChildProcess` on Windows too (cmd.exe; argument quoting through `--echo-args`).
+ - No app icon yet (none exists for Easy GUI); no code signing or notarization (README explains the warnings).
+
+Tests: `easy_gui_tests` (doctest, synthetic recordings and sessions). The integration tests against the real step 1
+binary run only when `EASY_GUI_TEST_RECORDING=` is set; a folder of symlinks to the first 4 file
+pairs of a recording is enough.
diff --git a/apps/easy_gui/docs/images/easy_gui_batch.png b/apps/easy_gui/docs/images/easy_gui_batch.png
new file mode 100644
index 00000000..56767044
Binary files /dev/null and b/apps/easy_gui/docs/images/easy_gui_batch.png differ
diff --git a/apps/easy_gui/docs/images/easy_gui_batch_running.png b/apps/easy_gui/docs/images/easy_gui_batch_running.png
new file mode 100644
index 00000000..953024b0
Binary files /dev/null and b/apps/easy_gui/docs/images/easy_gui_batch_running.png differ
diff --git a/apps/easy_gui/docs/images/easy_gui_window.png b/apps/easy_gui/docs/images/easy_gui_window.png
new file mode 100644
index 00000000..64a33ffd
Binary files /dev/null and b/apps/easy_gui/docs/images/easy_gui_window.png differ
diff --git a/apps/easy_gui/packaging/macos/build_proj.sh b/apps/easy_gui/packaging/macos/build_proj.sh
new file mode 100755
index 00000000..d7c5dc86
--- /dev/null
+++ b/apps/easy_gui/packaging/macos/build_proj.sh
@@ -0,0 +1,44 @@
+#!/usr/bin/env bash
+# Builds a static PROJ for the Easy Forest HDMapping macOS release.
+#
+# Usage: build_proj.sh [work dir]
+#
+# Why: core_math links PROJ::proj (core/src/gnss.cpp, GNSS georeferencing, which Easy GUI never calls), so every
+# build needs one. Homebrew's libproj is built for the runner's own macOS version (and pulls in libtiff/curl/sqlite
+# dylibs built the same way), so a bundle using it would not start on older macOS. A static PROJ built here for the
+# deployment target leaves nothing to bundle: only the system libsqlite3 and libc++ are linked dynamically.
+# TIFF (grids) and curl (network grids) are off: neither is needed to compile/link core_math.
+set -euo pipefail
+
+PROJ_VERSION=9.6.2
+PROJ_SHA256=53d0cafaee3bb2390264a38668ed31d90787de05e71378ad7a8f35bb34c575d1
+DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-13.0}"
+
+prefix="${1:?usage: build_proj.sh [work dir]}"
+work="${2:-$(mktemp -d)}"
+mkdir -p "$work"
+prefix="$(cd "$(dirname "$prefix")" && pwd)/$(basename "$prefix")"
+
+tarball="$work/proj-$PROJ_VERSION.tar.gz"
+if [ ! -f "$tarball" ]; then
+ curl -sSLf -o "$tarball" "https://github.com/OSGeo/PROJ/releases/download/$PROJ_VERSION/proj-$PROJ_VERSION.tar.gz"
+fi
+echo "$PROJ_SHA256 $tarball" | shasum -a 256 -c -
+
+rm -rf "$work/proj-$PROJ_VERSION" "$work/build"
+tar -xzf "$tarball" -C "$work"
+
+cmake -S "$work/proj-$PROJ_VERSION" -B "$work/build" \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_INSTALL_PREFIX="$prefix" \
+ -DCMAKE_OSX_DEPLOYMENT_TARGET="$DEPLOYMENT_TARGET" \
+ -DBUILD_SHARED_LIBS=OFF \
+ -DBUILD_TESTING=OFF \
+ -DBUILD_APPS=OFF \
+ -DENABLE_TIFF=OFF \
+ -DENABLE_CURL=OFF \
+ -DEMBED_PROJ_DATA_PATH=OFF
+cmake --build "$work/build" --config Release -j"$(sysctl -n hw.ncpu)"
+cmake --install "$work/build" --config Release
+
+echo "PROJ $PROJ_VERSION installed to $prefix (macOS $DEPLOYMENT_TARGET, static)"
diff --git a/apps/easy_gui/packaging/macos/make_app.py b/apps/easy_gui/packaging/macos/make_app.py
new file mode 100755
index 00000000..62228d13
--- /dev/null
+++ b/apps/easy_gui/packaging/macos/make_app.py
@@ -0,0 +1,208 @@
+#!/usr/bin/env python3
+"""Builds "Easy Forest HDMapping.app" from a build/bin directory.
+
+Usage: make_app.py [--version X.Y.Z] [--min-macos 13.0]
+
+Layout:
+ Contents/MacOS/easy_gui main executable (CFBundleExecutable)
+ Contents/MacOS/lidar_odometry_step_1 started by easy_gui as a child process from its own directory
+ Contents/Frameworks/*.dylib every non-system dylib either executable needs, recursively
+
+Every bundled Mach-O refers to its bundled dependencies as @rpath/; the executables get the single rpath
+@executable_path/../Frameworks and the dylibs @loader_path, so nothing points back into the build machine. The bundle
+is then re-signed ad hoc (path rewriting invalidates the linker's signature, and arm64 refuses to run unsigned code)
+and checked: every bundled binary must have minos <= --min-macos and may only reference the bundle, /usr/lib and
+/System. Any violation fails the script, so a CI build cannot ship a bundle that does not start on the target macOS.
+"""
+
+import argparse
+import os
+import plistlib
+import re
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+APP_NAME = "Easy Forest HDMapping"
+BUNDLE_ID = "io.github.martinmokros.easyforesthdmapping"
+EXECUTABLES = ["easy_gui", "lidar_odometry_step_1"]
+SYSTEM_PREFIXES = ("/usr/lib/", "/System/")
+
+
+def run(*args):
+ return subprocess.run(args, check=True, capture_output=True, text=True).stdout
+
+
+def is_macho(path):
+ with open(path, "rb") as f:
+ magic = f.read(4)
+ return magic in (b"\xcf\xfa\xed\xfe", b"\xce\xfa\xed\xfe", b"\xca\xfe\xba\xbe")
+
+
+def load_commands(path):
+ """(dependencies, rpaths, dylib id, minimum macOS) from otool -l."""
+ deps, rpaths, dylib_id, minos = [], [], None, None
+ cmd = None
+ for line in run("otool", "-l", str(path)).splitlines():
+ line = line.strip()
+ if line.startswith("cmd "):
+ cmd = line.split()[1]
+ elif line.startswith("name ") and cmd in ("LC_LOAD_DYLIB", "LC_LOAD_WEAK_DYLIB", "LC_REEXPORT_DYLIB"):
+ deps.append(line[5:].rsplit(" (offset", 1)[0])
+ elif line.startswith("name ") and cmd == "LC_ID_DYLIB":
+ dylib_id = line[5:].rsplit(" (offset", 1)[0]
+ elif line.startswith("path ") and cmd == "LC_RPATH":
+ rpaths.append(line[5:].rsplit(" (offset", 1)[0])
+ elif line.startswith("minos ") and cmd == "LC_BUILD_VERSION":
+ minos = line.split()[1]
+ elif line.startswith("version ") and cmd == "LC_VERSION_MIN_MACOSX":
+ minos = line.split()[1]
+ return deps, rpaths, dylib_id, minos
+
+
+def expand(path, loader, executable):
+ return Path(path.replace("@loader_path", str(loader.parent)).replace("@executable_path", str(executable.parent)))
+
+
+def resolve(dep, macho, rpaths, executable):
+ """Where the build-tree binary `macho` really loads `dep` from."""
+ if dep.startswith("@rpath/"):
+ for rpath in rpaths:
+ candidate = expand(rpath, macho, executable) / dep[len("@rpath/"):]
+ if candidate.exists():
+ return candidate.resolve()
+ return None
+ candidate = expand(dep, macho, executable)
+ return candidate.resolve() if candidate.exists() else None
+
+
+def version_tuple(v):
+ return tuple(int(x) for x in v.split("."))
+
+
+def bundle(bin_dir, out_dir, version, min_macos):
+ app = out_dir / f"{APP_NAME}.app"
+ if app.exists():
+ shutil.rmtree(app)
+ macos_dir = app / "Contents" / "MacOS"
+ frameworks = app / "Contents" / "Frameworks"
+ resources = app / "Contents" / "Resources"
+ for d in (macos_dir, frameworks, resources):
+ d.mkdir(parents=True)
+
+ # Source binary -> bundled copy, walked breadth first so each dylib is resolved with the rpaths of the build-tree
+ # binary that loads it.
+ queue = []
+ for name in EXECUTABLES:
+ src = bin_dir / name
+ if not src.is_file():
+ sys.exit(f"error: {src} not found")
+ dst = macos_dir / name
+ shutil.copy2(src, dst)
+ queue.append((src, dst, src))
+
+ bundled = {} # resolved source path -> bundled name
+ while queue:
+ src, dst, executable = queue.pop(0)
+ deps, rpaths, _, _ = load_commands(src)
+ for dep in deps:
+ if dep.startswith(SYSTEM_PREFIXES):
+ continue
+ real = resolve(dep, src, rpaths, executable)
+ if real is None:
+ sys.exit(f"error: {src.name} needs {dep}, which cannot be found")
+ name = Path(dep).name
+ if real not in bundled:
+ if any(n == name for n in bundled.values()):
+ sys.exit(f"error: two different dylibs are both called {name}")
+ bundled[real] = name
+ lib_dst = frameworks / name
+ shutil.copy2(real, lib_dst)
+ lib_dst.chmod(0o755)
+ queue.append((real, lib_dst, executable))
+ print(f" bundling {real} as Frameworks/{name}")
+ if dep != f"@rpath/{bundled[real]}":
+ run("install_name_tool", "-change", dep, f"@rpath/{bundled[real]}", str(dst))
+ for rpath in rpaths:
+ run("install_name_tool", "-delete_rpath", rpath, str(dst))
+ new_rpath = "@executable_path/../Frameworks" if dst.parent == macos_dir else "@loader_path"
+ run("install_name_tool", "-add_rpath", new_rpath, str(dst))
+ if dst.parent == frameworks:
+ run("install_name_tool", "-id", f"@rpath/{dst.name}", str(dst))
+
+ with open(app / "Contents" / "Info.plist", "wb") as f:
+ plistlib.dump(
+ {
+ "CFBundleDevelopmentRegion": "en",
+ "CFBundleDisplayName": APP_NAME,
+ "CFBundleName": APP_NAME,
+ "CFBundleExecutable": "easy_gui",
+ "CFBundleIdentifier": BUNDLE_ID,
+ "CFBundleInfoDictionaryVersion": "6.0",
+ "CFBundlePackageType": "APPL",
+ "CFBundleShortVersionString": version,
+ "CFBundleVersion": version,
+ "LSMinimumSystemVersion": min_macos,
+ "LSApplicationCategoryType": "public.app-category.utilities",
+ "NSHighResolutionCapable": True,
+ "NSHumanReadableCopyright": "MIT License. Built on HDMapping by Janusz Będkowski and the MapsHD contributors.",
+ },
+ f,
+ )
+
+ # Only after all rewriting: install_name_tool invalidates signatures.
+ run("codesign", "--force", "--deep", "-s", "-", str(app))
+ return app
+
+
+def verify(app, min_macos):
+ errors = []
+ machos = [p for p in app.rglob("*") if p.is_file() and not p.is_symlink() and is_macho(p)]
+ frameworks = app / "Contents" / "Frameworks"
+ print(f"Checking {len(machos)} Mach-O files (minos <= {min_macos}):")
+ for p in sorted(machos):
+ deps, rpaths, _, minos = load_commands(p)
+ rel = p.relative_to(app)
+ print(f" {rel}: minos {minos}")
+ if minos is None:
+ errors.append(f"{rel}: no minimum macOS version in the load commands")
+ elif version_tuple(minos) > version_tuple(min_macos):
+ errors.append(f"{rel}: minos {minos} > {min_macos}")
+ for rpath in rpaths:
+ if not rpath.startswith(("@executable_path", "@loader_path")):
+ errors.append(f"{rel}: rpath {rpath} points outside the bundle")
+ for dep in deps:
+ if dep.startswith(SYSTEM_PREFIXES):
+ continue
+ if not dep.startswith("@rpath/") or not (frameworks / dep[len("@rpath/"):]).is_file():
+ errors.append(f"{rel}: dependency {dep} is not in Contents/Frameworks")
+ try:
+ run("codesign", "--verify", "--deep", "--strict", str(app))
+ except subprocess.CalledProcessError as e:
+ errors.append(f"codesign --verify failed: {e.stderr.strip()}")
+ return errors
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("bin_dir", type=Path)
+ parser.add_argument("out_dir", type=Path)
+ parser.add_argument("--version", default="0.0.0")
+ parser.add_argument("--min-macos", default="13.0")
+ args = parser.parse_args()
+
+ version = re.sub(r"^[^0-9]*", "", args.version) or "0.0.0"
+ args.out_dir.mkdir(parents=True, exist_ok=True)
+ app = bundle(args.bin_dir.resolve(), args.out_dir.resolve(), version, args.min_macos)
+ errors = verify(app, args.min_macos)
+ if errors:
+ print("\nBundle check FAILED:", file=sys.stderr)
+ for e in errors:
+ print(f" {e}", file=sys.stderr)
+ sys.exit(1)
+ print(f"\n{app} OK")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/apps/easy_gui/packaging/macos/make_dmg.sh b/apps/easy_gui/packaging/macos/make_dmg.sh
new file mode 100755
index 00000000..cec64205
--- /dev/null
+++ b/apps/easy_gui/packaging/macos/make_dmg.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+# Packs "Easy Forest HDMapping.app" into a DMG whose window shows the app next to an Applications shortcut, so
+# installing is one drag (README: Installation -> Mac).
+#
+# Usage: make_dmg.sh
+set -euo pipefail
+
+app="${1:?usage: make_dmg.sh }"
+dmg="${2:?usage: make_dmg.sh }"
+
+staging="$(mktemp -d)"
+trap 'rm -rf "$staging"' EXIT
+
+# ditto keeps the bundle's symlinks, permissions and signature intact.
+ditto "$app" "$staging/$(basename "$app")"
+ln -s /Applications "$staging/Applications"
+
+rm -f "$dmg"
+hdiutil create -volname "Easy Forest HDMapping" -srcfolder "$staging" -fs HFS+ -format UDZO -ov "$dmg"
+hdiutil verify "$dmg"
diff --git a/apps/easy_gui/packaging/windows/make_zip.py b/apps/easy_gui/packaging/windows/make_zip.py
new file mode 100755
index 00000000..f95dbac1
--- /dev/null
+++ b/apps/easy_gui/packaging/windows/make_zip.py
@@ -0,0 +1,128 @@
+#!/usr/bin/env python3
+"""Builds the Easy Forest HDMapping Windows folder and zip from a build/bin/Release directory.
+
+Usage: make_zip.py [--search-dir DIR ...]
+
+The folder holds easy_gui.exe, lidar_odometry_step_1.exe (started by easy_gui from its own directory) and every DLL
+they need, found recursively with `dumpbin /dependents`. The MSVC runtime (msvcp140, vcruntime140, ...) is copied
+app-locally from Visual Studio's redist directory: the build runner has it installed system-wide, a user's PC may not.
+Everything else must be part of Windows (api-ms-win-*, or present in System32 and not a VC runtime DLL); a DLL that
+can be found neither in the build nor in --search-dir fails the script. The zip contains the files at its top level
+(README: extract into a folder, double-click easy_gui.exe).
+"""
+
+import argparse
+import glob
+import os
+import re
+import shutil
+import subprocess
+import sys
+import zipfile
+from pathlib import Path
+
+EXECUTABLES = ["easy_gui.exe", "lidar_odometry_step_1.exe"]
+VC_RUNTIME = re.compile(r"^(msvcp\d+.*|vcruntime\d+.*|concrt\d+|vcomp\d+|vccorlib\d+)\.dll$", re.IGNORECASE)
+SYSTEM32 = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32"
+
+
+def vswhere(*args):
+ exe = Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")) / "Microsoft Visual Studio/Installer/vswhere.exe"
+ return subprocess.run([str(exe), "-latest", "-products", "*", *args], check=True, capture_output=True, text=True).stdout.strip()
+
+
+def find_tools():
+ vs = Path(vswhere("-property", "installationPath"))
+ dumpbin = sorted(glob.glob(str(vs / "VC/Tools/MSVC/*/bin/Hostx64/x64/dumpbin.exe")))
+ redist = sorted(glob.glob(str(vs / "VC/Redist/MSVC/*/x64")))
+ if not dumpbin or not redist:
+ sys.exit(f"error: dumpbin or the VC redist directory not found under {vs}")
+ # Highest version wins; the redist folder holds Microsoft.VC14x.CRT (and .OpenMP) subfolders.
+ crt_dirs = [Path(d) for d in glob.glob(str(Path(redist[-1]) / "Microsoft.VC*.CRT"))]
+ crt_dirs += [Path(d) for d in glob.glob(str(Path(redist[-1]) / "Microsoft.VC*.OpenMP"))]
+ return Path(dumpbin[-1]), crt_dirs
+
+
+def dependents(dumpbin, binary):
+ out = subprocess.run([str(dumpbin), "/nologo", "/dependents", str(binary)], check=True, capture_output=True, text=True).stdout
+ return [line.strip() for line in out.splitlines() if line.strip().lower().endswith(".dll") and " " not in line.strip()]
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("bin_dir", type=Path)
+ parser.add_argument("out_dir", type=Path)
+ parser.add_argument("zip", type=Path)
+ parser.add_argument("--search-dir", type=Path, action="append", default=[])
+ args = parser.parse_args()
+
+ dumpbin, crt_dirs = find_tools()
+ print(f"dumpbin: {dumpbin}\nVC runtime: {', '.join(map(str, crt_dirs))}")
+
+ if args.out_dir.exists():
+ shutil.rmtree(args.out_dir)
+ args.out_dir.mkdir(parents=True)
+
+ queue = []
+ for name in EXECUTABLES:
+ src = args.bin_dir / name
+ if not src.is_file():
+ sys.exit(f"error: {src} not found")
+ shutil.copy2(src, args.out_dir / name)
+ queue.append(args.out_dir / name)
+
+ search = [args.bin_dir, *args.search_dir]
+ copied, system, errors = set(), set(), []
+ while queue:
+ binary = queue.pop(0)
+ for dll in dependents(dumpbin, binary):
+ key = dll.lower()
+ if key in copied or key in system:
+ continue
+ if key.startswith(("api-ms-win-", "ext-ms-")):
+ system.add(key)
+ continue
+ if VC_RUNTIME.match(dll):
+ source = next((d / dll for d in crt_dirs if (d / dll).is_file()), None)
+ else:
+ source = next((d / dll for d in search if (d / dll).is_file()), None)
+ if source is None and (SYSTEM32 / dll).is_file():
+ system.add(key)
+ continue
+ if source is None:
+ errors.append(f"{binary.name} needs {dll}, which is neither in the build, a search dir nor Windows")
+ continue
+ shutil.copy2(source, args.out_dir / source.name)
+ copied.add(key)
+ print(f" {binary.name} -> {dll} (from {source.parent})")
+ queue.append(args.out_dir / source.name)
+
+ # The C++ runtime DLLs load each other dynamically in places (e.g. msvcp140 -> msvcp140_1/_2 for some
+ # features), so ship the whole CRT folder rather than only what dumpbin lists.
+ for d in crt_dirs:
+ if d.name.endswith(".CRT"):
+ for dll in d.glob("*.dll"):
+ if dll.name.lower() not in copied:
+ shutil.copy2(dll, args.out_dir / dll.name)
+ copied.add(dll.name.lower())
+
+ if errors:
+ print("\nPackage check FAILED:", file=sys.stderr)
+ for e in errors:
+ print(f" {e}", file=sys.stderr)
+ sys.exit(1)
+
+ print(f"\nSystem DLLs (not bundled): {', '.join(sorted(system))}")
+ print(f"Folder contents:")
+ for f in sorted(args.out_dir.iterdir()):
+ print(f" {f.name:40s} {f.stat().st_size / 1e6:8.2f} MB")
+
+ args.zip.unlink(missing_ok=True)
+ with zipfile.ZipFile(args.zip, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z:
+ for f in sorted(args.out_dir.iterdir()):
+ z.write(f, f.name)
+ print(f"\n{args.zip}: {args.zip.stat().st_size / 1e6:.1f} MB")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/apps/easy_gui/scripts/check_export.py b/apps/easy_gui/scripts/check_export.py
new file mode 100644
index 00000000..b78eb59b
--- /dev/null
+++ b/apps/easy_gui/scripts/check_export.py
@@ -0,0 +1,275 @@
+#!/usr/bin/env python3
+"""Acceptance checks for an Easy GUI export (point_cloud.laz + trajectory.csv).
+
+ check_export.py [--step2 ]
+
+Needs laspy with a LAZ backend, numpy and scipy (pip install "laspy[lazrs]" numpy scipy).
+
+Checks:
+ fields LAS 1.4 / PF6, GPS time type bit, named extra bytes (range_m float32, point_origin uint8),
+ point_source_id = scan index, user_data = lidar id, gps_time is Unix seconds.
+ range histogram of range_m; nothing below the near limit, nothing at the sensor origin.
+ alignment only with an export made with --check-band: nearest-neighbour distance from each check-band point
+ (point_origin 3: raw point placed the near-point way) to step 1's own points. A few mm median = same
+ transform chain.
+ gap points nearest-neighbour distance from each gap point (point_origin 2) to step 1's points of the same and the
+ neighbouring scans, next to a control: step 1's own points from the 5 ms before each gap, queried against
+ step 1's points without them (a correctly placed missing slice).
+ reference with --step2: the point_origin = 0 points against step 2's full-resolution export of the same session
+ (multi_view_tls_registration_step_2 --export ): count, XYZ, intensity, trajectory.
+Exit code 1 if a check fails.
+"""
+
+import argparse
+import sys
+from pathlib import Path
+
+import laspy
+import numpy as np
+from scipy.spatial import cKDTree
+
+CHUNK = 5_000_000
+failures = []
+
+
+def check(ok, message):
+ print((" ok " if ok else " FAIL ") + message)
+ if not ok:
+ failures.append(message)
+
+
+def check_fields(laz):
+ print("fields")
+ with laspy.open(laz) as f:
+ h = f.header
+ check(str(h.version) == "1.4" and h.point_format.id == 6, f"LAS {h.version}, point format {h.point_format.id}")
+ check(not h.global_encoding.gps_time_type, "global encoding bit 0 (GPS time type) = 0")
+ extra = {d.name: d for d in h.point_format.extra_dimensions}
+ check(set(extra) == {"range_m", "point_origin"}, f"extra bytes: {sorted(extra)}")
+ check(np.dtype(extra["range_m"].dtype) == np.float32, f"range_m is {np.dtype(extra['range_m'].dtype)}")
+ check(np.dtype(extra["point_origin"].dtype) == np.uint8, f"point_origin is {np.dtype(extra['point_origin'].dtype)}")
+ print(f" {h.point_count:,} points, bounds {h.mins.round(1)} .. {h.maxs.round(1)}, {h.generating_software}")
+
+
+def scan(laz):
+ """One streaming pass: per-flag counts, range histogram, sensor-origin and time checks, and the points needed for
+ the alignment check (step 1 points up to 3 m + check-band points), all with bounded memory. 3 m, not the band's
+ 1.2 m: the band limits the horizontal range, steep points reach ~2 m in 3D."""
+ stats = {"count": np.zeros(4, np.int64), "hist": np.zeros(1201, np.int64), "range_min": [np.inf] * 4,
+ "range_max": [-np.inf] * 4, "t_min": np.inf, "t_max": -np.inf, "psid": set(), "user_data": set(),
+ "origin": 0, "close_odo": [], "band": [], "big_range": []}
+ with laspy.open(laz) as f:
+ for p in f.chunk_iterator(CHUNK):
+ flag = np.asarray(p.point_origin)
+ r = np.asarray(p.range_m)
+ xyz = np.column_stack([p.x, p.y, p.z])
+ for k in range(4):
+ m = flag == k
+ if m.any():
+ stats["count"][k] += m.sum()
+ stats["range_min"][k] = min(stats["range_min"][k], float(r[m].min()))
+ stats["range_max"][k] = max(stats["range_max"][k], float(r[m].max()))
+ stats["hist"] += np.bincount(np.minimum((r / 0.1).astype(np.int64), 1200), minlength=1201)
+ t = np.asarray(p.gps_time)
+ stats["t_min"], stats["t_max"] = min(stats["t_min"], t.min()), max(stats["t_max"], t.max())
+ stats["psid"].update(np.unique(np.asarray(p.point_source_id)).tolist())
+ stats["user_data"].update(np.unique(np.asarray(p.user_data)).tolist())
+ stats["origin"] += int((np.abs(xyz).sum(axis=1) == 0).sum()) + int((r == 0).sum())
+ m = (flag == 0) & (r < 3.0)
+ stats["close_odo"].append((xyz[m], r[m]))
+ stats["band"].append(xyz[flag == 3])
+ far = r > 70
+ if far.any():
+ stats["big_range"].append(np.column_stack([r[far], xyz[far, 2], np.asarray(p.point_source_id)[far]]))
+ return stats
+
+
+def check_range(stats, near_min):
+ print("range")
+ names = ["step 1 (0)", "near point (1)", "gap point (2)", "check band (3)"]
+ for k in range(4):
+ if stats["count"][k]:
+ print(f" {names[k]:15s} {stats['count'][k]:>12,} points, range_m {stats['range_min'][k]:.3f} .. {stats['range_max'][k]:.3f} m")
+ check(min(stats["range_min"]) >= near_min - 1e-6, f"no range_m below {near_min} m (min {min(stats['range_min']):.4f})")
+ check(stats["origin"] == 0, f"no points at (0,0,0) or with range 0 ({stats['origin']})")
+ h = stats["hist"]
+ edges = [0, 0.4, 1.0, 1.5, 3, 10, 30, 50, 70, 80, 100, 120.1]
+ print(" range_m histogram:")
+ for a, b in zip(edges[:-1], edges[1:]):
+ n = h[int(round(a * 10)):int(round(b * 10))].sum()
+ print(f" {a:6.1f} - {b:6.1f} m {n:>12,} {100 * n / h.sum():6.2f} %")
+ beyond = h[700:].sum()
+ print(f" beyond 70 m: {beyond:,} points ({100 * beyond / h.sum():.4f} %)")
+ if stats["big_range"]:
+ # step 1's 70 m limit is horizontal, in the lidar frame: steep returns can be further away in 3D
+ big = np.concatenate(stats["big_range"])
+ print(f" (step 1 limits the horizontal range only; these are in {len(np.unique(big[:, 2]))} scans, "
+ f"z {big[:, 1].min():.1f} .. {big[:, 1].max():.1f} m)")
+
+
+def check_alignment(stats):
+ band = np.concatenate(stats["band"]) if stats["band"] else np.empty((0, 3))
+ if len(band) == 0:
+ return
+ print("alignment (check-band points vs step 1 points)")
+ band_r_max = stats["range_max"][3]
+ odo = np.concatenate([o[0][o[1] <= band_r_max + 0.01] for o in stats["close_odo"]])
+ d_mm = cKDTree(odo).query(band, k=1, workers=-1)[0] * 1000
+ q = np.percentile(d_mm, [50, 90, 99])
+ print(f" {len(band):,} check-band points vs {len(odo):,} step 1 points up to {band_r_max:.2f} m")
+ print(f" NN distance: median {q[0]:.2f} mm, p90 {q[1]:.2f}, p99 {q[2]:.2f}, max {d_mm.max():.1f} mm")
+ print(f" exactly coincident (< 0.15 mm, same trajectory pose as step 1): {100 * (d_mm < 0.15).mean():.1f} %")
+ check(q[0] < 5.0, f"median NN distance {q[0]:.2f} mm < 5 mm")
+
+
+def check_gaps(laz, folder):
+ """Gap points vs step 1's points, per scan (tree: step 1 points of scans s-1..s+1), next to a control: step 1's
+ points from the last node interval before each gap ([node 18, node 19) of every 20-node piece), queried against the
+ same tree without them."""
+ print("gap points (vs step 1 points)")
+ parts = []
+ with laspy.open(laz) as f:
+ for p in f.chunk_iterator(CHUNK):
+ o = np.asarray(p.point_origin)
+ m = (o == 0) | (o == 2)
+ parts.append((np.column_stack([p.x[m], p.y[m], p.z[m]]).astype(np.float32), o[m], np.asarray(p.point_source_id)[m],
+ np.asarray(p.gps_time)[m], np.asarray(p.range_m)[m]))
+ xyz, origin, psid, t, r = (np.concatenate([q[i] for q in parts]) for i in range(5))
+ del parts
+ if not (origin == 2).any():
+ check(False, "gap points present")
+ return
+ traj = np.genfromtxt(folder / "trajectory.csv", delimiter=",", names=True, usecols=(0, 11))
+ node_in_scan = np.zeros(len(traj), np.int64)
+ for s in np.unique(traj["scan_index"]):
+ m = np.where(traj["scan_index"] == s)[0]
+ node_in_scan[m] = np.arange(len(m))
+ node = np.searchsorted(traj["timestamp_unix_s"], t, side="left") - 1
+ control = (origin == 0) & (node_in_scan[np.maximum(node, 0)] % 20 == 18)
+ # trajectory.csv has 1 us time resolution, so points within 0.5 us of a pose may land on either side of it
+ tg = t[origin == 2]
+ at_or_before = np.searchsorted(traj["timestamp_unix_s"], tg + 1e-6, side="right") - 1
+ before = np.searchsorted(traj["timestamp_unix_s"], tg - 1e-6, side="left") - 1
+ piece_end = (node_in_scan[at_or_before] % 20 == 19) | (node_in_scan[np.maximum(before, 0)] % 20 == 19)
+ exact = node_in_scan[np.maximum(node[origin == 2], 0)] % 20 == 19
+ check(np.all(piece_end), f"every gap point lies after the last pose of a step 1 piece "
+ f"({(~exact).sum()} within the CSV's 1 us rounding of a pose time)")
+ order = np.argsort(psid, kind="stable")
+ bounds = np.searchsorted(psid[order], np.arange(psid.max() + 2))
+ d_gap = np.full(len(xyz), np.nan, np.float32); d_ctl = np.full(len(xyz), np.nan, np.float32)
+ for s in range(psid.max() + 1):
+ win = order[bounds[max(s - 1, 0)]:bounds[min(s + 2, len(bounds) - 1)]]
+ own = order[bounds[s]:bounds[s + 1]]
+ g = own[origin[own] == 2]; c = own[control[own]]
+ base = win[(origin[win] == 0)]
+ if len(g):
+ d_gap[g] = cKDTree(xyz[base]).query(xyz[g], workers=-1)[0]
+ if len(c):
+ d_ctl[c] = cKDTree(xyz[base[~control[base]]]).query(xyz[c], workers=-1)[0]
+ gm, cm = origin == 2, control
+
+ def row(label, m_g, m_c):
+ g = d_gap[m_g] * 1000; c = d_ctl[m_c] * 1000
+ print(f" {label:>8} {len(g):>10,} {np.median(g):>9.2f} {np.percentile(g, 99):>8.2f} {len(c):>10,} {np.median(c):>9.2f} {np.percentile(c, 99):>8.2f}")
+
+ print(f" {'range':>8} {'gap pts':>10} {'median mm':>9} {'p99 mm':>8} {'control':>10} {'median mm':>9} {'p99 mm':>8}")
+ for lo, hi in ((1, 3), (3, 5), (5, 10), (10, 20), (20, 100)):
+ row(f"{lo}-{hi} m", gm & (r >= lo) & (r < hi), cm & (r >= lo) & (r < hi))
+ row("all", gm, cm)
+ med_g, med_c = np.median(d_gap[gm]), np.median(d_ctl[cm])
+ check(med_g <= 1.2 * med_c + 0.0005, f"gap points as close to step 1's points as a missing slice of step 1's own "
+ f"(median {med_g * 1000:.2f} vs {med_c * 1000:.2f} mm)")
+
+
+def check_reference(laz, step2_dir):
+ print("reference (point_origin = 0 vs step 2 export)")
+ ref_file = step2_dir / "all_step_2.laz"
+ n_ref = laspy.open(ref_file).header.point_count
+ with laspy.open(laz) as f, laspy.open(ref_file) as g:
+ it_ref = g.chunk_iterator(CHUNK)
+ pending = None
+ n = 0
+ max_d = np.zeros(3)
+ intensity_diff = 0
+ psid_diff = 0
+ gps_scale = None
+ for p in f.chunk_iterator(CHUNK):
+ m = np.asarray(p.point_origin) == 0
+ if not m.any():
+ continue
+ ours = np.column_stack([p.x[m], p.y[m], p.z[m]])
+ ours_i = np.asarray(p.intensity)[m]
+ ours_t = np.asarray(p.gps_time)[m]
+ ours_s = np.asarray(p.point_source_id)[m]
+ k = 0
+ while k < len(ours):
+ if pending is None or len(pending[0]) == 0:
+ q = next(it_ref)
+ pending = (np.column_stack([q.x, q.y, q.z]), np.asarray(q.intensity), np.asarray(q.gps_time),
+ np.asarray(q.point_source_id))
+ take = min(len(ours) - k, len(pending[0]))
+ max_d = np.maximum(max_d, np.abs(ours[k:k + take] - pending[0][:take]).max(axis=0))
+ intensity_diff += int((ours_i[k:k + take] != pending[1][:take]).sum())
+ psid_diff += int((ours_s[k:k + take] != pending[3][:take]).sum())
+ if gps_scale is None:
+ gps_scale = pending[2][0] / ours_t[k]
+ pending = tuple(a[take:] for a in pending)
+ k += take
+ n += len(ours)
+ check(n == n_ref, f"point count {n:,} (step 2: {n_ref:,})")
+ check(max_d.max() <= 0.0002, f"max |dXYZ| {max_d * 1000} mm <= 0.2 mm")
+ check(intensity_diff == 0, f"intensity identical ({intensity_diff} differ)")
+ check(psid_diff == 0, f"point_source_id identical ({psid_diff} differ)")
+ print(f" step 2 gps_time / ours = {gps_scale:.6g} (step 2 writes the session's ns times x 1e9)")
+
+
+def check_trajectory(folder, step2_dir, stats):
+ print("trajectory")
+ traj = np.genfromtxt(folder / "trajectory.csv", delimiter=",", names=True)
+ print(f" {len(traj):,} poses, {traj['timestamp_unix_s'][0]:.6f} .. {traj['timestamp_unix_s'][-1]:.6f} s")
+ check(np.all(np.diff(traj["timestamp_unix_s"]) > 0), "timestamps strictly increasing")
+ check(traj["timestamp_unix_s"][0] <= stats["t_min"] + 0.02 and traj["timestamp_unix_s"][-1] >= stats["t_max"] - 0.02,
+ "trajectory spans the point times")
+ q = np.column_stack([traj["qw"], traj["qx"], traj["qy"], traj["qz"]])
+ check(np.abs(np.linalg.norm(q, axis=1) - 1).max() < 1e-6, "unit quaternions")
+ check(set(np.unique(traj["scan_index"]).astype(int).tolist()) == stats["psid"], "scan_index matches point_source_id")
+ if step2_dir:
+ ref = np.loadtxt(step2_dir / "trajectories.csv", delimiter=",")
+ check(len(ref) == len(traj), f"pose count {len(traj):,} (step 2: {len(ref):,})")
+ dt = np.abs(ref[:, 0] / 1e9 - traj["timestamp_unix_s"]).max()
+ dxyz = np.abs(ref[:, 2:5] - np.column_stack([traj["x"], traj["y"], traj["z"]])).max()
+ # step 2: qx qy qz qw; q and -q are the same rotation
+ rq = ref[:, [8, 5, 6, 7]]
+ dq = np.minimum(np.abs(rq - q).max(axis=1), np.abs(rq + q).max(axis=1)).max()
+ check(dt < 1e-6, f"timestamps match step 2 (max diff {dt * 1e6:.3f} us)")
+ check(dxyz <= 0.00005, f"positions match step 2 (max diff {dxyz * 1000:.3f} mm)")
+ check(dq < 1e-8, f"orientations match step 2 (max quaternion diff {dq:.2e})")
+ host = np.abs(ref[:, 1] / 1e3 - traj["host_clock_unix_s"]).max()
+ check(host < 1e-3, f"host clock matches step 2's Unix column / 1000 (max diff {host:.4f} s)")
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("folder", type=Path)
+ ap.add_argument("--step2", type=Path)
+ ap.add_argument("--near-min", type=float, default=0.4)
+ a = ap.parse_args()
+ laz = a.folder / "point_cloud.laz"
+ check_fields(laz)
+ stats = scan(laz)
+ check(stats["user_data"] and len(stats["user_data"]) == 1, f"user_data (lidar id) values {sorted(stats['user_data'])}")
+ print(f" point_source_id {min(stats['psid'])} .. {max(stats['psid'])} ({len(stats['psid'])} scans)")
+ check(1.5e9 < stats["t_min"] < stats["t_max"] < 2.2e9, f"gps_time in Unix seconds ({stats['t_min']:.3f} .. {stats['t_max']:.3f})")
+ check_range(stats, a.near_min)
+ check_alignment(stats)
+ check_gaps(laz, a.folder)
+ if a.step2:
+ check_reference(laz, a.step2)
+ check_trajectory(a.folder, a.step2, stats)
+ check((a.folder / "README.txt").is_file(), "README.txt present")
+ print(f"\n{'ALL CHECKS PASSED' if not failures else f'{len(failures)} CHECK(S) FAILED'}")
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/apps/easy_gui/src/batch.cpp b/apps/easy_gui/src/batch.cpp
new file mode 100644
index 00000000..99849eb3
--- /dev/null
+++ b/apps/easy_gui/src/batch.cpp
@@ -0,0 +1,802 @@
+#include "batch.h"
+
+#include "sleep_blocker.h"
+#include "version.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace fs = std::filesystem;
+
+namespace easy_gui
+{
+ namespace
+ {
+ constexpr size_t kLogTailLines = 400;
+ constexpr const char* kInvalidNameCharacters = "/\\:*?\"<>|";
+
+ std::string timestamp()
+ {
+ const std::time_t now = std::time(nullptr);
+ std::tm tm{};
+#ifdef _WIN32
+ localtime_s(&tm, &now);
+#else
+ localtime_r(&now, &tm);
+#endif
+ std::ostringstream out;
+ out << std::put_time(&tm, "%Y-%m-%d %H:%M:%S");
+ return out.str();
+ }
+
+ std::string lowerAscii(std::string s)
+ {
+ for (char& c : s)
+ c = static_cast(std::tolower(static_cast(c)));
+ return s;
+ }
+
+ bool hasRecordingFiles(const RecordingFiles& files)
+ {
+ return !files.laz_files.empty() && !files.csv_files.empty();
+ }
+
+ bool ignoredFolder(const std::string& name)
+ {
+ return name.empty() || name[0] == '.' || name[0] == '$' || name == "System Volume Information";
+ }
+
+ std::string notARecordingReason(const fs::path& folder, const RecordingFiles& files)
+ {
+ std::error_code ec;
+ if (fs::exists(folder / BatchRunner::kSummaryFileName, ec) || fs::exists(folder / "easy_gui.log", ec))
+ return "Easy Forest HDMapping results, not a recording.";
+ std::string reason = files.laz_files.empty() && files.csv_files.empty()
+ ? "No point cloud files (lidar*.laz) and no IMU files (imu*.csv)."
+ : files.laz_files.empty() ? "No point cloud files (lidar*.laz)."
+ : "No IMU files (imu*.csv).";
+ for (const auto& entry : fs::directory_iterator(folder, ec))
+ if (entry.is_directory(ec) && !ignoredFolder(entry.path().filename().string()))
+ {
+ reason += " Recordings are only looked for one folder level down.";
+ break;
+ }
+ return reason;
+ }
+
+ //! Folder name with the characters outputNameProblem() rejects replaced.
+ std::string sanitizedName(const std::string& name)
+ {
+ std::string out;
+ for (char c : name)
+ out += (static_cast(c) < 32 || std::strchr(kInvalidNameCharacters, c)) ? '_' : c;
+ while (!out.empty() && (out.back() == ' ' || out.back() == '.'))
+ out.pop_back();
+ if (!outputNameProblem(out).empty())
+ out = "recording_" + out;
+ return outputNameProblem(out).empty() ? out : "recording";
+ }
+
+ std::string csvField(const std::string& s)
+ {
+ if (s.find_first_of(",\"\r\n") == std::string::npos)
+ return s;
+ std::string out = "\"";
+ for (char c : s)
+ out += c == '"' ? std::string("\"\"") : std::string(1, c);
+ return out + "\"";
+ }
+
+ std::string formatSeconds(double s)
+ {
+ std::ostringstream out;
+ out << std::fixed << std::setprecision(0) << s;
+ return out.str();
+ }
+
+ class PipelineJob : public RecordingJob
+ {
+ public:
+ bool start(const PipelineRequest& request, std::string& error) override
+ {
+ return pipeline_.start(request, error);
+ }
+ bool busy() const override
+ {
+ return pipeline_.busy();
+ }
+ PipelineStatus status() const override
+ {
+ return pipeline_.status();
+ }
+ void cancel() override
+ {
+ pipeline_.cancel();
+ }
+ std::vector logTail() const override
+ {
+ return pipeline_.logTail();
+ }
+
+ private:
+ Pipeline pipeline_;
+ };
+ } // namespace
+
+ std::vector discoverRecordings(const fs::path& parent)
+ {
+ std::vector found;
+ std::error_code ec;
+ if (!fs::is_directory(parent, ec))
+ return found;
+
+ const RecordingFiles own = classifyRecordingFiles(parent);
+ if (hasRecordingFiles(own))
+ {
+ const fs::path normal = parent.lexically_normal();
+ const std::string name = normal.filename().empty() ? normal.parent_path().filename().string() : normal.filename().string();
+ found.push_back({ parent, name.empty() ? std::string("recording") : name, true, {} });
+ }
+
+ std::vector subfolders;
+ for (const auto& entry : fs::directory_iterator(parent, ec))
+ if (entry.is_directory(ec) && !ignoredFolder(entry.path().filename().string()))
+ subfolders.push_back(entry.path());
+ std::sort(subfolders.begin(), subfolders.end());
+ for (const auto& folder : subfolders)
+ {
+ const RecordingFiles files = classifyRecordingFiles(folder);
+ DiscoveredFolder d{ folder, folder.filename().string(), hasRecordingFiles(files), {} };
+ if (!d.is_recording)
+ d.skip_reason = notARecordingReason(folder, files);
+ found.push_back(std::move(d));
+ }
+ return found;
+ }
+
+ std::string outputNameProblem(const std::string& name)
+ {
+ if (name.empty())
+ return "Enter a name.";
+ if (name == "." || name == "..")
+ return "'" + name + "' cannot be used as a folder name.";
+ for (char c : name)
+ if (static_cast(c) < 32 || std::strchr(kInvalidNameCharacters, c))
+ return "A folder name cannot contain / \\ : * ? \" < > |.";
+ if (name.back() == ' ' || name.back() == '.')
+ return "A folder name cannot end with a space or a dot.";
+ if (name.size() > 120)
+ return "The name is too long.";
+ // Device names Windows reserves, with or without an extension.
+ const std::string stem = lowerAscii(name.substr(0, name.find('.')));
+ static const std::set kReserved = { "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4",
+ "com5", "com6", "com7", "com8", "com9", "lpt1", "lpt2", "lpt3",
+ "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9" };
+ if (kReserved.contains(stem))
+ return "'" + name + "' is reserved by Windows.";
+ return {};
+ }
+
+ bool sameOutputName(const std::string& a, const std::string& b)
+ {
+ return lowerAscii(a) == lowerAscii(b);
+ }
+
+ std::string recordingFingerprint(const RecordingFiles& files)
+ {
+ uint64_t bytes = 0;
+ std::error_code ec;
+ for (size_t i = 0; i < files.pairs; i++)
+ bytes += fs::file_size(files.laz_files[i], ec) + fs::file_size(files.csv_files[i], ec);
+ // The first IMU timestamp tells apart recordings of the same size (e.g. two equally long walks).
+ std::string first_sample;
+ if (!files.csv_files.empty())
+ {
+ std::ifstream imu(files.csv_files.front());
+ std::string line;
+ while (first_sample.empty() && std::getline(imu, line))
+ if (!line.empty() && std::isdigit(static_cast(line[0])))
+ first_sample = line.substr(0, line.find_first_of(" ,\t\r"));
+ }
+ return std::to_string(files.pairs) + " file pairs, " + std::to_string(bytes) + " bytes, first IMU sample " +
+ (first_sample.empty() ? "?" : first_sample);
+ }
+
+ std::vector defaultOutputNames(const std::vector& recordings, const fs::path& output_root)
+ {
+ std::vector names;
+ for (const auto& r : recordings)
+ {
+ const std::string base = sanitizedName(r.name);
+ const std::string fingerprint = recordingFingerprint(classifyRecordingFiles(r.path));
+ auto usable = [&](const std::string& candidate)
+ {
+ for (const auto& n : names)
+ if (sameOutputName(n, candidate))
+ return false;
+ if (output_root.empty())
+ return true;
+ const fs::path folder = output_root / candidate;
+ std::error_code ec;
+ if (!fs::exists(folder, ec) || fs::is_empty(folder, ec) || holdsUnfinishedOutput(folder))
+ return true;
+ const auto marker = readDoneMarker(folder);
+ return marker && marker->fingerprint == fingerprint;
+ };
+ std::string candidate = base;
+ for (int i = 2; !usable(candidate); i++)
+ candidate = base + "_" + std::to_string(i);
+ names.push_back(candidate);
+ }
+ return names;
+ }
+
+ bool writeDoneMarker(const fs::path& output_folder, const DoneMarker& m, std::string& error)
+ {
+ const fs::path file = OutputLayout(output_folder).done_marker;
+ std::ofstream out(file, std::ios::binary);
+ out << "# Easy Forest HDMapping: this folder holds a finished result. Batch mode skips the recording when the\n"
+ "# same batch is started again; delete this file to process it again.\n"
+ << "version=" << kEasyForestVersion << "\n"
+ << "recording=" << m.recording << "\n"
+ << "fingerprint=" << m.fingerprint << "\n"
+ << "finished=" << m.finished << "\n"
+ << "duration_s=" << formatSeconds(m.duration_s) << "\n"
+ << "raw_points=" << m.raw_points << "\n"
+ << "exported_points=" << m.exported_points << "\n";
+ out.close();
+ if (!out)
+ {
+ error = "Could not write '" + file.string() + "'.";
+ return false;
+ }
+ return true;
+ }
+
+ std::optional readDoneMarker(const fs::path& output_folder)
+ {
+ std::ifstream in(OutputLayout(output_folder).done_marker, std::ios::binary);
+ if (!in)
+ return std::nullopt;
+ DoneMarker m;
+ std::string line;
+ while (std::getline(in, line))
+ {
+ if (!line.empty() && line.back() == '\r')
+ line.pop_back();
+ const auto eq = line.find('=');
+ if (line.empty() || line[0] == '#' || eq == std::string::npos)
+ continue;
+ const std::string key = line.substr(0, eq), value = line.substr(eq + 1);
+ if (key == "recording")
+ m.recording = value;
+ else if (key == "fingerprint")
+ m.fingerprint = value;
+ else if (key == "finished")
+ m.finished = value;
+ else if (key == "duration_s")
+ m.duration_s = std::atof(value.c_str());
+ else if (key == "raw_points")
+ m.raw_points = std::strtoull(value.c_str(), nullptr, 10);
+ else if (key == "exported_points")
+ m.exported_points = std::strtoull(value.c_str(), nullptr, 10);
+ }
+ if (m.fingerprint.empty())
+ return std::nullopt;
+ return m;
+ }
+
+ BatchDiskNeed batchDiskNeed(const std::vector& recordings)
+ {
+ BatchDiskNeed need;
+ for (const PreflightResult* r : recordings)
+ {
+ need.work_bytes = std::max(need.work_bytes, r->estimated_work_bytes);
+ need.result_bytes += r->estimated_result_bytes;
+ }
+ return need;
+ }
+
+ Finding batchDiskFinding(const BatchDiskNeed& need, bool space_known, uint64_t available)
+ {
+ const std::string text = "about " + formatBytes(need.total()) + " (" + formatBytes(need.work_bytes) +
+ " temporary for the largest recording + " + formatBytes(need.result_bytes) + " of results for all of them)";
+ if (!space_known)
+ return { Severity::Warning, "Could not determine the free disk space. The batch needs " + text + "." };
+ if (available < need.total())
+ return { Severity::Error,
+ "Not enough free disk space for the batch: " + text + " needed, " + formatBytes(available) +
+ " free. Free some space, choose an output folder on another disk, or select fewer recordings." };
+ if (available < need.total() + need.total() / 2)
+ return { Severity::Warning,
+ "Free disk space is tight for the batch: " + text + " needed, " + formatBytes(available) + " free." };
+ return { Severity::Ok, "Disk space for the batch: " + text + " needed, " + formatBytes(available) + " free." };
+ }
+
+ std::string batchOutputProblem(const fs::path& output_root, const std::vector& folders)
+ {
+ if (output_root.empty())
+ return "Choose an output folder.";
+ for (const auto& f : folders)
+ if (f.is_recording && isSameOrInside(output_root, f.path))
+ return "The output folder is inside the recording '" + f.name +
+ "'. Choose a folder elsewhere: Easy Forest HDMapping never writes into a recording.";
+ return {};
+ }
+
+ BatchCheck checkBatchRecording(const fs::path& recording, const fs::path& output_folder)
+ {
+ BatchCheck check;
+ check.preflight = runPreflight(recording, output_folder, true);
+ check.fingerprint = recordingFingerprint(check.preflight.files);
+ if (auto marker = readDoneMarker(output_folder); marker && marker->fingerprint == check.fingerprint)
+ check.done = marker;
+ return check;
+ }
+
+ std::string firstError(const PreflightResult& result)
+ {
+ for (const auto& f : result.findings)
+ if (f.severity == Severity::Error)
+ return f.message;
+ return {};
+ }
+
+ std::vector BatchPlan::toProcess() const
+ {
+ std::vector out;
+ for (size_t i = 0; i < checks.size(); i++)
+ if (request.items[i].include && !checks[i].done)
+ out.push_back(&checks[i].preflight);
+ return out;
+ }
+
+ BatchPlan planBatch(const fs::path& parent, const fs::path& output_root)
+ {
+ BatchPlan plan;
+ plan.found = discoverRecordings(parent);
+ plan.request.output_root = output_root;
+ std::vector recordings;
+ for (const auto& f : plan.found)
+ (f.is_recording ? recordings : plan.request.skipped).push_back(f);
+ const auto names = defaultOutputNames(recordings, output_root);
+ for (size_t i = 0; i < recordings.size(); i++)
+ {
+ plan.checks.push_back(checkBatchRecording(recordings[i].path, output_root / names[i]));
+ const BatchCheck& check = plan.checks.back();
+ BatchItem item{ recordings[i].path, recordings[i].name, names[i] };
+ item.total_points = check.preflight.total_points;
+ if (!check.done && !check.preflight.ok())
+ {
+ item.include = false;
+ item.exclude_reason = "Check failed: " + firstError(check.preflight);
+ }
+ plan.request.items.push_back(std::move(item));
+ }
+ return plan;
+ }
+
+ const char* batchStateToken(BatchItemState state)
+ {
+ switch (state)
+ {
+ case BatchItemState::Excluded:
+ return "excluded";
+ case BatchItemState::Pending:
+ return "pending";
+ case BatchItemState::Running:
+ return "running";
+ case BatchItemState::Succeeded:
+ return "succeeded";
+ case BatchItemState::Failed:
+ return "failed";
+ case BatchItemState::Cancelled:
+ return "cancelled";
+ case BatchItemState::AlreadyDone:
+ return "already_done";
+ case BatchItemState::NotRun:
+ return "not_run";
+ }
+ return "";
+ }
+
+ double pipelineFraction(const PipelineStatus& s)
+ {
+ constexpr double kOdometryShare = 0.9;
+ if (s.state == PipelineState::Succeeded)
+ return 1.0;
+ switch (s.stage)
+ {
+ case PipelineStage::Checking:
+ case PipelineStage::Staging:
+ return 0.0;
+ case PipelineStage::Odometry:
+ return kOdometryShare * std::clamp(s.step1.overallFraction(), 0.0, 1.0);
+ case PipelineStage::Export:
+ return kOdometryShare + (1.0 - kOdometryShare) * std::clamp(s.export_fraction, 0.0, 1.0);
+ case PipelineStage::Cleanup:
+ return s.session_folder.empty() ? 0.0 : 1.0;
+ }
+ return 0.0;
+ }
+
+ // ------------------------------------------------------------------------------------------------------------
+
+ BatchRunner::BatchRunner(RecordingJobFactory factory)
+ : factory_(std::move(factory))
+ {
+ if (!factory_)
+ factory_ = []
+ {
+ return std::make_unique();
+ };
+ }
+
+ BatchRunner::~BatchRunner()
+ {
+ stopNow();
+ wait();
+ }
+
+ bool BatchRunner::start(const BatchRequest& request, std::string& error)
+ {
+ if (busy_)
+ {
+ error = "A batch is already running.";
+ return false;
+ }
+ wait();
+ std::error_code ec;
+ if (!fs::create_directories(request.output_root, ec) && ec)
+ {
+ error = "Could not create the output folder '" + request.output_root.string() + "' (" + ec.message() + ").";
+ return false;
+ }
+
+ request_ = request;
+ request_.settings.clamp();
+ {
+ std::lock_guard lock(mutex_);
+ status_ = BatchStatus{};
+ status_.running = true;
+ status_.items.resize(request_.items.size());
+ std::vector known;
+ for (const auto& item : request_.items)
+ if (item.include && item.total_points > 0)
+ known.push_back(item.total_points);
+ const double average =
+ known.empty() ? 1.0 : static_cast(std::accumulate(known.begin(), known.end(), uint64_t{ 0 })) / known.size();
+ weights_.assign(request_.items.size(), 0.0);
+ for (size_t i = 0; i < request_.items.size(); i++)
+ {
+ const auto& item = request_.items[i];
+ status_.items[i].output_folder = request_.output_root / item.output_name;
+ status_.items[i].raw_points = item.total_points;
+ if (!item.include)
+ {
+ status_.items[i].state = BatchItemState::Excluded;
+ status_.items[i].error = item.exclude_reason;
+ }
+ else
+ weights_[i] = item.total_points > 0 ? static_cast(item.total_points) : average;
+ }
+ processing_s_ = 0.0;
+ processed_weight_ = 0.0;
+ log_tail_.clear();
+ started_ = std::chrono::steady_clock::now();
+ }
+ busy_ = true;
+ thread_ = std::thread(&BatchRunner::run, this);
+ return true;
+ }
+
+ void BatchRunner::stopAfterCurrent()
+ {
+ std::lock_guard lock(mutex_);
+ status_.stop_after_current = true;
+ }
+
+ void BatchRunner::stopNow()
+ {
+ std::lock_guard lock(mutex_);
+ status_.stop_after_current = true;
+ status_.stop_now = true;
+ if (job_)
+ job_->cancel();
+ }
+
+ void BatchRunner::wait()
+ {
+ if (thread_.joinable())
+ thread_.join();
+ }
+
+ BatchStatus BatchRunner::status() const
+ {
+ std::lock_guard lock(mutex_);
+ BatchStatus s = status_;
+ if (s.running)
+ s.elapsed_s = std::chrono::duration(std::chrono::steady_clock::now() - started_).count();
+ return s;
+ }
+
+ std::vector BatchRunner::logTail() const
+ {
+ std::lock_guard lock(mutex_);
+ return { log_tail_.begin(), log_tail_.end() };
+ }
+
+ std::vector BatchRunner::currentLogTail() const
+ {
+ std::lock_guard lock(mutex_);
+ return job_ ? job_->logTail() : std::vector{};
+ }
+
+ void BatchRunner::log(const std::string& line)
+ {
+ const std::string stamped = "[" + timestamp() + "] " + line;
+ if (log_file_.is_open())
+ log_file_ << stamped << std::endl; // flushed: the log must survive a crash or power loss mid-batch
+ std::lock_guard lock(mutex_);
+ log_tail_.push_back(stamped);
+ while (log_tail_.size() > kLogTailLines)
+ log_tail_.pop_front();
+ }
+
+ void BatchRunner::updateProgress()
+ {
+ const double total = std::accumulate(weights_.begin(), weights_.end(), 0.0);
+ double done = 0.0;
+ for (size_t i = 0; i < weights_.size(); i++)
+ {
+ const BatchItemState st = status_.items[i].state;
+ if (st != BatchItemState::Pending && st != BatchItemState::Running)
+ done += weights_[i];
+ }
+ double current_done = 0.0, current_s = 0.0;
+ if (status_.current >= 0)
+ {
+ current_done = weights_[status_.current] * pipelineFraction(status_.current_status);
+ current_s = std::chrono::duration(std::chrono::steady_clock::now() - item_started_).count();
+ }
+ status_.fraction = total > 0.0 ? std::clamp((done + current_done) / total, 0.0, 1.0) : 1.0;
+
+ // ETA from the processing rate of the recordings that really ran (already-done ones take no time).
+ double remaining = 0.0;
+ for (size_t i = 0; i < weights_.size(); i++)
+ if (status_.items[i].state == BatchItemState::Pending)
+ remaining += weights_[i];
+ if (status_.current >= 0)
+ remaining += weights_[status_.current] - current_done;
+ const double processed = processed_weight_ + current_done;
+ status_.eta_s = (total > 0.0 && processed > 0.02 * total) ? (processing_s_ + current_s) * remaining / processed : -1.0;
+ }
+
+ void BatchRunner::run()
+ {
+ // Several hours of unattended processing: the computer must not go to sleep in between.
+ SleepBlocker no_sleep("Easy Forest HDMapping is processing recordings");
+
+ log_file_.open(request_.output_root / kLogFileName, std::ios::out | std::ios::app);
+ log("batch started: " + versionString());
+ log("output folder: " + request_.output_root.string());
+ log("lidar odometry program: " + request_.step1_executable.string());
+ {
+ std::ostringstream s;
+ s << "settings: near_min_range_m=" << request_.settings.near_min_range_m
+ << " place_by_raw_time=" << request_.settings.place_by_raw_time << " keep_work_files=" << request_.settings.keep_work_files;
+ log(s.str());
+ }
+ log(std::string("preventing system sleep: ") + (no_sleep.active() ? "yes" : "no (not supported or refused)"));
+ size_t selected = 0;
+ for (const auto& item : request_.items)
+ selected += item.include ? 1 : 0;
+ log(std::to_string(request_.items.size()) + " recordings found, " + std::to_string(selected) + " selected");
+ for (const auto& item : request_.items)
+ if (!item.include)
+ log("excluded: " + item.name + (item.exclude_reason.empty() ? "" : " (" + item.exclude_reason + ")"));
+ for (const auto& folder : request_.skipped)
+ log("skipped: " + folder.name + " (" + folder.skip_reason + ")");
+ writeSummary();
+
+ for (size_t i = 0; i < request_.items.size(); i++)
+ {
+ if (!request_.items[i].include)
+ continue;
+ bool stop = false;
+ {
+ std::lock_guard lock(mutex_);
+ stop = status_.stop_after_current;
+ if (stop)
+ status_.items[i].state = BatchItemState::NotRun;
+ }
+ if (!stop)
+ processItem(i);
+ writeSummary();
+ }
+
+ BatchStatus final_status = status();
+ size_t counts[8] = {};
+ for (const auto& r : final_status.items)
+ counts[static_cast(r.state)]++;
+ std::ostringstream summary;
+ summary << "batch " << (final_status.stop_after_current ? "stopped" : "finished") << " after "
+ << formatSeconds(final_status.elapsed_s) << " s: " << counts[static_cast(BatchItemState::Succeeded)] << " succeeded, "
+ << counts[static_cast(BatchItemState::Failed)] << " failed, " << counts[static_cast(BatchItemState::AlreadyDone)]
+ << " already done, " << counts[static_cast(BatchItemState::Cancelled)] << " cancelled, "
+ << counts[static_cast(BatchItemState::NotRun)] << " not run, " << counts[static_cast(BatchItemState::Excluded)]
+ << " excluded";
+ log(summary.str());
+ log_file_.close();
+ {
+ std::lock_guard lock(mutex_);
+ status_.elapsed_s = std::chrono::duration(std::chrono::steady_clock::now() - started_).count();
+ status_.running = false;
+ status_.current = -1;
+ updateProgress();
+ }
+ busy_ = false;
+ }
+
+ void BatchRunner::processItem(size_t index)
+ {
+ const BatchItem& item = request_.items[index];
+ const fs::path out = request_.output_root / item.output_name;
+ {
+ std::lock_guard lock(mutex_);
+ status_.current = static_cast(index);
+ status_.current_status = PipelineStatus{};
+ status_.items[index].state = BatchItemState::Running;
+ item_started_ = std::chrono::steady_clock::now();
+ }
+ log("recording " + item.name + ": " + item.recording.string() + " -> " + out.string());
+
+ auto finishItem = [&](BatchItemState state, const std::string& error, bool ran)
+ {
+ std::lock_guard lock(mutex_);
+ BatchItemResult& r = status_.items[index];
+ r.state = state;
+ r.error = error;
+ if (ran)
+ {
+ r.duration_s = std::chrono::duration(std::chrono::steady_clock::now() - item_started_).count();
+ processing_s_ += r.duration_s;
+ processed_weight_ += weights_[index];
+ }
+ status_.current = -1;
+ updateProgress();
+ };
+
+ const BatchCheck check = checkBatchRecording(item.recording, out);
+ {
+ std::lock_guard lock(mutex_);
+ status_.items[index].raw_points = check.preflight.total_points;
+ }
+ if (check.done)
+ {
+ {
+ std::lock_guard lock(mutex_);
+ status_.items[index].duration_s = check.done->duration_s;
+ status_.items[index].exported_points = check.done->exported_points;
+ }
+ finishItem(BatchItemState::AlreadyDone, "", false);
+ log("recording " + item.name + ": already done (finished " + check.done->finished + "), skipped");
+ return;
+ }
+ if (!check.preflight.ok())
+ {
+ const std::string error = firstError(check.preflight);
+ finishItem(BatchItemState::Failed, error, false);
+ log("recording " + item.name + ": FAILED the check: " + error);
+ return;
+ }
+
+ std::unique_ptr job = factory_();
+ PipelineRequest request{ item.recording, out, request_.step1_executable, request_.settings, request_.check_band_outer_m };
+ request.resume_unfinished = true;
+ std::string error;
+ if (!job->start(request, error))
+ {
+ finishItem(BatchItemState::Failed, error, true);
+ log("recording " + item.name + ": FAILED to start: " + error);
+ return;
+ }
+ {
+ std::lock_guard lock(mutex_);
+ job_ = job.get();
+ if (status_.stop_now) // stopNow() may have run before job_ was published
+ job_->cancel();
+ }
+ PipelineStage last_stage = PipelineStage::Checking;
+ while (job->busy())
+ {
+ std::this_thread::sleep_for(std::chrono::milliseconds(200));
+ const PipelineStatus s = job->status();
+ if (s.stage != last_stage && s.state == PipelineState::Running)
+ {
+ static const char* kStageNames[] = { "checking", "preparing", "lidar odometry", "export", "cleaning up" };
+ log("recording " + item.name + ": " + kStageNames[static_cast(s.stage)]);
+ last_stage = s.stage;
+ }
+ std::lock_guard lock(mutex_);
+ status_.current_status = s;
+ updateProgress();
+ }
+ const PipelineStatus s = job->status();
+ {
+ std::lock_guard lock(mutex_);
+ job_ = nullptr;
+ status_.current_status = s;
+ }
+ job.reset();
+
+ if (s.state == PipelineState::Succeeded)
+ {
+ const ExportStats& e = s.export_stats;
+ const uint64_t exported = e.odometry_points + e.near_points + e.gap_points + e.check_band_points;
+ DoneMarker marker{
+ item.recording.string(), check.fingerprint, timestamp(), s.elapsed_s, check.preflight.total_points, exported
+ };
+ std::string marker_error;
+ const bool marked = writeDoneMarker(out, marker, marker_error);
+ {
+ std::lock_guard lock(mutex_);
+ status_.items[index].exported_points = exported;
+ }
+ finishItem(BatchItemState::Succeeded, marked ? "" : marker_error, true);
+ log("recording " + item.name + ": succeeded in " + formatSeconds(s.elapsed_s) + " s, " + std::to_string(exported) +
+ " points exported" + (marked ? "" : "; " + marker_error));
+ }
+ else if (s.state == PipelineState::Cancelled)
+ {
+ finishItem(BatchItemState::Cancelled, s.message, true);
+ log("recording " + item.name + ": cancelled");
+ }
+ else
+ {
+ const std::string message = s.message.empty() ? "Processing failed." : s.message;
+ finishItem(BatchItemState::Failed, message, true);
+ log("recording " + item.name + ": FAILED: " + message + (s.detail.empty() ? "" : " (" + s.detail + ")"));
+ }
+ }
+
+ void BatchRunner::writeSummary()
+ {
+ const BatchStatus s = status();
+ const fs::path file = request_.output_root / kSummaryFileName;
+ const fs::path temporary = request_.output_root / (std::string(kSummaryFileName) + ".tmp");
+ {
+ std::ofstream out(temporary, std::ios::binary);
+ out << "recording,output_name,status,duration_s,raw_points,exported_points,output_path,error\n";
+ for (size_t i = 0; i < request_.items.size(); i++)
+ {
+ const BatchItem& item = request_.items[i];
+ const BatchItemResult& r = s.items[i];
+ std::error_code ec;
+ const bool has_output = r.state != BatchItemState::Excluded && fs::exists(r.output_folder, ec);
+ out << csvField(item.recording.string()) << ',' << csvField(item.output_name) << ',' << batchStateToken(r.state) << ','
+ << (r.duration_s > 0.0 ? formatSeconds(r.duration_s) : "") << ',' << (r.raw_points ? std::to_string(r.raw_points) : "")
+ << ',' << (r.exported_points ? std::to_string(r.exported_points) : "") << ','
+ << csvField(has_output ? r.output_folder.string() : "") << ',' << csvField(r.error) << '\n';
+ }
+ for (const auto& folder : request_.skipped)
+ out << csvField(folder.path.string()) << ",,skipped,,,,," << csvField(folder.skip_reason) << '\n';
+ out.close();
+ if (!out)
+ {
+ log("WARNING: could not write " + temporary.string());
+ return;
+ }
+ }
+ std::error_code ec;
+ fs::rename(temporary, file, ec);
+ if (ec) // e.g. the summary is open in a spreadsheet program on Windows
+ log("WARNING: could not update " + file.string() + " (" + ec.message() + ")");
+ }
+} // namespace easy_gui
diff --git a/apps/easy_gui/src/batch.h b/apps/easy_gui/src/batch.h
new file mode 100644
index 00000000..bf6b2109
--- /dev/null
+++ b/apps/easy_gui/src/batch.h
@@ -0,0 +1,265 @@
+#pragma once
+
+#include "pipeline.h"
+#include "preflight.h"
+#include "settings.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// Batch mode: several recordings, one after another, through the unchanged single-recording Pipeline. Each recording
+// gets its own output folder // with the usual result files; the output root also holds
+// batch.log and batch_summary.csv. See docs/DESIGN.md, "Batch mode".
+namespace easy_gui
+{
+ //! A folder found by discoverRecordings().
+ struct DiscoveredFolder
+ {
+ std::filesystem::path path;
+ std::string name; // folder name (the parent's own name when the parent itself is a recording)
+ bool is_recording = false;
+ std::string skip_reason; // plain language, when !is_recording
+ };
+
+ //! The recordings among `parent`'s direct subfolders, plus `parent` itself if it is a recording (listed first).
+ //! Not recursive: a recording is the folder the scanner wrote, and searching deeper would also find step 1's own
+ //! folders in old manual runs. A recording is a folder with at least one point cloud and one IMU file as
+ //! classifyRecordingFiles sees them; the pre-flight check judges the rest. Subfolders that are not recordings are
+ //! listed with the reason; hidden and system folders (".*", "$*", "System Volume Information") are left out.
+ std::vector discoverRecordings(const std::filesystem::path& parent);
+
+ //! Why `name` cannot be used as an output folder name, or empty if it can. It must be a single folder name that
+ //! is valid on both Windows and macOS.
+ std::string outputNameProblem(const std::string& name);
+
+ //! Output names are compared ignoring (ASCII) case: the default file systems of macOS and Windows do.
+ bool sameOutputName(const std::string& a, const std::string& b);
+
+ //! Identifies a recording independently of where it is mounted (a USB stick may get another drive letter):
+ //! number of file pairs, the total size of the paired files and the first IMU timestamp. Reads file sizes and the
+ //! first lines of one IMU file only.
+ std::string recordingFingerprint(const RecordingFiles& files);
+
+ //! Default output names: the folder names (characters invalid in a folder name replaced by '_'), made unique
+ //! ignoring case with _2, _3, ... A name is also passed over when its folder under `output_root` already holds
+ //! something other than this recording's finished result or an unfinished attempt.
+ std::vector defaultOutputNames(const std::vector& recordings, const std::filesystem::path& output_root);
+
+ //! Contents of easy_gui_done.txt, the marker batch mode writes into a recording's output folder once its result
+ //! is complete. A restarted batch skips recordings whose marker matches.
+ struct DoneMarker
+ {
+ std::string recording; // path at the time it was processed (informative)
+ std::string fingerprint; // recordingFingerprint(): what identifies the recording
+ std::string finished; // local time
+ double duration_s = 0.0;
+ uint64_t raw_points = 0;
+ uint64_t exported_points = 0;
+ };
+
+ bool writeDoneMarker(const std::filesystem::path& output_folder, const DoneMarker& marker, std::string& error);
+ std::optional readDoneMarker(const std::filesystem::path& output_folder);
+
+ //! Disk space a batch needs at its start: the recordings run one at a time and each frees its temporary files
+ //! when it ends, so only the largest temporary need counts, plus the results of all of them.
+ struct BatchDiskNeed
+ {
+ uint64_t work_bytes = 0;
+ uint64_t result_bytes = 0;
+ uint64_t total() const
+ {
+ return work_bytes + result_bytes;
+ }
+ };
+
+ BatchDiskNeed batchDiskNeed(const std::vector& recordings);
+
+ //! The batch disk finding, with the same thresholds as the single-recording check (error below the estimate,
+ //! warning below 1.5x). `available` is ignored when `space_known` is false.
+ Finding batchDiskFinding(const BatchDiskNeed& need, bool space_known, uint64_t available);
+
+ //! Why the batch cannot write into `output_root`, or empty: batch.log and batch_summary.csv go there, so it must
+ //! not be inside any recording (the per-recording check catches the recordings' own output folders).
+ std::string batchOutputProblem(const std::filesystem::path& output_root, const std::vector& folders);
+
+ //! Pre-flight result of one batch recording, as shown in the table.
+ struct BatchCheck
+ {
+ PreflightResult preflight; // checked with resume_unfinished, against /
+ std::string fingerprint;
+ std::optional done; // set if the output folder already holds this recording's finished result
+ };
+
+ BatchCheck checkBatchRecording(const std::filesystem::path& recording, const std::filesystem::path& output_folder);
+
+ //! First error of a pre-flight result (empty if none).
+ std::string firstError(const PreflightResult& result);
+
+ // ------------------------------------------------------------------------------------------------------------
+ // Running
+
+ enum class BatchItemState
+ {
+ Excluded, // not selected, or the check failed
+ Pending,
+ Running,
+ Succeeded,
+ Failed,
+ Cancelled, // stopped by "stop now" while it ran
+ AlreadyDone, // finished in an earlier run of the same batch
+ NotRun, // the batch was stopped before it got there
+ };
+
+ //! Token in batch_summary.csv ("succeeded", "already_done", ...).
+ const char* batchStateToken(BatchItemState state);
+
+ struct BatchItem
+ {
+ std::filesystem::path recording;
+ std::string name; // as discovered (shown and logged)
+ std::string output_name;
+ bool include = true;
+ std::string exclude_reason; // written to the summary when !include
+ uint64_t total_points = 0; // from the check; weights the overall progress (0 = unknown, use the average)
+ };
+
+ struct BatchRequest
+ {
+ std::filesystem::path output_root;
+ std::vector items;
+ std::vector skipped; // folders that are not recordings: only for the summary and the log
+ std::filesystem::path step1_executable;
+ Settings settings;
+ double check_band_outer_m = 0.0; // dev/test option, passed to every recording
+ };
+
+ struct BatchItemResult
+ {
+ BatchItemState state = BatchItemState::Pending;
+ double duration_s = 0.0;
+ uint64_t raw_points = 0;
+ uint64_t exported_points = 0;
+ std::string error;
+ std::filesystem::path output_folder;
+ };
+
+ struct BatchStatus
+ {
+ bool running = false;
+ std::vector items; // parallel to BatchRequest::items
+ int current = -1; // index of the item being processed
+ PipelineStatus current_status; // of the item being processed
+ double elapsed_s = 0.0;
+ double fraction = 0.0; // overall, 0..1, weighted by points
+ double eta_s = -1.0; // < 0 while unknown
+ bool stop_after_current = false;
+ bool stop_now = false;
+ };
+
+ //! Share of one recording's processing that is done, 0..1. Odometry is ~90 % of the time, the export the rest
+ //! (§9 of DESIGN.md: ~21 min odometry, ~2.5 min export).
+ double pipelineFraction(const PipelineStatus& status);
+
+ //! Processes one recording. The batch runs a Pipeline; tests use a fake.
+ class RecordingJob
+ {
+ public:
+ virtual ~RecordingJob() = default;
+ virtual bool start(const PipelineRequest& request, std::string& error) = 0;
+ virtual bool busy() const = 0;
+ virtual PipelineStatus status() const = 0;
+ virtual void cancel() = 0;
+ virtual std::vector logTail() const
+ {
+ return {};
+ }
+ };
+
+ using RecordingJobFactory = std::function()>;
+
+ //! Runs a batch on a worker thread, one recording after another. A failed recording is marked failed and the
+ //! batch goes on. Each finished recording gets a done marker; recordings with a matching marker are skipped, so
+ //! restarting the same batch continues where it stopped. batch_summary.csv is rewritten after every recording,
+ //! so it is current even if the batch never ends normally. System sleep is prevented while it runs.
+ //! What happens before "Start", done synchronously (for --batch and tests; the GUI checks row by row in the
+ //! background): discover, name, check. Recordings that fail the check are not selected (exclude_reason says
+ //! why); finished ones stay selected, so the summary lists them as already done.
+ struct BatchPlan
+ {
+ std::vector found; // everything discoverRecordings() returned
+ BatchRequest request; // items + skipped filled; the caller sets step1_executable and the settings
+ std::vector checks; // parallel to request.items
+
+ //! Pre-flight results of the recordings that will really be processed (selected, not yet done).
+ std::vector toProcess() const;
+ };
+
+ BatchPlan planBatch(const std::filesystem::path& parent, const std::filesystem::path& output_root);
+
+ class BatchRunner
+ {
+ public:
+ //! An empty factory runs the real Pipeline.
+ explicit BatchRunner(RecordingJobFactory factory = {});
+ ~BatchRunner(); // stops now and joins
+
+ bool start(const BatchRequest& request, std::string& error);
+ //! Lets the running recording finish, then stops.
+ void stopAfterCurrent();
+ //! Cancels the running recording (its unfinished results are removed, as in single mode) and stops.
+ void stopNow();
+ bool busy() const
+ {
+ return busy_.load();
+ }
+ //! Blocks until the worker thread has finished.
+ void wait();
+
+ BatchStatus status() const;
+ const BatchRequest& request() const
+ {
+ return request_; // only changed by start(), which refuses while busy
+ }
+ //! Last lines of batch.log, oldest first.
+ std::vector logTail() const;
+ //! Last lines of the running recording's own log.
+ std::vector currentLogTail() const;
+
+ static constexpr const char* kLogFileName = "batch.log";
+ static constexpr const char* kSummaryFileName = "batch_summary.csv";
+
+ private:
+ void run();
+ void processItem(size_t index);
+ void log(const std::string& line);
+ void writeSummary();
+ void updateProgress(); // called with mutex_ held
+
+ RecordingJobFactory factory_;
+ BatchRequest request_;
+ std::thread thread_;
+ std::atomic busy_{ false };
+
+ mutable std::mutex mutex_;
+ BatchStatus status_;
+ std::vector weights_; // per item, for the overall fraction
+ double processing_s_ = 0.0; // time spent in finished recordings that really ran (for the ETA)
+ double processed_weight_ = 0.0; // their weight
+ std::chrono::steady_clock::time_point started_;
+ std::chrono::steady_clock::time_point item_started_;
+ RecordingJob* job_ = nullptr; // valid while a recording runs; guarded by mutex_
+ std::deque log_tail_;
+ std::ofstream log_file_; // only touched by the worker thread
+ };
+} // namespace easy_gui
diff --git a/apps/easy_gui/src/child_process.cpp b/apps/easy_gui/src/child_process.cpp
new file mode 100644
index 00000000..f7efbe53
--- /dev/null
+++ b/apps/easy_gui/src/child_process.cpp
@@ -0,0 +1,414 @@
+#include "child_process.h"
+
+#include
+#include
+#include
+
+#ifdef _WIN32
+#ifndef NOMINMAX
+#define NOMINMAX
+#endif
+#include
+#else
+#include
+#include
+#include
+#include
+#include
+extern char** environ;
+#endif
+
+#ifdef __APPLE__
+#include
+#endif
+
+namespace fs = std::filesystem;
+
+namespace easy_gui
+{
+#ifdef _WIN32
+ struct ChildProcess::Impl
+ {
+ HANDLE process = nullptr;
+ HANDLE job = nullptr;
+ HANDLE read_pipe = nullptr;
+ bool waited = false;
+ int exit_code = -1;
+ };
+
+ namespace
+ {
+ std::wstring widen(const std::string& s)
+ {
+ if (s.empty())
+ return {};
+ const int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast(s.size()), nullptr, 0);
+ std::wstring out(n, L'\0');
+ MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast(s.size()), out.data(), n);
+ return out;
+ }
+
+ // Quoting rules of CommandLineToArgvW / the MSVC runtime: backslashes are literal except directly before a
+ // double quote, where they have to be doubled.
+ std::wstring quoteArgument(const std::wstring& arg)
+ {
+ if (!arg.empty() && arg.find_first_of(L" \t\n\v\"") == std::wstring::npos)
+ return arg;
+ std::wstring out = L"\"";
+ for (auto it = arg.begin();; ++it)
+ {
+ size_t backslashes = 0;
+ while (it != arg.end() && *it == L'\\')
+ {
+ ++it;
+ ++backslashes;
+ }
+ if (it == arg.end())
+ {
+ out.append(backslashes * 2, L'\\');
+ break;
+ }
+ if (*it == L'"')
+ out.append(backslashes * 2 + 1, L'\\');
+ else
+ out.append(backslashes, L'\\');
+ out.push_back(*it);
+ }
+ out.push_back(L'"');
+ return out;
+ }
+
+ std::string lastErrorMessage()
+ {
+ return "Windows error " + std::to_string(GetLastError());
+ }
+ } // namespace
+
+ ChildProcess::ChildProcess()
+ : impl_(new Impl)
+ {
+ }
+
+ ChildProcess::~ChildProcess()
+ {
+ if (impl_->process && !impl_->waited)
+ {
+ kill();
+ wait();
+ }
+ if (impl_->read_pipe)
+ CloseHandle(impl_->read_pipe);
+ if (impl_->process)
+ CloseHandle(impl_->process);
+ if (impl_->job)
+ CloseHandle(impl_->job);
+ delete impl_;
+ }
+
+ bool ChildProcess::start(const fs::path& executable, const std::vector& arguments, std::string& error)
+ {
+ SECURITY_ATTRIBUTES sa{ sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE };
+ HANDLE read_pipe = nullptr;
+ HANDLE write_pipe = nullptr;
+ if (!CreatePipe(&read_pipe, &write_pipe, &sa, 0))
+ {
+ error = "Could not create a pipe (" + lastErrorMessage() + ")";
+ return false;
+ }
+ SetHandleInformation(read_pipe, HANDLE_FLAG_INHERIT, 0);
+
+ HANDLE nul = CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, OPEN_EXISTING, 0, nullptr);
+
+ // The job object makes kill() take down the whole process tree and also kills the child if Easy GUI itself
+ // dies (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE).
+ HANDLE job = CreateJobObjectW(nullptr, nullptr);
+ if (job)
+ {
+ JOBOBJECT_EXTENDED_LIMIT_INFORMATION info{};
+ info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
+ SetInformationJobObject(job, JobObjectExtendedLimitInformation, &info, sizeof(info));
+ }
+
+ std::wstring command_line = quoteArgument(executable.wstring());
+ for (const auto& arg : arguments)
+ command_line += L" " + quoteArgument(widen(arg));
+
+ STARTUPINFOW si{};
+ si.cb = sizeof(si);
+ si.dwFlags = STARTF_USESTDHANDLES;
+ si.hStdInput = nul;
+ si.hStdOutput = write_pipe;
+ si.hStdError = write_pipe;
+
+ PROCESS_INFORMATION pi{};
+ const BOOL ok = CreateProcessW(
+ executable.wstring().c_str(),
+ command_line.data(),
+ nullptr,
+ nullptr,
+ TRUE,
+ CREATE_NO_WINDOW | CREATE_SUSPENDED,
+ nullptr,
+ nullptr,
+ &si,
+ &pi);
+ const std::string create_error = ok ? std::string() : lastErrorMessage();
+
+ CloseHandle(write_pipe);
+ if (nul != INVALID_HANDLE_VALUE)
+ CloseHandle(nul);
+
+ if (!ok)
+ {
+ CloseHandle(read_pipe);
+ if (job)
+ CloseHandle(job);
+ error = "Could not start '" + executable.string() + "' (" + create_error + ")";
+ return false;
+ }
+
+ if (job)
+ AssignProcessToJobObject(job, pi.hProcess);
+ ResumeThread(pi.hThread);
+ CloseHandle(pi.hThread);
+
+ impl_->process = pi.hProcess;
+ impl_->job = job;
+ impl_->read_pipe = read_pipe;
+ return true;
+ }
+
+ bool ChildProcess::readLine(std::string& line)
+ {
+ for (;;)
+ {
+ const auto newline = buffer_.find('\n');
+ if (newline != std::string::npos)
+ {
+ line = buffer_.substr(0, newline);
+ buffer_.erase(0, newline + 1);
+ std::erase(line, '\r');
+ return true;
+ }
+ if (eof_ || !impl_->read_pipe)
+ {
+ if (buffer_.empty())
+ return false;
+ line.swap(buffer_);
+ buffer_.clear();
+ std::erase(line, '\r');
+ return true;
+ }
+ char chunk[4096];
+ DWORD n = 0;
+ if (!ReadFile(impl_->read_pipe, chunk, sizeof(chunk), &n, nullptr) || n == 0)
+ eof_ = true;
+ else
+ buffer_.append(chunk, n);
+ }
+ }
+
+ void ChildProcess::kill()
+ {
+ killed_ = true;
+ if (impl_->job)
+ TerminateJobObject(impl_->job, 1);
+ else if (impl_->process)
+ TerminateProcess(impl_->process, 1);
+ }
+
+ int ChildProcess::wait()
+ {
+ if (!impl_->process)
+ return -1;
+ if (!impl_->waited)
+ {
+ WaitForSingleObject(impl_->process, INFINITE);
+ DWORD code = 0;
+ impl_->exit_code = GetExitCodeProcess(impl_->process, &code) ? static_cast(code) : -1;
+ impl_->waited = true;
+ }
+ return killed_ ? -1 : impl_->exit_code;
+ }
+
+ fs::path currentExecutableDirectory()
+ {
+ std::wstring buffer(32768, L'\0');
+ const DWORD n = GetModuleFileNameW(nullptr, buffer.data(), static_cast(buffer.size()));
+ if (n == 0 || n >= buffer.size())
+ return {};
+ buffer.resize(n);
+ return fs::path(buffer).parent_path();
+ }
+
+ void attachToParentConsole()
+ {
+ if (!AttachConsole(ATTACH_PARENT_PROCESS))
+ return;
+ FILE* stream = nullptr;
+ freopen_s(&stream, "CONOUT$", "w", stdout);
+ freopen_s(&stream, "CONOUT$", "w", stderr);
+ }
+
+#else // POSIX
+
+ struct ChildProcess::Impl
+ {
+ pid_t pid = -1;
+ int read_fd = -1;
+ std::atomic reaped{ false };
+ int exit_code = -1;
+ };
+
+ ChildProcess::ChildProcess()
+ : impl_(new Impl)
+ {
+ }
+
+ ChildProcess::~ChildProcess()
+ {
+ if (impl_->pid > 0 && !impl_->reaped)
+ {
+ kill();
+ wait();
+ }
+ if (impl_->read_fd >= 0)
+ close(impl_->read_fd);
+ delete impl_;
+ }
+
+ bool ChildProcess::start(const fs::path& executable, const std::vector& arguments, std::string& error)
+ {
+ int fds[2];
+ if (pipe(fds) != 0)
+ {
+ error = std::string("Could not create a pipe (") + std::strerror(errno) + ")";
+ return false;
+ }
+ // Only the child's copy of the write end may stay open, or the parent never sees end-of-output.
+ fcntl(fds[0], F_SETFD, FD_CLOEXEC);
+ fcntl(fds[1], F_SETFD, FD_CLOEXEC);
+
+ posix_spawn_file_actions_t actions;
+ posix_spawn_file_actions_init(&actions);
+ posix_spawn_file_actions_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0);
+ posix_spawn_file_actions_adddup2(&actions, fds[1], STDOUT_FILENO);
+ posix_spawn_file_actions_adddup2(&actions, fds[1], STDERR_FILENO);
+
+ // Own process group, so kill() can signal everything the child starts.
+ posix_spawnattr_t attributes;
+ posix_spawnattr_init(&attributes);
+ posix_spawnattr_setflags(&attributes, POSIX_SPAWN_SETPGROUP);
+ posix_spawnattr_setpgroup(&attributes, 0);
+
+ const std::string exe = executable.string();
+ std::vector argv_storage;
+ argv_storage.reserve(arguments.size() + 1);
+ argv_storage.push_back(exe);
+ argv_storage.insert(argv_storage.end(), arguments.begin(), arguments.end());
+ std::vector argv;
+ for (auto& a : argv_storage)
+ argv.push_back(a.data());
+ argv.push_back(nullptr);
+
+ pid_t pid = -1;
+ const int rc = posix_spawn(&pid, exe.c_str(), &actions, &attributes, argv.data(), environ);
+
+ posix_spawn_file_actions_destroy(&actions);
+ posix_spawnattr_destroy(&attributes);
+ close(fds[1]);
+
+ if (rc != 0)
+ {
+ close(fds[0]);
+ error = "Could not start '" + exe + "' (" + std::strerror(rc) + ")";
+ return false;
+ }
+
+ impl_->pid = pid;
+ impl_->read_fd = fds[0];
+ return true;
+ }
+
+ bool ChildProcess::readLine(std::string& line)
+ {
+ for (;;)
+ {
+ const auto newline = buffer_.find('\n');
+ if (newline != std::string::npos)
+ {
+ line = buffer_.substr(0, newline);
+ buffer_.erase(0, newline + 1);
+ std::erase(line, '\r');
+ return true;
+ }
+ if (eof_ || impl_->read_fd < 0)
+ {
+ if (buffer_.empty())
+ return false;
+ line.swap(buffer_);
+ buffer_.clear();
+ std::erase(line, '\r');
+ return true;
+ }
+ char chunk[4096];
+ const ssize_t n = read(impl_->read_fd, chunk, sizeof(chunk));
+ if (n > 0)
+ buffer_.append(chunk, static_cast(n));
+ else if (n < 0 && errno == EINTR)
+ continue;
+ else
+ eof_ = true;
+ }
+ }
+
+ void ChildProcess::kill()
+ {
+ killed_ = true;
+ // SIGKILL rather than SIGTERM: step 1 installs no handlers, so there is nothing to shut down gracefully, and
+ // a stuck TBB worker must not be able to delay the cancel.
+ if (impl_->pid > 0 && !impl_->reaped)
+ ::kill(-impl_->pid, SIGKILL);
+ }
+
+ int ChildProcess::wait()
+ {
+ if (impl_->pid <= 0)
+ return -1;
+ if (!impl_->reaped)
+ {
+ int status = 0;
+ pid_t r;
+ do
+ r = waitpid(impl_->pid, &status, 0);
+ while (r < 0 && errno == EINTR);
+ impl_->exit_code = (r == impl_->pid && WIFEXITED(status)) ? WEXITSTATUS(status) : -1;
+ impl_->reaped = true;
+ }
+ return impl_->exit_code;
+ }
+
+ fs::path currentExecutableDirectory()
+ {
+#ifdef __APPLE__
+ uint32_t size = 0;
+ _NSGetExecutablePath(nullptr, &size);
+ std::string buffer(size, '\0');
+ if (_NSGetExecutablePath(buffer.data(), &size) != 0)
+ return {};
+ buffer.resize(std::strlen(buffer.c_str()));
+ std::error_code ec;
+ const fs::path resolved = fs::weakly_canonical(buffer, ec);
+ return (ec ? fs::path(buffer) : resolved).parent_path();
+#else
+ std::error_code ec;
+ const fs::path exe = fs::read_symlink("/proc/self/exe", ec);
+ return ec ? fs::path() : exe.parent_path();
+#endif
+ }
+
+ void attachToParentConsole()
+ {
+ }
+#endif
+} // namespace easy_gui
diff --git a/apps/easy_gui/src/child_process.h b/apps/easy_gui/src/child_process.h
new file mode 100644
index 00000000..6340c2e9
--- /dev/null
+++ b/apps/easy_gui/src/child_process.h
@@ -0,0 +1,63 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+namespace easy_gui
+{
+ //! Runs one external program with stdout and stderr merged into a single pipe and stdin closed.
+ //!
+ //! Easy GUI runs lidar odometry as a child process instead of a thread because the step 1 sources have
+ //! abort()/exit() paths (corrupt LAZ, calibration mismatch, out of memory) that would take the whole GUI down,
+ //! and because compute_step_2 can only be paused, not cancelled. Killing the child is the cancel.
+ //!
+ //! stdin is closed on purpose: load_data waits on std::cin.get() before std::exit on a calibration mismatch, and
+ //! an EOF stdin lets it exit immediately instead of hanging forever.
+ //!
+ //! readLine() is meant for one reader thread; kill() may be called from any other thread and makes a blocked
+ //! readLine() return false once the child's end of the pipe closes.
+ class ChildProcess
+ {
+ public:
+ ChildProcess();
+ ~ChildProcess(); // kills a still-running child and reaps it
+ ChildProcess(const ChildProcess&) = delete;
+ ChildProcess& operator=(const ChildProcess&) = delete;
+
+ bool start(const std::filesystem::path& executable, const std::vector& arguments, std::string& error);
+
+ //! Next output line without its line terminator ('\r' characters are removed too: step 1 prints progress
+ //! as "\rrunning iterations: ..."). Returns false at end of output.
+ bool readLine(std::string& line);
+
+ //! Terminates the child (on POSIX its whole process group, so helper processes go too). Safe to call twice.
+ void kill();
+
+ //! Waits for the child to exit and returns its exit code, or -1 if it was killed by a signal / could not be
+ //! waited for.
+ int wait();
+
+ bool wasKilled() const
+ {
+ return killed_.load();
+ }
+
+ private:
+ struct Impl;
+ Impl* impl_ = nullptr;
+ std::string buffer_;
+ bool eof_ = false;
+ std::atomic killed_{ false };
+ };
+
+ //! Directory of the running executable (empty if it cannot be determined).
+ std::filesystem::path currentExecutableDirectory();
+
+ //! Windows: easy_gui.exe is a windowed program, so it has no console and printf goes nowhere. The command-line
+ //! modes (--headless, --export-only) call this to print into the console they were started from, if any.
+ //! No-op elsewhere.
+ void attachToParentConsole();
+} // namespace easy_gui
diff --git a/apps/easy_gui/src/easy_gui.manifest b/apps/easy_gui/src/easy_gui.manifest
new file mode 100644
index 00000000..21db78cd
--- /dev/null
+++ b/apps/easy_gui/src/easy_gui.manifest
@@ -0,0 +1,11 @@
+
+
+
+
+
+ UTF-8
+
+
+
diff --git a/apps/easy_gui/src/easy_gui_main.cpp b/apps/easy_gui/src/easy_gui_main.cpp
new file mode 100644
index 00000000..bacf75f9
--- /dev/null
+++ b/apps/easy_gui/src/easy_gui_main.cpp
@@ -0,0 +1,1574 @@
+// HDMapping Easy GUI: raw Mandeye recording -> lidar odometry (precision forestry preset) -> point cloud + trajectory.
+// See apps/easy_gui/docs/DESIGN.md for the decisions behind the pipeline.
+
+#include "batch.h"
+#include "child_process.h"
+#include "export.h"
+#include "pipeline.h"
+#include "preflight.h"
+#include "settings.h"
+#include "version.h"
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace fs = std::filesystem;
+using namespace easy_gui;
+
+namespace
+{
+ const ImVec4 kGreen(0.35f, 0.80f, 0.40f, 1.0f);
+ const ImVec4 kAmber(0.95f, 0.70f, 0.20f, 1.0f);
+ const ImVec4 kRed(0.95f, 0.35f, 0.35f, 1.0f);
+ const ImVec4 kGrey(0.60f, 0.60f, 0.60f, 1.0f);
+
+ fs::path homeFolder()
+ {
+#ifdef _WIN32
+ const char* home = std::getenv("USERPROFILE");
+#else
+ const char* home = std::getenv("HOME");
+#endif
+ return home ? fs::path(home) : fs::current_path();
+ }
+
+ //! ~/Documents/Easy Forest HDMapping/, with _2, _3, ... appended if that already exists. Never next to
+ //! the recording: that may be the scanner's USB stick.
+ fs::path defaultOutputFolder(const fs::path& recording)
+ {
+ const fs::path base = homeFolder() / "Documents" / "Easy Forest HDMapping";
+ const std::string name = recording.filename().empty() ? recording.parent_path().filename().string() : recording.filename().string();
+ std::error_code ec;
+ fs::path candidate = base / name;
+ for (int i = 2; fs::exists(candidate, ec); i++)
+ candidate = base / (name + "_" + std::to_string(i));
+ return candidate;
+ }
+
+ std::string formatDuration(double seconds)
+ {
+ const int s = static_cast(seconds + 0.5);
+ char buffer[32];
+ if (s >= 3600)
+ std::snprintf(buffer, sizeof(buffer), "%dh %02dm", s / 3600, (s / 60) % 60);
+ else
+ std::snprintf(buffer, sizeof(buffer), "%dm %02ds", s / 60, s % 60);
+ return buffer;
+ }
+
+ void wrappedColored(const ImVec4& color, const std::string& text)
+ {
+ ImGui::PushStyleColor(ImGuiCol_Text, color);
+ ImGui::TextWrapped("%s", text.c_str());
+ ImGui::PopStyleColor();
+ }
+
+ std::string folderName(const fs::path& folder)
+ {
+ const fs::path normal = folder.lexically_normal();
+ return normal.filename().empty() ? normal.parent_path().filename().string() : normal.filename().string();
+ }
+
+ //! ~/Documents/Easy Forest HDMapping/. Unlike single mode, an existing folder from an earlier
+ //! batch is reused (restarting the same batch continues it); _2, _3, ... only when the folder holds something else.
+ fs::path defaultBatchOutputFolder(const fs::path& parent)
+ {
+ const fs::path base = homeFolder() / "Documents" / "Easy Forest HDMapping";
+ const std::string name = folderName(parent);
+ std::error_code ec;
+ fs::path candidate = base / name;
+ for (int i = 2;
+ fs::exists(candidate, ec) && !fs::is_empty(candidate, ec) && !fs::exists(candidate / BatchRunner::kSummaryFileName, ec);
+ i++)
+ candidate = base / (name + "_" + std::to_string(i));
+ return candidate;
+ }
+
+ //! Checks the batch table's rows one at a time on a worker thread: they read the recordings, which may be on a
+ //! slow USB stick, and the window must stay responsive. Each result carries the generation it was requested for,
+ //! so results for a parent folder the user has since replaced are dropped.
+ class RowChecker
+ {
+ public:
+ struct Result
+ {
+ size_t row;
+ int generation;
+ fs::path output_folder; // the check was made against this output folder
+ BatchCheck check;
+ };
+
+ RowChecker()
+ {
+ thread_ = std::thread(&RowChecker::run, this);
+ }
+ ~RowChecker()
+ {
+ {
+ std::lock_guard lock(mutex_);
+ stop_ = true;
+ }
+ cv_.notify_all();
+ thread_.join();
+ }
+
+ void request(size_t row, int generation, const fs::path& recording, const fs::path& output_folder)
+ {
+ std::lock_guard lock(mutex_);
+ std::erase_if(
+ queue_,
+ [&](const Job& j)
+ {
+ return j.generation != generation || j.row == row;
+ });
+ queue_.push_back({ row, generation, recording, output_folder });
+ cv_.notify_all();
+ }
+
+ std::vector take()
+ {
+ std::lock_guard lock(mutex_);
+ return std::exchange(results_, {});
+ }
+
+ private:
+ struct Job
+ {
+ size_t row;
+ int generation;
+ fs::path recording;
+ fs::path output_folder;
+ };
+
+ void run()
+ {
+ std::unique_lock lock(mutex_);
+ while (true)
+ {
+ cv_.wait(
+ lock,
+ [&]
+ {
+ return stop_ || !queue_.empty();
+ });
+ if (stop_)
+ return;
+ const Job job = queue_.front();
+ queue_.pop_front();
+ lock.unlock();
+ BatchCheck check = checkBatchRecording(job.recording, job.output_folder);
+ lock.lock();
+ results_.push_back({ job.row, job.generation, job.output_folder, std::move(check) });
+ }
+ }
+
+ std::mutex mutex_;
+ std::condition_variable cv_;
+ std::deque queue_;
+ std::vector results_;
+ bool stop_ = false;
+ std::thread thread_; // last: starts after the members above exist
+ };
+
+ struct BatchRow
+ {
+ DiscoveredFolder folder;
+ std::string output_name;
+ bool name_edited = false; // keep the user's name when the output folder changes
+ bool selected = true; // the user's choice; a recording that fails the check is never included
+ std::optional check;
+ fs::path checked_output;
+
+ bool checkCurrent(const fs::path& output_root) const
+ {
+ return check && checked_output == output_root / output_name;
+ }
+ bool usable() const
+ {
+ return check && (check->done || check->preflight.ok());
+ }
+ bool included() const
+ {
+ return selected && usable();
+ }
+ //! Will write into its output folder, as far as is known: included, or selected and not checked yet.
+ bool active(const fs::path& output_root) const
+ {
+ return selected && (!checkCurrent(output_root) || usable());
+ }
+ };
+
+ struct BatchUi
+ {
+ fs::path parent;
+ fs::path output_root;
+ bool output_chosen_by_user = false;
+ std::vector found;
+ std::vector rows; // the recordings
+ std::vector skipped; // the other folders
+ int generation = 0;
+ RowChecker checker;
+
+ bool disk_known = false;
+ uint64_t available = 0;
+ double disk_checked_at = -1e9;
+
+ BatchRunner runner;
+ bool started = false; // the runner was started; the table shows the status column until "new batch"
+ std::string start_error;
+
+ void setParent(const fs::path& folder)
+ {
+ parent = folder;
+ found = discoverRecordings(folder);
+ rows.clear();
+ skipped.clear();
+ std::vector recordings;
+ for (const auto& f : found)
+ (f.is_recording ? recordings : skipped).push_back(f);
+ if (!output_chosen_by_user)
+ output_root = defaultBatchOutputFolder(folder);
+ const auto names = defaultOutputNames(recordings, output_root);
+ for (size_t i = 0; i < recordings.size(); i++)
+ rows.push_back({ recordings[i], names[i] });
+ generation++;
+ checkAll();
+ }
+
+ void setOutputRoot(const fs::path& folder)
+ {
+ output_root = folder;
+ output_chosen_by_user = true;
+ std::vector recordings;
+ for (const auto& r : rows)
+ recordings.push_back(r.folder);
+ const auto names = defaultOutputNames(recordings, output_root);
+ for (size_t i = 0; i < rows.size(); i++)
+ if (!rows[i].name_edited)
+ rows[i].output_name = names[i];
+ checkAll();
+ }
+
+ void checkRow(size_t i)
+ {
+ if (outputNameProblem(rows[i].output_name).empty())
+ checker.request(i, generation, rows[i].folder.path, output_root / rows[i].output_name);
+ }
+
+ void checkAll()
+ {
+ for (size_t i = 0; i < rows.size(); i++)
+ checkRow(i);
+ disk_checked_at = -1e9;
+ }
+
+ void poll()
+ {
+ for (auto& r : checker.take())
+ if (r.generation == generation && r.row < rows.size())
+ {
+ rows[r.row].check = std::move(r.check);
+ rows[r.row].checked_output = r.output_folder;
+ disk_checked_at = -1e9;
+ }
+ // Free space changes while other programs run; refreshed every few seconds before the start.
+ if (!runner.busy() && !started && ImGui::GetTime() - disk_checked_at > 3.0 && !output_root.empty())
+ {
+ fs::path checked;
+ disk_known = availableSpace(output_root, available, checked);
+ disk_checked_at = ImGui::GetTime();
+ }
+ }
+
+ bool checking() const
+ {
+ for (const auto& r : rows)
+ if (!r.checkCurrent(output_root) && outputNameProblem(r.output_name).empty())
+ return true;
+ return false;
+ }
+
+ //! Why row `i`'s output name cannot be used, or empty.
+ std::string nameProblem(size_t i) const
+ {
+ if (std::string p = outputNameProblem(rows[i].output_name); !p.empty())
+ return p;
+ for (size_t j = 0; j < rows.size(); j++)
+ if (j != i && rows[j].active(output_root) && sameOutputName(rows[i].output_name, rows[j].output_name))
+ return "Another recording uses the same output folder name.";
+ return {};
+ }
+
+ std::vector toProcess() const
+ {
+ std::vector out;
+ for (const auto& r : rows)
+ if (r.included() && r.checkCurrent(output_root) && !r.check->done)
+ out.push_back(&r.check->preflight);
+ return out;
+ }
+
+ Finding diskFinding() const
+ {
+ return batchDiskFinding(batchDiskNeed(toProcess()), disk_known, available);
+ }
+
+ bool readyToStart() const
+ {
+ if (runner.busy() || started || rows.empty() || checking() || !batchOutputProblem(output_root, found).empty())
+ return false;
+ bool any = false;
+ for (size_t i = 0; i < rows.size(); i++)
+ {
+ if (!rows[i].active(output_root))
+ continue;
+ if (!nameProblem(i).empty())
+ return false;
+ any = any || rows[i].included();
+ }
+ return any && diskFinding().severity != Severity::Error;
+ }
+
+ BatchRequest makeRequest(const Settings& settings) const
+ {
+ BatchRequest request;
+ request.output_root = output_root;
+ request.skipped = skipped;
+ request.step1_executable = findStep1Executable();
+ request.settings = settings;
+ for (const auto& r : rows)
+ {
+ BatchItem item{ r.folder.path, r.folder.name, r.output_name, r.included() };
+ item.total_points = r.check ? r.check->preflight.total_points : 0;
+ if (!r.selected)
+ item.exclude_reason = "Not selected.";
+ else if (!r.usable())
+ item.exclude_reason = "Check failed: " + firstError(r.check->preflight);
+ request.items.push_back(std::move(item));
+ }
+ return request;
+ }
+
+ void reset()
+ {
+ runner.wait();
+ parent.clear();
+ found.clear();
+ rows.clear();
+ skipped.clear();
+ generation++;
+ if (!output_chosen_by_user)
+ output_root.clear();
+ started = false;
+ start_error.clear();
+ }
+ };
+
+ enum class Mode
+ {
+ Single,
+ Batch
+ };
+
+ struct App
+ {
+ Mode mode = Mode::Single;
+ BatchUi batch;
+
+ fs::path recording;
+ fs::path output_folder;
+ bool output_chosen_by_user = false;
+ Settings settings;
+
+ std::future preflight_future;
+ std::optional preflight;
+ fs::path preflight_for_recording;
+ fs::path preflight_for_output;
+
+ Pipeline pipeline;
+ std::string start_error;
+ bool show_log = false;
+
+ void setRecording(const fs::path& folder)
+ {
+ recording = folder;
+ if (!output_chosen_by_user)
+ output_folder = defaultOutputFolder(folder);
+ startPreflight();
+ }
+
+ void startPreflight()
+ {
+ if (recording.empty())
+ return;
+ preflight.reset();
+ preflight_for_recording = recording;
+ preflight_for_output = output_folder;
+ preflight_future = std::async(std::launch::async, runPreflight, recording, output_folder, false);
+ }
+
+ bool checking() const
+ {
+ return preflight_future.valid();
+ }
+
+ void poll()
+ {
+ if (preflight_future.valid() && preflight_future.wait_for(std::chrono::seconds(0)) == std::future_status::ready)
+ preflight = preflight_future.get();
+ }
+
+ bool readyToStart() const
+ {
+ return preflight && preflight->ok() && preflight_for_recording == recording && preflight_for_output == output_folder &&
+ !pipeline.busy();
+ }
+ };
+
+ void drawAdvancedSettings(Settings& settings)
+ {
+ if (ImGui::CollapsingHeader("Advanced settings"))
+ {
+ ImGui::Indent();
+ float near = static_cast(settings.near_min_range_m);
+ ImGui::SetNextItemWidth(220);
+ if (ImGui::SliderFloat(
+ "Closest points kept [m]",
+ &near,
+ static_cast(Settings::kNearMinRangeLowest),
+ static_cast(Settings::kNearMinRangeHighest),
+ "%.2f m"))
+ {
+ settings.near_min_range_m = near;
+ settings.clamp();
+ }
+ ImGui::SameLine();
+ ImGui::TextDisabled("(?)");
+ if (ImGui::IsItemHovered())
+ ImGui::SetTooltip(
+ "Points closer to the scanner than this are left out of the point cloud.\n"
+ "0.4 m (default) removes the operator's body and the handle.\n"
+ "Lower values keep more of the close surroundings but may include the operator.");
+ ImGui::Checkbox("Place all points by their measurement time (experimental)", &settings.place_by_raw_time);
+ ImGui::SameLine();
+ ImGui::TextDisabled("(?)");
+ if (ImGui::IsItemHovered())
+ ImGui::SetTooltip(
+ "Off (default): the point cloud uses lidar odometry's own points.\n"
+ "On: every point is taken from the raw recording and placed at its exact measurement time.");
+ ImGui::Checkbox("Keep intermediate files (for troubleshooting)", &settings.keep_work_files);
+ ImGui::Unindent();
+ }
+ }
+
+ void drawInputs(App& app, bool locked)
+ {
+ ImGui::BeginDisabled(locked);
+
+ ImGui::SeparatorText("1. Recording");
+ ImGui::TextWrapped("The folder the Mandeye scanner wrote (lidar*.laz and imu*.csv files). You can also drag it onto this window.");
+ if (ImGui::Button("Choose recording..."))
+ {
+ const std::string folder = mandeye::fd::SelectFolder("Choose the Mandeye recording folder");
+ if (!folder.empty())
+ app.setRecording(folder);
+ }
+ ImGui::SameLine();
+ ImGui::TextUnformatted(app.recording.empty() ? "(none chosen)" : app.recording.string().c_str());
+
+ ImGui::SeparatorText("2. Output folder");
+ ImGui::TextWrapped(
+ "Results go here. It must be a new or empty folder outside the recording; nothing is ever written into the recording.");
+ if (ImGui::Button("Choose output folder..."))
+ {
+ const std::string folder = mandeye::fd::SelectFolder("Choose where to save the results");
+ if (!folder.empty())
+ {
+ app.output_folder = folder;
+ app.output_chosen_by_user = true;
+ app.startPreflight();
+ }
+ }
+ ImGui::SameLine();
+ ImGui::TextUnformatted(
+ app.output_folder.empty() ? "(set automatically once a recording is chosen)" : app.output_folder.string().c_str());
+
+ drawAdvancedSettings(app.settings);
+
+ ImGui::EndDisabled();
+ }
+
+ void drawPreflight(App& app)
+ {
+ ImGui::SeparatorText("3. Check");
+ if (app.recording.empty())
+ {
+ ImGui::TextDisabled("Choose a recording first.");
+ return;
+ }
+ if (app.checking())
+ {
+ ImGui::TextUnformatted("Checking the recording...");
+ return;
+ }
+ if (!app.preflight)
+ {
+ if (ImGui::Button("Check recording"))
+ app.startPreflight();
+ return;
+ }
+ if (app.preflight_for_recording != app.recording || app.preflight_for_output != app.output_folder)
+ {
+ if (ImGui::Button("Check again"))
+ app.startPreflight();
+ return;
+ }
+ for (const auto& f : app.preflight->findings)
+ {
+ const char* tag = f.severity == Severity::Error ? "[X]" : f.severity == Severity::Warning ? "[!]" : "[OK]";
+ const ImVec4& color = f.severity == Severity::Error ? kRed : f.severity == Severity::Warning ? kAmber : kGreen;
+ ImGui::TextColored(color, "%s", tag);
+ ImGui::SameLine();
+ ImGui::TextWrapped("%s", f.message.c_str());
+ }
+ if (!app.preflight->ok() && ImGui::Button("Check again"))
+ app.startPreflight();
+ }
+
+ void stageRow(const char* label, int index, int current, bool failed)
+ {
+ if (index < current)
+ ImGui::TextColored(kGreen, "[done]");
+ else if (index == current)
+ ImGui::TextColored(failed ? kRed : kAmber, failed ? "[failed]" : "[ .. ]");
+ else
+ ImGui::TextColored(kGrey, "[ ]");
+ ImGui::SameLine();
+ ImGui::TextUnformatted(label);
+ }
+
+ //! What the running pipeline is doing right now: a label and a progress bar.
+ void drawStageProgress(const PipelineStatus& s)
+ {
+ if (s.stage == PipelineStage::Odometry)
+ {
+ const double phase = s.step1.phaseFraction();
+ std::string label = phaseLabel(s.step1.phase);
+ if (s.step1.phase == Step1Phase::Odometry && s.step1.chunks_total > 0)
+ label += " (" + std::to_string(s.step1.chunks_done) + " / " + std::to_string(s.step1.chunks_total) + ")";
+ ImGui::TextUnformatted(label.c_str());
+ if (phase >= 0.0)
+ ImGui::ProgressBar(static_cast(phase), ImVec2(-1, 0));
+ else
+ ImGui::ProgressBar(-1.0f * static_cast(ImGui::GetTime()), ImVec2(-1, 0), "working...");
+ }
+ else if (s.stage == PipelineStage::Export)
+ {
+ ImGui::TextUnformatted(s.export_step.empty() ? "Exporting..." : s.export_step.c_str());
+ ImGui::ProgressBar(static_cast(s.export_fraction), ImVec2(-1, 0));
+ }
+ else
+ {
+ ImGui::TextUnformatted(s.stage == PipelineStage::Cleanup ? "Cleaning up..." : "Preparing...");
+ ImGui::ProgressBar(-1.0f * static_cast(ImGui::GetTime()), ImVec2(-1, 0), "working...");
+ }
+ }
+
+ void drawRun(App& app)
+ {
+ const PipelineStatus s = app.pipeline.status();
+ ImGui::SeparatorText("4. Process");
+
+ if (s.state == PipelineState::Idle)
+ {
+ ImGui::BeginDisabled(!app.readyToStart());
+ if (ImGui::Button("Start processing", ImVec2(220, 0)))
+ {
+ PipelineRequest request{ app.recording, app.output_folder, findStep1Executable(), app.settings };
+ app.start_error.clear();
+ app.pipeline.start(request, app.start_error);
+ }
+ ImGui::EndDisabled();
+ if (!app.start_error.empty())
+ wrappedColored(kRed, app.start_error);
+ return;
+ }
+
+ // Stage list. Stage indices: 0 check+prepare, 1 odometry, 2 export, 3 = all done.
+ const bool failed = s.state == PipelineState::Failed;
+ int current = 0;
+ if (s.stage == PipelineStage::Odometry)
+ current = 1;
+ if (s.stage == PipelineStage::Export || (s.stage == PipelineStage::Cleanup && !s.session_folder.empty()))
+ current = 2;
+ if (s.state == PipelineState::Succeeded)
+ current = 3;
+ stageRow("Check and prepare the recording", 0, current, failed && current == 0);
+ stageRow("Lidar odometry (precision forestry settings)", 1, current, failed && current == 1);
+ stageRow("Export point cloud and trajectory", 2, current, failed && current == 2);
+
+ if (s.state == PipelineState::Running)
+ {
+ ImGui::Spacing();
+ drawStageProgress(s);
+ if (s.stage == PipelineStage::Odometry)
+ {
+ const double overall = s.step1.overallFraction();
+ std::string eta = "Elapsed " + formatDuration(s.elapsed_s);
+ if (overall > 0.05)
+ eta += ", about " + formatDuration(s.elapsed_s * (1.0 - overall) / overall) + " left";
+ ImGui::TextUnformatted("Overall");
+ ImGui::ProgressBar(static_cast(overall), ImVec2(-1, 0), eta.c_str());
+ }
+ else if (s.stage == PipelineStage::Export)
+ ImGui::TextUnformatted(("Elapsed " + formatDuration(s.elapsed_s)).c_str());
+
+ ImGui::Spacing();
+ if (ImGui::Button("Cancel", ImVec2(120, 0)))
+ ImGui::OpenPopup("Cancel processing?");
+ if (ImGui::BeginPopupModal("Cancel processing?", nullptr, ImGuiWindowFlags_AlwaysAutoResize))
+ {
+ ImGui::TextUnformatted("Stop processing? The work done so far will be discarded.");
+ if (ImGui::Button("Yes, stop", ImVec2(120, 0)))
+ {
+ app.pipeline.cancel();
+ ImGui::CloseCurrentPopup();
+ }
+ ImGui::SameLine();
+ if (ImGui::Button("No, continue", ImVec2(120, 0)))
+ ImGui::CloseCurrentPopup();
+ ImGui::EndPopup();
+ }
+ return;
+ }
+
+ ImGui::Spacing();
+ if (s.state == PipelineState::Succeeded)
+ {
+ wrappedColored(kGreen, s.message + " (took " + formatDuration(s.elapsed_s) + ")");
+ const OutputLayout out(app.output_folder);
+ const ExportStats& e = s.export_stats;
+ ImGui::BulletText(
+ "%s: %.1f million points (%.1f million from lidar odometry, %.1f million near and %.1f million gap points "
+ "added), %.0f MB",
+ out.point_cloud.filename().string().c_str(),
+ (e.odometry_points + e.near_points + e.gap_points + e.check_band_points) / 1e6,
+ e.odometry_points / 1e6,
+ e.near_points / 1e6,
+ e.gap_points / 1e6,
+ e.laz_bytes / 1e6);
+ ImGui::BulletText("%s: %zu scanner poses", out.trajectory.filename().string().c_str(), e.trajectory_nodes);
+ ImGui::BulletText("%s describes every field of both files.", out.readme.filename().string().c_str());
+ }
+ else if (s.state == PipelineState::Cancelled)
+ wrappedColored(kAmber, s.message);
+ else
+ {
+ wrappedColored(kRed, s.message);
+ if (!s.detail.empty())
+ ImGui::TextDisabled("Technical detail: %s", s.detail.c_str());
+ ImGui::TextDisabled("The full log is in %s", OutputLayout(app.output_folder).log_file.string().c_str());
+ }
+
+ if (ImGui::Button("Open output folder"))
+ OpenURL(app.output_folder.string().c_str());
+ ImGui::SameLine();
+ if (ImGui::Button("Process another recording"))
+ {
+ app.pipeline.reset();
+ app.recording.clear();
+ app.output_folder.clear();
+ app.output_chosen_by_user = false;
+ app.preflight.reset();
+ }
+ }
+
+ void drawLog(App& app)
+ {
+ ImGui::Spacing();
+ if (!ImGui::CollapsingHeader("Details (processing log)"))
+ return;
+ const auto lines = app.pipeline.logTail();
+ ImGui::BeginChild("log", ImVec2(0, 240), ImGuiChildFlags_Borders, ImGuiWindowFlags_HorizontalScrollbar);
+ for (const auto& line : lines)
+ ImGui::TextUnformatted(line.c_str());
+ if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 5.0f) // follow the end unless the user scrolled up
+ ImGui::SetScrollHereY(1.0f);
+ ImGui::EndChild();
+ }
+
+ // ---------------------------------------------------------------------------------------------------------------
+ // Batch mode ("Several recordings")
+
+ void drawBatchInputs(App& app, bool locked)
+ {
+ BatchUi& b = app.batch;
+ ImGui::BeginDisabled(locked);
+
+ ImGui::SeparatorText("1. Folder with recordings");
+ ImGui::TextWrapped(
+ "A folder that contains several recording folders, for example a copy of the scanner's USB stick. Recordings are looked "
+ "for one folder level down. You can also drag the folder onto this window.");
+ if (ImGui::Button("Choose folder..."))
+ {
+ const std::string folder = mandeye::fd::SelectFolder("Choose the folder that contains the recordings");
+ if (!folder.empty())
+ b.setParent(folder);
+ }
+ ImGui::SameLine();
+ ImGui::TextUnformatted(b.parent.empty() ? "(none chosen)" : b.parent.string().c_str());
+
+ ImGui::SeparatorText("2. Output folder");
+ ImGui::TextWrapped(
+ "Each recording's results go into a folder of its own in here, named in the table below. Choosing the same output folder "
+ "again continues an interrupted batch: finished recordings are skipped.");
+ if (ImGui::Button("Choose output folder..."))
+ {
+ const std::string folder = mandeye::fd::SelectFolder("Choose where to save the results");
+ if (!folder.empty())
+ b.setOutputRoot(folder);
+ }
+ ImGui::SameLine();
+ ImGui::TextUnformatted(b.output_root.empty() ? "(set automatically once a folder is chosen)" : b.output_root.string().c_str());
+
+ drawAdvancedSettings(app.settings);
+ ImGui::EndDisabled();
+ }
+
+ void drawCheckCell(const BatchRow& row, bool current)
+ {
+ if (!current)
+ {
+ ImGui::TextDisabled(outputNameProblem(row.output_name).empty() ? "checking..." : "-");
+ return;
+ }
+ const BatchCheck& c = *row.check;
+ if (c.done)
+ {
+ ImGui::TextColored(kGreen, "already done");
+ if (ImGui::IsItemHovered())
+ ImGui::SetTooltip("Finished on %s. It will be skipped.", c.done->finished.c_str());
+ return;
+ }
+ size_t warnings = 0;
+ for (const auto& f : c.preflight.findings)
+ warnings += f.severity == Severity::Warning ? 1 : 0;
+ if (!c.preflight.ok())
+ ImGui::TextColored(kRed, "[X] problem");
+ else if (warnings > 0)
+ ImGui::TextColored(kAmber, "[!] %zu note%s", warnings, warnings > 1 ? "s" : "");
+ else
+ ImGui::TextColored(kGreen, "[OK]");
+ if (ImGui::IsItemHovered())
+ {
+ ImGui::BeginTooltip();
+ ImGui::PushTextWrapPos(ImGui::GetFontSize() * 40.0f);
+ for (const auto& f : c.preflight.findings)
+ {
+ const char* tag = f.severity == Severity::Error ? "[X]" : f.severity == Severity::Warning ? "[!]" : "[OK]";
+ ImGui::TextWrapped("%s %s", tag, f.message.c_str());
+ }
+ ImGui::PopTextWrapPos();
+ ImGui::EndTooltip();
+ }
+ }
+
+ void drawStatusCell(const BatchItemResult& r, bool current, const BatchStatus& s)
+ {
+ switch (r.state)
+ {
+ case BatchItemState::Excluded:
+ ImGui::TextDisabled("not selected");
+ break;
+ case BatchItemState::Pending:
+ ImGui::TextDisabled("waiting");
+ break;
+ case BatchItemState::Running:
+ ImGui::TextColored(kAmber, "running %d%%", current ? static_cast(pipelineFraction(s.current_status) * 100) : 0);
+ break;
+ case BatchItemState::Succeeded:
+ ImGui::TextColored(kGreen, "done (%s)", formatDuration(r.duration_s).c_str());
+ break;
+ case BatchItemState::AlreadyDone:
+ ImGui::TextColored(kGreen, "already done");
+ break;
+ case BatchItemState::Failed:
+ ImGui::TextColored(kRed, "failed");
+ break;
+ case BatchItemState::Cancelled:
+ ImGui::TextColored(kAmber, "stopped");
+ break;
+ case BatchItemState::NotRun:
+ ImGui::TextDisabled("not run");
+ break;
+ }
+ if (!r.error.empty() && ImGui::IsItemHovered())
+ {
+ ImGui::BeginTooltip();
+ ImGui::PushTextWrapPos(ImGui::GetFontSize() * 40.0f);
+ ImGui::TextWrapped("%s", r.error.c_str());
+ ImGui::PopTextWrapPos();
+ ImGui::EndTooltip();
+ }
+ }
+
+ void drawBatchTable(App& app, bool locked)
+ {
+ BatchUi& b = app.batch;
+ ImGui::SeparatorText("3. Recordings");
+ if (b.parent.empty())
+ {
+ ImGui::TextDisabled("Choose a folder first.");
+ return;
+ }
+ if (b.rows.empty())
+ {
+ wrappedColored(
+ kRed,
+ "No recordings were found in this folder or in the folders directly inside it. A recording folder contains "
+ "lidar*.laz and imu*.csv files.");
+ }
+
+ const BatchStatus s = b.runner.status();
+ const bool show_status = b.started && s.items.size() == b.rows.size();
+ if (!b.rows.empty())
+ {
+ const int columns = show_status ? 6 : 5;
+ const float row_height = ImGui::GetFrameHeightWithSpacing();
+ const float height = row_height * static_cast(std::min(b.rows.size(), 10) + 1) + 6.0f;
+ if (ImGui::BeginTable(
+ "recordings",
+ columns,
+ ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp,
+ ImVec2(0, height)))
+ {
+ ImGui::TableSetupScrollFreeze(0, 1);
+ ImGui::TableSetupColumn("Include", ImGuiTableColumnFlags_WidthFixed);
+ ImGui::TableSetupColumn("Recording", ImGuiTableColumnFlags_WidthStretch, 3.0f);
+ ImGui::TableSetupColumn("Length", ImGuiTableColumnFlags_WidthFixed);
+ ImGui::TableSetupColumn("Check", ImGuiTableColumnFlags_WidthFixed);
+ ImGui::TableSetupColumn("Output folder name", ImGuiTableColumnFlags_WidthStretch, 3.0f);
+ if (show_status)
+ ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed);
+ ImGui::TableHeadersRow();
+
+ for (size_t i = 0; i < b.rows.size(); i++)
+ {
+ BatchRow& row = b.rows[i];
+ const bool current = row.checkCurrent(b.output_root);
+ ImGui::PushID(static_cast(i));
+ ImGui::TableNextRow();
+
+ ImGui::TableNextColumn();
+ ImGui::BeginDisabled(locked || (current && !row.usable()));
+ bool include = current ? row.included() : row.selected;
+ if (ImGui::Checkbox("##include", &include))
+ row.selected = include;
+ ImGui::EndDisabled();
+
+ ImGui::TableNextColumn();
+ ImGui::AlignTextToFramePadding();
+ ImGui::TextUnformatted(row.folder.name.c_str());
+ if (ImGui::IsItemHovered())
+ ImGui::SetTooltip("%s", row.folder.path.string().c_str());
+
+ ImGui::TableNextColumn();
+ if (row.check)
+ ImGui::TextUnformatted(formatDuration(row.check->preflight.duration_s).c_str());
+ else
+ ImGui::TextDisabled("...");
+
+ ImGui::TableNextColumn();
+ drawCheckCell(row, current);
+
+ ImGui::TableNextColumn();
+ const std::string problem = row.active(b.output_root) ? b.nameProblem(i) : std::string();
+ ImGui::BeginDisabled(locked);
+ if (!problem.empty())
+ ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.45f, 0.12f, 0.12f, 1.0f));
+ ImGui::SetNextItemWidth(-FLT_MIN);
+ ImGui::InputText("##output_name", &row.output_name);
+ if (ImGui::IsItemDeactivatedAfterEdit())
+ {
+ row.name_edited = true;
+ b.checkRow(i);
+ }
+ if (!problem.empty())
+ {
+ ImGui::PopStyleColor();
+ if (ImGui::IsItemHovered())
+ ImGui::SetTooltip("%s", problem.c_str());
+ }
+ ImGui::EndDisabled();
+
+ if (show_status)
+ {
+ ImGui::TableNextColumn();
+ drawStatusCell(s.items[i], s.current == static_cast(i), s);
+ }
+ ImGui::PopID();
+ }
+ ImGui::EndTable();
+ }
+ }
+
+ if (!b.started)
+ {
+ // Problems in words, below the table (the table only has room for a mark).
+ for (size_t i = 0; i < b.rows.size(); i++)
+ {
+ const BatchRow& row = b.rows[i];
+ if (const std::string problem = row.active(b.output_root) ? b.nameProblem(i) : std::string(); !problem.empty())
+ wrappedColored(kRed, row.folder.name + ": output folder name: " + problem);
+ if (!row.checkCurrent(b.output_root) || row.check->done)
+ continue;
+ for (const auto& f : row.check->preflight.findings)
+ if (f.severity != Severity::Ok)
+ wrappedColored(f.severity == Severity::Error ? kRed : kAmber, row.folder.name + ": " + f.message);
+ }
+ if (const std::string problem = batchOutputProblem(b.output_root, b.found); !problem.empty())
+ wrappedColored(kRed, problem);
+ else if (!b.rows.empty() && !b.checking())
+ {
+ const Finding disk = b.diskFinding();
+ wrappedColored(
+ disk.severity == Severity::Error ? kRed
+ : disk.severity == Severity::Warning ? kAmber
+ : kGreen,
+ disk.message);
+ }
+ }
+
+ if (!b.skipped.empty() &&
+ ImGui::TreeNode("skipped", "%zu folder%s skipped (not recordings)", b.skipped.size(), b.skipped.size() > 1 ? "s" : ""))
+ {
+ for (const auto& f : b.skipped)
+ ImGui::BulletText("%s: %s", f.name.c_str(), f.skip_reason.c_str());
+ ImGui::TreePop();
+ }
+ }
+
+ void drawBatchRun(App& app)
+ {
+ BatchUi& b = app.batch;
+ ImGui::SeparatorText("4. Process");
+ if (!b.started)
+ {
+ size_t selected = 0;
+ for (const auto& r : b.rows)
+ selected += r.included() ? 1 : 0;
+ ImGui::BeginDisabled(!b.readyToStart());
+ const std::string label = "Start processing " + std::to_string(selected) + " recording" + (selected == 1 ? "" : "s");
+ if (ImGui::Button(label.c_str(), ImVec2(260, 0)))
+ {
+ b.start_error.clear();
+ if (b.runner.start(b.makeRequest(app.settings), b.start_error))
+ b.started = true;
+ }
+ ImGui::EndDisabled();
+ if (!b.start_error.empty())
+ wrappedColored(kRed, b.start_error);
+ return;
+ }
+
+ const BatchStatus s = b.runner.status();
+ const BatchRequest& request = b.runner.request();
+ size_t selected = 0, finished = 0;
+ size_t counts[8] = {};
+ for (size_t i = 0; i < s.items.size(); i++)
+ {
+ counts[static_cast(s.items[i].state)]++;
+ if (!request.items[i].include)
+ continue;
+ selected++;
+ if (s.items[i].state != BatchItemState::Pending && s.items[i].state != BatchItemState::Running)
+ finished++;
+ }
+
+ if (s.running)
+ {
+ std::string overall = std::to_string(finished) + " of " + std::to_string(selected) + " recordings finished, elapsed " +
+ formatDuration(s.elapsed_s);
+ if (s.eta_s >= 0.0)
+ overall += ", about " + formatDuration(s.eta_s) + " left";
+ ImGui::TextUnformatted("All recordings");
+ ImGui::ProgressBar(static_cast(s.fraction), ImVec2(-1, 0), overall.c_str());
+ ImGui::Spacing();
+ if (s.current >= 0)
+ {
+ ImGui::Text(
+ "Now: %s (%d%% of this recording)",
+ request.items[s.current].name.c_str(),
+ static_cast(pipelineFraction(s.current_status) * 100));
+ drawStageProgress(s.current_status);
+ }
+ else
+ ImGui::TextUnformatted("Checking the next recording...");
+
+ ImGui::Spacing();
+ ImGui::BeginDisabled(s.stop_after_current);
+ if (ImGui::Button(s.stop_after_current ? "Stopping after this recording..." : "Stop after this recording", ImVec2(260, 0)))
+ b.runner.stopAfterCurrent();
+ ImGui::EndDisabled();
+ ImGui::SameLine();
+ if (ImGui::Button("Stop now", ImVec2(120, 0)))
+ ImGui::OpenPopup("Stop now?");
+ if (ImGui::BeginPopupModal("Stop now?", nullptr, ImGuiWindowFlags_AlwaysAutoResize))
+ {
+ ImGui::TextUnformatted(
+ "Stop now? The recording being processed is discarded.\nRecordings that are already finished are kept.");
+ if (ImGui::Button("Yes, stop now", ImVec2(140, 0)))
+ {
+ b.runner.stopNow();
+ ImGui::CloseCurrentPopup();
+ }
+ ImGui::SameLine();
+ if (ImGui::Button("No, continue", ImVec2(140, 0)))
+ ImGui::CloseCurrentPopup();
+ ImGui::EndPopup();
+ }
+ ImGui::TextDisabled("The computer is kept from going to sleep while the batch runs.");
+ return;
+ }
+
+ const size_t failed = counts[static_cast(BatchItemState::Failed)] + counts[static_cast(BatchItemState::Cancelled)];
+ const size_t not_run = counts[static_cast(BatchItemState::NotRun)];
+ std::string summary = std::string(s.stop_after_current ? "Batch stopped" : "Batch finished") + " after " +
+ formatDuration(s.elapsed_s) + ": " + std::to_string(counts[static_cast(BatchItemState::Succeeded)]) + " succeeded";
+ if (counts[static_cast(BatchItemState::AlreadyDone)])
+ summary += ", " + std::to_string(counts[static_cast(BatchItemState::AlreadyDone)]) + " already done before";
+ if (failed)
+ summary += ", " + std::to_string(failed) + " failed or stopped";
+ if (not_run)
+ summary += ", " + std::to_string(not_run) + " not run";
+ summary += ".";
+ wrappedColored(failed || not_run ? kAmber : kGreen, summary);
+ if (failed)
+ ImGui::TextWrapped(
+ "Point at 'failed' in the table to see why. Each recording's own easy_gui.log has the details. Start the same batch "
+ "again to retry the recordings that did not finish.");
+ ImGui::TextDisabled("batch_summary.csv and batch.log are in %s", request.output_root.string().c_str());
+
+ if (ImGui::Button("Open output folder"))
+ OpenURL(request.output_root.string().c_str());
+ ImGui::SameLine();
+ if (ImGui::Button("Process another batch"))
+ b.reset();
+ }
+
+ void drawLogLines(const char* id, const std::vector& lines)
+ {
+ ImGui::BeginChild(id, ImVec2(0, 200), ImGuiChildFlags_Borders, ImGuiWindowFlags_HorizontalScrollbar);
+ for (const auto& line : lines)
+ ImGui::TextUnformatted(line.c_str());
+ if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 5.0f) // follow the end unless the user scrolled up
+ ImGui::SetScrollHereY(1.0f);
+ ImGui::EndChild();
+ }
+
+ void drawBatchLog(App& app)
+ {
+ ImGui::Spacing();
+ if (!ImGui::CollapsingHeader("Details (processing log)"))
+ return;
+ ImGui::TextUnformatted("Batch (batch.log)");
+ drawLogLines("batch_log", app.batch.runner.logTail());
+ ImGui::TextUnformatted("Current recording (its easy_gui.log)");
+ drawLogLines("recording_log", app.batch.runner.currentLogTail());
+ }
+
+ void drawModeSwitch(App& app, bool locked)
+ {
+ ImGui::BeginDisabled(locked);
+ int mode = static_cast(app.mode);
+ ImGui::RadioButton("One recording", &mode, static_cast(Mode::Single));
+ ImGui::SameLine();
+ ImGui::RadioButton("Several recordings", &mode, static_cast(Mode::Batch));
+ app.mode = static_cast(mode);
+ ImGui::EndDisabled();
+ ImGui::Spacing();
+ }
+} // namespace
+
+namespace
+{
+ //! `easy_gui --headless `: the same pipeline without a window, for scripted runs
+ //! (the dev validation script) and remote machines. Exit code 0 = success, 1 = failed / refused, 2 = usage.
+ int runHeadless(const fs::path& recording, const fs::path& output_folder, const Settings& settings, double check_band_outer_m)
+ {
+ // Unbuffered, so progress shows up as it happens even when redirected to a file or pipe (MSVC treats _IOLBF as
+ // full buffering); the output is a line every few seconds.
+ std::setvbuf(stdout, nullptr, _IONBF, 0);
+ const PreflightResult check = runPreflight(recording, output_folder);
+ for (const auto& f : check.findings)
+ std::printf(
+ "%s %s\n",
+ f.severity == Severity::Error ? "[X]"
+ : f.severity == Severity::Warning ? "[!]"
+ : "[OK]",
+ f.message.c_str());
+ if (!check.ok())
+ return 1;
+
+ Pipeline pipeline;
+ std::string error;
+ if (!pipeline.start(PipelineRequest{ recording, output_folder, findStep1Executable(), settings, check_band_outer_m }, error))
+ {
+ std::printf("[X] %s\n", error.c_str());
+ return 1;
+ }
+ std::string last;
+ while (pipeline.busy())
+ {
+ const PipelineStatus s = pipeline.status();
+ char line[256];
+ const double f = s.step1.phaseFraction();
+ if (s.stage == PipelineStage::Export)
+ std::snprintf(line, sizeof(line), "Export: %.0f%%", s.export_fraction * 100.0);
+ else
+ std::snprintf(
+ line,
+ sizeof(line),
+ "%s: %s overall %.0f%%",
+ phaseLabel(s.step1.phase),
+ f >= 0.0 ? (std::to_string(static_cast(f * 100)) + "%,").c_str() : "",
+ s.step1.overallFraction() * 100.0);
+ if (last != line)
+ std::printf("[%s] %s\n", formatDuration(s.elapsed_s).c_str(), line);
+ last = line;
+ std::this_thread::sleep_for(std::chrono::seconds(5));
+ }
+ const PipelineStatus s = pipeline.status();
+ std::printf(
+ "%s %s (%s)\n", s.state == PipelineState::Succeeded ? "[OK]" : "[X]", s.message.c_str(), formatDuration(s.elapsed_s).c_str());
+ if (!s.detail.empty())
+ std::printf(" detail: %s\n", s.detail.c_str());
+ return s.state == PipelineState::Succeeded ? 0 : 1;
+ }
+
+ std::atomic g_interrupts{ 0 };
+
+ extern "C" void onInterrupt(int)
+ {
+ g_interrupts++;
+ std::signal(SIGINT, onInterrupt); // some C libraries reset the handler after each signal
+ }
+
+ std::string pointsText(uint64_t points)
+ {
+ char buffer[32];
+ std::snprintf(buffer, sizeof(buffer), "%.1f M", points / 1e6);
+ return buffer;
+ }
+
+ //! `easy_gui --batch `: batch mode without a window, with the GUI's rules: every
+ //! recording that passes the check is processed, finished ones are skipped. Ctrl+C once stops after the current
+ //! recording, twice stops now. Exit code 0 = every selected recording succeeded (or was already done), 1 = some
+ //! failed, were not run or the batch was refused, 2 = usage.
+ int runBatchHeadless(const fs::path& parent, const fs::path& output_root, const Settings& settings, double check_band_outer_m)
+ {
+ std::setvbuf(stdout, nullptr, _IONBF, 0);
+ std::error_code ec;
+ if (!fs::is_directory(parent, ec))
+ {
+ std::printf("[X] The folder '%s' does not exist or is not a folder.\n", parent.string().c_str());
+ return 1;
+ }
+ std::printf("Looking for recordings in '%s' and checking them...\n", parent.string().c_str());
+ BatchPlan plan = planBatch(parent, output_root);
+ BatchRequest& request = plan.request;
+ request.step1_executable = findStep1Executable();
+ request.settings = settings;
+ request.check_band_outer_m = check_band_outer_m;
+ for (const auto& f : request.skipped)
+ std::printf("[skipped] %s: %s\n", f.name.c_str(), f.skip_reason.c_str());
+ if (request.items.empty())
+ {
+ std::printf("[X] No recordings found in '%s' or its subfolders.\n", parent.string().c_str());
+ return 1;
+ }
+ if (const std::string problem = batchOutputProblem(output_root, plan.found); !problem.empty())
+ {
+ std::printf("[X] %s\n", problem.c_str());
+ return 1;
+ }
+
+ for (size_t i = 0; i < request.items.size(); i++)
+ {
+ const BatchItem& item = request.items[i];
+ const BatchCheck& c = plan.checks[i];
+ const std::string label = item.name + (item.output_name == item.name ? "" : " -> " + item.output_name);
+ if (c.done)
+ std::printf("[done] %s: already processed (finished %s)\n", label.c_str(), c.done->finished.c_str());
+ else if (!item.include)
+ std::printf("[X] %s: %s\n", label.c_str(), firstError(c.preflight).c_str());
+ else
+ {
+ std::printf(
+ "[OK] %s: %s, %s points\n",
+ label.c_str(),
+ formatDuration(c.preflight.duration_s).c_str(),
+ pointsText(c.preflight.total_points).c_str());
+ for (const auto& f : c.preflight.findings)
+ if (f.severity == Severity::Warning)
+ std::printf(" [!] %s\n", f.message.c_str());
+ }
+ }
+ if (std::none_of(
+ request.items.begin(),
+ request.items.end(),
+ [](const BatchItem& item)
+ {
+ return item.include;
+ }))
+ {
+ std::printf("[X] None of the recordings passed the check; nothing to process.\n");
+ return 1;
+ }
+
+ uint64_t available = 0;
+ fs::path checked;
+ const bool known = availableSpace(output_root, available, checked);
+ const Finding disk = batchDiskFinding(batchDiskNeed(plan.toProcess()), known, available);
+ std::printf(
+ "%s %s\n",
+ disk.severity == Severity::Error ? "[X]"
+ : disk.severity == Severity::Warning ? "[!]"
+ : "[OK]",
+ disk.message.c_str());
+ if (disk.severity == Severity::Error)
+ return 1;
+
+ BatchRunner runner;
+ std::string error;
+ if (!runner.start(request, error))
+ {
+ std::printf("[X] %s\n", error.c_str());
+ return 1;
+ }
+ std::signal(SIGINT, onInterrupt);
+ int interrupts_seen = 0;
+ std::string last;
+ auto last_print = std::chrono::steady_clock::now() - std::chrono::seconds(5);
+ while (runner.busy())
+ {
+ std::this_thread::sleep_for(std::chrono::milliseconds(500));
+ if (const int n = g_interrupts.load(); n != interrupts_seen)
+ {
+ interrupts_seen = n;
+ if (n == 1)
+ {
+ runner.stopAfterCurrent();
+ std::printf("Stopping after the current recording. Press Ctrl+C again to stop now.\n");
+ }
+ else
+ {
+ runner.stopNow();
+ std::printf("Stopping now.\n");
+ }
+ }
+ const BatchStatus s = runner.status();
+ std::string line = "overall " + std::to_string(static_cast(s.fraction * 100)) + "%";
+ if (s.eta_s >= 0.0)
+ line += ", about " + formatDuration(s.eta_s) + " left";
+ if (s.current >= 0)
+ {
+ const PipelineStatus& p = s.current_status;
+ std::string stage = p.stage == PipelineStage::Odometry ? phaseLabel(p.step1.phase)
+ : p.stage == PipelineStage::Export ? "Export"
+ : "Preparing";
+ line += " | " + std::to_string(s.current + 1) + "/" + std::to_string(request.items.size()) + " " +
+ request.items[s.current].name + ": " + stage + " " + std::to_string(static_cast(pipelineFraction(p) * 100)) + "%";
+ }
+ const auto now = std::chrono::steady_clock::now();
+ if (line != last && now - last_print >= std::chrono::seconds(5))
+ {
+ std::printf("[%s] %s\n", formatDuration(s.elapsed_s).c_str(), line.c_str());
+ last = line;
+ last_print = now;
+ }
+ }
+ std::signal(SIGINT, SIG_DFL);
+
+ const BatchStatus s = runner.status();
+ bool all_good = true;
+ for (size_t i = 0; i < request.items.size(); i++)
+ {
+ const BatchItemResult& r = s.items[i];
+ const bool good = r.state == BatchItemState::Succeeded || r.state == BatchItemState::AlreadyDone;
+ if (request.items[i].include && !good)
+ all_good = false;
+ std::printf(
+ "%s %s: %s%s%s\n",
+ good ? "[OK]"
+ : r.state == BatchItemState::Excluded ? "[--]"
+ : "[X]",
+ request.items[i].name.c_str(),
+ batchStateToken(r.state),
+ r.error.empty() ? "" : ": ",
+ r.error.c_str());
+ }
+ std::printf(
+ "Batch %s after %s. Summary: %s\n",
+ s.stop_after_current ? "stopped" : "finished",
+ formatDuration(s.elapsed_s).c_str(),
+ (output_root / BatchRunner::kSummaryFileName).string().c_str());
+ return all_good ? 0 : 1;
+ }
+
+ //! Dev aid: `easy_gui --export-only [--check-band ]` runs only the
+ //! export, on an existing step 1 session of that recording (step 1 takes ~20 min on a 10-min walk; the export
+ //! about a minute). The output folder may exist; the three result files in it are overwritten.
+ int runExportOnly(
+ const fs::path& recording,
+ const fs::path& session_file,
+ const fs::path& output_folder,
+ const Settings& settings,
+ double check_band_outer_m)
+ {
+ std::setvbuf(stdout, nullptr, _IONBF, 0);
+ const PreflightResult check = runPreflight(recording, output_folder);
+ if (check.files.pairs == 0)
+ {
+ std::printf("[X] no lidar/IMU file pairs in '%s'\n", recording.string().c_str());
+ return 1;
+ }
+ const OutputLayout out(output_folder);
+ ExportRequest request;
+ request.session_file = session_file;
+ request.recording = check.files;
+ request.lidar_id = std::max(0, check.lidar_id);
+ request.near_min_range_m = settings.near_min_range_m;
+ request.check_band_outer_m = check_band_outer_m;
+ request.place_by_raw_time = settings.place_by_raw_time;
+ request.laz_file = out.point_cloud;
+ request.trajectory_file = out.trajectory;
+ request.readme_file = out.readme;
+
+ std::atomic cancel{ false };
+ int last_percent = -1;
+ ExportStats stats;
+ std::string error;
+ const bool ok = exportSession(
+ request,
+ cancel,
+ [&](double fraction, const std::string& step)
+ {
+ const int percent = static_cast(fraction * 100);
+ if (percent / 10 != last_percent / 10)
+ std::printf("[%3d%%] %s\n", percent, step.c_str());
+ last_percent = percent;
+ },
+ stats,
+ error);
+ if (!ok)
+ {
+ std::printf("[X] %s\n", error.c_str());
+ return 1;
+ }
+ std::printf(
+ "[OK] %llu odometry + %llu near + %llu gap + %llu check-band points; raw read %llu, no-return %llu, outside trajectory %llu; "
+ "range %.3f - %.3f m; %zu poses; LAZ %llu bytes, CSV %llu bytes\n",
+ static_cast(stats.odometry_points),
+ static_cast(stats.near_points),
+ static_cast(stats.gap_points),
+ static_cast(stats.check_band_points),
+ static_cast(stats.raw_points_read),
+ static_cast(stats.raw_no_return),
+ static_cast(stats.raw_outside_trajectory),
+ stats.range_min_m,
+ stats.range_max_m,
+ stats.trajectory_nodes,
+ static_cast(stats.laz_bytes),
+ static_cast(stats.trajectory_bytes));
+ return 0;
+ }
+
+ //! Removes `--check-band ` from the arguments; returns the value, or 0 when absent.
+ double takeCheckBand(std::vector& args)
+ {
+ for (size_t i = 0; i + 1 < args.size(); i++)
+ if (args[i] == "--check-band")
+ {
+ const double value = std::atof(args[i + 1].c_str());
+ args.erase(args.begin() + static_cast(i), args.begin() + static_cast(i) + 2);
+ return value;
+ }
+ return 0.0;
+ }
+
+ //! Removes `flag` from the arguments; returns whether it was there.
+ bool takeFlag(std::vector& args, const std::string& flag)
+ {
+ const auto it = std::find(args.begin(), args.end(), flag);
+ if (it == args.end())
+ return false;
+ args.erase(it);
+ return true;
+ }
+} // namespace
+
+int main(int argc, char* argv[])
+{
+ std::vector