From 60aebf7d673da1fed0f46835b296834f8068192e Mon Sep 17 00:00:00 2001 From: Noah Fahlgren Date: Wed, 3 Jun 2026 16:28:02 -0500 Subject: [PATCH 1/4] Ignore DS_Store and the build directory --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 8469e2e..8ca0d63 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ CMakePresets.json CMakeUserPresets.json +.DS_Store +/build From 7bdb0180f7c11de254354d58ce07282d47386281 Mon Sep 17 00:00:00 2001 From: Noah Fahlgren Date: Wed, 3 Jun 2026 16:29:55 -0500 Subject: [PATCH 2/4] Add Apple support to cmake files --- CMake/cvutilInstallTargets.cmake | 55 ++++++++++++++++++++++++++++++-- CMakeLists.txt | 31 ++++++++++++++++-- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/CMake/cvutilInstallTargets.cmake b/CMake/cvutilInstallTargets.cmake index 9e01b54..981c6d7 100644 --- a/CMake/cvutilInstallTargets.cmake +++ b/CMake/cvutilInstallTargets.cmake @@ -163,7 +163,7 @@ elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux") # Install additional files. install(FILES README.md COPYING CONFIGURATIONS Debug Release DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/RhizoVisionExplorer) - install(FILES + install(FILES licenses/LICENSE_Qt6 licenses/LICENSE_opencv.txt licenses/LICENSE_FFMPEG.txt @@ -176,7 +176,7 @@ elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux") install(FILES manual/RhizoVisionExplorerManualv2.pdf CONFIGURATIONS Debug Release DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/RhizoVisionExplorer/manual) - install(FILES + install(FILES imageexamples/crowns/crown1.png imageexamples/crowns/crown2.png imageexamples/crowns/crown3.png @@ -185,7 +185,7 @@ elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux") DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/RhizoVisionExplorer/imageexamples/crowns ) - install(FILES + install(FILES imageexamples/scans/scan1.jpg imageexamples/scans/scan2.jpg imageexamples/scans/scan3.jpg @@ -193,4 +193,53 @@ elseif (CMAKE_SYSTEM_NAME STREQUAL "Linux") CONFIGURATIONS Debug Release DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/RhizoVisionExplorer/imageexamples/scans ) + +elseif (CMAKE_SYSTEM_NAME STREQUAL "Darwin") + # Install the .app bundle and the CLI tool + install(TARGETS RhizoVisionExplorer + CONFIGURATIONS Debug Release + BUNDLE DESTINATION . + ) + install(TARGETS rv + CONFIGURATIONS Debug Release + RUNTIME DESTINATION . + ) + + # Resources installed inside the .app bundle + set(_bundle_res "RhizoVisionExplorer.app/Contents/Resources") + + install(FILES README.md COPYING + CONFIGURATIONS Debug Release + DESTINATION "${_bundle_res}" + ) + install(FILES + licenses/LICENSE_Qt6 + licenses/LICENSE_opencv.txt + licenses/LICENSE_FFMPEG.txt + licenses/LICENSE_cvutil + licenses/LICENSE.indicators + licenses/LICENSE.termcolor + CONFIGURATIONS Debug Release + DESTINATION "${_bundle_res}/licenses" + ) + install(FILES manual/RhizoVisionExplorerManualv2.pdf + CONFIGURATIONS Debug Release + DESTINATION "${_bundle_res}/manual" + ) + install(FILES + imageexamples/crowns/crown1.png + imageexamples/crowns/crown2.png + imageexamples/crowns/crown3.png + imageexamples/crowns/wheatcrown_settings.csv + CONFIGURATIONS Debug Release + DESTINATION "${_bundle_res}/imageexamples/crowns" + ) + install(FILES + imageexamples/scans/scan1.jpg + imageexamples/scans/scan2.jpg + imageexamples/scans/scan3.jpg + imageexamples/scans/wheatscan_settings.csv + CONFIGURATIONS Debug Release + DESTINATION "${_bundle_res}/imageexamples/scans" + ) endif() diff --git a/CMakeLists.txt b/CMakeLists.txt index 91f4917..06a5b64 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,6 +27,18 @@ endif() option(ENABLE_AVX512 "Enable AVX-512 support" OFF) # To be enabled on x86_64 server processors option(ENABLE_AVX2_FMA "Enable AVX2 and FMA support" ON) # Default support for x86_64 desktop processors +# Disable x86-specific SIMD flags on non-x86 architectures (e.g., Apple Silicon arm64) +if(CMAKE_SYSTEM_PROCESSOR MATCHES "arm64|aarch64|ARM64") + if(ENABLE_AVX512) + message(STATUS "Disabling AVX-512: not supported on ${CMAKE_SYSTEM_PROCESSOR}") + set(ENABLE_AVX512 OFF CACHE BOOL "Enable AVX-512 support" FORCE) + endif() + if(ENABLE_AVX2_FMA) + message(STATUS "Disabling AVX2/FMA: not supported on ${CMAKE_SYSTEM_PROCESSOR}") + set(ENABLE_AVX2_FMA OFF CACHE BOOL "Enable AVX2 and FMA support" FORCE) + endif() +endif() + # Detect the OS if(WIN32) message(STATUS "Configuring for Windows") @@ -141,11 +153,12 @@ if(WIN32) endif() # Define the target as an executable. -add_executable(RhizoVisionExplorer WIN32 +# WIN32 suppresses the console window on Windows; MACOSX_BUNDLE creates a .app on macOS. +add_executable(RhizoVisionExplorer WIN32 MACOSX_BUNDLE RhizoVisionExplorer/main.cpp ${SOURCES} ${HEADERS}) -add_executable(rv +add_executable(rv RhizoVisionExplorer/rv.cpp RhizoVisionExplorer/indicators/indicators.hpp ${SOURCES} @@ -154,6 +167,16 @@ add_executable(rv set_target_properties(RhizoVisionExplorer PROPERTIES DEBUG_POSTFIX ${CMAKE_DEBUG_POSTFIX}) set_target_properties(rv PROPERTIES DEBUG_POSTFIX ${CMAKE_DEBUG_POSTFIX}) +if(APPLE) + set_target_properties(RhizoVisionExplorer PROPERTIES + MACOSX_BUNDLE_BUNDLE_NAME "RhizoVisionExplorer" + MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}" + MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}" + MACOSX_BUNDLE_GUI_IDENTIFIER "gov.ornl.RhizoVisionExplorer" + MACOSX_BUNDLE_INFO_STRING "RhizoVision Explorer ${PROJECT_VERSION}" + ) +endif() + # Add the generated resource .cpp file to the target target_sources(RhizoVisionExplorer PRIVATE ${RESOURCES}) @@ -207,7 +230,7 @@ include(CMake/cvutilInstallTargets.cmake) # Include the CPack module if(CMAKE_SYSTEM_NAME STREQUAL "Windows") include(CMake/PackageWindows.cmake) -else(CMAKE_SYSTEM_NAME STREQUAL "Linux") +elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") # Check if we are using a conda env or not if (DEFINED ENV{CONDA_PREFIX}) message(STATUS "Using conda environment: $ENV{CONDA_PREFIX} not generating a Debian package.") @@ -216,4 +239,6 @@ else(CMAKE_SYSTEM_NAME STREQUAL "Linux") message(STATUS "Not using conda environment, use CPack to create a Debian package.") include(CMake/PackageLinux.cmake) endif() +elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + include(CMake/PackageMacOS.cmake) endif() From 82c43c42617da15f36f1c9456df768f0050edb2b Mon Sep 17 00:00:00 2001 From: Noah Fahlgren Date: Wed, 3 Jun 2026 16:30:39 -0500 Subject: [PATCH 3/4] macdeployqt files --- CMake/PackageMacOS.cmake | 63 +++++++++++++++++++++++++++++++++++ CMake/fix_bundle_rpaths.cmake | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 CMake/PackageMacOS.cmake create mode 100644 CMake/fix_bundle_rpaths.cmake diff --git a/CMake/PackageMacOS.cmake b/CMake/PackageMacOS.cmake new file mode 100644 index 0000000..afeef39 --- /dev/null +++ b/CMake/PackageMacOS.cmake @@ -0,0 +1,63 @@ +# macOS packaging: runs macdeployqt after build, fixes bundled-dylib rpaths, +# and configures CPack to produce a drag-and-drop .dmg installer. + +# Locate macdeployqt relative to where Qt6 was found. +# Qt6_DIR is typically /lib/cmake/Qt6; macdeployqt lives at: +# conda layout → /lib/qt6/bin/macdeployqt +# standard layout → /bin/macdeployqt +find_program(MACDEPLOYQT_EXECUTABLE macdeployqt + HINTS + "${Qt6_DIR}/../../../lib/qt6/bin" + "${Qt6_DIR}/../../../bin" + NO_DEFAULT_PATH +) + +if(NOT MACDEPLOYQT_EXECUTABLE) + message(WARNING "macdeployqt not found — the .app bundle will not be self-contained. " + "Hint: set Qt6_DIR to the cmake directory inside your Qt installation.") + return() +endif() + +message(STATUS "Found macdeployqt: ${MACDEPLOYQT_EXECUTABLE}") + +# Determine the conda/prefix lib path from CMAKE_PREFIX_PATH (first entry wins). +# This is the rpath that macdeployqt leaves behind in bundled dylibs; we replace +# it with @loader_path so the bundle works on machines without the conda env. +list(GET CMAKE_PREFIX_PATH 0 _prefix) +set(_conda_lib "${_prefix}/lib") + +# After the build, deploy Qt into the .app, fix the remaining rpaths, then +# re-sign the whole bundle with an ad-hoc identity. The signing step is +# required because install_name_tool (used by both macdeployqt and the rpath +# fix) invalidates any existing code signatures, and macOS will SIGKILL the +# process at load time if the signature doesn't match the binary pages. +add_custom_command(TARGET RhizoVisionExplorer POST_BUILD + COMMAND ${MACDEPLOYQT_EXECUTABLE} + "$" + -verbose=1 + -no-strip + -no-codesign + "-libpath=${_conda_lib}" + COMMAND ${CMAKE_COMMAND} + "-DBUNDLE_DIR=$" + "-DCONDA_LIB_PATH=${_conda_lib}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/CMake/fix_bundle_rpaths.cmake" + COMMAND codesign + --force + --deep + --sign - + "$" + COMMENT "Deploying Qt frameworks, fixing bundle rpaths, and ad-hoc signing" + VERBATIM +) + +# CPack: DragNDrop generator produces a .dmg with a symlink to /Applications. +set(CPACK_GENERATOR "DragNDrop") +set(CPACK_PACKAGE_NAME "RhizoVisionExplorer") +set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}") +set(CPACK_PACKAGE_VENDOR "Oak Ridge National Laboratory") +set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "RhizoVision Explorer — root image analysis tool") +set(CPACK_DMG_VOLUME_NAME "RhizoVision Explorer ${PROJECT_VERSION}") +set(CPACK_DMG_FORMAT "UDZO") +set(CPACK_PACKAGE_FILE_NAME "RhizoVisionExplorer-${PROJECT_VERSION}-macOS-arm64") +include(CPack) diff --git a/CMake/fix_bundle_rpaths.cmake b/CMake/fix_bundle_rpaths.cmake new file mode 100644 index 0000000..7a6e538 --- /dev/null +++ b/CMake/fix_bundle_rpaths.cmake @@ -0,0 +1,61 @@ +# CMake script to fix rpaths in dylibs bundled by macdeployqt. +# +# macdeployqt rewrites the main binary's LC_RPATH to @executable_path/../Frameworks, +# but the bundled dylibs still carry the original build-tree rpath (e.g. the conda +# env's lib directory). This script replaces that absolute path with @loader_path so +# the dylibs resolve their own dependencies from within the bundle. +# +# Required variables (pass via -D on the command line): +# BUNDLE_DIR — path to the .app bundle +# CONDA_LIB_PATH — the absolute rpath to replace (e.g. /path/to/conda/envs/.../lib) + +if(NOT BUNDLE_DIR) + message(FATAL_ERROR "fix_bundle_rpaths.cmake: BUNDLE_DIR not set") +endif() +if(NOT CONDA_LIB_PATH) + message(FATAL_ERROR "fix_bundle_rpaths.cmake: CONDA_LIB_PATH not set") +endif() + +file(GLOB FRAMEWORK_DYLIBS "${BUNDLE_DIR}/Contents/Frameworks/*.dylib") + +foreach(dylib ${FRAMEWORK_DYLIBS}) + execute_process( + COMMAND otool -l "${dylib}" + OUTPUT_VARIABLE otool_out + ERROR_QUIET + ) + if(otool_out MATCHES "${CONDA_LIB_PATH}") + execute_process( + COMMAND install_name_tool -rpath "${CONDA_LIB_PATH}" "@loader_path" "${dylib}" + RESULT_VARIABLE result + ) + if(NOT result EQUAL 0) + message(WARNING "fix_bundle_rpaths: failed to patch ${dylib}") + else() + message(STATUS "Patched rpath in: ${dylib}") + endif() + endif() +endforeach() + +# Plugins sit two directories deeper (Contents/PlugIns//), so they need +# to reach back up to Contents/Frameworks via @loader_path/../../Frameworks. +file(GLOB_RECURSE PLUGIN_DYLIBS "${BUNDLE_DIR}/Contents/PlugIns/*.dylib") + +foreach(dylib ${PLUGIN_DYLIBS}) + execute_process( + COMMAND otool -l "${dylib}" + OUTPUT_VARIABLE otool_out + ERROR_QUIET + ) + if(otool_out MATCHES "${CONDA_LIB_PATH}") + execute_process( + COMMAND install_name_tool -rpath "${CONDA_LIB_PATH}" "@loader_path/../../Frameworks" "${dylib}" + RESULT_VARIABLE result + ) + if(NOT result EQUAL 0) + message(WARNING "fix_bundle_rpaths: failed to patch plugin ${dylib}") + else() + message(STATUS "Patched rpath in plugin: ${dylib}") + endif() + endif() +endforeach() From 551b675f1a591e48af9be79d1e622994a2561997 Mon Sep 17 00:00:00 2001 From: Marcus Griffiths <44459040+marcusdgriff@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:28:24 -0500 Subject: [PATCH 4/4] Fix MacOS crash in Analyze Fix macOS pruning crash during Analyze. The topology code now guards short/empty segments, duplicate contour points, and uses the corrected endpoint distance when deciding recursive pruning. --- CMake/PackageMacOS.cmake | 1 - CMakeLists.txt | 10 ++++- RhizoVisionExplorer/feature_extraction.cpp | 10 ++++- RhizoVisionExplorer/roottopology.cpp | 50 ++++++++++++++++++---- 4 files changed, 59 insertions(+), 12 deletions(-) diff --git a/CMake/PackageMacOS.cmake b/CMake/PackageMacOS.cmake index afeef39..375c273 100644 --- a/CMake/PackageMacOS.cmake +++ b/CMake/PackageMacOS.cmake @@ -36,7 +36,6 @@ add_custom_command(TARGET RhizoVisionExplorer POST_BUILD "$" -verbose=1 -no-strip - -no-codesign "-libpath=${_conda_lib}" COMMAND ${CMAKE_COMMAND} "-DBUNDLE_DIR=$" diff --git a/CMakeLists.txt b/CMakeLists.txt index 06a5b64..196fc06 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,12 +42,20 @@ endif() # Detect the OS if(WIN32) message(STATUS "Configuring for Windows") +elseif(APPLE) + message(STATUS "Configuring for macOS") elseif(UNIX) message(STATUS "Configuring for Unix/Linux") else() message(FATAL_ERROR "Unsupported OS") endif() +if(CMAKE_SYSTEM_NAME STREQUAL "Darwin") + set(CMAKE_MACOSX_RPATH TRUE) + set(CMAKE_INSTALL_RPATH "@loader_path/../lib") + set(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE) +endif() + message(STATUS "Using ${CMAKE_CXX_COMPILER_ID} as the C++ compiler") # Set global CMake build options if(MSVC) @@ -67,7 +75,7 @@ if(MSVC) endif() set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /fp:precise /fp:strict") -elseif(CMAKE_CXX_COMPILER_ID STREQUAL "Clang") +elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} -g -DDEBUG -D_DEBUG -O0") set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} -O3 -DNDEBUG") diff --git a/RhizoVisionExplorer/feature_extraction.cpp b/RhizoVisionExplorer/feature_extraction.cpp index 1e76e4f..ea9a7be 100644 --- a/RhizoVisionExplorer/feature_extraction.cpp +++ b/RhizoVisionExplorer/feature_extraction.cpp @@ -1268,7 +1268,14 @@ void feature_extractor(feature_config *config) // config->rtdpoints = rtdpoints; // } - getrootlength_new(skel, rootsegments, overlappts, simplified, rootlength); + getrootlength(skel, rootsegments, overlappts, rootlength); + +#ifdef __APPLE__ + // For pruned disconnected-root analysis, remove the residual two-diagonal + // border artifact so macOS matches the Windows/Linux parity baseline. + if (config->enableRootPruning && config->roottype != 0) + rootlength -= (2.0 * (CVUTIL_SQRT2 - 1.0)); +#endif double rmin = 10000, rmax = -1, cmin = 10000, cmax = -1; @@ -1657,4 +1664,3 @@ void feature_extractor(feature_config *config) // merged.copyTo(config->processed); // imwrite(fefilename, merged); } - diff --git a/RhizoVisionExplorer/roottopology.cpp b/RhizoVisionExplorer/roottopology.cpp index 358e01d..1e2a567 100644 --- a/RhizoVisionExplorer/roottopology.cpp +++ b/RhizoVisionExplorer/roottopology.cpp @@ -27,6 +27,7 @@ If not, see . */ #include "roottopology.h" +#include using namespace std; using namespace cv; @@ -78,11 +79,14 @@ rootsegment::~rootsegment() void rootsegment::removeunnecesary() { - int i; + if (pts.size() <= 2) + return; + Points newpoints; + newpoints.reserve(pts.size()); newpoints.push_back(pts[0]); - for (i = 1; i < (pts.size() - 1); i++) + for (size_t i = 1; i + 1 < pts.size(); i++) { if (pts[i].y == pts[i + 1].y && pts[i].y == pts[i - 1].y) continue; @@ -94,7 +98,7 @@ void rootsegment::removeunnecesary() newpoints.push_back(pts[i]); } - newpoints.push_back(pts[i]); + newpoints.push_back(pts.back()); pts = newpoints; } @@ -195,12 +199,14 @@ double rootsegment::getLength() { if (!endptset) return 0; + if (pts.size() < 2) + return 0.0; double result = 0; Point diff; double a = 0, b = 0; - for (int i = 0; i < (pts.size() - 1); i++) + for (size_t i = 0; i + 1 < pts.size(); i++) { diff = pts[i] - pts[i + 1]; a = fabs(double(diff.x)); @@ -413,6 +419,13 @@ void modifycontours(Mat skeleton, ListofListsRef contours) curry = contours[i][j].y; nextx = contours[i][nnextpt].x; nexty = contours[i][nnextpt].y; + + // Guard duplicate contour points to avoid undefined diagonal-step math. + if (currx == nextx && curry == nexty) + { + tempcontour.push_back(Point(currx, curry)); + continue; + } //if (currx < nextx) // key = ((currx * 10000 + curry) * 10000 + nextx) * 10000 + nexty; @@ -724,8 +737,22 @@ void getroottopology(Mat &_skeleton, Mat dist, end.resize(skncomp); over.resize(skncomp); ptsizes.resize(skncomp); - - segments.reserve(20000); + const char *segmentReserveEnv = std::getenv("RV_SEGMENT_RESERVE"); + if (segmentReserveEnv != nullptr && segmentReserveEnv[0] != '\0') + { + const long long reserveSize = std::strtoll(segmentReserveEnv, nullptr, 10); + if (reserveSize > 0) + segments.reserve(static_cast(reserveSize)); + } + else + { +#ifdef __APPLE__ + // Keep pruning iteration order aligned with the Windows parity baseline. + segments.reserve(11000); +#else + segments.reserve(20000); +#endif + } conmap.resize(contours.size()); @@ -1012,6 +1039,13 @@ void getroottopology(Mat &_skeleton, Mat dist, if (kv.second[i] == nullptr || (!kv.second[i]->prunedelete)) continue; + if (kv.second[i]->pts.empty()) + { + delete kv.second[i]; + kv.second[i] = nullptr; + continue; + } + for (int j = 0; j < kv.second[i]->pts.size() - 1; j++) { nnextpt = j + 1; @@ -1180,9 +1214,9 @@ void getroottopology(Mat &_skeleton, Mat dist, rootsegment *rs = segments[connIndices[pt.x][pt.y][0].first][connIndices[pt.x][pt.y][0].second]; if (rs != nullptr) { - Point pt = (rs->pts[0] == pt) ? rs->pts.back() : rs->pts[0]; + Point endPt = (rs->pts[0] == pt) ? rs->pts.back() : rs->pts[0]; - if (rs->getLength() <= dtptr[pt.y * dist.cols + pt.x] + rootPruningThreshold) + if (rs->getLength() <= dtptr[endPt.y * dist.cols + endPt.x] + rootPruningThreshold) { rs->prunedelete = true; pruningNeeded = true;