diff --git a/Libs/Optimize/Function/CorrespondenceFunction.cpp b/Libs/Optimize/Function/CorrespondenceFunction.cpp index fd7ae7a8d4..8799906551 100644 --- a/Libs/Optimize/Function/CorrespondenceFunction.cpp +++ b/Libs/Optimize/Function/CorrespondenceFunction.cpp @@ -8,6 +8,8 @@ #include "vnl/algo/vnl_svd.h" #include "vnl/vnl_diag_matrix.h" +#include + namespace shapeworks { void CorrespondenceFunction::ComputeUpdates(const ParticleSystem* c) { @@ -178,48 +180,54 @@ void CorrespondenceFunction::ComputeUpdates(const ParticleSystem* c) { vnl_diag_matrix W; - vnl_matrix_type gramMat(num_samples, num_samples, 0.0); - vnl_matrix_type pinvMat(num_samples, num_samples, 0.0); // gramMat inverse + vnl_matrix_type Q; if (this->m_UseMeanEnergy) { - pinvMat.set_identity(); + // In mean-energy mode the inverse covariance is the identity, so Q is just + // the mean-centered points. Skip building an N×N identity and the multiply, + // which is a no-op costing O(P·N²) per iteration. + Q = points_minus_mean; } else { + using RowMatrixXd = Eigen::Matrix; + TIME_START("correspondence::gramMat"); // Create Eigen maps that point to the VNL data - Eigen::Map> points_minus_mean_map( - points_minus_mean.data_block(), points_minus_mean.rows(), points_minus_mean.cols()); + Eigen::Map points_minus_mean_map(points_minus_mean.data_block(), points_minus_mean.rows(), + points_minus_mean.cols()); - // Perform the computation directly on the mapped data + // gramMat = Yᵀ Y is symmetric positive-semidefinite. Eigen::MatrixXd gramMat_eigen = points_minus_mean_map.transpose() * points_minus_mean_map; - // Copy result back to VNL matrix - gramMat.set_size(gramMat_eigen.rows(), gramMat_eigen.cols()); - std::memcpy(gramMat.data_block(), gramMat_eigen.data(), gramMat_eigen.size() * sizeof(double)); - TIME_STOP("correspondence::gramMat"); TIME_START("correspondence::svd"); - vnl_svd svd(gramMat); - vnl_matrix_type UG = svd.U(); - W = svd.W(); - vnl_diag_matrix invLambda = svd.W(); + // The Gram matrix is symmetric positive-semidefinite, so a self-adjoint + // eigensolver yields the same eigenpairs as a general SVD but much faster, + // and it parallelizes the surrounding dense algebra through Eigen. + Eigen::SelfAdjointEigenSolver eig(gramMat_eigen); + const Eigen::VectorXd& eigvals = eig.eigenvalues(); + const Eigen::MatrixXd& UG = eig.eigenvectors(); TIME_STOP("correspondence::svd"); - invLambda.set_diagonal(invLambda.get_diagonal() / (double)(num_samples - 1) + m_MinimumVariance); - invLambda.invert_in_place(); + // Energy term uses the eigenvalues (== singular values of the Gram matrix). + W = vnl_diag_matrix(num_samples); + for (int i = 0; i < num_samples; ++i) W(i) = eigvals(i); + + // pinvMat = UG · diag(1 / (λ/(N-1) + α)) · UGᵀ (symmetric). + Eigen::ArrayXd invLambda = (eigvals.array() / (double)(num_samples - 1) + m_MinimumVariance).inverse(); TIME_START("correspondence::pinvMat"); - pinvMat = (UG * invLambda) * UG.transpose(); + Eigen::MatrixXd pinv = UG * invLambda.matrix().asDiagonal() * UG.transpose(); TIME_STOP("correspondence::pinvMat"); - // Note: The full dM × dM inverse covariance matrix is NOT needed. - // Per thesis equation 2.35, the gradient is simply: Y × (Y^T Y + αI)^{-1} - // which is Y × pinvMat. No covariance_multiply required. + // Q = Y · pinvMat (thesis eq. 2.35: gradient is Y × (YᵀY + αI)⁻¹). + RowMatrixXd Q_eigen; + Q_eigen.noalias() = points_minus_mean_map * pinv; + Q.set_size(num_dims, num_samples); + std::memcpy(Q.data_block(), Q_eigen.data(), Q_eigen.size() * sizeof(double)); } - vnl_matrix_type Q = points_minus_mean * pinvMat; - // Compute the update matrix in coordinate space by multiplication with the // Jacobian. Each shape gradient must be transformed by a different Jacobian // so we have to do this individually for each shape (sample). diff --git a/Libs/Optimize/Function/LegacyCorrespondenceFunction.cpp b/Libs/Optimize/Function/LegacyCorrespondenceFunction.cpp index 92da12ab58..1942f71e4b 100644 --- a/Libs/Optimize/Function/LegacyCorrespondenceFunction.cpp +++ b/Libs/Optimize/Function/LegacyCorrespondenceFunction.cpp @@ -1,6 +1,7 @@ #include "LegacyCorrespondenceFunction.h" +#include #include #include "Libs/Optimize/Domain/ImageDomainWithGradients.h" @@ -160,31 +161,41 @@ void LegacyCorrespondenceFunction ::ComputeCovarianceMatrix() { vnl_diag_matrix W; - vnl_matrix_type gramMat(num_samples, num_samples, 0.0); - vnl_matrix_type pinvMat(num_samples, num_samples, 0.0); // gramMat inverse - if (this->m_UseMeanEnergy) { - pinvMat.set_identity(); + // In mean-energy mode the inverse covariance is the identity, so the update + // is just the mean-centered points. Skip building an N×N identity and the + // subsequent multiply, which is a no-op costing O(P·N²) per iteration. + m_PointsUpdate->update(points_minus_mean); } else { - gramMat = points_minus_mean.transpose() * points_minus_mean; - - vnl_svd svd(gramMat); - - vnl_matrix_type UG = svd.U(); - W = svd.W(); - - vnl_diag_matrix invLambda = svd.W(); - - invLambda.set_diagonal(invLambda.get_diagonal() / (double)(num_samples - 1) + m_MinimumVariance); - invLambda.invert_in_place(); - - pinvMat = (UG * invLambda) * UG.transpose(); - - // Note: The full dM × dM inverse covariance matrix is NOT needed. - // Per thesis equation 2.35, the gradient is simply: Y × (Y^T Y + αI)^{-1} - // which is Y × pinvMat. No covariance matrix multiplication required. + vnl_matrix_type gramMat = points_minus_mean.transpose() * points_minus_mean; + + // gramMat = Yᵀ Y is symmetric positive-semidefinite, so a self-adjoint + // eigensolver yields the same eigenpairs as a general SVD but much faster, + // and it parallelizes the surrounding dense algebra through Eigen. gramMat is + // symmetric, so the row-major VNL buffer maps to an identical Eigen matrix. + Eigen::Map gram_map(gramMat.data_block(), num_samples, num_samples); + Eigen::SelfAdjointEigenSolver eig(gram_map); + const Eigen::VectorXd& eigvals = eig.eigenvalues(); + const Eigen::MatrixXd& UG = eig.eigenvectors(); + + // Energy term uses the eigenvalues (== singular values of the Gram matrix). + W = vnl_diag_matrix(num_samples); + for (unsigned int i = 0; i < num_samples; ++i) W(i) = eigvals(i); + + // pinvMat = UG · diag(1 / (λ/(N-1) + α)) · UGᵀ (symmetric). + using RowMatrixXd = Eigen::Matrix; + Eigen::ArrayXd invLambda = (eigvals.array() / (double)(num_samples - 1) + m_MinimumVariance).inverse(); + Eigen::MatrixXd pinv = UG * invLambda.matrix().asDiagonal() * UG.transpose(); + + // update = Y · pinvMat (thesis eq. 2.35: gradient is Y × (YᵀY + αI)⁻¹). + Eigen::Map Y_map(points_minus_mean.data_block(), num_dims, num_samples); + RowMatrixXd update_eigen; + update_eigen.noalias() = Y_map * pinv; + + vnl_matrix_type update(num_dims, num_samples); + std::memcpy(update.data_block(), update_eigen.data(), update_eigen.size() * sizeof(double)); + m_PointsUpdate->update(update); } - m_PointsUpdate->update(points_minus_mean * pinvMat); // std::cout << m_PointsUpdate.extract(num_dims, num_samples,0,0) << std::endl; diff --git a/docs/img/optimize-scaling/cores_init_vs_opt.png b/docs/img/optimize-scaling/cores_init_vs_opt.png deleted file mode 100644 index b0bf560b1a..0000000000 Binary files a/docs/img/optimize-scaling/cores_init_vs_opt.png and /dev/null differ diff --git a/docs/img/optimize-scaling/cores_mixed_vs_N.png b/docs/img/optimize-scaling/cores_mixed_vs_N.png deleted file mode 100644 index 927cb9fa47..0000000000 Binary files a/docs/img/optimize-scaling/cores_mixed_vs_N.png and /dev/null differ diff --git a/docs/img/optimize-scaling/cores_vs_N.png b/docs/img/optimize-scaling/cores_vs_N.png index 4de2baffeb..6feea7fa60 100644 Binary files a/docs/img/optimize-scaling/cores_vs_N.png and b/docs/img/optimize-scaling/cores_vs_N.png differ diff --git a/docs/img/optimize-scaling/improvement_e2e_walltime.png b/docs/img/optimize-scaling/improvement_e2e_walltime.png new file mode 100644 index 0000000000..0facebadf6 Binary files /dev/null and b/docs/img/optimize-scaling/improvement_e2e_walltime.png differ diff --git a/docs/img/optimize-scaling/opt_fraction_vs_N.png b/docs/img/optimize-scaling/opt_fraction_vs_N.png deleted file mode 100644 index 32b7e17e35..0000000000 Binary files a/docs/img/optimize-scaling/opt_fraction_vs_N.png and /dev/null differ diff --git a/docs/img/optimize-scaling/phase_vs_N.png b/docs/img/optimize-scaling/phase_vs_N.png deleted file mode 100644 index 20244046d5..0000000000 Binary files a/docs/img/optimize-scaling/phase_vs_N.png and /dev/null differ diff --git a/docs/img/optimize-scaling/phase_walltime_vs_N.png b/docs/img/optimize-scaling/phase_walltime_vs_N.png deleted file mode 100644 index 8975487275..0000000000 Binary files a/docs/img/optimize-scaling/phase_walltime_vs_N.png and /dev/null differ diff --git a/docs/img/optimize-scaling/runtime_vs_N.png b/docs/img/optimize-scaling/runtime_vs_N.png index bfea9d7c57..20cd6b5b8d 100644 Binary files a/docs/img/optimize-scaling/runtime_vs_N.png and b/docs/img/optimize-scaling/runtime_vs_N.png differ diff --git a/docs/img/optimize-scaling/runtime_vs_P.png b/docs/img/optimize-scaling/runtime_vs_P.png index f30494ff3a..0be22a4847 100644 Binary files a/docs/img/optimize-scaling/runtime_vs_P.png and b/docs/img/optimize-scaling/runtime_vs_P.png differ diff --git a/docs/workflow/optimize-scaling.md b/docs/workflow/optimize-scaling.md index f8e1b65cd9..ea0c35f0b0 100644 --- a/docs/workflow/optimize-scaling.md +++ b/docs/workflow/optimize-scaling.md @@ -1,117 +1,117 @@ # Optimization Performance & Scaling -This page explains how `shapeworks optimize` scales with the size of your dataset: how runtime grows -with the number of shapes and particles, and why very large cohorts tend to use only a single CPU -core. If you are optimizing hundreds or thousands of shapes and seeing long runtimes or low CPU -utilization, this page describes what to expect and what you can do about it. +This page explains how `shapeworks optimize` scales with the size of your dataset, and what recent +improvements changed. If you are optimizing hundreds or thousands of shapes and want to know what to +expect for runtime and CPU usage, start here. ## What to expect -- Runtime grows cubically with the number of shapes (subjects, N). Doubling the number of shapes can - multiply optimization time by roughly 8x in the large-cohort regime. -- Large cohorts use about one CPU core. Small and medium datasets parallelize across many cores. As - the number of shapes grows into the thousands, the optimizer spends most of its time in a serial - step, so utilization falls toward a single core. -- Particle count is much cheaper than shape count. Increasing the number of particles grows runtime - far more slowly than increasing the number of shapes. - -## What you can do - -- Use incremental optimization for large cohorts. It optimizes a small initial subset of shapes from - scratch, then adds the remaining shapes in batches. The expensive particle initialization runs only - on the initial subset, and each later batch starts from already-placed particles, so the - full-cohort passes need far fewer iterations. This lowers total runtime. It does not remove the - per-iteration cost: the final batch still includes every shape and pays the O(N³) SVD each - iteration. See [Incremental Supershapes](../use-cases/multistep/incremental_supershapes.md) for a - worked example. Incremental optimization is available through the Python and command-line - interfaces; ShapeWorks Studio does not currently support it. -- Lowering the iteration count does not reduce the per-iteration cost. The expensive step runs every - iteration, so lowering `optimization_iterations` reduces the number of iterations but not the cost - of each one. -- If you need higher-resolution models, adding particles is much cheaper than adding shapes. - -## Why +- Runtime grows faster with the number of shapes (subjects, N) than with the number of particles. The + correspondence step at the center of each iteration decomposes an N×N matrix built from the shapes, + which costs O(N³), so adding shapes is much more expensive than adding particles. +- Recent improvements cut this cost and restored multi-core use in both phases of a run. A full run at + N=1024 is about 3.5x faster than before, and the gap grows with N. See + [Recent improvements](#recent-improvements) below. +- Adding particles is much cheaper than adding shapes. -Each optimization iteration has two parts: - -| part | complexity | parallel? | -|---|---|---| -| per-particle gradient update (`tbb::parallel_for` over subjects) | O(N·P) | yes, across subjects | -| correspondence entropy: an N×N Gram matrix and one SVD | O(N³) | no, single-threaded | +## Recent improvements -N is the number of subjects and P the number of particles. The per-particle gradient is spread across -cores, but the correspondence step runs a serial SVD on an N×N matrix once per iteration, which is -O(N³). As N grows, that serial cubic step dominates, so total runtime grows cubically and CPU usage -falls toward one core. This happens whether or not `use_normals` is enabled, because both -correspondence formulations build the same N×N SVD. +Two changes to the per-iteration correspondence update made both phases of a run faster and brought +back multi-core usage on large cohorts. Neither changes the optimization result. -## Measurements +1. Initialization no longer multiplies by an identity matrix. In the initialization (mean-energy) + phase the update reduces to the mean-centered points, but the code used to build an N×N identity + and run the full multiply every iteration, an O(P·N²) no-op. Removing it makes initialization far + faster at scale (the whole initialization phase at N=2048 went from about 10 minutes to about 45 + seconds) and keeps it multi-core. -Measured on a 24-core machine with 128 GB of memory. Cohorts were built by replicating a single femur -mesh (15,002 vertices) N times with a small random rigid transform and per-vertex noise, so N can be -varied over a wide range. Shape content does not affect the O(N³) SVD, which depends only on the -matrix size. Metrics are reported per iteration, since a full 100-iteration run at N=4096 takes days. +2. The optimization phase uses a symmetric eigensolver. The N×N matrix is symmetric + positive-semidefinite, so a symmetric eigensolver replaces the general SVD and the surrounding + matrix work runs multithreaded. This is faster per iteration by an amount that grows with N: about + 2.6x at N=512, 8x at N=2048, and 14x at N=4096. -### Runtime per iteration vs number of shapes +End to end, both changes together (initialization plus optimization), on the same cohorts: -![runtime vs N](../img/optimize-scaling/runtime_vs_N.png) +![end to end before and after](../img/optimize-scaling/improvement_e2e_walltime.png) -The curve is flat at small N, where the parallel work dominates, then bends up to follow the N³ -reference beyond N=512. The lines converge because the SVD cost does not depend on the particle -count. +| N | before | after | speedup | +|---|---|---|---| +| 256 | 10.0 s | 8.0 s | 1.3x | +| 512 | 53.6 s | 22.6 s | 2.4x | +| 1024 | 224.9 s | 64.0 s | 3.5x | -### The serial SVD overtakes the parallel work +Below a few hundred shapes the difference is small, because the parts these changes target only +dominate at larger N. The payoff grows with the cohort. -![phase vs N](../img/optimize-scaling/phase_vs_N.png) +CPU utilization also recovers. Mean cores busy for each phase (of 24), before and after, at P=256: -The serial SVD (slope near 3) passes the parallel per-particle work (slope near 1) at about N=1024 and -dominates beyond that. +| N | initialization before / after | optimization before / after | +|---|---|---| +| 256 | 11.9 / 13.8 | 9.0 / 10.9 | +| 1024 | 4.0 / 12.7 | 4.0 / 7.4 | +| 2048 | 2.0 / 12.6 | 2.4 / 7.0 | -### CPU utilization vs number of shapes +![CPU utilization vs N](../img/optimize-scaling/cores_vs_N.png) -![cores vs N](../img/optimize-scaling/cores_vs_N.png) +Before the changes, optimization utilization fell to about 1.5 cores at N=4096. After, it stays around +6, because the eigensolve itself is still serial even though the matrix work around it is now +threaded. So utilization is much better but still declines at the very high end. -Mean cores busy rises to a peak around N=64, then falls as the serial SVD takes over, dropping to -about 1.3 cores at N=4096. A cohort of thousands of shapes sits on the right side of this curve, in -the single-core regime. The high utilization on small datasets is the left side of the same curve. +## Why shapes cost more than particles -### Particle scaling is much milder +Each optimization iteration has two parts: -![runtime vs P](../img/optimize-scaling/runtime_vs_P.png) +| part | complexity | parallel? | +|---|---|---| +| per-particle gradient update (`tbb::parallel_for` over subjects) | O(N·P) | yes, across subjects | +| correspondence: build the N×N matrix and decompose it | O(N³) | the eigensolve is serial; the surrounding matrix work runs multithreaded | -Adding particles grows the cheaper terms, not the cubic SVD, so it scales far more slowly than adding -shapes. +N is the number of subjects and P the number of particles. The decomposition is O(N³) in the number +of shapes, so runtime still grows cubically with N even after the improvements. The changes lower the +constant and parallelize most of the surrounding work, but the eigensolve itself is serial, which is +why very large cohorts remain expensive. This holds whether or not `use_normals` is enabled, because +both correspondence formulations build the same N×N decomposition. -## Initialization vs optimization +![runtime vs N](../img/optimize-scaling/runtime_vs_N.png) -A run has two phases. Initialization adds particles by splitting, running `iterations_per_split` -iterations at each level. Optimization then runs `optimization_iterations` at the full particle count. -The expensive SVD runs in the optimization phase; initialization uses a cheaper mean-energy mode. For -a typical run, initialization is often the majority of the wall time. +Optimization time per iteration, before and after. Both follow the N³ reference at large N, so the +slope is unchanged, but the eigensolver change shifts the curve down by a widening margin, reaching +about 14x at N=4096. At very small N the two are within measurement noise; the eigensolver has a small +fixed overhead that does not matter once the matrices are tiny. -![cores init vs opt](../img/optimize-scaling/cores_init_vs_opt.png) +![runtime vs P](../img/optimize-scaling/runtime_vs_P.png) -Core utilization for each phase measured in isolation (P=256): +Adding particles grows the cheaper terms, not the cubic decomposition, so it scales far more slowly +than adding shapes. -| N | initialization cores | optimization cores | -|---|---|---| -| 256 | 11.9 | 9.0 | -| 1024 | 4.0 | 4.0 | -| 2048 | 2.0 | 2.4 | +## What you can do for very large cohorts -Initialization parallelizes well for typical cohorts of up to a few hundred shapes, but at thousands -of shapes it also becomes serial-bound. Very large cohorts see reduced CPU utilization in both phases. +- Use incremental optimization. It optimizes a small initial subset of shapes from scratch, then adds + the remaining shapes in batches. The expensive particle initialization runs only on the initial + subset, and each later batch starts from already-placed particles, so the full-cohort passes need + far fewer iterations. It does not remove the per-iteration cost: the final batch still includes + every shape and pays the O(N³) decomposition each iteration. See + [Incremental Supershapes](../use-cases/multistep/incremental_supershapes.md) for a worked example. + Incremental optimization is available through the Python and command-line interfaces; ShapeWorks + Studio does not currently support it. +- Lowering the iteration count does not reduce the per-iteration cost. The decomposition runs every + iteration, so lowering `optimization_iterations` reduces the number of iterations but not the cost + of each one. +- If you need higher-resolution models, adding particles is much cheaper than adding shapes. -![cores mixed](../img/optimize-scaling/cores_mixed_vs_N.png) +## Measurements -The blended view of a realistic run shows high utilization at small N, falling toward a single core as -the number of shapes reaches the thousands. +Measured on a 24-core machine with 128 GB of memory. Cohorts were built by replicating a single femur +mesh (15,002 vertices) N times with a small random rigid transform and per-vertex noise, so N can be +varied over a wide range. Shape content does not affect the decomposition cost, which depends only on +the matrix size. Before and after numbers come from back-to-back runs with the binary swapped per N +under the same load, since machine contention alone can move cross-run timings by roughly 1.8x. ## Summary -- The cubic runtime and single-core behavior on large cohorts are expected. A serial O(N³) SVD runs - once per optimization iteration. +- Runtime still grows cubically with the number of shapes, because the correspondence step decomposes + an N×N matrix each iteration. Recent improvements lowered the constant and restored multi-core use, + but did not change the cubic shape. - The number of shapes drives the cost. The number of particles has less of an impact. -- No setting removes the per-iteration O(N³) cost. Incremental optimization is the most useful option - for large cohorts: it does the expensive initialization on a small subset and lets later batches run - with far fewer iterations. +- For very large cohorts, incremental optimization is the most useful option: it does the expensive + initialization on a small subset and lets later batches run with far fewer iterations. diff --git a/install_shapeworks.sh b/install_shapeworks.sh index e591635d96..4dbc637e50 100644 --- a/install_shapeworks.sh +++ b/install_shapeworks.sh @@ -57,6 +57,26 @@ fi echo "Creating new conda environment for ShapeWorks called \"$CONDAENV\"..." +# Retry a network-flaky command (e.g. pip installs) up to $RETRY_MAX times. +# pip's own --retries only covers connection errors, not a completed-but-corrupt +# download that fails sha256 validation, so we retry at the shell level and purge +# any partially-cached/corrupt downloads between attempts. +RETRY_MAX=${RETRY_MAX:-3} +retry() { + local attempt=1 + while true; do + "$@" && return 0 + if (( attempt >= RETRY_MAX )); then + echo "Command failed after ${attempt} attempts: $*" + return 1 + fi + echo "Command failed (attempt ${attempt}/${RETRY_MAX}), purging pip cache and retrying in 10s: $*" + python -m pip cache purge || true + sleep 10 + attempt=$((attempt+1)) + done +} + function install_conda() { if ! command -v conda 2>/dev/null 1>&2; then echo "Installing Miniconda..." @@ -158,7 +178,7 @@ function install_conda() { echo "PATH: $PATH" echo "========================================" - if ! python -m pip install -r python_requirements.txt; then return 1; fi + if ! retry python -m pip install -r python_requirements.txt; then return 1; fi # install pytorch using light-the-torch # Use python -m to ensure we use the conda env's light_the_torch, not ~/.local/bin/ltt @@ -172,24 +192,24 @@ function install_conda() { echo "=========================================" if [[ $(uname -s) == "Darwin" ]] && [[ $(uname -m) == "x86_64" ]]; then # Intel Mac - use older versions with NumPy 1.x - if ! python -m light_the_torch install torch==2.2.2 torchaudio==2.2.2 torchvision==0.17.2; then return 1; fi + if ! retry python -m light_the_torch install torch==2.2.2 torchaudio==2.2.2 torchvision==0.17.2; then return 1; fi python -m pip install "numpy<2" else # Apple Silicon, Linux, Windows - use latest with NumPy 2.x support - if ! python -m light_the_torch install torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0; then return 1; fi + if ! retry python -m light_the_torch install torch==2.8.0 torchaudio==2.8.0 torchvision==0.23.0; then return 1; fi fi # for network analysis (optional - not available on all platforms/Python versions) # open3d needs to be installed differently on each platform so it's not part of python_requirements.txt OPEN3D_INSTALLED=NO if [[ "$(uname)" == "Linux" ]]; then - if python -m pip install open3d-cpu==0.19.0; then + if retry python -m pip install open3d-cpu==0.19.0; then OPEN3D_INSTALLED=YES else echo "WARNING: open3d-cpu could not be installed. Network analysis features will not be available." fi elif [[ "$(uname)" == "Darwin" ]]; then - if python -m pip install open3d==0.19.0; then + if retry python -m pip install open3d==0.19.0; then OPEN3D_INSTALLED=YES if [[ "$(uname -m)" == "arm64" ]]; then pushd $CONDA_PREFIX/lib/python3.12/site-packages/open3d/cpu @@ -202,7 +222,7 @@ function install_conda() { echo "WARNING: open3d could not be installed. Network analysis features will not be available." fi else - if python -m pip install open3d==0.19.0; then + if retry python -m pip install open3d==0.19.0; then OPEN3D_INSTALLED=YES else echo "WARNING: open3d could not be installed. Network analysis features will not be available."