forked from facebookresearch/pytorch3d
-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathobj_io.py
1330 lines (1160 loc) · 50 KB
/
obj_io.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# 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."""
import os
import warnings
from collections import namedtuple
from pathlib import Path
from typing import List, Optional, Dict, Union, Tuple
import numpy as np
import torch
from iopath.common.file_io import PathManager
from PIL import Image
from pytorch3d.utils.obj_utils import (
parse_obj_to_mesh_by_texture,
_reindex_face_normals_by_index,
_reindex_obj_materials_by_index,
_reindex_verts_faces_by_index,
_reindex_verts_faces_uvs_by_index,
_validate_obj,
)
from pytorch3d.common.datatypes import Device
from pytorch3d.io.mtl_io import load_mtl, make_mesh_texture_atlas
from pytorch3d.io.utils import _check_faces_indices, _make_tensor, _open_file, PathOrStr
from pytorch3d.renderer.mesh.textures import TexturesAtlas, TexturesUV
from pytorch3d.structures import join_meshes_as_batch, join_meshes_as_scene, Meshes
from .pluggable_formats import endswith, MeshFormatInterpreter
# Faces & Aux type returned from load_obj function.
_Faces = namedtuple("Faces", "verts_idx normals_idx textures_idx materials_idx")
_Aux = namedtuple(
"Properties", "normals verts_uvs material_colors texture_images texture_atlas"
)
def _format_faces_indices(faces_indices, max_index: int, device, pad_value=None):
"""
Format indices and check for invalid values. Indices can refer to
values in one of the face properties: vertices, textures or normals.
See comments of the load_obj function for more details.
Args:
faces_indices: List of ints of indices.
max_index: Max index for the face property.
pad_value: if any of the face_indices are padded, specify
the value of the padding (e.g. -1). This is only used
for texture indices indices where there might
not be texture information for all the faces.
Returns:
faces_indices: List of ints of indices.
Raises:
ValueError if indices are not in a valid range.
"""
faces_indices = _make_tensor(
faces_indices, cols=3, dtype=torch.int64, device=device
)
if pad_value is not None:
mask = faces_indices.eq(pad_value).all(dim=-1)
# Change to 0 based indexing.
faces_indices[(faces_indices > 0)] -= 1
# Negative indexing counts from the end.
faces_indices[(faces_indices < 0)] += max_index
if pad_value is not None:
# pyre-fixme[61]: `mask` is undefined, or not always defined.
faces_indices[mask] = pad_value
return _check_faces_indices(faces_indices, max_index, pad_value)
def load_obj(
f,
load_textures: bool = True,
create_texture_atlas: bool = False,
texture_atlas_size: int = 4,
texture_wrap: Optional[str] = "repeat",
device: Device = "cpu",
path_manager: Optional[PathManager] = None,
high_precision: Optional[bool] = False,
):
"""
Load a mesh from a .obj file and optionally textures from a .mtl file.
Currently this handles verts, faces, vertex texture uv coordinates, normals,
texture images and material reflectivity values.
Note .obj files are 1-indexed. The tensors returned from this function
are 0-indexed. OBJ spec reference: http://www.martinreddy.net/gfx/3d/OBJ.spec
Example .obj file format:
::
# this is a comment
v 1.000000 -1.000000 -1.000000
v 1.000000 -1.000000 1.000000
v -1.000000 -1.000000 1.000000
v -1.000000 -1.000000 -1.000000
v 1.000000 1.000000 -1.000000
vt 0.748573 0.750412
vt 0.749279 0.501284
vt 0.999110 0.501077
vt 0.999455 0.750380
vn 0.000000 0.000000 -1.000000
vn -1.000000 -0.000000 -0.000000
vn -0.000000 -0.000000 1.000000
f 5/2/1 1/2/1 4/3/1
f 5/1/1 4/3/1 2/4/1
The first character of the line denotes the type of input:
::
- v is a vertex
- vt is the texture coordinate of one vertex
- vn is the normal of one vertex
- f is a face
Faces are interpreted as follows:
::
5/2/1 describes the first vertex of the first triangle
- 5: index of vertex [1.000000 1.000000 -1.000000]
- 2: index of texture coordinate [0.749279 0.501284]
- 1: index of normal [0.000000 0.000000 -1.000000]
If there are faces with more than 3 vertices
they are subdivided into triangles. Polygonal faces are assumed to have
vertices ordered counter-clockwise so the (right-handed) normal points
out of the screen e.g. a proper rectangular face would be specified like this:
::
0_________1
| |
| |
3 ________2
The face would be split into two triangles: (0, 2, 1) and (0, 3, 2),
both of which are also oriented counter-clockwise and have normals
pointing out of the screen.
Args:
f: A file-like object (with methods read, readline, tell, and seek),
a pathlib path or a string containing a file name.
load_textures: Boolean indicating whether material files are loaded
create_texture_atlas: Bool, If True a per face texture map is created and
a tensor `texture_atlas` is also returned in `aux`.
texture_atlas_size: Int specifying the resolution of the texture map per face
when `create_texture_atlas=True`. A (texture_size, texture_size, 3)
map is created per face.
texture_wrap: string, one of ["repeat", "clamp"]. This applies when computing
the texture atlas.
If `texture_mode="repeat"`, for uv values outside the range [0, 1] the integer part
is ignored and a repeating pattern is formed.
If `texture_mode="clamp"` the values are clamped to the range [0, 1].
If None, then there is no transformation of the texture values.
device: Device (as str or torch.device) on which to return the new tensors.
path_manager: optionally a PathManager object to interpret paths.
high_precision: optionally use torch.float64 tensors instead of default torch.float32 tensors for vertices.
Returns:
6-element tuple containing
- **verts**: FloatTensor of shape (V, 3).
- **faces**: NamedTuple with fields:
- verts_idx: LongTensor of vertex indices, shape (F, 3).
- normals_idx: (optional) LongTensor of normal indices, shape (F, 3).
- textures_idx: (optional) LongTensor of texture indices, shape (F, 3).
This can be used to index into verts_uvs.
- materials_idx: (optional) List of indices indicating which
material the texture is derived from for each face.
If there is no material for a face, the index is -1.
This can be used to retrieve the corresponding values
in material_colors/texture_images after they have been
converted to tensors or Materials/Textures data
structures - see textures.py and materials.py for
more info.
- **aux**: NamedTuple with fields:
- normals: FloatTensor of shape (N, 3)
- verts_uvs: FloatTensor of shape (T, 2), giving the uv coordinate per
vertex. If a vertex is shared between two faces, it can have
a different uv value for each instance. Therefore it is
possible that the number of verts_uvs is greater than
num verts i.e. T > V.
vertex.
- material_colors: if `load_textures=True` and the material has associated
properties this will be a dict of material names and properties of the form:
.. code-block:: python
{
material_name_1: {
"ambient_color": tensor of shape (1, 3),
"diffuse_color": tensor of shape (1, 3),
"specular_color": tensor of shape (1, 3),
"shininess": tensor of shape (1)
},
material_name_2: {},
...
}
If a material does not have any properties it will have an
empty dict. If `load_textures=False`, `material_colors` will None.
- texture_images: if `load_textures=True` and the material has a texture map,
this will be a dict of the form:
.. code-block:: python
{
material_name_1: (H, W, 3) image,
...
}
If `load_textures=False`, `texture_images` will None.
- texture_atlas: if `load_textures=True` and `create_texture_atlas=True`,
this will be a FloatTensor of the form: (F, texture_size, textures_size, 3)
If the material does not have a texture map, then all faces
will have a uniform white texture. Otherwise `texture_atlas` will be
None.
"""
data_dir = "./"
if isinstance(f, (str, bytes, Path)):
# pyre-fixme[6]: For 1st argument expected `PathLike[Variable[AnyStr <:
# [str, bytes]]]` but got `Union[Path, bytes, str]`.
data_dir = os.path.dirname(f)
if path_manager is None:
path_manager = PathManager()
with _open_file(f, path_manager, "r") as f:
return _load_obj(
f,
data_dir=data_dir,
load_textures=load_textures,
create_texture_atlas=create_texture_atlas,
texture_atlas_size=texture_atlas_size,
texture_wrap=texture_wrap,
path_manager=path_manager,
device=device,
high_precision=high_precision,
)
def subset_obj(
obj: Tuple[torch.Tensor, _Faces, _Aux],
faces_to_subset: torch.Tensor,
device: Device,
) -> Tuple[torch.Tensor, _Faces, _Aux]:
"""A utility function to subset an obj by a faces_to_subset that represents
the face indices to keep. Provides support for multitexture objs.
Args:
obj: An obj which is a 3-tuple of verts (torch.Tensor),
faces (NamedTuple containing: verts_idx normals_idx textures_idx materials_idx),
and aux (NamedTuple containing: normals verts_uvs material_colors texture_images texture_atlas).
faces_to_subset: A 1-dimentional tensor that represents the desired
indices of the faces to keep in the subset.
device: Device (as str or torch.device) on which to return the new tensors.
Returns:
An subset of the input obj which effectively copies the input according to faces_to_subset.
- obj: A subset copy of the input; 3-tuple of data structures of verts, faces, and aux.
"""
# initialize default/empty values
_texture_images, _texture_atlas, _material_colors = None, None, None
_verts_uvs, _faces_uvs, _mtl_idx = None, None, None
_normals, _normals_idx = None, None
if len(obj) != 3:
message = "obj must be 3-tuple"
raise ValueError(message)
if not isinstance(obj[1], _Faces):
message = "obj[1] must be a _Faces NamedTuple object that defines obj faces"
raise ValueError(message)
if not isinstance(obj[2], _Aux):
message = "obj[2] must be an _Aux NamedTuple object that defines obj properties"
raise ValueError(message)
_validate_obj(
verts=obj[0],
faces=obj[1].verts_idx,
faces_uvs=obj[1].textures_idx,
verts_uvs=obj[2].verts_uvs,
texture_images=obj[2].texture_images,
materials_idx=obj[1].materials_idx,
)
# raise errors for specific conditions
if faces_to_subset.shape[0] == 0:
message = "faces_to_subset is empty."
raise ValueError(message)
if not isinstance(faces_to_subset, torch.Tensor):
message = "faces_to_subset must be a torch.Tensor"
raise ValueError(message)
unique_faces_to_subset = torch.unique(faces_to_subset)
if unique_faces_to_subset.shape[0] != faces_to_subset.shape[0]:
message = "Face indices are repeated in faces_to_subset."
warnings.warn(message)
if (
unique_faces_to_subset.max() >= obj[1].verts_idx.shape[0]
or unique_faces_to_subset.min() < 0
):
message = "faces_to_subset contains invalid indices."
raise ValueError(message)
subset_device = faces_to_subset.device
if not all(
[
obj[0].device == subset_device,
obj[1].verts_idx.device == subset_device,
obj[1].normals_idx.device == subset_device
if obj[1].normals_idx is not None
else True,
obj[1].textures_idx.device == subset_device
if obj[1].textures_idx is not None
else True,
obj[1].materials_idx.device == subset_device
if obj[1].materials_idx is not None
else True,
obj[2].texture_atlas.device == subset_device
if obj[2].texture_atlas is not None
else True,
obj[2].verts_uvs.device == subset_device
if obj[2].verts_uvs is not None
else True,
]
):
message = "obj and faces_to_subset are not on the same device"
raise ValueError(message)
# reindex faces and verts according to faces_to_subset
_verts_idx, _faces = _reindex_verts_faces_by_index(
obj[1].verts_idx, faces_to_subset
)
# reindex face normals according to faces_to_subset, if present
if obj[2].normals is not None and obj[1].normals_idx is not None:
_normals_idx_orig, _normals_idx = _reindex_face_normals_by_index(
obj[1].normals_idx, faces_to_subset
)
_normals = obj[2].normals[_normals_idx_orig]
# slice texture_atlas by faces_to_subset, if present
if obj[2].texture_atlas is not None:
_texture_atlas = obj[2].texture_atlas[faces_to_subset].to(device)
if obj[2].verts_uvs is not None:
# if textures exist, reindex them in addition to faces and verts
_verts_uvs, _faces_uvs = _reindex_verts_faces_uvs_by_index(
obj[1].textures_idx, faces_to_subset
)
_mtl_idx_orig, _mtl_idx = _reindex_obj_materials_by_index(
obj[1].materials_idx, faces_to_subset
)
# obtain a list of the texture's image keys based on the original texture order
_tex_image_keys = list(obj[2].texture_images.keys())
# for each index in _mtl_idx_orig, get the corresponding value in _tex_image_keys
_tex_image_keys = list(map(_tex_image_keys.__getitem__, _mtl_idx_orig.tolist()))
# reconstruct the texture images dictionary with the new texture image keys
_texture_images = {
k: obj[2].texture_images[k].to(device) for k in _tex_image_keys
}
if len(_texture_images) != len(torch.unique(_mtl_idx)):
warnings.warn("Invalid textures.")
# select material_colors, if present, according to filtered texture_images
if obj[2].material_colors is not None:
_material_colors = {
k: {
k_i: k_j.to(device)
for k_i, k_j in obj[2].material_colors[k].items()
}
for k in list(_texture_images.keys())
if k in obj[2].material_colors.keys()
}
# use new index tensors to slice, assemble, and return a subset obj
verts = obj[0][_verts_idx].to(device)
faces = _Faces(
verts_idx=_faces.to(device),
normals_idx=_normals_idx.to(device)
if _normals_idx is not None
else _normals_idx,
textures_idx=_faces_uvs.to(device) if _faces_uvs is not None else _faces_uvs,
materials_idx=_mtl_idx.to(device) if _mtl_idx is not None else _mtl_idx,
)
aux = _Aux(
normals=_normals.to(device) if _normals is not None else None,
verts_uvs=obj[2].verts_uvs[_verts_uvs].to(device)
if _verts_uvs is not None
else None,
material_colors=_material_colors,
texture_images=_texture_images,
texture_atlas=_texture_atlas,
)
return verts, faces, aux
def load_objs_as_meshes(
files: list,
device: Optional[Device] = None,
load_textures: bool = True,
create_texture_atlas: bool = False,
texture_atlas_size: int = 4,
texture_wrap: Optional[str] = "repeat",
path_manager: Optional[PathManager] = None,
high_precision: Optional[bool] = False,
):
"""
Load meshes from a list of .obj files using the load_obj function, and
return them as a Meshes object. Input .obj files with multiple texture
images are supported. See the load_obj function for more details.
normals are not stored.
Args:
files: A list of file-like objects (with methods read, readline, tell,
and seek), pathlib paths or strings containing file names.
device: Desired device of returned Meshes. Default:
uses the current device for the default tensor type.
load_textures: Boolean indicating whether material files are loaded
create_texture_atlas, texture_atlas_size, texture_wrap: as for load_obj.
path_manager: optionally a PathManager object to interpret paths.
high_precision: optionally use torch.float64 tensors instead of default torch.float32 tensors for vertices.
Returns:
New Meshes object.
"""
mesh_list = []
for f_obj in files:
verts, faces, aux = load_obj(
f_obj,
load_textures=load_textures,
create_texture_atlas=create_texture_atlas,
texture_atlas_size=texture_atlas_size,
texture_wrap=texture_wrap,
path_manager=path_manager,
high_precision=high_precision,
)
if create_texture_atlas:
# TexturesAtlas type
mesh = Meshes(
verts=[verts.to(device)],
faces=[faces.verts_idx.to(device)],
textures=TexturesAtlas(atlas=[aux.texture_atlas.to(device)]),
)
elif aux.texture_images is not None and len(aux.texture_images) > 0:
# TexturesUV type with support for multiple texture inputs
mesh = parse_obj_to_mesh_by_texture(
verts=verts,
faces=faces.verts_idx,
verts_uvs=aux.verts_uvs,
faces_uvs=faces.textures_idx,
texture_images=aux.texture_images,
device=device,
materials_idx=faces.materials_idx,
texture_atlas=aux.texture_atlas,
)
# combine partial meshes into a single scene
mesh = join_meshes_as_scene(mesh)
else:
# if there are no valid textures
mesh = Meshes(verts=[verts.to(device)], faces=[faces.verts_idx.to(device)])
mesh_list.append(mesh)
if len(mesh_list) == 1:
return mesh_list[0]
else:
return join_meshes_as_batch(mesh_list)
class MeshObjFormat(MeshFormatInterpreter):
def __init__(self) -> None:
self.known_suffixes = (".obj",)
def read(
self,
path: PathOrStr,
include_textures: bool,
device: Device,
path_manager: PathManager,
create_texture_atlas: bool = False,
texture_atlas_size: int = 4,
texture_wrap: Optional[str] = "repeat",
high_precision: Optional[bool] = False,
**kwargs,
) -> Optional[Meshes]:
if not endswith(path, self.known_suffixes):
return None
mesh = load_objs_as_meshes(
files=[path],
device=device,
load_textures=include_textures,
create_texture_atlas=create_texture_atlas,
texture_atlas_size=texture_atlas_size,
texture_wrap=texture_wrap,
path_manager=path_manager,
high_precision=high_precision,
)
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]
verts_uvs: Optional[torch.Tensor] = None
faces_uvs: Optional[torch.Tensor] = None
texture_map: Optional[torch.Tensor] = None
if isinstance(data.textures, TexturesUV):
verts_uvs = data.textures.verts_uvs_padded()[0]
faces_uvs = data.textures.faces_uvs_padded()[0]
texture_map = data.textures.maps_padded()[0]
save_obj(
f=path,
verts=verts,
faces=faces,
decimal_places=decimal_places,
path_manager=path_manager,
verts_uvs=verts_uvs,
faces_uvs=faces_uvs,
texture_map=texture_map,
)
return True
def _parse_face(
line,
tokens,
material_idx,
faces_verts_idx,
faces_normals_idx,
faces_textures_idx,
faces_materials_idx,
) -> None:
face = tokens[1:]
face_list = [f.split("/") for f in face]
face_verts = []
face_normals = []
face_textures = []
for vert_props in face_list:
# Vertex index.
face_verts.append(int(vert_props[0]))
if len(vert_props) > 1:
if vert_props[1] != "":
# Texture index is present e.g. f 4/1/1.
face_textures.append(int(vert_props[1]))
# if len(vert_props) > 2:
if len(vert_props) > 2 and len(vert_props[2]) != 0:
# do not parse normals if empty: ValueError: invalid literal for int() with base 10: ''
# Normal index present e.g. 4/1/1 or 4//1.
face_normals.append(int(vert_props[2]))
if len(vert_props) > 3:
raise ValueError(
"Face vertices can only have 3 properties. \
Face vert %s, Line: %s"
% (str(vert_props), str(line))
)
# Triplets must be consistent for all vertices in a face e.g.
# legal statement: f 4/1/1 3/2/1 2/1/1.
# illegal statement: f 4/1/1 3//1 2//1.
# If the face does not have normals or textures indices
# fill with pad value = -1. This will ensure that
# all the face index tensors will have F values where
# F is the number of faces.
if len(face_normals) > 0:
if not (len(face_verts) == len(face_normals)):
raise ValueError(
"Face %s is an illegal statement. \
Vertex properties are inconsistent. Line: %s"
% (str(face), str(line))
)
else:
face_normals = [-1] * len(face_verts) # Fill with -1
if len(face_textures) > 0:
if not (len(face_verts) == len(face_textures)):
raise ValueError(
"Face %s is an illegal statement. \
Vertex properties are inconsistent. Line: %s"
% (str(face), str(line))
)
else:
face_textures = [-1] * len(face_verts) # Fill with -1
# Subdivide faces with more than 3 vertices.
# See comments of the load_obj function for more details.
for i in range(len(face_verts) - 2):
faces_verts_idx.append((face_verts[0], face_verts[i + 1], face_verts[i + 2]))
faces_normals_idx.append(
(face_normals[0], face_normals[i + 1], face_normals[i + 2])
)
faces_textures_idx.append(
(face_textures[0], face_textures[i + 1], face_textures[i + 2])
)
faces_materials_idx.append(material_idx)
def _parse_obj(f, data_dir: str):
"""
Load a mesh from a file-like object. See load_obj function for more details
about the return values.
"""
verts, normals, verts_uvs = [], [], []
faces_verts_idx, faces_normals_idx, faces_textures_idx = [], [], []
faces_materials_idx = []
material_names = []
mtl_path = None
lines = [line.strip() for line in f]
# startswith expects each line to be a string. If the file is read in as
# bytes then first decode to strings.
if lines and isinstance(lines[0], bytes):
lines = [el.decode("utf-8") for el in lines]
materials_idx = -1
for line in lines:
tokens = line.strip().split()
if line.startswith("mtllib"):
if len(tokens) < 2:
raise ValueError("material file name is not specified")
# NOTE: only allow one .mtl file per .obj.
# Definitions for multiple materials can be included
# in this one .mtl file.
mtl_path = line[len(tokens[0]) :].strip() # Take the remainder of the line
mtl_path = os.path.join(data_dir, mtl_path)
elif len(tokens) and tokens[0] == "usemtl":
material_name = tokens[1]
# materials are often repeated for different parts
# of a mesh.
if material_name not in material_names:
material_names.append(material_name)
materials_idx = len(material_names) - 1
else:
materials_idx = material_names.index(material_name)
elif line.startswith("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.
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.
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.
# Update face properties info.
_parse_face(
line,
tokens,
materials_idx,
faces_verts_idx,
faces_normals_idx,
faces_textures_idx,
faces_materials_idx,
)
return (
verts,
normals,
verts_uvs,
faces_verts_idx,
faces_normals_idx,
faces_textures_idx,
faces_materials_idx,
material_names,
mtl_path,
)
def _load_materials(
material_names: List[str],
f: Optional[str],
*,
data_dir: str,
load_textures: bool,
device: Device,
path_manager: PathManager,
):
"""
Load materials and optionally textures from the specified path.
Args:
material_names: a list of the material names found in the .obj file.
f: path to the material information.
data_dir: the directory where the material texture files are located.
load_textures: whether textures should be loaded.
device: Device (as str or torch.device) on which to return the new tensors.
path_manager: PathManager object to interpret paths.
Returns:
material_colors: dict of properties for each material.
texture_images: dict of material names and texture images.
"""
if not load_textures:
return None, None
if f is None:
warnings.warn("No mtl file provided")
return None, None
if not path_manager.exists(f):
warnings.warn(f"Mtl file does not exist: {f}")
return None, None
# Texture mode uv wrap
return load_mtl(
f,
material_names=material_names,
data_dir=data_dir,
path_manager=path_manager,
device=device,
)
def _load_obj(
f_obj,
*,
data_dir: str,
load_textures: bool = True,
create_texture_atlas: bool = False,
texture_atlas_size: int = 4,
texture_wrap: Optional[str] = "repeat",
path_manager: PathManager,
device: Device = "cpu",
high_precision: Optional[bool] = False,
):
"""
Load a mesh from a file-like object. See load_obj function more details.
Any material files associated with the obj are expected to be in the
directory given by data_dir.
"""
if texture_wrap is not None and texture_wrap not in ["repeat", "clamp"]:
msg = "texture_wrap must be one of ['repeat', 'clamp'] or None, got %s"
raise ValueError(msg % texture_wrap)
(
verts,
normals,
verts_uvs,
faces_verts_idx,
faces_normals_idx,
faces_textures_idx,
faces_materials_idx,
material_names,
mtl_path,
) = _parse_obj(f_obj, data_dir)
verts = _make_tensor(
verts,
cols=3,
dtype=torch.float64 if high_precision else torch.float32,
device=device,
) # (V, 3)
normals = _make_tensor(
normals,
cols=3,
dtype=torch.float32,
device=device,
) # (N, 3)
verts_uvs = _make_tensor(
verts_uvs,
cols=2,
dtype=torch.float32,
device=device,
) # (T, 2)
faces_verts_idx = _format_faces_indices(
faces_verts_idx, verts.shape[0], device=device
)
# Repeat for normals and textures if present.
if len(faces_normals_idx):
faces_normals_idx = _format_faces_indices(
faces_normals_idx, normals.shape[0], device=device, pad_value=-1
)
if len(faces_textures_idx):
faces_textures_idx = _format_faces_indices(
faces_textures_idx, verts_uvs.shape[0], device=device, pad_value=-1
)
if len(faces_materials_idx):
faces_materials_idx = torch.tensor(
faces_materials_idx, dtype=torch.int64, device=device
)
texture_atlas = None
material_colors, texture_images = _load_materials(
material_names,
mtl_path,
data_dir=data_dir,
load_textures=load_textures,
path_manager=path_manager,
device=device,
)
if material_colors and not material_names:
# usemtl was not present but single material was present in the .mtl file
material_names.append(next(iter(material_colors.keys())))
# replace all -1 by 0 material idx
if torch.is_tensor(faces_materials_idx):
faces_materials_idx.clamp_(min=0)
if create_texture_atlas:
# Using the images and properties from the
# material file make a per face texture map.
# Create an array of strings of material names for each face.
# If faces_materials_idx == -1 then that face doesn't have a material.
idx = faces_materials_idx.cpu().numpy()
face_material_names = np.array(material_names)[idx] # (F,)
face_material_names[idx == -1] = ""
# Construct the atlas.
texture_atlas = make_mesh_texture_atlas(
material_colors,
texture_images,
face_material_names,
faces_textures_idx,
verts_uvs,
texture_atlas_size,
texture_wrap,
)
faces = _Faces(
verts_idx=faces_verts_idx,
normals_idx=faces_normals_idx,
textures_idx=faces_textures_idx,
materials_idx=faces_materials_idx,
)
aux = _Aux(
normals=normals if len(normals) else None,
verts_uvs=verts_uvs if len(verts_uvs) else None,
material_colors=material_colors,
texture_images=texture_images,
texture_atlas=texture_atlas,
)
return verts, faces, aux
def save_obj(
f: PathOrStr,
verts,
faces,
decimal_places: Optional[int] = None,
path_manager: Optional[PathManager] = None,
*,
normals: Optional[torch.Tensor] = None,
faces_normals_idx: Optional[torch.Tensor] = None,
verts_uvs: Optional[torch.Tensor] = None,
faces_uvs: Optional[torch.Tensor] = None,
texture_map: Optional[torch.Tensor] = None,
texture_images: Optional[Dict[str, torch.tensor]] = None,
materials_idx: Optional[torch.Tensor] = None,
image_quality: Optional[int] = 75,
image_subsampling: Optional[Union[str, int]] = 0,
image_format: Optional[str] = "png",
**image_name_kwargs,
) -> None:
"""
Save a mesh to an .obj file.
Args:
f: File (str or path) to which the mesh should be written.
verts: FloatTensor of shape (V, 3) giving vertex coordinates.
faces: LongTensor of shape (F, 3) giving faces.
decimal_places: Number of decimal places for saving.
path_manager: Optional PathManager for interpreting f if
it is a str.
normals: FloatTensor of shape (V, 3) giving normals for faces_normals_idx
to index into.
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.
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
for the mesh which will be saved as an image. The values are expected
to be in the range [0, 1]. Supports saving an obj with a single texture.
If providing texture_map, texture_images must be None.
texture_images: Dictionary of str:FloatTensor of shape (H, W, 3) where
where each key value pair, in order, represnts a material name and
texture map; in objs, this value is often the aux.texture_images object.
Providing texture_images enables saving an obj with multiple textures
and texture files. If providing texture_images, texture_map must be None
and materials_idx must be provided.
materials_idx: IntTensor of shape (F, ) giving the material index that links
each face in faces to a texture in texture_images. If saving multiple
textures and providing a texture_images object, materials_idx must be
provided. This value is often the aux.materials_idx value in an obj.
image_quality: An optional integer value between 0 and 95 to pass through
to PIL that sets texture image quality. See PIL docs for details:
https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html.
image_subsampling: An optional string or integer value to pass through to
PIL that sets subsampling. See PIL docs for details:
https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html.
image_format: An optional string value that can be either 'png' or 'jpeg'
to pass through to PIL that sets the image type. See PIL docs for details:
https://pillow.readthedocs.io/en/stable/handbook/image-file-formats.html.
**image_name_kwargs: Optional kwargs to determine image file names.
For an obj with n textures, the default behavior saves an image name with
the obj basename appended with the index position of the texture. Example:
output.obj with 2 texture files writes output_0.png and output_1.png as
image files.
Available image_name_kwargs include:
* material_name_as_file_name: If True, the image file is saved with the
material name as the filename. Example: output.obj with material_1 and
material_2 as textures writes image files of material_1.png and
material_2.png, respectively.
* reuse_material_files: If True, material names are used as filenames.
In addition, image files are reused (without rewriting) for all objs within the
same directory that reference the same material name. This option may be
convenient if the same materials are re-used across obj files or if writing
subsets of the input obj to the origin directory. In the latter case the original
material files are preserverd and only new mtl and obj files of the subsets are written.
"""
_validate_obj(
verts=verts,
faces=faces,
faces_uvs=faces_uvs,
verts_uvs=verts_uvs,
texture_images=texture_images,
materials_idx=materials_idx,
normals=normals,
faces_normals_idx=faces_normals_idx,
)
if texture_map is not None and texture_images is not None:
message = "texture_map is not None and texture_images is not None; only one can be provided"
raise ValueError(message)
if not any([image_format == i for i in ["png", "jpeg"]]):
message = "'image_format' must be either 'png' or 'jpeg'"
raise ValueError(message)
if image_quality < 1 or image_quality > 95:
message = "'image_quality is recommended to be set between 0 and 95 according to PIL documentation"
warnings.warn(message)
if path_manager is None:
path_manager = PathManager()
save_texture = all([t is not None for t in [faces_uvs, verts_uvs]]) and (
texture_map is not None or texture_images is not None
)
output_path = Path(f)
if save_texture and texture_images is None:
# if a single texture map is given, treat it like a texture_images with a single image
texture_images = dict(mesh=texture_map)
materials_idx = torch.zeros(faces.shape[0], dtype=torch.int64)
# configure how image names are written to disk
enumerate_material_filename_by_index = True # the default setting
reuse_material_files = False
material_name_as_file_name = False
if image_name_kwargs is not None: