7 Commits

Author SHA1 Message Date
generatedunixname89002005307016
204fe5831b Remove unused type error suppressions (landing group 42)
Summary:
This Phabricator Diff was generated from Pyrefly type-checker output by the per-target upgrade tool. It was not generated by an AI coding system.

It changes only type-error suppression comments: it removes suppressions that are no longer needed and adds `# pyrefly: ignore[...]` comments for remaining errors. It does not change Python runtime behavior or application logic.

Landing group: `74a406ba3ec1f23501f4841695298046d3c8db50f864040e2c4b64506cdb66bd` (attempt 1, 20 batches, 391 files)
Merged patch SHA-256: `c3e986b54ae5248e9810275f66372a9b2047a8e0aa14b3d3301ff8783f4e7e0e`

#pyreupgrade

Differential Revision: D126124958

fbshipit-source-id: f89b7d78ab77074fd80a15de7cef6f1f9fdb4237
2026-10-19 15:24:05 -07:00
Raashish Aggarwal
8b3ba6ede4 Accept tab-separated lines in load_obj (#2061)
Summary:
`load_obj` recognizes vertex, texture, normal and face lines with `line.startswith("v ")`, `"vt "`, `"vn "` and `"f "`. The OBJ format separates fields with any whitespace, and some exporters write a tab after the keyword. Those lines are skipped without any error, so the mesh silently loses vertices and faces:

```python
from io import StringIO
from pytorch3d.io import load_obj

verts, faces, _ = load_obj(StringIO("v\t0 0 0\nv 1 0 0\nv 0 1 0\nf\t1 2 3\n"))
verts.shape, faces.verts_idx.shape
# before: (2, 3), (0, 3)   after: (3, 3), (1, 3)
```

This dispatches on the first token of the already split line (`tokens[:1] == ["v"]`), the same way the `usemtl` branch already does.

Pull Request resolved: https://github.com/facebookresearch/pytorch3d/pull/2061

Test Plan: added `test_load_obj_tab_separated` to `tests/test_io_obj.py`, using tabs in `v`, `vt`, `vn` and `f` lines. It fails on main and passes with the change. I ran `tests/test_io_obj.py` on Windows without the C++ extension: the same 19 tests fail before and after (they reopen a `NamedTemporaryFile` or need `_C`), and the rest pass. flake8 is clean on the changed lines.

Reviewed By: MichaelRamamonjisoa

Differential Revision: D124656223

Pulled By: bottler

fbshipit-source-id: ab0ef3dc0f3c998c44874e6a416e6a9649f29378
2026-10-19 14:36:14 -07:00
generatedunixname89002005307016
88e182f989 Suppress type errors for Pyre upgrade
Summary:
This diff was automatically generated by the Pyre per-target upgrade tool.

It adds `# pyrefly: ignore` comments for type errors that an upcoming Pyrefly version will report. These suppressions keep existing type-checking tests passing while the upgrade rolls out. This diff changes comments only; it does not change Python runtime behavior or application logic.

pyre-upgrade-september-29

#pyreupgrade

Differential Revision: D122460195

fbshipit-source-id: 2ca62cd61e77d222c5b42ac89e73fc570a9781ae
2026-09-29 23:00:50 -07:00
Jeremy Reizenstein
978cd99221 replace IASF/FASI pulsar macros with inline bit-cast functions
Summary: This avoids UB as reported in https://github.com/facebookresearch/pytorch3d/pull/2035

Reviewed By: MichaelRamamonjisoa

Differential Revision: D113009614

fbshipit-source-id: 92e3c6fe34731104ce85de909892b292b4464160
2026-09-15 03:55:04 -07:00
Leo Camus
716c594eab Fix symeig3x3 for inputs that are not of order 1 (#2045)
Summary:
## Motivation

`symeig3x3` uses a fixed absolute epsilon (`torch.finfo(torch.float).eps`) in several places where the magnitude of the input actually matters:

- `p1.clamp(self._eps)`
- the `diag_soft_cond` test, which decides whether a matrix is treated as diagonal
- the `+= eps * sign(...)` regularizers in `_get_ev0` and `_get_ev1`

Since those thresholds are absolute, the routine only behaves as intended for matrices whose entries are of order 1. Once the entries drop below roughly `1e-3` the regularizers dominate the data and the output stops being an eigendecomposition at all. At the other end, `1e10` is already wrong and `1e30` returns NaN.

This is not an exotic corner. `estimate_pointcloud_normals` and `estimate_pointcloud_local_coord_frames` call `symeig3x3` by default (`use_symeig_workaround=True`) on per-point covariance matrices, and those scale with the square of the local neighbourhood size. The denser the point cloud, the smaller the covariances, and the worse the normals get, silently.

Points sampled on a sphere, `neighborhood_size=50`, median angle between the estimated normal and the true radial normal:

| points | radius | mean abs cov entry | main | this PR | `torch.linalg.eigh` |
|--------|--------|--------------------|------|---------|---------------------|
| 4 000 | 1.0 | 4.5e-03 | 1.60° | 1.61° | 1.61° |
| 20 000 | 1.0 | 9.1e-04 | 1.55° | 0.68° | 0.68° |
| 100 000 | 1.0 | 1.8e-04 | 31.86° | 0.31° | 0.31° |
| 4 000 | 0.1 | 4.5e-05 | 50.24° | 1.54° | 1.54° |

This looks like the cause of https://github.com/facebookresearch/pytorch3d/issues/1577, where the reporter narrowed bad normals down to `symeig3x3` and found that passing `use_symeig_workaround=False` fixed them.

## Fix

The decomposition is homogeneous: `eig(s * A) == s * eig(A)` and the eigenvectors do not change. So preconditioning each matrix to unit maximum magnitude and undoing the scaling on the eigenvalues afterwards is exact, and it puts the absolute thresholds back in the regime they were written for.

The scale is detached. For any fixed scale the expression is exactly the eigendecomposition of the input, so gradients are unaffected, and this avoids differentiating through `amax`.

Max relative reconstruction error `max|V diag(w) Vᵀ - A| / max|A|` over 2 000 random symmetric matrices:

| input scale | main | this PR |
|-------------|---------|---------|
| 1e30 | nan | 9.9e-07 |
| 1e10 | 1.00 | 1.0e-06 |
| 1e0 | 1.6e-06 | 1.0e-06 |
| 1e-10 | 1.74 | 9.7e-07 |
| 1e-30 | 1.73 | 9.7e-07 |

float64 inputs break on main below about `1e-8` and are accurate here down to `1e-60`. Exactly-zero, identity, diagonal and rank-1 inputs, and batches mixing magnitudes within one call, all behave.

## Tests

Added `test_is_eigen_small_magnitude`, which runs the existing eigen check on the same random matrices scaled by `1e-2`, `1e-4` and `1e-6` with a correspondingly scaled `atol`. On main it fails with a max relative difference of 101.6; with the fix it passes.

The 13 existing tests in `tests/test_symeig3x3.py` pass unchanged both before and after, on CPU and on GPU, `gradcheck` included.

Cost is one `amax` and one division: no measurable change on GPU, around 2% on CPU at 1e6 matrices.

Pull Request resolved: https://github.com/facebookresearch/pytorch3d/pull/2045

Reviewed By: MichaelRamamonjisoa

Differential Revision: D119970731

Pulled By: bottler

fbshipit-source-id: 35d85a342513c11faca4e6d8ee98052be90172a6
2026-09-15 03:54:46 -07:00
Jeremy Reizenstein
87a6e51e34 Speed up save_obj text formatting, tidy _save helpers
Summary:
Format OBJ text through detached numpy views instead of per-element tensor indexing, and simplify the helpers accordingly.

___

Differential Revision: D119966991

fbshipit-source-id: a3c0bc069431eb1feb913069de922914c2e1ab5e
2026-09-15 03:53:54 -07:00
trueoneplusone
fa22ad16c4 Allow saving OBJ UVs without a texture map (#2047)
Summary:
Closes https://github.com/facebookresearch/pytorch3d/issues/2001.

`save_obj` currently omits UV coordinates and face UV indices unless `texture_map` is also supplied. This loses UV topology when exporting mesh optimization snapshots or meshes whose UVs describe non-color data, unless a redundant texture image and material file are written for every OBJ.

Save UVs whenever both `verts_uvs` and `faces_uvs` are supplied. Material headers and `.mtl`/`.png` files still require a texture map. The public signature is unchanged, and the docstring describes the independent UV export.

The regression test round-trips UV coordinates and distinct face UV indices, both with and without normals, and checks that no material references or sidecar files are created.

## Validation

- Confirmed both regression subcases fail on unchanged main because exported faces omit UV indices.
- Six targeted CPU OBJ IO tests pass: the new regression, existing texture export, normal export, combined normal/texture export, invalid texture inputs, and the pluggable IO texture export.
- Black, flake8, and `git diff --check` pass for the changed files.
- Native extension/full-suite validation was unavailable: the installed MSVC 14.20 toolchain fails while compiling the installed PyTorch headers (C2642/C2737). The targeted tests ran the actual Python OBJ IO code with a local import-only placeholder for `_C`; it exposes only two Pulsar default constants and raises on any native API access. No native operations were exercised. CI should verify the standard test runner with a built extension.

Pull Request resolved: https://github.com/facebookresearch/pytorch3d/pull/2047

Reviewed By: MichaelRamamonjisoa

Differential Revision: D119957610

Pulled By: bottler

fbshipit-source-id: 8ddbbf42b13c264a8f93d699fbbfe96eaa2c0650
2026-09-15 03:53:54 -07:00
17 changed files with 252 additions and 74 deletions

View File

@@ -77,6 +77,20 @@ class _SymEig3x3(nn.Module):
if inputs.shape[-2:] != (3, 3):
raise ValueError("Only inputs of shape (..., 3, 3) are supported.")
# The thresholds and regularizers used below are absolute, so they are
# only meaningful for inputs of order 1: rescale each matrix to unit
# maximum magnitude and undo it on the eigenvalues. The clamp only
# matters for the all-zero matrix. The scale is detached because the
# decomposition is homogeneous of degree 1, so its Jacobian is
# homogeneous of degree 0 and holding the scale constant is exact.
scale = (
inputs.detach()
.abs()
.amax(dim=(-2, -1), keepdim=True)
.clamp(min=torch.finfo(inputs.dtype).tiny)
)
inputs = inputs / scale
inputs_diag = inputs.diagonal(dim1=-2, dim2=-1)
inputs_trace = inputs_diag.sum(-1)
q = inputs_trace / 3.0
@@ -90,7 +104,6 @@ class _SymEig3x3(nn.Module):
)
p = torch.sqrt(p2 / 6.0)
# pyrefly: ignore [unsupported-operation]
B = (inputs - q[..., None, None] * self._identity) / p[..., None, None]
r = torch.det(B) / 2.0
@@ -120,7 +133,7 @@ class _SymEig3x3(nn.Module):
else:
eigenvecs = None
return eigenvals, eigenvecs
return eigenvals * scale.squeeze(-1), eigenvecs
def _construct_eigenvecs_set(
self, inputs: torch.Tensor, eigenvals: torch.Tensor
@@ -175,10 +188,8 @@ class _SymEig3x3(nn.Module):
"""
# Find the eigenvector corresponding to alpha0, its eigenvalue is distinct
# pyrefly: ignore [unsupported-operation]
ev0 = self._get_ev0(inputs - alpha0[..., None, None] * self._identity)
u, v = self._get_uv(ev0)
# pyrefly: ignore [unsupported-operation]
ev1 = self._get_ev1(inputs - alpha1[..., None, None] * self._identity, u, v)
# Third eigenvector is computed as the cross-product of the other two
ev2 = torch.cross(ev0, ev1, dim=-1)
@@ -253,7 +264,6 @@ class _SymEig3x3(nn.Module):
"""
min_idx = w.abs().argmin(dim=-1)
# pyrefly: ignore [bad-index]
rotation_2d = self._rotations_3d[min_idx].to(w)
u = F.normalize((rotation_2d @ w[..., None])[..., 0], dim=-1)

View File

@@ -159,8 +159,6 @@ INLINE DEVICE float3 WARP_SUM_FLOAT3(
#define FFLOOR(a) floorf(a)
#define FROUND(x) nearbyintf(x)
#define FABS(a) abs(a)
#define IASF(a, loc) (loc) = __int_as_float(a)
#define FASI(a, loc) (loc) = __float_as_int(a)
#define FABSLEQAS(a, b, c) \
((a) <= (b) ? FSUB((b), (a)) <= (c) : FSUB((a), (b)) < (c))
#define I2F(a) __int2float_rn(a)

View File

@@ -114,8 +114,6 @@ INLINE float atomicMin(float* address, float val) {
#define FMA(x, y, z) ((x) * (y) + (z))
#define I2F(a) static_cast<float>(a)
#define FRCP(x) (1.f / (x))
#define IASF(x, loc) memcpy(&(loc), &(x), sizeof(x))
#define FASI(x, loc) memcpy(&(loc), &(x), sizeof(x))
#define DMAX(a, b) std::max((a), (b))
#define DMIN(a, b) std::min((a), (b))
#define DSATURATE(a) DMIN(1., DMAX(0., (a)))

View File

@@ -9,7 +9,11 @@
#ifndef PULSAR_NATIVE_COMMANDS_ROUTING_H_
#define PULSAR_NATIVE_COMMANDS_ROUTING_H_
#include <cstring>
#include "../global.h"
#if __cplusplus >= 202002L
#include <bit>
#endif
// Commands available everywhere.
#define MALLOC_HOST(VAR, TYPE, SIZE) \
@@ -32,4 +36,37 @@
#include "../host/commands.h"
#endif
// Host + device int <-> float bit punning. Replaces IASF / FASI macros.
IHD float pulsar_int_as_float(int v) noexcept {
#ifdef __CUDA_ARCH__
return __int_as_float(v);
#elif defined(__HIP_DEVICE_COMPILE__)
return __int_as_float(v);
#else
#if __cpp_lib_bit_cast >= 201806L
return std::bit_cast<float>(v);
#else
float f;
std::memcpy(&f, &v, sizeof(f));
return f;
#endif
#endif
}
IHD int pulsar_float_as_int(float v) noexcept {
#ifdef __CUDA_ARCH__
return __float_as_int(v);
#elif defined(__HIP_DEVICE_COMPILE__)
return __float_as_int(v);
#else
#if __cpp_lib_bit_cast >= 201806L
return std::bit_cast<int>(v);
#else
int i;
std::memcpy(&i, &v, sizeof(i));
return i;
#endif
#endif
}
#endif

View File

@@ -121,8 +121,8 @@ GLOBAL void calc_gradients(
grad_im_l[2]);
// Start processing.
for (int grad_idx = 0; grad_idx < n_track; ++grad_idx) {
int sphere_idx;
FASI(forw_info_d[fwi_loc + 3 + 2 * grad_idx], sphere_idx);
int sphere_idx =
pulsar_float_as_int(forw_info_d[fwi_loc + 3 + 2 * grad_idx]);
PASSERT(
sphere_idx == -1 ||
sphere_idx >= 0 && static_cast<uint>(sphere_idx) < num_balls);

View File

@@ -41,7 +41,8 @@ GLOBAL void fill_bg(
// sphere IDs and intersection depths.
for (int i = 0; i < renderer.n_track; ++i) {
int sphere_id = -1;
IASF(sphere_id, renderer.forw_info_d[write_loc + 3 + i * 2]);
renderer.forw_info_d[write_loc + 3 + i * 2] =
pulsar_int_as_float(sphere_id);
renderer.forw_info_d[write_loc + 3 + i * 2 + 1] = -1.f;
}
if (mode == 0) {

View File

@@ -406,7 +406,7 @@ GLOBAL void render(
IMIN(n_track, tracker.get_n_hits()));
for (int i = 0; i < n_track; ++i) {
int sphere_id = tracker.get_closest_sphere_id(i);
IASF(sphere_id, forw_info_d[write_loc + 3 + i * 2]);
forw_info_d[write_loc + 3 + i * 2] = pulsar_int_as_float(sphere_id);
forw_info_d[write_loc + 3 + i * 2 + 1] =
tracker.get_closest_sphere_depth(i) == MAX_FLOAT
? -1.f

View File

@@ -182,10 +182,12 @@ def generate_eval_video_cameras(
)
test_cameras = PerspectiveCameras(
# pyrefly: ignore [bad-argument-type]
focal_length=focal_length,
principal_point=principal_point,
R=R,
T=T,
# pyrefly: ignore [missing-attribute]
device=focal_length.device,
)

View File

@@ -472,26 +472,26 @@ def _parse_obj(f, data_dir: str):
materials_idx = len(material_names) - 1
else:
materials_idx = material_names.index(material_name)
elif line.startswith("v "): # Line is a vertex.
elif tokens[:1] == ["v"]: # Line is a vertex.
vert = [float(x) for x in tokens[1:4]]
if len(vert) != 3:
msg = "Vertex %s does not have 3 values. Line: %s"
raise ValueError(msg % (str(vert), str(line)))
verts.append(vert)
elif line.startswith("vt "): # Line is a texture.
elif tokens[:1] == ["vt"]: # Line is a texture.
tx = [float(x) for x in tokens[1:3]]
if len(tx) != 2:
raise ValueError(
"Texture %s does not have 2 values. Line: %s" % (str(tx), str(line))
)
verts_uvs.append(tx)
elif line.startswith("vn "): # Line is a normal.
elif tokens[:1] == ["vn"]: # Line is a normal.
norm = [float(x) for x in tokens[1:4]]
if len(norm) != 3:
msg = "Normal %s does not have 3 values. Line: %s"
raise ValueError(msg % (str(norm), str(line)))
normals.append(norm)
elif line.startswith("f "): # Line is a face.
elif tokens[:1] == ["f"]: # Line is a face.
# Update face properties info.
_parse_face(
line,
@@ -712,6 +712,7 @@ def save_obj(
faces_normals_idx: LongTensor of shape (F, 3) giving the index into
normals for each vertex in the face.
verts_uvs: FloatTensor of shape (V, 2) giving the uv coordinate per vertex.
When provided with faces_uvs, UVs are saved even without a texture_map.
faces_uvs: LongTensor of shape (F, 3) giving the index into verts_uvs for
each vertex in the face.
texture_map: FloatTensor of shape (H, W, 3) representing the texture map
@@ -757,7 +758,8 @@ def save_obj(
if path_manager is None:
path_manager = PathManager()
save_texture = all(t is not None for t in [faces_uvs, verts_uvs, texture_map])
save_uvs = faces_uvs is not None and verts_uvs is not None
save_texture = save_uvs and texture_map is not None
output_path = Path(f)
# Save the .obj file
@@ -778,7 +780,7 @@ def save_obj(
faces_normals_idx=faces_normals_idx,
verts_uvs=verts_uvs,
faces_uvs=faces_uvs,
save_texture=save_texture,
save_uvs=save_uvs,
save_normals=normals is not None,
)
@@ -792,7 +794,7 @@ def save_obj(
mtl_path = str(mtl_path)
# Save texture map to output folder
# pyre-fixme[16] # undefined attribute cpu
# undefined attribute cpu
texture_map = texture_map.detach().cpu() * 255.0
image = Image.fromarray(texture_map.numpy().astype(np.uint8))
with _open_file(image_path, path_manager, "wb") as im_f:
@@ -816,7 +818,7 @@ def _save(
faces_normals_idx: Optional[torch.Tensor] = None,
verts_uvs: Optional[torch.Tensor] = None,
faces_uvs: Optional[torch.Tensor] = None,
save_texture: bool = False,
save_uvs: bool = False,
save_normals: bool = False,
) -> None:
if len(verts) and (verts.dim() != 2 or verts.size(1) != 3):
@@ -832,26 +834,24 @@ def _save(
return
verts, faces = verts.cpu(), faces.cpu()
vert_rows = verts.detach().cpu().numpy()
lines = ""
lines = []
if decimal_places is None:
float_str = "%f"
else:
float_str = "%" + ".%df" % decimal_places
if len(verts):
V, D = verts.shape
for i in range(V):
vert = [float_str % verts[i, j] for j in range(D)]
lines += "v %s\n" % " ".join(vert)
for vert in vert_rows:
lines.append("v %s\n" % " ".join(float_str % v for v in vert))
if save_normals:
assert normals is not None
assert faces_normals_idx is not None
lines += _write_normals(normals, faces_normals_idx, float_str)
lines.append(_write_normals(normals, float_str))
if save_texture:
if save_uvs:
assert faces_uvs is not None
assert verts_uvs is not None
@@ -864,15 +864,13 @@ def _save(
raise ValueError(message)
verts_uvs, faces_uvs = verts_uvs.cpu(), faces_uvs.cpu()
uv_rows = verts_uvs.detach().cpu().numpy()
# Save verts uvs after verts
if len(verts_uvs):
uV, uD = verts_uvs.shape
for i in range(uV):
uv = [float_str % verts_uvs[i, j] for j in range(uD)]
lines += "vt %s\n" % " ".join(uv)
for uv in uv_rows:
lines.append("vt %s\n" % " ".join(float_str % u for u in uv))
f.write(lines)
f.write("".join(lines))
if torch.any(faces >= verts.shape[0]) or torch.any(faces < 0):
warnings.warn("Faces have invalid indices")
@@ -881,31 +879,21 @@ def _save(
_write_faces(
f,
faces,
faces_uvs if save_texture else None,
faces_uvs if save_uvs else None,
faces_normals_idx if save_normals else None,
)
def _write_normals(
normals: torch.Tensor, faces_normals_idx: torch.Tensor, float_str: str
) -> str:
if faces_normals_idx.dim() != 2 or faces_normals_idx.size(1) != 3:
message = (
"'faces_normals_idx' should either be empty or of shape (num_faces, 3)."
)
raise ValueError(message)
def _write_normals(normals: torch.Tensor, float_str: str) -> str:
if normals.dim() != 2 or normals.size(1) != 3:
message = "'normals' should either be empty or of shape (num_verts, 3)."
raise ValueError(message)
normals, faces_normals_idx = normals.cpu(), faces_normals_idx.cpu()
normal_rows = normals.detach().cpu().numpy()
lines = []
V, D = normals.shape
for i in range(V):
normal = [float_str % normals[i, j] for j in range(D)]
lines.append("vn %s\n" % " ".join(normal))
for normal in normal_rows:
lines.append("vn %s\n" % " ".join(float_str % n for n in normal))
return "".join(lines)
@@ -915,31 +903,37 @@ def _write_faces(
faces_uvs: Optional[torch.Tensor],
faces_normals_idx: Optional[torch.Tensor],
) -> None:
face_rows = faces.detach().cpu().numpy()
uv_rows = faces_uvs.detach().cpu().numpy() if faces_uvs is not None else None
normal_rows = (
faces_normals_idx.detach().cpu().numpy()
if faces_normals_idx is not None
else None
)
F, P = faces.shape
for i in range(F):
if faces_normals_idx is not None:
if faces_uvs is not None:
face_row = face_rows[i]
if normal_rows is not None:
normal_row = normal_rows[i]
if uv_rows is not None:
uv_row = uv_rows[i]
# Format faces as {verts_idx}/{verts_uvs_idx}/{verts_normals_idx}
face = [
"%d/%d/%d"
% (
faces[i, j] + 1,
faces_uvs[i, j] + 1,
faces_normals_idx[i, j] + 1,
)
"%d/%d/%d" % (face_row[j] + 1, uv_row[j] + 1, normal_row[j] + 1)
for j in range(P)
]
else:
# Format faces as {verts_idx}//{verts_normals_idx}
face = [
"%d//%d" % (faces[i, j] + 1, faces_normals_idx[i, j] + 1)
for j in range(P)
"%d//%d" % (face_row[j] + 1, normal_row[j] + 1) for j in range(P)
]
elif faces_uvs is not None:
elif uv_rows is not None:
uv_row = uv_rows[i]
# Format faces as {verts_idx}/{verts_uvs_idx}
face = ["%d/%d" % (faces[i, j] + 1, faces_uvs[i, j] + 1) for j in range(P)]
face = ["%d/%d" % (face_row[j] + 1, uv_row[j] + 1) for j in range(P)]
else:
face = ["%d" % (faces[i, j] + 1) for j in range(P)]
face = ["%d" % (face_row[j] + 1) for j in range(P)]
if i + 1 < F:
f.write("f %s\n" % " ".join(face))

View File

@@ -340,8 +340,11 @@ def corresponding_points_alignment(
total_weight = torch.clamp(num_points, 1)
# special handling for heterogeneous point clouds and/or input weights
if weights is not None:
# pyrefly: ignore [bad-index]
Xc *= weights[:, :, None]
# pyrefly: ignore [bad-index]
Yc *= weights[:, :, None]
# pyrefly: ignore [missing-attribute]
total_weight = torch.clamp(weights.sum(1), eps)
if (num_points < (dim + 1)).any():

View File

@@ -427,9 +427,11 @@ class CamerasBase(TensorProperties):
if isinstance(index, tensor_types["bool"]):
# LongTensor]` has no attribute `ndim`.
# LongTensor]` has no attribute `shape`.
# pyrefly: ignore [missing-attribute]
if index.ndim != 1 or index.shape[0] != len(self):
raise ValueError(
# LongTensor]` has no attribute `shape`.
# pyrefly: ignore [missing-attribute]
f"Boolean index of shape {index.shape} does not match cameras"
)
elif max(index) >= len(self):
@@ -1790,11 +1792,13 @@ def get_ndc_to_screen_transform(
image_size = torch.tensor(image_size, device=cameras.device)
# pyrefly: ignore [missing-attribute]
image_size = image_size.view(-1, 2) # of shape (1 or B)x2
# pyrefly: ignore [missing-attribute]
height, width = image_size.unbind(1)
# For non square images, we scale the points such that smallest side
# has range [-1, 1] and the largest side has range [-u, u], with u > 1.
# This convention is consistent with the PyTorch3D renderer
# pyrefly: ignore [missing-attribute]
scale = (image_size.min(dim=1).values - 0.0) / 2.0
K[:, 0, 0] = scale

View File

@@ -136,16 +136,13 @@ class HarmonicEmbedding(torch.nn.Module):
[..., (n_harmonic_functions * 2 + int(append_input)) * num_points_per_ray]
"""
# [..., dim, n_harmonic_functions]
# pyrefly: ignore [unsupported-operation]
embed = x[..., None] * self._frequencies
# [..., 1, dim, n_harmonic_functions] + [2, 1, 1] => [..., 2, dim, n_harmonic_functions]
# pyrefly: ignore [bad-index]
embed = embed[..., None, :, :] + self._zero_half_pi[..., None, None]
# Use the trig identity cos(x) = sin(x + pi/2)
# and do one vectorized call to sin([x, x+pi/2]) instead of (sin(x), cos(x)).
embed = embed.sin()
if diag_cov is not None:
# pyrefly: ignore [no-matching-overload]
x_var = diag_cov[..., None] * torch.pow(self._frequencies, 2)
exp_var = torch.exp(-0.5 * x_var)
# [..., 2, dim, n_harmonic_functions]
@@ -184,7 +181,6 @@ class HarmonicEmbedding(torch.nn.Module):
"""
return self.get_output_dim_static(
input_dims,
# pyrefly: ignore [bad-argument-type]
len(self._frequencies),
self.append_input,
)

View File

@@ -239,12 +239,10 @@ class MeshRasterizer(nn.Module):
if raster_settings.perspective_correct is not None:
perspective_correct = raster_settings.perspective_correct
else:
# pyrefly: ignore [missing-attribute]
perspective_correct = cameras.is_perspective()
if raster_settings.z_clip_value is not None:
z_clip = raster_settings.z_clip_value
else:
# pyrefly: ignore [missing-attribute]
znear = cameras.get_znear()
if isinstance(znear, torch.Tensor):
znear = znear.min().item()

View File

@@ -510,7 +510,9 @@ class Meshes:
index = index.squeeze(1) if index.numel() > 0 else index
# pyrefly: ignore [missing-attribute]
index = index.tolist()
# pyrefly: ignore [not-iterable]
verts = [self.verts_list()[i] for i in index]
# pyrefly: ignore [not-iterable]
faces = [self.faces_list()[i] for i in index]
else:
raise IndexError(index)

View File

@@ -411,10 +411,13 @@ class Pointclouds:
index = index.squeeze(1) if index.numel() > 0 else index
# pyrefly: ignore [missing-attribute]
index = index.tolist()
# pyrefly: ignore [not-iterable]
points = [self.points_list()[i] for i in index]
if normals_list is not None:
# pyrefly: ignore [not-iterable]
normals = [normals_list[i] for i in index]
if features_list is not None:
# pyrefly: ignore [not-iterable]
features = [features_list[i] for i in index]
else:
raise IndexError(index)

View File

@@ -319,6 +319,29 @@ class TestMeshObjIO(TestCaseMixin, unittest.TestCase):
self.assertTrue(materials is None)
self.assertTrue(tex_maps is None)
def test_load_obj_tab_separated(self):
# Tabs are whitespace too, including between the keyword and the values.
obj_file = "\n".join(
[
"v\t0.1\t0.2\t0.3",
"v 0.2 0.3 0.4",
"v\t0.3 0.4 0.5",
"vt\t0.5\t0.5",
"vn\t0.0\t0.0\t1.0",
"f\t1/1/1\t2/1/1\t3/1/1",
]
)
verts, faces, aux = load_obj(StringIO(obj_file))
expected_verts = torch.tensor(
[[0.1, 0.2, 0.3], [0.2, 0.3, 0.4], [0.3, 0.4, 0.5]], dtype=torch.float32
)
self.assertClose(verts, expected_verts)
self.assertClose(faces.verts_idx, torch.tensor([[0, 1, 2]]))
self.assertClose(faces.textures_idx, torch.tensor([[0, 0, 0]]))
self.assertClose(faces.normals_idx, torch.tensor([[0, 0, 0]]))
self.assertClose(aux.verts_uvs, torch.tensor([[0.5, 0.5]]))
self.assertClose(aux.normals, torch.tensor([[0.0, 0.0, 1.0]]))
def test_load_obj_error_textures(self):
obj_file = "\n".join(["vt 0.1"])
with NamedTemporaryFile(mode="w", suffix=".obj") as f:
@@ -955,6 +978,60 @@ class TestMeshObjIO(TestCaseMixin, unittest.TestCase):
with open(obj_file, "r") as actual_file:
self.assertEqual(actual_file.read(), expected_obj_file)
def test_save_obj_with_uvs_without_texture_map(self):
verts = torch.tensor(
[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
dtype=torch.float32,
)
faces = torch.tensor([[0, 2, 1]], dtype=torch.int64)
verts_uvs = torch.tensor(
[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]],
dtype=torch.float32,
)
faces_uvs = torch.tensor([[3, 1, 2]], dtype=torch.int64)
normals = torch.tensor([[0.0, 0.0, 1.0], [0.0, 1.0, 0.0]])
faces_normals_idx = torch.tensor([[1, 0, 1]], dtype=torch.int64)
for save_normals in (False, True):
with (
self.subTest(save_normals=save_normals),
TemporaryDirectory() as temp_dir,
):
obj_file = Path(temp_dir) / "mesh.obj"
save_obj(
obj_file,
verts,
faces,
verts_uvs=verts_uvs,
faces_uvs=faces_uvs,
normals=normals if save_normals else None,
faces_normals_idx=faces_normals_idx if save_normals else None,
)
self.assertEqual(os.listdir(temp_dir), ["mesh.obj"])
obj_text = obj_file.read_text()
self.assertNotIn("mtllib", obj_text)
self.assertNotIn("usemtl", obj_text)
expected_face = (
"f 1/4/2 3/2/1 2/3/2" if save_normals else "f 1/4 3/2 2/3"
)
self.assertIn(expected_face, obj_text)
loaded_verts, loaded_faces, aux = load_obj(
obj_file, load_textures=False
)
self.assertClose(loaded_verts, verts)
self.assertClose(loaded_faces.verts_idx, faces)
self.assertClose(aux.verts_uvs, verts_uvs)
self.assertClose(loaded_faces.textures_idx, faces_uvs)
self.assertIsNone(aux.material_colors)
self.assertIsNone(aux.texture_images)
if save_normals:
self.assertClose(aux.normals, normals)
self.assertClose(loaded_faces.normals_idx, faces_normals_idx)
else:
self.assertIsNone(aux.normals)
def test_save_obj_with_texture(self):
verts = torch.tensor(
[[0.01, 0.2, 0.301], [0.2, 0.03, 0.408], [0.3, 0.4, 0.05], [0.6, 0.7, 0.8]],

View File

@@ -66,6 +66,51 @@ class TestSymEig3x3(TestCaseMixin, unittest.TestCase):
rtol=rtol,
)
def test_is_eigen_scaled_gpu(self):
self._test_is_eigen_scaled(self._gpu)
def test_is_eigen_scaled_cpu(self):
self._test_is_eigen_scaled(self._cpu)
def _test_is_eigen_scaled(self, device):
"""
The decomposition is homogeneous, so scaling the input must not affect
the relative accuracy of the result.
"""
test_input = self.create_random_sym3x3(device, n=self.TEST_BATCH_SIZE)
for scale in (1e-6, 1e-4, 1e-2, 1e2, 1e4, 1e6):
scaled_input = test_input * scale
self._test_is_not_nan_or_inf(scaled_input)
self._test_is_eigen(scaled_input, atol=1e-04 * scale, rtol=1e-02)
self._test_eigenvectors_are_orthonormal(scaled_input)
def test_mixed_magnitudes_gpu(self):
self._test_mixed_magnitudes(self._gpu)
def test_mixed_magnitudes_cpu(self):
self._test_mixed_magnitudes(self._cpu)
def _test_mixed_magnitudes(self, device):
"""
Each matrix in a batch must be conditioned on its own magnitude, so
matrices spanning many orders of magnitude can be batched together.
"""
test_input = self.create_random_sym3x3(device, n=self.TEST_BATCH_SIZE)
scales = torch.logspace(-6, 6, self.TEST_BATCH_SIZE, device=device)[
:, None, None
]
scaled_input = test_input * scales
eigenvalues, eigenvectors = symeig3x3(scaled_input, eigenvectors=True)
reconstruction = (
eigenvectors @ eigenvalues.diag_embed() @ eigenvectors.transpose(-2, -1)
)
self.assertClose(test_input, reconstruction / scales, atol=1e-04, rtol=1e-02)
self._test_eigenvectors_are_orthonormal(scaled_input)
def test_eigenvectors_are_orthonormal_gpu(self):
test_input = self.create_random_sym3x3(self._gpu, n=self.TEST_BATCH_SIZE)
@@ -192,21 +237,31 @@ class TestSymEig3x3(TestCaseMixin, unittest.TestCase):
def test_degenerate_eigenvalues_cpu(self):
self._test_degenerate_eigenvalues(self._cpu)
def _test_degenerate_eigenvalues(self, device):
def test_degenerate_eigenvalues_scaled_gpu(self):
for scale in (1e-6, 1e6):
self._test_degenerate_eigenvalues(self._gpu, scale=scale)
def test_degenerate_eigenvalues_scaled_cpu(self):
for scale in (1e-6, 1e6):
self._test_degenerate_eigenvalues(self._cpu, scale=scale)
def _test_degenerate_eigenvalues(self, device, scale: float = 1.0):
"""
Test degenerate eigenvalues like zero-valued and with 2-/3-multiplicity
Test degenerate eigenvalues like zero-valued and with 2-/3-multiplicity.
These are the cases held together by the regularizers inside the
decomposition, which only bite at the right input magnitude.
"""
# Error tolerances for degenerate values are increased as things might become
# numerically unstable
deg_atol = 1e-3
deg_atol = 1e-3 * scale
deg_rtol = 1.0
# Construct random orthonormal sets
test_eigenvecs = random_rotations(n=self.TEST_BATCH_SIZE, device=device)
# Construct random eigenvalues
test_eigenvals = torch.randn(
(self.TEST_BATCH_SIZE, 3), device=test_eigenvecs.device
test_eigenvals = (
torch.randn((self.TEST_BATCH_SIZE, 3), device=test_eigenvecs.device) * scale
)
self._test_eigenvalues_and_eigenvectors(
test_eigenvecs, test_eigenvals, atol=deg_atol, rtol=deg_rtol