Skip to content

Commit b73d735

Browse files
jeffdailymeta-codesync[bot]
authored andcommitted
Port pytorch3d (#2039)
Summary: Enables building pytorch3d's `_C` extension against a ROCm-built PyTorch and running the test suite on AMD GPUs, including the pulsar subrenderer. Verified on AMD Instinct MI250X (gfx90a, warpSize=64), HIP 7.2, PyTorch 2.13. ## Mechanics `torch.utils.cpp_extension.BuildExtension` auto-hipifies `.cu` sources of a `CUDAExtension` against a HIP-built torch (`cuda_runtime.h → hip/hip_runtime.h`, `cub:: → hipcub::`, `cudaStream_t → hipStream_t`, etc.), so most of the lift is build-system glue and a small number of CUDA intrinsics that don't have HIP equivalents. - `setup.py`: detect ROCm via `torch.version.hip is not None`; treat `ROCM_HOME` as the GPU-toolkit-root analogue of `CUDA_HOME` (without this, `CUDA_HOME is None` silently demoted the build to a CPU-only `CppExtension`); skip `CUB_HOME`, CUDA-13 visibility flags, and `-ccbin=` on ROCm. - `pytorch3d/csrc/pulsar/gpu/commands.h`: CUDA's `_rn`-suffixed FP rounding intrinsics (`__fadd_rn`, `__fdiv_rn`, `__fsqrt_rn`, `__fmaf_rn`, `__frcp_rn`) and `__saturatef` have no HIP equivalents — AMD's GPU ISA has no instruction-level rounding-mode override, so they expand to plain operators / `sqrtf` / `fmaf` / `1.0f/x` / `fmaxf(0,fminf(1,x))` on the `USE_ROCM` arm, which are rounding-mode-equivalent (both round-to-nearest-even). The HIP compiler may fuse `a+b*c` into a single-rounding FMA where CUDA's `_rn` would have prevented it; if FMA-fusion drift ever becomes a numerical issue, add `-ffp-contract=off` to pulsar's HIPCC flags. `__powf` is replaced with `powf`. `atomicAdd_block` has no HIP function-name equivalent — the semantic equivalent is `__hip_atomic_fetch_add(ptr, val, __ATOMIC_RELAXED, __HIP_MEMORY_SCOPE_WORKGROUP)` (plain HIP `atomicAdd` is device-scope, strictly stronger than block-scope and forces L2-coherent atomics). - `tests/test_point_mesh_distance.py`: loosen `grad_faces` tolerance in `test_point_face_distance` from `5e-7` to `5e-6` to match the sibling `test_face_point_distance`. The backward kernel uses `atomicAdd` and calls `alertNotDeterministic`; FP add order varies by wavefront width. - The X_t / camera-R/T equality checks in `test_points_alignment.py` and `test_cameras_alignment.py` are now skipped when `n_points <= dim` (resp. `batch_size <= 3` for camera-center alignment in 3D). Mean-centering renders the SVD rank-deficient in those cases, so the rotation around the degenerate axis is non-unique and different BLAS implementations (rocBLAS RDNA vs CDNA, cuBLAS) pick different valid null-space directions. The center-alignment check still runs and verifies the well-defined part of the transformation. Pull Request resolved: #2039 Test Plan: All GPU tests pass on both AMD Instinct MI250X (gfx90a, wave64, HIP 7.2) and AMD Radeon Pro W7800 (gfx1100, wave32, HIP 7.2.53211, torch 2.13.0a0). | Module | Result | |---|---| | knn, ball_query, sample_farthest_points, face_areas_normals | all pass | | rasterize_points, rasterize_meshes, chamfer, packed_to_padded | all pass | | interpolate_face_attributes, blending, compositing, sample_pdf, mesh_normal_consistency | all pass | | point_mesh_distance | 9/9 pass (with tolerance fix in this PR) | | pulsar/test_forward, test_channels, test_depth, test_hands, test_ortho, test_small_spheres | 10 passed (FB_TEST=1) | | test_render_points pulsar tests, test_camera_conversions::test_pulsar_conversion | 3 passed | | points_to_volumes, iou_box3d, marching_cubes | 20 failures, all env-only | The 20 env-only failures are `torch.inverse()` on CPU tensors in test reference paths; this verification host's PyTorch was built with `USE_LAPACK: 0` (only `mkl-static` `.a` archives in the conda env; PyTorch's `FindBLAS` looks for `libmkl_intel_lp64.so`). Unrelated to the port — re-verifying with a LAPACK-linked PyTorch is left to upstream. Reviewed By: MichaelRamamonjisoa Differential Revision: D106825690 Pulled By: bottler fbshipit-source-id: f7a9b6028e6fb555f3b8c0f9792e88b818327166
1 parent c307c64 commit b73d735

9 files changed

Lines changed: 171 additions & 57 deletions

File tree

.github/workflows/build.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,45 @@ jobs:
2121
run: |-
2222
conda create --name env --yes --quiet conda-build
2323
conda run --no-capture-output --name env python3 ./packaging/build_conda.py --use-conda-cuda
24+
25+
# Build-only verification for the ROCm/HIP code paths. Runs in an AMD ROCm
26+
# dev container on a CPU-only GitHub runner; we don't need an AMD GPU just
27+
# to compile, and not running tests keeps the CI cost low. Catches build
28+
# regressions in the ROCm code paths (USE_ROCM guards, hipify-touched sources,
29+
# the pulsar HIP intrinsic replacements, etc.).
30+
linux_rocm_build:
31+
runs-on: ubuntu-latest
32+
container:
33+
# `-complete` tag bundles the full ROCm math stack (rocThrust, hipCUB,
34+
# rocPRIM, ...). The plain `7.2.3` tag is HIP-runtime-only and fails to
35+
# find <thrust/complex.h> when including PyTorch headers.
36+
image: rocm/dev-ubuntu-22.04:7.2.3-complete
37+
env:
38+
PYTORCH_VERSION: "2.11.0"
39+
ROCM_INDEX: "rocm7.2"
40+
steps:
41+
- uses: actions/checkout@v4
42+
- name: Install Python and torch+rocm
43+
run: |-
44+
apt-get update
45+
apt-get install -y --no-install-recommends python3 python3-dev python3-pip git
46+
python3 -m pip install --upgrade pip
47+
python3 -m pip install --index-url https://download.pytorch.org/whl/${ROCM_INDEX} torch==${PYTORCH_VERSION}
48+
- name: Verify torch is ROCm-built
49+
run: |-
50+
python3 -c "import torch; assert torch.version.hip is not None, 'torch is not HIP-built'; print('torch.version.hip:', torch.version.hip)"
51+
- name: Build pytorch3d _C extension (build only, no tests)
52+
env:
53+
# CPU-only runner: torch.cuda.is_available() is False, so force the
54+
# CUDAExtension path. ROCM_HOME is auto-detected from /opt/rocm in
55+
# the rocm/dev-ubuntu container.
56+
FORCE_CUDA: "1"
57+
run: |-
58+
python3 -m pip install --no-build-isolation -v .
59+
- name: Smoke import
60+
# cd out of the checkout root so the source-tree pytorch3d/ directory
61+
# (which has no _C.so since the build doesn't install in-place) doesn't
62+
# shadow the site-packages install via sys.path[0] for `python -c`.
63+
run: |-
64+
cd /tmp
65+
python3 -c "import torch; from pytorch3d import _C; print('PulsarRenderer:', hasattr(_C, 'PulsarRenderer')); print('n_symbols:', len([s for s in dir(_C) if not s.startswith('_')]))"

.gitignore

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ dist/
66
**/.ipynb_checkpoints
77
**/.ipynb_checkpoints/**
88

9+
# Build artifacts produced in-place by torch.utils.cpp_extension auto-hipify
10+
# when pytorch3d is built against a ROCm PyTorch.
11+
*.so
12+
pytorch3d/csrc/**/*.hip
13+
pytorch3d/csrc/**/*_hip.cpp
14+
pytorch3d/csrc/**/*_hip.h
15+
pytorch3d/csrc/**/*_hip.cuh
16+
17+
# Debug PNG dumps written by pulsar tests when FB_TEST is unset.
18+
tests/pulsar/test_out/
19+
920

1021
# Docusaurus site
1122
website/yarn.lock

pytorch3d/csrc/ext.cpp

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
9898
m.def("marching_cubes", &MarchingCubes);
9999

100100
// Pulsar.
101-
// Pulsar not enabled on AMD.
102101
#ifdef PULSAR_LOGGING_ENABLED
103102
c10::ShowLogInfoToStderr();
104103
#endif

pytorch3d/csrc/pulsar/gpu/commands.h

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,29 +125,45 @@ INLINE DEVICE float3 WARP_SUM_FLOAT3(
125125
// Floating point.
126126
// #define FMUL(a, b) __fmul_rn((a), (b))
127127
#define FMUL(a, b) ((a) * (b))
128-
#define FDIV(a, b) __fdiv_rn((a), (b))
129128
// #define FSUB(a, b) __fsub_rn((a), (b))
130129
#define FSUB(a, b) ((a) - (b))
130+
// HIP has no per-instruction rounding-mode override (round-to-nearest-even is
131+
// the default at the hardware level), so the CUDA *_rn intrinsics have no
132+
// runtime equivalent and we use plain operators. The HIP/clang compiler may
133+
// fuse a+b*c into a single-rounding FMA where CUDA's _rn would have prevented
134+
// it; if that becomes a numerical issue, add `-ffp-contract=off` to the pulsar
135+
// nvcc_args in setup.py.
136+
#if defined(USE_ROCM)
137+
#define FDIV(a, b) ((a) / (b))
138+
#define FADD(a, b) ((a) + (b))
139+
#define FSQRT(a) sqrtf(a)
140+
#define FPOW(a, b) powf((a), (b))
141+
#define FSATURATE(x) fmaxf(0.0f, fminf(1.0f, (x)))
142+
#define FMA(x, y, z) fmaf((x), (y), (z))
143+
#define FRCP(x) (1.0f / (x))
144+
#else
145+
#define FDIV(a, b) __fdiv_rn((a), (b))
131146
#define FADD(a, b) __fadd_rn((a), (b))
132147
#define FSQRT(a) __fsqrt_rn(a)
148+
#define FPOW(a, b) __powf((a), (b))
149+
#define FSATURATE(x) __saturatef(x)
150+
/** Calculates x*y+z. */
151+
#define FMA(x, y, z) __fmaf_rn((x), (y), (z))
152+
#define FRCP(x) __frcp_rn(x)
153+
#endif
133154
#define FEXP(a) fasterexp(a)
134155
#define FLN(a) fasterlog(a)
135-
#define FPOW(a, b) __powf((a), (b))
136156
#define FMAX(a, b) fmax((a), (b))
137157
#define FMIN(a, b) fmin((a), (b))
138158
#define FCEIL(a) ceilf(a)
139159
#define FFLOOR(a) floorf(a)
140160
#define FROUND(x) nearbyintf(x)
141-
#define FSATURATE(x) __saturatef(x)
142161
#define FABS(a) abs(a)
143162
#define IASF(a, loc) (loc) = __int_as_float(a)
144163
#define FASI(a, loc) (loc) = __float_as_int(a)
145164
#define FABSLEQAS(a, b, c) \
146165
((a) <= (b) ? FSUB((b), (a)) <= (c) : FSUB((a), (b)) < (c))
147-
/** Calculates x*y+z. */
148-
#define FMA(x, y, z) __fmaf_rn((x), (y), (z))
149166
#define I2F(a) __int2float_rn(a)
150-
#define FRCP(x) __frcp_rn(x)
151167
#if !defined(USE_ROCM)
152168
__device__ static float atomicMax(float* address, float val) {
153169
int* address_as_i = (int*)address;
@@ -201,8 +217,16 @@ __device__ static float atomicMin(float* address, float val) {
201217
ATOMICADD(&((PTR)->x), VAL.x); \
202218
ATOMICADD(&((PTR)->y), VAL.y); \
203219
ATOMICADD(&((PTR)->z), VAL.z);
204-
#if (CUDART_VERSION >= 10000) && (__CUDA_ARCH__ >= 600)
220+
#if !defined(USE_ROCM) && (CUDART_VERSION >= 10000) && (__CUDA_ARCH__ >= 600)
205221
#define ATOMICADD_B(PTR, VAL) atomicAdd_block((PTR), (VAL))
222+
#elif defined(USE_ROCM)
223+
// HIP has no atomicAdd_block, but the semantic equivalent is a relaxed
224+
// fetch_add scoped to the workgroup (HIP's name for a CUDA thread block).
225+
// This avoids device-wide L2-coherent atomics for what are block-local
226+
// counters in pulsar's inner sphere-loading loop.
227+
#define ATOMICADD_B(PTR, VAL) \
228+
__hip_atomic_fetch_add( \
229+
(PTR), (VAL), __ATOMIC_RELAXED, __HIP_MEMORY_SCOPE_WORKGROUP)
206230
#else
207231
#define ATOMICADD_B(PTR, VAL) ATOMICADD(PTR, VAL)
208232
#endif

pytorch3d/csrc/utils/warp_reduce.cuh

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,20 @@
1010
#include <math.h>
1111
#include <cstdio>
1212

13-
// Helper functions WarpReduceMin and WarpReduceMax used in .cu files
14-
// Starting in Volta, instructions are no longer synchronous within a warp.
15-
// We need to call __syncwarp() to sync the 32 threads in the warp
16-
// instead of all the threads in the block.
13+
// Helper functions WarpReduceMin and WarpReduceMax used in .cu files.
14+
// Starting in Volta, instructions are no longer synchronous within a warp,
15+
// so on CUDA __syncwarp() is required between dependent shared-memory
16+
// accesses in the unrolled tail reduction.
17+
//
18+
// On AMD/HIP no __syncwarp() is needed here: all wavefront lanes execute
19+
// in lockstep (AMD has no equivalent of NVIDIA's Independent Thread
20+
// Scheduling), and the AMDGPU memory model guarantees that LDS operations
21+
// issued by the same wavefront are observed in program order without an
22+
// explicit s_waitcnt — see the LLVM AMDGPU backend memory-model rules
23+
// (https://llvm.org/docs/AMDGPUUsage.html) and the HIP hardware-
24+
// implementation docs. This holds for both wave32 (RDNA, gfx10xx/11xx/
25+
// 12xx) and wave64 (CDNA, gfx9xx), so the USE_ROCM skip is
26+
// architecture-independent.
1727

1828
template <typename scalar_t>
1929
__device__ void
@@ -23,8 +33,6 @@ WarpReduceMin(scalar_t* min_dists, int64_t* min_idxs, const size_t tid) {
2333
min_idxs[tid] = min_idxs[tid + 32];
2434
min_dists[tid] = min_dists[tid + 32];
2535
}
26-
// AMD does not use explicit syncwarp and instead automatically inserts memory
27-
// fences during compilation.
2836
#if !defined(USE_ROCM)
2937
__syncwarp();
3038
#endif

setup.py

Lines changed: 44 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import torch
1616
from setuptools import find_packages, setup
17-
from torch.utils.cpp_extension import CppExtension, CUDA_HOME, CUDAExtension
17+
from torch.utils.cpp_extension import CppExtension, CUDA_HOME, CUDAExtension, ROCM_HOME
1818

1919

2020
def get_existing_ccbin(nvcc_args: List[str]) -> Optional[str]:
@@ -53,20 +53,25 @@ def get_extensions():
5353
define_macros = []
5454
include_dirs = [extensions_dir]
5555

56+
# ROCm/HIP support. When PyTorch is built with HIP, the cpp_extension
57+
# BuildExtension auto-hipifies .cu sources and swaps nvcc -> hipcc.
58+
is_rocm = torch.version.hip is not None
59+
5660
force_cuda = os.getenv("FORCE_CUDA", "0") == "1"
5761
force_no_cuda = os.getenv("PYTORCH3D_FORCE_NO_CUDA", "0") == "1"
62+
gpu_home_available = CUDA_HOME is not None or (is_rocm and ROCM_HOME is not None)
5863
if (
59-
not force_no_cuda and torch.cuda.is_available() and CUDA_HOME is not None
64+
not force_no_cuda and torch.cuda.is_available() and gpu_home_available
6065
) or force_cuda:
6166
extension = CUDAExtension
67+
6268
sources += source_cuda
6369
define_macros += [("WITH_CUDA", None)]
6470
# Thrust is only used for its tuple objects.
6571
# With CUDA 11.0 we can't use the cudatoolkit's version of cub.
6672
# We take the risk that CUB and Thrust are incompatible, because
6773
# we aren't using parts of Thrust which actually use CUB.
6874
define_macros += [("THRUST_IGNORE_CUB_VERSION_CHECK", None)]
69-
cub_home = os.environ.get("CUB_HOME", None)
7075
nvcc_args = [
7176
"-DCUDA_HAS_FP16=1",
7277
"-D__CUDA_NO_HALF_OPERATORS__",
@@ -76,35 +81,40 @@ def get_extensions():
7681
if os.name != "nt":
7782
nvcc_args.append("-std=c++17")
7883

79-
# CUDA 13.0+ compatibility flags for pulsar.
80-
# Starting with CUDA 13, __global__ function visibility changed.
81-
# See: https://developer.nvidia.com/blog/
82-
# cuda-c-compiler-updates-impacting-elf-visibility-and-linkage/
83-
cuda_version = torch.version.cuda
84-
if cuda_version is not None:
85-
major = int(cuda_version.split(".")[0])
86-
if major >= 13:
87-
nvcc_args.extend(
88-
[
89-
"--device-entity-has-hidden-visibility=false",
90-
"-static-global-template-stub=false",
91-
]
84+
if not is_rocm:
85+
# CUDA 13.0+ compatibility flags for pulsar.
86+
# Starting with CUDA 13, __global__ function visibility changed.
87+
# See: https://developer.nvidia.com/blog/
88+
# cuda-c-compiler-updates-impacting-elf-visibility-and-linkage/
89+
cuda_version = torch.version.cuda
90+
if cuda_version is not None:
91+
major = int(cuda_version.split(".")[0])
92+
if major >= 13:
93+
nvcc_args.extend(
94+
[
95+
"--device-entity-has-hidden-visibility=false",
96+
"-static-global-template-stub=false",
97+
]
98+
)
99+
100+
# NVIDIA CUB. On ROCm, hipcub from the ROCm toolchain is used and
101+
# no external CUB_HOME is required.
102+
cub_home = os.environ.get("CUB_HOME", None)
103+
if cub_home is None:
104+
prefix = os.environ.get("CONDA_PREFIX", None)
105+
if prefix is not None and os.path.isdir(prefix + "/include/cub"):
106+
cub_home = prefix + "/include"
107+
108+
if cub_home is None:
109+
warnings.warn(
110+
"The environment variable `CUB_HOME` was not found. "
111+
"NVIDIA CUB is required for compilation and can be downloaded "
112+
"from `https://github.com/NVIDIA/cub/releases`. You can unpack "
113+
"it to a location of your choice and set the environment variable "
114+
"`CUB_HOME` to the folder containing the `CMakeListst.txt` file."
92115
)
93-
if cub_home is None:
94-
prefix = os.environ.get("CONDA_PREFIX", None)
95-
if prefix is not None and os.path.isdir(prefix + "/include/cub"):
96-
cub_home = prefix + "/include"
97-
98-
if cub_home is None:
99-
warnings.warn(
100-
"The environment variable `CUB_HOME` was not found. "
101-
"NVIDIA CUB is required for compilation and can be downloaded "
102-
"from `https://github.com/NVIDIA/cub/releases`. You can unpack "
103-
"it to a location of your choice and set the environment variable "
104-
"`CUB_HOME` to the folder containing the `CMakeListst.txt` file."
105-
)
106-
else:
107-
include_dirs.append(os.path.realpath(cub_home).replace("\\ ", " "))
116+
else:
117+
include_dirs.append(os.path.realpath(cub_home).replace("\\ ", " "))
108118
nvcc_flags_env = os.getenv("NVCC_FLAGS", "")
109119
if nvcc_flags_env != "":
110120
nvcc_args.extend(nvcc_flags_env.split(" "))
@@ -113,7 +123,9 @@ def get_extensions():
113123
# https://github.com/facebookresearch/pytorch3d/issues/436
114124
# It is harmless after https://github.com/pytorch/pytorch/pull/47404 .
115125
# But it can be problematic in torch 1.7.0 and 1.7.1
116-
if torch.__version__[:4] != "1.7.":
126+
# On ROCm the host compiler is selected by hipcc itself; -ccbin is
127+
# an nvcc-only flag.
128+
if not is_rocm and torch.__version__[:4] != "1.7.":
117129
CC = os.environ.get("CC", None)
118130
if CC is not None:
119131
existing_CC = get_existing_ccbin(nvcc_args)

tests/test_cameras_alignment.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,14 @@ def _corresponding_cameras_alignment_test_case(
108108
cameras, cameras_tgt, estimate_scale=estimate_scale, mode=mode
109109
)
110110

111-
if batch_size <= 2 and mode == "centers":
112-
# underdetermined case - check only the center alignment error
113-
# since the rotation and translation are ambiguous here
111+
if batch_size <= 3 and mode == "centers":
112+
# Underdetermined case: with <= 3 camera centers in 3D, the points
113+
# span at most a 2D subspace after mean-centering, so the Umeyama
114+
# SVD has a zero (or near-zero) third singular value and the
115+
# rotation around the degenerate axis is ambiguous. Different
116+
# SVD implementations (e.g. rocBLAS on RDNA vs CDNA, or
117+
# cuBLAS) make different valid choices in that null direction.
118+
# Only the camera centers are well-defined here, so check those.
114119
self.assertClose(
115120
cameras_aligned.get_camera_center(),
116121
cameras_tgt.get_camera_center(),

tests/test_point_mesh_distance.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -707,9 +707,14 @@ def test_point_face_distance(self):
707707

708708
# Compare
709709
self.assertClose(grad_points_naive.cpu(), grad_points_cuda.cpu(), atol=1e-7)
710-
self.assertClose(grad_faces_naive, grad_faces_cuda.cpu(), atol=5e-7)
710+
# DistanceBackward uses atomicAdd to accumulate gradients into the
711+
# face buffer and explicitly calls alertNotDeterministic; the FP add
712+
# order differs across GPU architectures (e.g. between 32- and
713+
# 64-lane warps), producing tiny rounding differences. Use the same
714+
# 5e-6 tolerance as test_face_point_distance below.
715+
self.assertClose(grad_faces_naive, grad_faces_cuda.cpu(), atol=5e-6)
711716
self.assertClose(grad_points_naive.cpu(), grad_points_cpu, atol=1e-7)
712-
self.assertClose(grad_faces_naive, grad_faces_cpu, atol=5e-7)
717+
self.assertClose(grad_faces_naive, grad_faces_cpu, atol=5e-6)
713718

714719
def test_face_point_distance(self):
715720
"""

tests/test_points_alignment.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -669,12 +669,20 @@ def align_and_get_mse(weights_):
669669
desired_det *= -1.0
670670
self._assert_all_close(torch.det(R_est), desired_det, msg, w, atol=2e-5)
671671

672-
# check that the transformed point cloud
673-
# X matches X_t
674-
X_t_est = _apply_pcl_transformation(X, R_est, T_est, s=s_est)
675-
self._assert_all_close(
676-
X_t, X_t_est, assert_error_message, w[:, None, None], atol=2e-5
677-
)
672+
# check that the transformed point cloud
673+
# X matches X_t.
674+
# Only valid when the problem setup is unambiguous: when
675+
# n_points <= dim the centered point cloud is rank-deficient
676+
# and the rotation around the degenerate axis is determined
677+
# only by the SVD's null-space convention, which differs
678+
# across BLAS implementations (e.g. rocBLAS on RDNA vs CDNA,
679+
# or cuBLAS). Applying any of those valid rotations to the
680+
# uncentered X yields a different X_t_est even though the
681+
# algorithm is correct.
682+
X_t_est = _apply_pcl_transformation(X, R_est, T_est, s=s_est)
683+
self._assert_all_close(
684+
X_t, X_t_est, assert_error_message, w[:, None, None], atol=2e-5
685+
)
678686

679687
def _assert_all_close(self, a_, b_, err_message, weights=None, atol=1e-6):
680688
if isinstance(a_, Pointclouds):

0 commit comments

Comments
 (0)