pytorch3d/tests/test_io_off.py
Daisy 5241b7dd4e Handle header has no newline or space after OFF. (#665)
Summary:
Allow a line like `OFF10 10 10` as the start of an OFF file. Such things apparently occur in ModelNet40.

This resolves https://github.com/facebookresearch/pytorch3d/issues/663.

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

Test Plan: New test

Reviewed By: nikhilaravi

Differential Revision: D28180006

Pulled By: bottler

fbshipit-source-id: 7f474c6a262e32da012217e09f76e8672a7f0278
2021-05-07 05:01:58 -07:00

331 lines
11 KiB
Python

# Copyright (c) Facebook, Inc. and its affiliates. All rights reserved.
import unittest
from tempfile import NamedTemporaryFile
import torch
from common_testing import TestCaseMixin
from pytorch3d.io import IO
from pytorch3d.renderer import TexturesAtlas, TexturesVertex
from pytorch3d.utils import ico_sphere
CUBE_FACES = [
[0, 1, 2],
[7, 4, 0],
[4, 5, 1],
[5, 6, 2],
[3, 2, 6],
[6, 5, 4],
[0, 2, 3],
[7, 0, 3],
[4, 1, 0],
[5, 2, 1],
[3, 6, 7],
[6, 4, 7],
]
class TestMeshOffIO(TestCaseMixin, unittest.TestCase):
def test_load_face_colors(self):
# Example from wikipedia
off_file_lines = [
"OFF",
"# cube.off",
"# A cube",
" ",
"8 6 12",
" 1.0 0.0 1.4142",
" 0.0 1.0 1.4142",
"-1.0 0.0 1.4142",
" 0.0 -1.0 1.4142",
" 1.0 0.0 0.0",
" 0.0 1.0 0.0",
"-1.0 0.0 0.0",
" 0.0 -1.0 0.0",
"4 0 1 2 3 255 0 0 #red",
"4 7 4 0 3 0 255 0 #green",
"4 4 5 1 0 0 0 255 #blue",
"4 5 6 2 1 0 255 0 ",
"4 3 2 6 7 0 0 255",
"4 6 5 4 7 255 0 0",
]
off_file = "\n".join(off_file_lines)
io = IO()
with NamedTemporaryFile(mode="w", suffix=".off") as f:
f.write(off_file)
f.flush()
mesh = io.load_mesh(f.name)
self.assertEqual(mesh.verts_padded().shape, (1, 8, 3))
verts_str = " ".join(off_file_lines[5:13])
verts_data = torch.tensor([float(i) for i in verts_str.split()])
self.assertClose(mesh.verts_padded().flatten(), verts_data)
self.assertClose(mesh.faces_padded(), torch.tensor(CUBE_FACES)[None])
faces_colors_full = mesh.textures.atlas_padded()
self.assertEqual(faces_colors_full.shape, (1, 12, 1, 1, 3))
faces_colors = faces_colors_full[0, :, 0, 0]
max_color = faces_colors.max()
self.assertEqual(max_color, 1)
# Every face has one color 1, the rest 0.
total_color = faces_colors.sum(dim=1)
self.assertEqual(total_color.max(), max_color)
self.assertEqual(total_color.min(), max_color)
def test_load_vertex_colors(self):
# Example with no faces and with integer vertex colors
off_file_lines = [
"8 1 12",
" 1.0 0.0 1.4142 0 1 0",
" 0.0 1.0 1.4142 0 1 0",
"-1.0 0.0 1.4142 0 1 0",
" 0.0 -1.0 1.4142 0 1 0",
" 1.0 0.0 0.0 0 1 0",
" 0.0 1.0 0.0 0 1 0",
"-1.0 0.0 0.0 0 1 0",
" 0.0 -1.0 0.0 0 1 0",
"3 0 1 2",
]
off_file = "\n".join(off_file_lines)
io = IO()
with NamedTemporaryFile(mode="w", suffix=".off") as f:
f.write(off_file)
f.flush()
mesh = io.load_mesh(f.name)
self.assertEqual(mesh.verts_padded().shape, (1, 8, 3))
verts_lines = (line.split()[:3] for line in off_file_lines[1:9])
verts_data = [[[float(x) for x in line] for line in verts_lines]]
self.assertClose(mesh.verts_padded(), torch.tensor(verts_data))
self.assertClose(mesh.faces_padded(), torch.tensor([[[0, 1, 2]]]))
self.assertIsInstance(mesh.textures, TexturesVertex)
colors = mesh.textures.verts_features_padded()
self.assertEqual(colors.shape, (1, 8, 3))
self.assertClose(colors[0, :, [0, 2]], torch.zeros(8, 2))
self.assertClose(colors[0, :, 1], torch.full((8,), 1.0 / 255))
def test_load_lumpy(self):
# Example off file whose faces have different numbers of vertices.
off_file_lines = [
"8 3 12",
" 1.0 0.0 1.4142",
" 0.0 1.0 1.4142",
"-1.0 0.0 1.4142",
" 0.0 -1.0 1.4142",
" 1.0 0.0 0.0",
" 0.0 1.0 0.0",
"-1.0 0.0 0.0",
" 0.0 -1.0 0.0",
"3 0 1 2 255 0 0 #red",
"4 7 4 0 3 0 255 0 #green",
"4 4 5 1 0 0 0 255 #blue",
]
off_file = "\n".join(off_file_lines)
io = IO()
with NamedTemporaryFile(mode="w", suffix=".off") as f:
f.write(off_file)
f.flush()
mesh = io.load_mesh(f.name)
self.assertEqual(mesh.verts_padded().shape, (1, 8, 3))
verts_str = " ".join(off_file_lines[1:9])
verts_data = torch.tensor([float(i) for i in verts_str.split()])
self.assertClose(mesh.verts_padded().flatten(), verts_data)
self.assertEqual(mesh.faces_padded().shape, (1, 5, 3))
faces_expected = [[0, 1, 2], [7, 4, 0], [7, 0, 3], [4, 5, 1], [4, 1, 0]]
self.assertClose(mesh.faces_padded()[0], torch.tensor(faces_expected))
def test_save_load_icosphere(self):
# Test that saving a mesh as an off file and loading it results in the
# same data on the correct device, for all permitted types of textures.
# Standard test is for random colors, but also check totally white,
# because there's a different in OFF semantics between "1.0" color (=full)
# and "1" (= 1/255 color)
sphere = ico_sphere(0)
io = IO()
device = torch.device("cuda:0")
atlas_padded = torch.rand(1, sphere.faces_list()[0].shape[0], 1, 1, 3)
atlas = TexturesAtlas(atlas_padded)
atlas_padded_white = torch.ones(1, sphere.faces_list()[0].shape[0], 1, 1, 3)
atlas_white = TexturesAtlas(atlas_padded_white)
verts_colors_padded = torch.rand(1, sphere.verts_list()[0].shape[0], 3)
vertex_texture = TexturesVertex(verts_colors_padded)
verts_colors_padded_white = torch.ones(1, sphere.verts_list()[0].shape[0], 3)
vertex_texture_white = TexturesVertex(verts_colors_padded_white)
# No colors case
with NamedTemporaryFile(mode="w", suffix=".off") as f:
io.save_mesh(sphere, f.name)
f.flush()
mesh1 = io.load_mesh(f.name, device=device)
self.assertEqual(mesh1.device, device)
mesh1 = mesh1.cpu()
self.assertClose(mesh1.verts_padded(), sphere.verts_padded())
self.assertClose(mesh1.faces_padded(), sphere.faces_padded())
self.assertIsNone(mesh1.textures)
# Atlas case
sphere.textures = atlas
with NamedTemporaryFile(mode="w", suffix=".off") as f:
io.save_mesh(sphere, f.name)
f.flush()
mesh2 = io.load_mesh(f.name, device=device)
self.assertEqual(mesh2.device, device)
mesh2 = mesh2.cpu()
self.assertClose(mesh2.verts_padded(), sphere.verts_padded())
self.assertClose(mesh2.faces_padded(), sphere.faces_padded())
self.assertClose(mesh2.textures.atlas_padded(), atlas_padded, atol=1e-4)
# White atlas case
sphere.textures = atlas_white
with NamedTemporaryFile(mode="w", suffix=".off") as f:
io.save_mesh(sphere, f.name)
f.flush()
mesh3 = io.load_mesh(f.name)
self.assertClose(mesh3.textures.atlas_padded(), atlas_padded_white, atol=1e-4)
# TexturesVertex case
sphere.textures = vertex_texture
with NamedTemporaryFile(mode="w", suffix=".off") as f:
io.save_mesh(sphere, f.name)
f.flush()
mesh4 = io.load_mesh(f.name, device=device)
self.assertEqual(mesh4.device, device)
mesh4 = mesh4.cpu()
self.assertClose(mesh4.verts_padded(), sphere.verts_padded())
self.assertClose(mesh4.faces_padded(), sphere.faces_padded())
self.assertClose(
mesh4.textures.verts_features_padded(), verts_colors_padded, atol=1e-4
)
# white TexturesVertex case
sphere.textures = vertex_texture_white
with NamedTemporaryFile(mode="w", suffix=".off") as f:
io.save_mesh(sphere, f.name)
f.flush()
mesh5 = io.load_mesh(f.name)
self.assertClose(
mesh5.textures.verts_features_padded(), verts_colors_padded_white, atol=1e-4
)
def test_bad(self):
# Test errors from various invalid OFF files.
io = IO()
def load(lines):
off_file = "\n".join(lines)
with NamedTemporaryFile(mode="w", suffix=".off") as f:
f.write(off_file)
f.flush()
io.load_mesh(f.name)
# First a good example
lines = [
"4 2 12",
" 1.0 0.0 1.4142",
" 0.0 1.0 1.4142",
" 1.0 0.0 0.4142",
" 0.0 1.0 0.4142",
"3 0 1 2 ",
"3 1 3 0 ",
]
# This example passes.
load(lines)
# OFF can occur on the first line separately
load(["OFF"] + lines)
# OFF line can be merged in to the first line
lines2 = lines.copy()
lines2[0] = "OFF " + lines[0]
load(lines2)
# OFF line can be merged in to the first line with no space
lines2 = lines.copy()
lines2[0] = "OFF" + lines[0]
load(lines2)
with self.assertRaisesRegex(ValueError, "Not enough face data."):
load(lines[:-1])
lines2 = lines.copy()
lines2[0] = "4 1 12"
with self.assertRaisesRegex(ValueError, "Extra data at end of file:"):
load(lines2)
lines2 = lines.copy()
lines2[-1] = "2 1 3"
with self.assertRaisesRegex(ValueError, "Faces must have at least 3 vertices."):
load(lines2)
lines2 = lines.copy()
lines2[-1] = "4 1 3 0"
with self.assertRaisesRegex(
ValueError, "A line of face data did not have the specified length."
):
load(lines2)
lines2 = lines.copy()
lines2[0] = "6 2 0"
with self.assertRaisesRegex(ValueError, "Wrong number of columns at line 5"):
load(lines2)
lines2[0] = "5 1 0"
with self.assertRaisesRegex(ValueError, "Wrong number of columns at line 5"):
load(lines2)
lines2[0] = "16 2 0"
with self.assertRaisesRegex(ValueError, "Wrong number of columns at line 5"):
load(lines2)
lines2[0] = "3 3 0"
# This is a bit of a special case because the last vertex could be a face
with self.assertRaisesRegex(ValueError, "Faces must have at least 3 vertices."):
load(lines2)
lines2[4] = "7.3 4.2 8.3"
with self.assertRaisesRegex(
ValueError, "A line of face data did not have the specified length."
):
load(lines2)
# Now try bad number of colors
lines2 = lines.copy()
lines2[2] = "7.3 4.2 8.3 932"
with self.assertRaisesRegex(ValueError, "Wrong number of columns at line 2"):
load(lines2)
lines2[1] = "7.3 4.2 8.3 932"
lines2[3] = "7.3 4.2 8.3 932"
lines2[4] = "7.3 4.2 8.3 932"
with self.assertRaisesRegex(ValueError, "Bad vertex data."):
load(lines2)
lines2 = lines.copy()
lines2[5] = "3 0 1 2 0.9"
lines2[6] = "3 0 3 0 0.9"
with self.assertRaisesRegex(ValueError, "Unexpected number of colors."):
load(lines2)
lines2 = lines.copy()
for i in range(1, 7):
lines2[i] = lines2[i] + " 4 4 4 4"
msg = "Faces colors ignored because vertex colors provided too."
with self.assertWarnsRegex(UserWarning, msg):
load(lines2)