From 909dc835050f84fc739fe75fd02884f305195afb Mon Sep 17 00:00:00 2001 From: Jeremy Reizenstein Date: Tue, 25 Aug 2020 11:26:58 -0700 Subject: [PATCH] amalgamate meshes with texture into a single scene Summary: Add a join_scene method to all the textures to allow the join_mesh function to include textures. Rename the join_mesh function to join_meshes_as_scene. For TexturesAtlas, we now interpolate if the user attempts to have the resolution vary across the batch. This doesn't look great if the resolution is already very low. For TexturesUV, a rectangle packing function is required, this does something simple. Reviewed By: gkioxari Differential Revision: D23188773 fbshipit-source-id: c013db061a04076e13e90ccc168a7913e933a9c5 --- pytorch3d/renderer/mesh/textures.py | 180 +++++++++++++++-- pytorch3d/renderer/mesh/utils.py | 183 ++++++++++++++++++ pytorch3d/structures/__init__.py | 2 +- pytorch3d/structures/meshes.py | 31 ++- tests/data/test_joinatlas_final.png | Bin 0 -> 25917 bytes tests/data/test_joinuvs0_final.png | Bin 0 -> 11814 bytes tests/data/test_joinuvs0_map.png | Bin 0 -> 807 bytes tests/data/test_joinuvs1_final.png | Bin 0 -> 11815 bytes tests/data/test_joinuvs1_map.png | Bin 0 -> 819 bytes tests/data/test_joinuvs2_final.png | Bin 0 -> 11661 bytes tests/data/test_joinuvs2_map.png | Bin 0 -> 806 bytes tests/data/test_joinverts_final.png | Bin 0 -> 11699 bytes tests/test_render_meshes.py | 290 +++++++++++++++++++++++++++- tests/test_texturing.py | 78 ++++++++ 14 files changed, 741 insertions(+), 23 deletions(-) create mode 100644 tests/data/test_joinatlas_final.png create mode 100644 tests/data/test_joinuvs0_final.png create mode 100644 tests/data/test_joinuvs0_map.png create mode 100644 tests/data/test_joinuvs1_final.png create mode 100644 tests/data/test_joinuvs1_map.png create mode 100644 tests/data/test_joinuvs2_final.png create mode 100644 tests/data/test_joinuvs2_map.png create mode 100644 tests/data/test_joinverts_final.png diff --git a/pytorch3d/renderer/mesh/textures.py b/pytorch3d/renderer/mesh/textures.py index 0d69450f..cccb3d75 100644 --- a/pytorch3d/renderer/mesh/textures.py +++ b/pytorch3d/renderer/mesh/textures.py @@ -2,7 +2,7 @@ import itertools import warnings -from typing import Dict, List, Optional, Tuple, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union import torch import torch.nn.functional as F @@ -10,6 +10,8 @@ from pytorch3d.ops import interpolate_face_attributes from pytorch3d.structures.utils import list_to_packed, list_to_padded, padded_to_list from torch.nn.functional import interpolate +from .utils import pack_rectangles + # This file contains classes and helper functions for texturing. # There are three types of textures: TexturesVertex, TexturesAtlas @@ -329,6 +331,7 @@ class TexturesAtlas(TexturesBase): [1] Liu et al, 'Soft Rasterizer: A Differentiable Renderer for Image-based 3D Reasoning', ICCV 2019 + See also https://github.com/ShichenLiu/SoftRas/issues/21 """ if isinstance(atlas, (list, tuple)): correct_format = all( @@ -336,11 +339,15 @@ class TexturesAtlas(TexturesBase): torch.is_tensor(elem) and elem.ndim == 4 and elem.shape[1] == elem.shape[2] + and elem.shape[1] == atlas[0].shape[1] ) for elem in atlas ) if not correct_format: - msg = "Expected atlas to be a list of tensors of shape (F, R, R, D)" + msg = ( + "Expected atlas to be a list of tensors of shape (F, R, R, D) " + "with the same value of R." + ) raise ValueError(msg) self._atlas_list = atlas self._atlas_padded = None @@ -529,6 +536,12 @@ class TexturesAtlas(TexturesBase): new_tex._num_faces_per_mesh = num_faces_per_mesh return new_tex + def join_scene(self) -> "TexturesAtlas": + """ + Return a new TexturesAtlas amalgamating the batch. + """ + return self.__class__(atlas=[torch.cat(self.atlas_list())]) + class TexturesUV(TexturesBase): def __init__( @@ -560,7 +573,7 @@ class TexturesUV(TexturesBase): the two align_corners options at https://discuss.pytorch.org/t/22663/9 . - An example of how the indexing into the maps, with align_corners=True + An example of how the indexing into the maps, with align_corners=True, works is as follows. If maps[i] has shape [101, 1001] and the value of verts_uvs[i][j] is [0.4, 0.3], then a value of j in faces_uvs[i] means a vertex @@ -574,10 +587,11 @@ class TexturesUV(TexturesBase): If maps[i] has shape [100, 1000] and the value of verts_uvs[i][j] is [0.405, 0.2995], then a value of j in faces_uvs[i] means a vertex whose color is given by maps[i][700, 40]. - In this case, padding_mode even matters for values in verts_uvs - slightly above 0 or slightly below 1. In this case, it matters if the - first value is outside the interval [0.0005, 0.9995] or if the second - is outside the interval [0.005, 0.995]. + When align_corners=False, padding_mode even matters for values in + verts_uvs slightly above 0 or slightly below 1. In this case, the + padding_mode matters if the first value is outside the interval + [0.0005, 0.9995] or if the second is outside the interval + [0.005, 0.995]. """ super().__init__() self.padding_mode = padding_mode @@ -805,12 +819,9 @@ class TexturesUV(TexturesBase): def maps_padded(self) -> torch.Tensor: return self._maps_padded - def maps_list(self) -> torch.Tensor: - # maps_list is not used anywhere currently - maps - # are padded to ensure the (H, W) of all maps is the - # same across the batch and we don't store the - # unpadded sizes of the maps. Therefore just return - # the unbinded padded tensor. + def maps_list(self) -> List[torch.Tensor]: + if self._maps_list is not None: + return self._maps_list return self._maps_padded.unbind(0) def extend(self, N: int) -> "TexturesUV": @@ -965,6 +976,143 @@ class TexturesUV(TexturesBase): new_tex._num_faces_per_mesh = num_faces_per_mesh return new_tex + def _place_map_into_single_map( + self, + single_map: torch.Tensor, + map_: torch.Tensor, + location: Tuple[int, int, bool], # (x,y) and whether flipped + ) -> None: + """ + Copy map into a larger tensor single_map at the destination specified by location. + If align_corners is False, we add the needed border around the destination. + + Used by join_scene. + + Args: + single_map: (total_H, total_W, 3) + map_: (H, W, 3) source data + location: where to place map + """ + do_flip = location[2] + source = map_.transpose(0, 1) if do_flip else map_ + border_width = 0 if self.align_corners else 1 + lower_u = location[0] + border_width + lower_v = location[1] + border_width + upper_u = lower_u + source.shape[0] + upper_v = lower_v + source.shape[1] + single_map[lower_u:upper_u, lower_v:upper_v] = source + + if self.padding_mode != "zeros" and not self.align_corners: + single_map[lower_u - 1, lower_v:upper_v] = single_map[ + lower_u, lower_v:upper_v + ] + single_map[upper_u, lower_v:upper_v] = single_map[ + upper_u - 1, lower_v:upper_v + ] + single_map[lower_u:upper_u, lower_v - 1] = single_map[ + lower_u:upper_u, lower_v + ] + single_map[lower_u:upper_u, upper_v] = single_map[ + lower_u:upper_u, upper_v - 1 + ] + single_map[lower_u - 1, lower_v - 1] = single_map[lower_u, lower_v] + single_map[lower_u - 1, upper_v] = single_map[lower_u, upper_v - 1] + single_map[upper_u, lower_v - 1] = single_map[upper_u - 1, lower_v] + single_map[upper_u, upper_v] = single_map[upper_u - 1, upper_v - 1] + + def join_scene(self) -> "TexturesUV": + """ + Return a new TexturesUV amalgamating the batch. + + We calculate a large single map which contains the original maps, + and find verts_uvs to point into it. This will not replicate + behavior of padding for verts_uvs values outside [0,1]. + + If align_corners=False, we need to add an artificial border around + every map. + + We use the function `pack_rectangles` to provide a layout for the + single map. _place_map_into_single_map is used to copy the maps + into the single map. The merging of verts_uvs and faces_uvs are + handled locally in this function. + """ + maps = self.maps_list() + heights_and_widths = [] + extra_border = 0 if self.align_corners else 2 + for map_ in maps: + heights_and_widths.append( + (map_.shape[0] + extra_border, map_.shape[1] + extra_border) + ) + merging_plan = pack_rectangles(heights_and_widths) + # pyre-fixme[16]: `Tensor` has no attribute `new_zeros`. + single_map = maps[0].new_zeros((*merging_plan.total_size, 3)) + verts_uvs = self.verts_uvs_list() + verts_uvs_merged = [] + + for map_, loc, uvs in zip(maps, merging_plan.locations, verts_uvs): + new_uvs = uvs.clone() + self._place_map_into_single_map(single_map, map_, loc) + do_flip = loc[2] + x_shape = map_.shape[1] if do_flip else map_.shape[0] + y_shape = map_.shape[0] if do_flip else map_.shape[1] + + if do_flip: + # Here we have flipped / transposed the map. + # In uvs, the y values are decreasing from 1 to 0 and the x + # values increase from 0 to 1. We subtract all values from 1 + # as the x's become y's and the y's become x's. + new_uvs = 1.0 - new_uvs[:, [1, 0]] + if TYPE_CHECKING: + new_uvs = torch.Tensor(new_uvs) + + # If align_corners is True, then an index of x (where x is in + # the range 0 .. map_.shape[]-1) in one of the input maps + # was hit by a u of x/(map_.shape[]-1). + # That x is located at the index loc[] + x in the single_map, and + # to hit that we need u to equal (loc[] + x) / (total_size[]-1) + # so the old u should be mapped to + # { u*(map_.shape[]-1) + loc[] } / (total_size[]-1) + + # If align_corners is False, then an index of x (where x is in + # the range 1 .. map_.shape[]-2) in one of the input maps + # was hit by a u of (x+0.5)/(map_.shape[]). + # That x is located at the index loc[] + 1 + x in the single_map, + # (where the 1 is for the border) + # and to hit that we need u to equal (loc[] + 1 + x + 0.5) / (total_size[]) + # so the old u should be mapped to + # { loc[] + 1 + u*map_.shape[]-0.5 + 0.5 } / (total_size[]) + # = { loc[] + 1 + u*map_.shape[] } / (total_size[]) + + # We change the y's in new_uvs for the scaling of height, + # and the x's for the scaling of width. + # That is why the 1's and 0's are mismatched in these lines. + one_if_align = 1 if self.align_corners else 0 + one_if_not_align = 1 - one_if_align + denom_x = merging_plan.total_size[0] - one_if_align + scale_x = x_shape - one_if_align + denom_y = merging_plan.total_size[1] - one_if_align + scale_y = y_shape - one_if_align + new_uvs[:, 1] *= scale_x / denom_x + new_uvs[:, 1] += (loc[0] + one_if_not_align) / denom_x + new_uvs[:, 0] *= scale_y / denom_y + new_uvs[:, 0] += (loc[1] + one_if_not_align) / denom_y + + verts_uvs_merged.append(new_uvs) + + faces_uvs_merged = [] + offset = 0 + for faces_uvs_, verts_uvs_ in zip(self.faces_uvs_list(), verts_uvs): + faces_uvs_merged.append(offset + faces_uvs_) + offset += verts_uvs_.shape[0] + + return self.__class__( + maps=[single_map], + verts_uvs=[torch.cat(verts_uvs_merged)], + faces_uvs=[torch.cat(faces_uvs_merged)], + align_corners=self.align_corners, + padding_mode=self.padding_mode, + ) + class TexturesVertex(TexturesBase): def __init__( @@ -1156,3 +1304,9 @@ class TexturesVertex(TexturesBase): new_tex = self.__class__(verts_features=verts_features_list) new_tex._num_verts_per_mesh = num_faces_per_mesh return new_tex + + def join_scene(self) -> "TexturesVertex": + """ + Return a new TexturesVertex amalgamating the batch. + """ + return self.__class__(verts_features=[torch.cat(self.verts_features_list())]) diff --git a/pytorch3d/renderer/mesh/utils.py b/pytorch3d/renderer/mesh/utils.py index 749a746d..81ce0f1f 100644 --- a/pytorch3d/renderer/mesh/utils.py +++ b/pytorch3d/renderer/mesh/utils.py @@ -1,6 +1,8 @@ # Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. +from typing import List, NamedTuple, Tuple + import torch from pytorch3d.ops import interpolate_face_attributes @@ -58,3 +60,184 @@ def _interpolate_zbuf( ] # (1, H, W, K) zbuf[pix_to_face == -1] = -1 return zbuf + + +# ----------- Rectangle Packing -------------------- # + +# Note the order of members matters here because it determines the queue order. +# We want to place longer rectangles first. +class _UnplacedRectangle(NamedTuple): + size: Tuple[int, int] + ind: int + flipped: bool + + +def _try_place_rectangle( + rect: _UnplacedRectangle, + placed_so_far: List[Tuple[int, int, bool]], + occupied: List[Tuple[int, int]], +) -> bool: + """ + Try to place rect within the current bounding box. + Part of the implementation of pack_rectangles. + + Note that the arguments `placed_so_far` and `occupied` are modified. + + Args: + rect: rectangle to place + placed_so_far: the locations decided upon so far - a list of + (x, y, whether flipped). The nth element is the + location of the nth rectangle if it has been decided. + (modified in place) + occupied: the nodes of the graph of extents of rightmost placed + rectangles - (modified in place) + + Returns: + True on success. + + Example: + (We always have placed the first rectangle horizontally and other + rectangles above it.) + Let's say the placed boxes 1-4 are layed out like this. + The coordinates of the points marked X are stored in occupied. + It is to the right of the X's that we seek to place rect. + + +-----------------------X + |2 | + | +---X + | |4 | + | | | + | +---+X + | |3 | + | | | + +-----------------------+----+------X +y |1 | +^ | --->x | +| +-----------------------------------+ + + We want to place this rectangle. + + +-+ + |5| + | | + | | = rect + | | + | | + | | + +-+ + + The call will succeed, returning True, leaving us with + + +-----------------------X + |2 | +-X + | +---+|5| + | |4 || | + | | || | + | +---++ | + | |3 | | + | | | | + +-----------------------+----+-+----X + |1 | + | | + +-----------------------------------+ . + + """ + total_width = occupied[0][0] + needed_height = rect.size[1] + current_start_idx = None + current_max_width = 0 + previous_height = 0 + currently_packed = 0 + for idx, interval in enumerate(occupied): + if interval[0] <= total_width - rect.size[0]: + currently_packed += interval[1] - previous_height + current_max_width = max(interval[0], current_max_width) + if current_start_idx is None: + current_start_idx = idx + if currently_packed >= needed_height: + current_max_width = max(interval[0], current_max_width) + placed_so_far[rect.ind] = ( + current_max_width, + occupied[current_start_idx - 1][1], + rect.flipped, + ) + new_occupied = ( + current_max_width + rect.size[0], + occupied[current_start_idx - 1][1] + needed_height, + ) + if currently_packed == needed_height: + occupied[idx] = new_occupied + del occupied[current_start_idx:idx] + elif idx > current_start_idx: + occupied[idx - 1] = new_occupied + del occupied[current_start_idx : (idx - 1)] + else: + occupied.insert(idx, new_occupied) + return True + else: + current_start_idx = None + current_max_width = 0 + currently_packed = 0 + previous_height = interval[1] + return False + + +class PackedRectangles(NamedTuple): + total_size: Tuple[int, int] + locations: List[Tuple[int, int, bool]] # (x,y) and whether flipped + + +def pack_rectangles(sizes: List[Tuple[int, int]]) -> PackedRectangles: + """ + Naive rectangle packing in to a large rectangle. Flipping (i.e. rotating + a rectangle by 90 degrees) is allowed. + + This is used to join several uv maps into a single scene, see + TexturesUV.join_scene. + + Args: + sizes: List of sizes of rectangles to pack + + Returns: + total_size: size of total large rectangle + rectangles: location for each of the input rectangles + """ + + if len(sizes) < 2: + raise ValueError("Cannot pack less than two boxes") + + queue = [] + for i, size in enumerate(sizes): + if size[0] < size[1]: + queue.append(_UnplacedRectangle((size[1], size[0]), i, True)) + else: + queue.append(_UnplacedRectangle((size[0], size[1]), i, False)) + queue.sort() + placed_so_far = [(-1, -1, False)] * len(sizes) + + biggest = queue.pop() + total_width, current_height = biggest.size + placed_so_far[biggest.ind] = (0, 0, biggest.flipped) + + second = queue.pop() + placed_so_far[second.ind] = (0, current_height, second.flipped) + current_height += second.size[1] + occupied = [biggest.size, (second.size[0], current_height)] + + for rect in reversed(queue): + if _try_place_rectangle(rect, placed_so_far, occupied): + continue + + rotated = _UnplacedRectangle( + (rect.size[1], rect.size[0]), rect.ind, not rect.flipped + ) + if _try_place_rectangle(rotated, placed_so_far, occupied): + continue + + # rect wasn't placed in the current bounding box, + # so we add extra space to fit it in. + placed_so_far[rect.ind] = (0, current_height, rect.flipped) + current_height += rect.size[1] + occupied.append((rect.size[0], current_height)) + + return PackedRectangles((total_width, current_height), placed_so_far) diff --git a/pytorch3d/structures/__init__.py b/pytorch3d/structures/__init__.py index 78d24a26..e83db39e 100644 --- a/pytorch3d/structures/__init__.py +++ b/pytorch3d/structures/__init__.py @@ -1,6 +1,6 @@ # Copyright (c) Facebook, Inc. and its affiliates. All rights reserved. -from .meshes import Meshes, join_meshes_as_batch +from .meshes import Meshes, join_meshes_as_batch, join_meshes_as_scene from .pointclouds import Pointclouds from .utils import list_to_packed, list_to_padded, packed_to_list, padded_to_list diff --git a/pytorch3d/structures/meshes.py b/pytorch3d/structures/meshes.py index c621b6a7..42a1ed81 100644 --- a/pytorch3d/structures/meshes.py +++ b/pytorch3d/structures/meshes.py @@ -1254,7 +1254,7 @@ class Meshes(object): """ verts_packed = self.verts_packed() if vert_offsets_packed.shape != verts_packed.shape: - raise ValueError("Verts offsets must have dimension (all_v, 2).") + raise ValueError("Verts offsets must have dimension (all_v, 3).") # update verts packed self._verts_packed = verts_packed + vert_offsets_packed new_verts_list = list( @@ -1548,26 +1548,43 @@ def join_meshes_as_batch(meshes: List[Meshes], include_textures: bool = True): return Meshes(verts=verts, faces=faces, textures=tex) -def join_mesh(meshes: Union[Meshes, List[Meshes]]) -> Meshes: +def join_meshes_as_scene( + meshes: Union[Meshes, List[Meshes]], include_textures: bool = True +) -> Meshes: """ Joins a batch of meshes in the form of a Meshes object or a list of Meshes - objects as a single mesh. If the input is a list, the Meshes objects in the list - must all be on the same device. This version ignores all textures in the input meshes. + objects as a single mesh. If the input is a list, the Meshes objects in the + list must all be on the same device. Unless include_textures is False, the + meshes must all have the same type of texture or must all not have textures. + + If textures are included, then the textures are joined as a single scene in + addition to the meshes. For this, texture types have an appropriate method + called join_scene which joins mesh textures into a single texture. + If the textures are TexturesAtlas then they must have the same resolution. + If they are TexturesUV then they must have the same align_corners and + padding_mode. Values in verts_uvs outside [0, 1] will not + be respected. Args: - meshes: Meshes object that contains a batch of meshes or a list of Meshes objects + meshes: Meshes object that contains a batch of meshes, or a list of + Meshes objects. + include_textures: (bool) whether to try to join the textures. Returns: new Meshes object containing a single mesh """ if isinstance(meshes, List): - meshes = join_meshes_as_batch(meshes, include_textures=False) + meshes = join_meshes_as_batch(meshes, include_textures=include_textures) if len(meshes) == 1: return meshes verts = meshes.verts_packed() # (sum(V_n), 3) # Offset automatically done by faces_packed faces = meshes.faces_packed() # (sum(F_n), 3) + textures = None - mesh = Meshes(verts=verts.unsqueeze(0), faces=faces.unsqueeze(0)) + if include_textures and meshes.textures is not None: + textures = meshes.textures.join_scene() + + mesh = Meshes(verts=verts.unsqueeze(0), faces=faces.unsqueeze(0), textures=textures) return mesh diff --git a/tests/data/test_joinatlas_final.png b/tests/data/test_joinatlas_final.png new file mode 100644 index 0000000000000000000000000000000000000000..3f7605553377b66155077531a28daa108e6c6d0c GIT binary patch literal 25917 zcmYg&2{@GN`~N#L8e7AJvX&90g`_M=nL$EMC`1y9vL#DN)|qS(qK!(KNY=6o6{8Z0 z?E5yBBI^(aGvs~<5$GiS!VsNTKT?GD&eZ# z%z2`OQ;>twxwk(`z+Orz+wjn(oqgm74ElDzx@du%suy;*hs;zy&EJ?9{-C-dMKYk4 ztbV&3S47s79C8^Q+FSjw6oY~Y_;e1!nhz0Ill|gwib?#|MYmht|MIrl6*gwoBj}1kjZl}w%Ra3vh;O`cMd9;aS6dQ0l$V47nP+ z)$g$AkOTZT{0z~+f0rr`KSc3PoTW9aGVb4F4TPu#r1PO9FEO<)ueijW{=f`t$lsGm zf}n*MXT(5(t#an=U;eZR%F~<7!};^lM+)J(6Q{%FASi;8#}W2{@Rx^#MbLUmER^AA zc)HpddBo2HLBzH9NJIi2FG{1TU!}*Kh=lkcC{>Gm#$@rz%^~Ee7)Pu#q!EulH>5d$ zkcq(;s9T)$*VCT@A=C&D!sL)u9UodeM{%Mk3 z>xL6&iB53?$f`fbWUyt9_8TJX4kcdna}YqasRvwh-Iz%GW=&136EwmBvpP2Jj7T~S z!UJmLdsaG^*i+IwD@b1Ee9r-Smj)iT$DX<-IMu#k ze!I=Ke~;>N=hw+?r^cU%t<&=3iYD|u4E`=_*X1AMNcKNvo{4SLZYo(f63P4wzV>Of z3(lHZCt`08P0mV}e5h@;un3m4T-NfZn(tpO81bXt&c6^+r_t|0>gJv~$$Jh76BM$( z_@#N{Ju`ixQU3!;yz>KfTgC_3O=pJ`Q)kXmH4fyu<)O~YqJeqZ=ynm`&T}6z^p2OB zjScPODq6n?>Holpc!OQ01$5sqE5qHoxVQ-oQCzZFyhTV{QLd;jd062dKJoljqR~pZ z5^E3^74I56a^uPsX`F<1u!s}bx=39A#;EyS~LQ>{wQs~W6i6JyeeiTGX_~!@(2GfX5KTcTJ6iL#6JJ zPOb#T*@|z4O?rC~sTFwR`n2&5mBaYUZ3%oi^P;2Y$Yt|DegkP9B#nO_cOb$8d5MKy zWb0EZ=kH!d%KV5YN(~$ut<<||Wx4m>U|rkXN!=U')R=h4=Tq_477gdn9k8a=jb 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 BRqy}+ literal 0 HcmV?d00001 diff --git a/tests/data/test_joinuvs0_final.png b/tests/data/test_joinuvs0_final.png new file mode 100644 index 0000000000000000000000000000000000000000..3394f7f568b820f4933bc46a4adf3d3a65bcd789 GIT binary patch literal 11814 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 Sg*Tf22B;}%Dt=Y43jZITx_v$X literal 0 HcmV?d00001 diff --git a/tests/data/test_joinuvs0_map.png b/tests/data/test_joinuvs0_map.png new file mode 100644 index 0000000000000000000000000000000000000000..163afcf61f6606f413de1f4e20cd42a11947de31 GIT binary patch literal 807 zcmeAS@N?(olHy`uVBq!ia0vp^CxAGGg9%9bJb4krz`*p*)5S5QV$R#Ece5r3O0Yh> zt5S9Q((83Amj8Qlpz*|aj%)$`rmcs+yuFqGd`_|T^M9}Jf1h6W0^TVZW9v7bbo7MiG-RNqptd4SrDz3Zbx&0Yo){FphGk~glv|0aCqkB7nRf`xNdj}kv>lfzE!`HuX(Xz- z^{Cf`l^!c~!=%5T9xK zAiN25+4lFqAUyczXYNL>@9W`AMBou}FA-MI$S`QMLIRHu9FyNf)If2JGg_fB(GE@) z)m*{|m!TMjqW>^BS=hE>q!GAb6?^LEd@kI2RM=*%e0;sY%fxT-uYehV!PC{xWt~$( F695vyV3_~_ literal 0 HcmV?d00001 diff --git a/tests/data/test_joinuvs1_final.png b/tests/data/test_joinuvs1_final.png new file mode 100644 index 0000000000000000000000000000000000000000..b624ef7599b57b45417891be19608e30fa31dc22 GIT binary patch literal 11815 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 Ksn@H)qyGoKe41wf literal 0 HcmV?d00001 diff --git a/tests/data/test_joinuvs1_map.png b/tests/data/test_joinuvs1_map.png new file mode 100644 index 0000000000000000000000000000000000000000..616d08e83e9579a73b30281235018d43948fb1dd GIT binary patch literal 819 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 literal 0 HcmV?d00001 diff --git a/tests/data/test_joinverts_final.png b/tests/data/test_joinverts_final.png new file mode 100644 index 0000000000000000000000000000000000000000..9f85e06ffcb1a6790f8038b3f2b49215600ff03c GIT binary patch literal 11699 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 VdsAOmpe|zrR28)p>g25>{|8hcfjIyG literal 0 HcmV?d00001 diff --git a/tests/test_render_meshes.py b/tests/test_render_meshes.py index e0535846..0b4ab155 100644 --- a/tests/test_render_meshes.py +++ b/tests/test_render_meshes.py @@ -33,7 +33,11 @@ from pytorch3d.renderer.mesh.shader import ( SoftSilhouetteShader, TexturedSoftPhongShader, ) -from pytorch3d.structures.meshes import Meshes, join_mesh, join_meshes_as_batch +from pytorch3d.structures.meshes import ( + Meshes, + join_meshes_as_batch, + join_meshes_as_scene, +) from pytorch3d.utils.ico_sphere import ico_sphere from pytorch3d.utils.torus import torus @@ -571,6 +575,288 @@ class TestRenderMeshes(TestCaseMixin, unittest.TestCase): self.assertClose(outputs[0][0, ..., :3], outputs[1][0, ..., :3], atol=1e-5) self.assertClose(outputs[0][1, ..., :3], outputs[2][0, ..., :3], atol=1e-5) + def test_join_uvs(self): + """Meshes with TexturesUV joined into a scene""" + # Test the result of rendering three tori with separate textures. + # The expected result is consistent with rendering them each alone. + # This tests TexturesUV.join_scene with rectangle flipping, + # and we check the form of the merged map as well. + torch.manual_seed(1) + device = torch.device("cuda:0") + + R, T = look_at_view_transform(18, 0, 0) + cameras = FoVPerspectiveCameras(device=device, R=R, T=T) + + raster_settings = RasterizationSettings( + image_size=256, blur_radius=0.0, faces_per_pixel=1 + ) + + lights = PointLights( + device=device, + ambient_color=((1.0, 1.0, 1.0),), + diffuse_color=((0.0, 0.0, 0.0),), + specular_color=((0.0, 0.0, 0.0),), + ) + blend_params = BlendParams( + sigma=1e-1, + gamma=1e-4, + background_color=torch.tensor([1.0, 1.0, 1.0], device=device), + ) + renderer = MeshRenderer( + rasterizer=MeshRasterizer(cameras=cameras, raster_settings=raster_settings), + shader=HardPhongShader( + device=device, blend_params=blend_params, cameras=cameras, lights=lights + ), + ) + + plain_torus = torus(r=1, R=4, sides=5, rings=6, device=device) + [verts] = plain_torus.verts_list() + verts_shifted1 = verts.clone() + verts_shifted1 *= 0.5 + verts_shifted1[:, 1] += 7 + verts_shifted2 = verts.clone() + verts_shifted2 *= 0.5 + verts_shifted2[:, 1] -= 7 + + [faces] = plain_torus.faces_list() + nocolor = torch.zeros((100, 100), device=device) + color_gradient = torch.linspace(0, 1, steps=100, device=device) + color_gradient1 = color_gradient[None].expand_as(nocolor) + color_gradient2 = color_gradient[:, None].expand_as(nocolor) + colors1 = torch.stack([nocolor, color_gradient1, color_gradient2], dim=2) + colors2 = torch.stack([color_gradient1, color_gradient2, nocolor], dim=2) + verts_uvs1 = torch.rand(size=(verts.shape[0], 2), device=device) + verts_uvs2 = torch.rand(size=(verts.shape[0], 2), device=device) + + for i, align_corners, padding_mode in [ + (0, True, "border"), + (1, False, "border"), + (2, False, "zeros"), + ]: + textures1 = TexturesUV( + maps=[colors1], + faces_uvs=[faces], + verts_uvs=[verts_uvs1], + align_corners=align_corners, + padding_mode=padding_mode, + ) + + # These downsamplings of colors2 are chosen to ensure a flip and a non flip + # when the maps are merged. + # We have maps of size (100, 100), (50, 99) and (99, 50). + textures2 = TexturesUV( + maps=[colors2[::2, :-1]], + faces_uvs=[faces], + verts_uvs=[verts_uvs2], + align_corners=align_corners, + padding_mode=padding_mode, + ) + offset = torch.tensor([0, 0, 0.5], device=device) + textures3 = TexturesUV( + maps=[colors2[:-1, ::2] + offset], + faces_uvs=[faces], + verts_uvs=[verts_uvs2], + align_corners=align_corners, + padding_mode=padding_mode, + ) + mesh1 = Meshes(verts=[verts], faces=[faces], textures=textures1) + mesh2 = Meshes(verts=[verts_shifted1], faces=[faces], textures=textures2) + mesh3 = Meshes(verts=[verts_shifted2], faces=[faces], textures=textures3) + mesh = join_meshes_as_scene([mesh1, mesh2, mesh3]) + + output = renderer(mesh)[0, ..., :3].cpu() + output1 = renderer(mesh1)[0, ..., :3].cpu() + output2 = renderer(mesh2)[0, ..., :3].cpu() + output3 = renderer(mesh3)[0, ..., :3].cpu() + # The background color is white and the objects do not overlap, so we can + # predict the merged image by taking the minimum over every channel + merged = torch.min(torch.min(output1, output2), output3) + + image_ref = load_rgb_image(f"test_joinuvs{i}_final.png", DATA_DIR) + map_ref = load_rgb_image(f"test_joinuvs{i}_map.png", DATA_DIR) + + if DEBUG: + Image.fromarray((output.numpy() * 255).astype(np.uint8)).save( + DATA_DIR / f"test_joinuvs{i}_final_.png" + ) + Image.fromarray((output.numpy() * 255).astype(np.uint8)).save( + DATA_DIR / f"test_joinuvs{i}_merged.png" + ) + + Image.fromarray((output1.numpy() * 255).astype(np.uint8)).save( + DATA_DIR / f"test_joinuvs{i}_1.png" + ) + Image.fromarray((output2.numpy() * 255).astype(np.uint8)).save( + DATA_DIR / f"test_joinuvs{i}_2.png" + ) + Image.fromarray((output3.numpy() * 255).astype(np.uint8)).save( + DATA_DIR / f"test_joinuvs{i}_3.png" + ) + Image.fromarray( + (mesh.textures.maps_padded()[0].cpu().numpy() * 255).astype( + np.uint8 + ) + ).save(DATA_DIR / f"test_joinuvs{i}_map_.png") + Image.fromarray( + (mesh2.textures.maps_padded()[0].cpu().numpy() * 255).astype( + np.uint8 + ) + ).save(DATA_DIR / f"test_joinuvs{i}_map2.png") + Image.fromarray( + (mesh3.textures.maps_padded()[0].cpu().numpy() * 255).astype( + np.uint8 + ) + ).save(DATA_DIR / f"test_joinuvs{i}_map3.png") + + self.assertClose(output, merged, atol=0.015) + self.assertClose(output, image_ref, atol=0.05) + self.assertClose(mesh.textures.maps_padded()[0].cpu(), map_ref, atol=0.05) + + def test_join_verts(self): + """Meshes with TexturesVertex joined into a scene""" + # Test the result of rendering two tori with separate textures. + # The expected result is consistent with rendering them each alone. + torch.manual_seed(1) + device = torch.device("cuda:0") + plain_torus = torus(r=1, R=4, sides=5, rings=6, device=device) + [verts] = plain_torus.verts_list() + verts_shifted1 = verts.clone() + verts_shifted1 *= 0.5 + verts_shifted1[:, 1] += 7 + + faces = plain_torus.faces_list() + textures1 = TexturesVertex(verts_features=[torch.rand_like(verts)]) + textures2 = TexturesVertex(verts_features=[torch.rand_like(verts)]) + mesh1 = Meshes(verts=[verts], faces=faces, textures=textures1) + mesh2 = Meshes(verts=[verts_shifted1], faces=faces, textures=textures2) + mesh = join_meshes_as_scene([mesh1, mesh2]) + + R, T = look_at_view_transform(18, 0, 0) + cameras = FoVPerspectiveCameras(device=device, R=R, T=T) + + raster_settings = RasterizationSettings( + image_size=256, blur_radius=0.0, faces_per_pixel=1 + ) + + lights = PointLights( + device=device, + ambient_color=((1.0, 1.0, 1.0),), + diffuse_color=((0.0, 0.0, 0.0),), + specular_color=((0.0, 0.0, 0.0),), + ) + blend_params = BlendParams( + sigma=1e-1, + gamma=1e-4, + background_color=torch.tensor([1.0, 1.0, 1.0], device=device), + ) + renderer = MeshRenderer( + rasterizer=MeshRasterizer(cameras=cameras, raster_settings=raster_settings), + shader=HardPhongShader( + device=device, blend_params=blend_params, cameras=cameras, lights=lights + ), + ) + + output = renderer(mesh) + + image_ref = load_rgb_image("test_joinverts_final.png", DATA_DIR) + + if DEBUG: + debugging_outputs = [] + for mesh_ in [mesh1, mesh2]: + debugging_outputs.append(renderer(mesh_)) + Image.fromarray( + (output[0, ..., :3].cpu().numpy() * 255).astype(np.uint8) + ).save(DATA_DIR / "test_joinverts_final_.png") + Image.fromarray( + (debugging_outputs[0][0, ..., :3].cpu().numpy() * 255).astype(np.uint8) + ).save(DATA_DIR / "test_joinverts_1.png") + Image.fromarray( + (debugging_outputs[1][0, ..., :3].cpu().numpy() * 255).astype(np.uint8) + ).save(DATA_DIR / "test_joinverts_2.png") + + result = output[0, ..., :3].cpu() + self.assertClose(result, image_ref, atol=0.05) + + def test_join_atlas(self): + """Meshes with TexturesAtlas joined into a scene""" + # Test the result of rendering two tori with separate textures. + # The expected result is consistent with rendering them each alone. + torch.manual_seed(1) + device = torch.device("cuda:0") + plain_torus = torus(r=1, R=4, sides=5, rings=6, device=device) + [verts] = plain_torus.verts_list() + verts_shifted1 = verts.clone() + verts_shifted1 *= 1.2 + verts_shifted1[:, 0] += 4 + verts_shifted1[:, 1] += 5 + verts[:, 0] -= 4 + verts[:, 1] -= 4 + + [faces] = plain_torus.faces_list() + map_size = 3 + # Two random atlases. + # The averaging of the random numbers here is not consistent with the + # meaning of the atlases, but makes each face a bit smoother than + # if everything had a random color. + atlas1 = torch.rand(size=(faces.shape[0], map_size, map_size, 3), device=device) + atlas1[:, 1] = 0.5 * atlas1[:, 0] + 0.5 * atlas1[:, 2] + atlas1[:, :, 1] = 0.5 * atlas1[:, :, 0] + 0.5 * atlas1[:, :, 2] + atlas2 = torch.rand(size=(faces.shape[0], map_size, map_size, 3), device=device) + atlas2[:, 1] = 0.5 * atlas2[:, 0] + 0.5 * atlas2[:, 2] + atlas2[:, :, 1] = 0.5 * atlas2[:, :, 0] + 0.5 * atlas2[:, :, 2] + + textures1 = TexturesAtlas(atlas=[atlas1]) + textures2 = TexturesAtlas(atlas=[atlas2]) + mesh1 = Meshes(verts=[verts], faces=[faces], textures=textures1) + mesh2 = Meshes(verts=[verts_shifted1], faces=[faces], textures=textures2) + mesh_joined = join_meshes_as_scene([mesh1, mesh2]) + + R, T = look_at_view_transform(18, 0, 0) + cameras = FoVPerspectiveCameras(device=device, R=R, T=T) + + raster_settings = RasterizationSettings( + image_size=512, blur_radius=0.0, faces_per_pixel=1 + ) + + lights = PointLights( + device=device, + ambient_color=((1.0, 1.0, 1.0),), + diffuse_color=((0.0, 0.0, 0.0),), + specular_color=((0.0, 0.0, 0.0),), + ) + blend_params = BlendParams( + sigma=1e-1, + gamma=1e-4, + background_color=torch.tensor([1.0, 1.0, 1.0], device=device), + ) + renderer = MeshRenderer( + rasterizer=MeshRasterizer(cameras=cameras, raster_settings=raster_settings), + shader=HardPhongShader( + device=device, blend_params=blend_params, cameras=cameras, lights=lights + ), + ) + + output = renderer(mesh_joined) + + image_ref = load_rgb_image("test_joinatlas_final.png", DATA_DIR) + + if DEBUG: + debugging_outputs = [] + for mesh_ in [mesh1, mesh2]: + debugging_outputs.append(renderer(mesh_)) + Image.fromarray( + (output[0, ..., :3].cpu().numpy() * 255).astype(np.uint8) + ).save(DATA_DIR / "test_joinatlas_final_.png") + Image.fromarray( + (debugging_outputs[0][0, ..., :3].cpu().numpy() * 255).astype(np.uint8) + ).save(DATA_DIR / "test_joinatlas_1.png") + Image.fromarray( + (debugging_outputs[1][0, ..., :3].cpu().numpy() * 255).astype(np.uint8) + ).save(DATA_DIR / "test_joinatlas_2.png") + + result = output[0, ..., :3].cpu() + self.assertClose(result, image_ref, atol=0.05) + def test_joined_spheres(self): """ Test a list of Meshes can be joined as a single mesh and @@ -595,7 +881,7 @@ class TestRenderMeshes(TestCaseMixin, unittest.TestCase): sphere_mesh_list.append( Meshes(verts=verts, faces=sphere_list[i].faces_padded()) ) - joined_sphere_mesh = join_mesh(sphere_mesh_list) + joined_sphere_mesh = join_meshes_as_scene(sphere_mesh_list) joined_sphere_mesh.textures = TexturesVertex( verts_features=torch.ones_like(joined_sphere_mesh.verts_padded()) ) diff --git a/tests/test_texturing.py b/tests/test_texturing.py index 5e847073..543291d0 100644 --- a/tests/test_texturing.py +++ b/tests/test_texturing.py @@ -12,6 +12,7 @@ from pytorch3d.renderer.mesh.textures import ( TexturesUV, TexturesVertex, _list_to_padded_wrapper, + pack_rectangles, ) from pytorch3d.structures import Meshes, list_to_packed, packed_to_list from test_meshes import TestMeshes @@ -730,3 +731,80 @@ class TestTexturesUV(TestCaseMixin, unittest.TestCase): index = torch.tensor([1, 2], dtype=torch.int64) tryindex(self, index, tex, meshes, source) tryindex(self, [2, 4], tex, meshes, source) + + +class TestRectanglePacking(TestCaseMixin, unittest.TestCase): + def setUp(self) -> None: + super().setUp() + torch.manual_seed(42) + + def wrap_pack(self, sizes): + """ + Call the pack_rectangles function, which we want to test, + and return its outputs. + Additionally makes some sanity checks on the output. + """ + res = pack_rectangles(sizes) + total = res.total_size + self.assertGreaterEqual(total[0], 0) + self.assertGreaterEqual(total[1], 0) + mask = torch.zeros(total, dtype=torch.bool) + seen_x_bound = False + seen_y_bound = False + for (in_x, in_y), loc in zip(sizes, res.locations): + self.assertGreaterEqual(loc[0], 0) + self.assertGreaterEqual(loc[1], 0) + placed_x, placed_y = (in_y, in_x) if loc[2] else (in_x, in_y) + upper_x = placed_x + loc[0] + upper_y = placed_y + loc[1] + self.assertGreaterEqual(total[0], upper_x) + if total[0] == upper_x: + seen_x_bound = True + self.assertGreaterEqual(total[1], upper_y) + if total[1] == upper_y: + seen_y_bound = True + already_taken = torch.sum(mask[loc[0] : upper_x, loc[1] : upper_y]) + self.assertEqual(already_taken, 0) + mask[loc[0] : upper_x, loc[1] : upper_y] = 1 + self.assertTrue(seen_x_bound) + self.assertTrue(seen_y_bound) + + self.assertTrue(torch.all(torch.sum(mask, dim=0, dtype=torch.int32) > 0)) + self.assertTrue(torch.all(torch.sum(mask, dim=1, dtype=torch.int32) > 0)) + return res + + def assert_bb(self, sizes, expected): + """ + Apply the pack_rectangles function to sizes and verify the + bounding box dimensions are expected. + """ + self.assertSetEqual(set(self.wrap_pack(sizes).total_size), expected) + + def test_simple(self): + self.assert_bb([(3, 4), (4, 3)], {6, 4}) + self.assert_bb([(2, 2), (2, 4), (2, 2)], {4, 4}) + + # many squares + self.assert_bb([(2, 2)] * 9, {2, 18}) + + # One big square and many small ones. + self.assert_bb([(3, 3)] + [(1, 1)] * 2, {3, 4}) + self.assert_bb([(3, 3)] + [(1, 1)] * 3, {3, 4}) + self.assert_bb([(3, 3)] + [(1, 1)] * 4, {3, 5}) + self.assert_bb([(3, 3)] + [(1, 1)] * 5, {3, 5}) + self.assert_bb([(1, 1)] * 6 + [(3, 3)], {3, 5}) + self.assert_bb([(3, 3)] + [(1, 1)] * 7, {3, 6}) + + # many identical rectangles + self.assert_bb([(7, 190)] * 4 + [(190, 7)] * 4, {190, 56}) + + # require placing the flipped version of a rectangle + self.assert_bb([(1, 100), (5, 96), (4, 5)], {100, 6}) + + def test_random(self): + for _ in range(5): + vals = torch.randint(size=(20, 2), low=1, high=18) + sizes = [] + for j in range(vals.shape[0]): + sizes.append((int(vals[j, 0]), int(vals[j, 1]))) + self.wrap_pack(sizes)