pytorch3d/pytorch3d/io/off_io.py
generatedunixname89002005307016 8c2b0b01f8 upgrade pyre version in fbcode/vision - batch 2
Differential Revision: D43044534

fbshipit-source-id: dc841b6704ccd562f5a40e7b2834e26063a9f7ae
2023-02-06 09:05:41 -08:00

495 lines
16 KiB
Python

# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.
"""
This module implements utility functions for loading and saving
meshes as .off files.
This format is introduced, for example, at
http://www.geomview.org/docs/html/OFF.html .
"""
import warnings
from typing import cast, Optional, Tuple, Union
import numpy as np
import torch
from iopath.common.file_io import PathManager
from pytorch3d.io.utils import _check_faces_indices, _open_file, PathOrStr
from pytorch3d.renderer import TexturesAtlas, TexturesVertex
from pytorch3d.structures import Meshes
from .pluggable_formats import endswith, MeshFormatInterpreter
def _is_line_empty(line: Union[str, bytes]) -> bool:
"""
Returns whether line is not relevant in an OFF file.
"""
line = line.strip()
return len(line) == 0 or line[:1] == b"#"
def _count_next_line_periods(file) -> int:
"""
Returns the number of . characters before any # on the next
meaningful line.
"""
old_offset = file.tell()
line = file.readline()
while _is_line_empty(line):
line = file.readline()
if len(line) == 0:
raise ValueError("Premature end of file")
contents = line.split(b"#")[0]
count = contents.count(b".")
file.seek(old_offset)
return count
def _read_faces_lump(
file, n_faces: int, n_colors: Optional[int]
) -> Optional[Tuple[np.ndarray, int, Optional[np.ndarray]]]:
"""
Parse n_faces faces and faces_colors from the file,
if they all have the same number of vertices.
This is used in two ways.
1) To try to read all faces.
2) To read faces one-by-one if that failed.
Args:
file: file-like object being read.
n_faces: The known number of faces yet to read.
n_colors: The number of colors if known already.
Returns:
- 2D numpy array of faces
- number of colors found
- 2D numpy array of face colors if found.
of None if there are faces with different numbers of vertices.
"""
if n_faces == 0:
return np.array([[]]), 0, None
old_offset = file.tell()
try:
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore", message=".* Empty input file.*", category=UserWarning
)
data = np.loadtxt(file, dtype=np.float32, ndmin=2, max_rows=n_faces)
except ValueError as e:
if n_faces > 1 and "Wrong number of columns" in e.args[0]:
file.seek(old_offset)
return None
raise ValueError("Not enough face data.") from None
if len(data) != n_faces:
raise ValueError("Not enough face data.")
face_size = int(data[0, 0])
if (data[:, 0] != face_size).any():
msg = "A line of face data did not have the specified length."
raise ValueError(msg)
if face_size < 3:
raise ValueError("Faces must have at least 3 vertices.")
n_colors_found = data.shape[1] - 1 - face_size
if n_colors is not None and n_colors_found != n_colors:
raise ValueError("Number of colors differs between faces.")
n_colors = n_colors_found
if n_colors not in [0, 3, 4]:
raise ValueError("Unexpected number of colors.")
face_raw_data = data[:, 1 : 1 + face_size].astype("int64")
if face_size == 3:
face_data = face_raw_data
else:
face_arrays = [
face_raw_data[:, [0, i + 1, i + 2]] for i in range(face_size - 2)
]
face_data = np.vstack(face_arrays)
if n_colors == 0:
return face_data, 0, None
colors = data[:, 1 + face_size :]
if face_size == 3:
return face_data, n_colors, colors
return face_data, n_colors, np.tile(colors, (face_size - 2, 1))
def _read_faces(
file, n_faces: int
) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]:
"""
Returns faces and face colors from the file.
Args:
file: file-like object being read.
n_faces: The known number of faces.
Returns:
2D numpy arrays of faces and face colors, or None for each if
they are not present.
"""
if n_faces == 0:
return None, None
color_is_int = 0 == _count_next_line_periods(file)
color_scale = 1 / 255.0 if color_is_int else 1
faces_ncolors_colors = _read_faces_lump(file, n_faces=n_faces, n_colors=None)
if faces_ncolors_colors is not None:
faces, _, colors = faces_ncolors_colors
if colors is None:
return faces, None
return faces, colors * color_scale
faces_list, colors_list = [], []
n_colors = None
for _ in range(n_faces):
faces_ncolors_colors = _read_faces_lump(file, n_faces=1, n_colors=n_colors)
faces_found, n_colors, colors_found = cast(
Tuple[np.ndarray, int, Optional[np.ndarray]], faces_ncolors_colors
)
faces_list.append(faces_found)
colors_list.append(colors_found)
faces = np.vstack(faces_list)
if n_colors == 0:
colors = None
else:
colors = np.vstack(colors_list) * color_scale
return faces, colors
def _read_verts(file, n_verts: int) -> Tuple[np.ndarray, Optional[np.ndarray]]:
"""
Returns verts and vertex colors from the file.
Args:
file: file-like object being read.
n_verts: The known number of faces.
Returns:
2D numpy arrays of verts and (if present)
vertex colors.
"""
color_is_int = 3 == _count_next_line_periods(file)
color_scale = 1 / 255.0 if color_is_int else 1
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore", message=".* Empty input file.*", category=UserWarning
)
data = np.loadtxt(file, dtype=np.float32, ndmin=2, max_rows=n_verts)
if data.shape[0] != n_verts:
raise ValueError("Not enough vertex data.")
if data.shape[1] not in [3, 6, 7]:
raise ValueError("Bad vertex data.")
if data.shape[1] == 3:
return data, None
return data[:, :3], data[:, 3:] * color_scale # []
def _load_off_stream(file) -> dict:
"""
Load the data from a stream of an .off file.
Example .off file format:
off
8 6 1927 { number of vertices, faces, and (not used) edges }
# comment { comments with # sign }
0 0 0 { start of vertex list }
0 0 1
0 1 1
0 1 0
1 0 0
1 0 1
1 1 1
1 1 0
4 0 1 2 3 { start of face list }
4 7 6 5 4
4 0 4 5 1
4 1 5 6 2
4 2 6 7 3
4 3 7 4 0
Args:
file: A binary file-like object (with methods read, readline,
tell and seek).
Returns dictionary possibly containing:
verts: (always present) FloatTensor of shape (V, 3).
verts_colors: FloatTensor of shape (V, C) where C is 3 or 4.
faces: LongTensor of vertex indices, split into triangles, shape (F, 3).
faces_colors: FloatTensor of shape (F, C), where C is 3 or 4.
"""
header = file.readline()
while _is_line_empty(header):
header = file.readline()
if header[:3].lower() == b"off":
header = header[3:]
while _is_line_empty(header):
header = file.readline()
items = header.split()
if len(items) < 3:
raise ValueError("Invalid counts line: %s" % header)
try:
n_verts = int(items[0])
except ValueError:
raise ValueError("Invalid counts line: %s" % header) from None
try:
n_faces = int(items[1])
except ValueError:
raise ValueError("Invalid counts line: %s" % header) from None
if (len(items) > 3 and not items[3].startswith(b"#")) or n_verts < 0 or n_faces < 0:
raise ValueError("Invalid counts line: %s" % header)
verts, verts_colors = _read_verts(file, n_verts)
faces, faces_colors = _read_faces(file, n_faces)
end = file.read().strip()
if len(end) != 0:
raise ValueError("Extra data at end of file: " + str(end[:20]))
out = {"verts": verts}
if verts_colors is not None:
out["verts_colors"] = verts_colors
if faces is not None:
out["faces"] = faces
if faces_colors is not None:
out["faces_colors"] = faces_colors
return out
def _write_off_data(
file,
verts: torch.Tensor,
verts_colors: Optional[torch.Tensor] = None,
faces: Optional[torch.LongTensor] = None,
faces_colors: Optional[torch.Tensor] = None,
decimal_places: Optional[int] = None,
) -> None:
"""
Internal implementation for saving 3D data to a .off file.
Args:
file: Binary file object to which the 3D data should be written.
verts: FloatTensor of shape (V, 3) giving vertex coordinates.
verts_colors: FloatTensor of shape (V, C) giving vertex colors where C is 3 or 4.
faces: LongTensor of shape (F, 3) giving faces.
faces_colors: FloatTensor of shape (V, C) giving face colors where C is 3 or 4.
decimal_places: Number of decimal places for saving.
"""
nfaces = 0 if faces is None else faces.shape[0]
file.write(f"off\n{verts.shape[0]} {nfaces} 0\n".encode("ascii"))
if verts_colors is not None:
verts = torch.cat((verts, verts_colors), dim=1)
if decimal_places is None:
float_str = "%f"
else:
float_str = "%" + ".%df" % decimal_places
np.savetxt(file, verts.cpu().detach().numpy(), float_str)
if faces is not None:
_check_faces_indices(faces, max_index=verts.shape[0])
if faces_colors is not None:
face_data = torch.cat(
[
cast(torch.Tensor, faces).cpu().to(torch.float64),
faces_colors.detach().cpu().to(torch.float64),
],
dim=1,
)
format = "3 %d %d %d" + " %f" * faces_colors.shape[1]
np.savetxt(file, face_data.numpy(), format)
elif faces is not None:
np.savetxt(file, faces.cpu().detach().numpy(), "3 %d %d %d")
def _save_off(
file,
*,
verts: torch.Tensor,
verts_colors: Optional[torch.Tensor] = None,
faces: Optional[torch.LongTensor] = None,
faces_colors: Optional[torch.Tensor] = None,
decimal_places: Optional[int] = None,
path_manager: PathManager,
) -> None:
"""
Save a mesh to an ascii .off file.
Args:
file: File (or path) to which the mesh should be written.
verts: FloatTensor of shape (V, 3) giving vertex coordinates.
verts_colors: FloatTensor of shape (V, C) giving vertex colors where C is 3 or 4.
faces: LongTensor of shape (F, 3) giving faces.
faces_colors: FloatTensor of shape (V, C) giving face colors where C is 3 or 4.
decimal_places: Number of decimal places for saving.
"""
if len(verts) and not (verts.dim() == 2 and verts.size(1) == 3):
message = "Argument 'verts' should either be empty or of shape (num_verts, 3)."
raise ValueError(message)
if verts_colors is not None and 0 == len(verts_colors):
verts_colors = None
if faces_colors is not None and 0 == len(faces_colors):
faces_colors = None
if faces is not None and 0 == len(faces):
faces = None
if verts_colors is not None:
if not (verts_colors.dim() == 2 and verts_colors.size(1) in [3, 4]):
message = "verts_colors should have shape (num_faces, C)."
raise ValueError(message)
if verts_colors.shape[0] != verts.shape[0]:
message = "verts_colors should have the same length as verts."
raise ValueError(message)
if faces is not None and not (faces.dim() == 2 and faces.size(1) == 3):
message = "Argument 'faces' if present should have shape (num_faces, 3)."
raise ValueError(message)
if faces_colors is not None and faces is None:
message = "Cannot have face colors without faces"
raise ValueError(message)
if faces_colors is not None:
if not (faces_colors.dim() == 2 and faces_colors.size(1) in [3, 4]):
message = "faces_colors should have shape (num_faces, C)."
raise ValueError(message)
if faces_colors.shape[0] != cast(torch.LongTensor, faces).shape[0]:
message = "faces_colors should have the same length as faces."
raise ValueError(message)
with _open_file(file, path_manager, "wb") as f:
_write_off_data(f, verts, verts_colors, faces, faces_colors, decimal_places)
class MeshOffFormat(MeshFormatInterpreter):
"""
Loads and saves meshes in the ascii OFF format. This is a simple
format which can only deal with the following texture types:
- TexturesVertex, i.e. one color for each vertex
- TexturesAtlas with R=1, i.e. one color for each face.
There are some possible features of OFF files which we do not support
and which appear to be rare:
- Four dimensional data.
- Binary data.
- Vertex Normals.
- Texture coordinates.
- "COFF" header.
Example .off file format:
off
8 6 1927 { number of vertices, faces, and (not used) edges }
# comment { comments with # sign }
0 0 0 { start of vertex list }
0 0 1
0 1 1
0 1 0
1 0 0
1 0 1
1 1 1
1 1 0
4 0 1 2 3 { start of face list }
4 7 6 5 4
4 0 4 5 1
4 1 5 6 2
4 2 6 7 3
4 3 7 4 0
"""
def __init__(self) -> None:
self.known_suffixes = (".off",)
def read(
self,
path: PathOrStr,
include_textures: bool,
device,
path_manager: PathManager,
**kwargs,
) -> Optional[Meshes]:
if not endswith(path, self.known_suffixes):
return None
with _open_file(path, path_manager, "rb") as f:
data = _load_off_stream(f)
verts = torch.from_numpy(data["verts"]).to(device)
if "faces" in data:
faces = torch.from_numpy(data["faces"]).to(dtype=torch.int64, device=device)
else:
faces = torch.zeros((0, 3), dtype=torch.int64, device=device)
textures = None
if "verts_colors" in data:
if "faces_colors" in data:
msg = "Faces colors ignored because vertex colors provided too."
warnings.warn(msg)
verts_colors = torch.from_numpy(data["verts_colors"]).to(device)
textures = TexturesVertex([verts_colors])
elif "faces_colors" in data:
faces_colors = torch.from_numpy(data["faces_colors"]).to(device)
textures = TexturesAtlas([faces_colors[:, None, None, :]])
mesh = Meshes(
verts=[verts.to(device)], faces=[faces.to(device)], textures=textures
)
return mesh
def save(
self,
data: Meshes,
path: PathOrStr,
path_manager: PathManager,
binary: Optional[bool],
decimal_places: Optional[int] = None,
**kwargs,
) -> bool:
if not endswith(path, self.known_suffixes):
return False
verts = data.verts_list()[0]
faces = data.faces_list()[0]
if isinstance(data.textures, TexturesVertex):
[verts_colors] = data.textures.verts_features_list()
else:
verts_colors = None
faces_colors = None
if isinstance(data.textures, TexturesAtlas):
[atlas] = data.textures.atlas_list()
F, R, _, D = atlas.shape
if R == 1:
faces_colors = atlas[:, 0, 0, :]
_save_off(
file=path,
verts=verts,
faces=faces,
verts_colors=verts_colors,
faces_colors=faces_colors,
decimal_places=decimal_places,
path_manager=path_manager,
)
return True