Exemple #1
0
    def save(
        self,
        data: Pointclouds,
        path: Union[str, Path],
        path_manager: PathManager,
        binary: Optional[bool],
        decimal_places: Optional[int] = None,
        **kwargs,
    ) -> bool:
        if not endswith(path, self.known_suffixes):
            return False

        points = data.points_list()[0]
        features = data.features_list()[0]
        with _open_file(path, path_manager, "wb") as f:
            _save_ply(
                f=f,
                verts=points,
                verts_colors=features,
                verts_normals=torch.FloatTensor([]),
                faces=None,
                ascii=binary is False,
                decimal_places=decimal_places,
            )
        return True
Exemple #2
0
    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
Exemple #3
0
def save_obj(
    f,
    verts,
    faces,
    decimal_places: Optional[int] = None,
    path_manager: Optional[PathManager] = None,
):
    """
    Save a mesh to an .obj file.

    Args:
        f: File (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.
    """
    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 len(faces) and not (faces.dim() == 2 and faces.size(1) == 3):
        message = "Argument 'faces' should either be empty or of shape (num_faces, 3)."
        raise ValueError(message)

    if path_manager is None:
        path_manager = PathManager()

    with _open_file(f, path_manager, "w") as f:
        return _save(f, verts, faces, decimal_places)
Exemple #4
0
def save_ply(
    f,
    verts: torch.Tensor,
    faces: Optional[torch.LongTensor] = None,
    verts_normals: Optional[torch.Tensor] = None,
    ascii: bool = False,
    decimal_places: Optional[int] = None,
    path_manager: Optional[PathManager] = None,
) -> None:
    """
    Save a mesh to a .ply file.

    Args:
        f: File (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.
        verts_normals: FloatTensor of shape (V, 3) giving vertex normals.
        ascii: (bool) whether to use the ascii ply format.
        decimal_places: Number of decimal places for saving if ascii=True.
        path_manager: PathManager for interpreting f if it is a str.

    """

    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 (
        faces is not None
        and len(faces)
        and not (faces.dim() == 2 and faces.size(1) == 3)
    ):
        message = "Argument 'faces' should either be empty or of shape (num_faces, 3)."
        raise ValueError(message)

    if (
        verts_normals is not None
        and len(verts_normals)
        and not (
            verts_normals.dim() == 2
            and verts_normals.size(1) == 3
            and verts_normals.size(0) == verts.size(0)
        )
    ):
        message = "Argument 'verts_normals' should either be empty or of shape (num_verts, 3)."
        raise ValueError(message)

    if path_manager is None:
        path_manager = PathManager()
    with _open_file(f, path_manager, "wb") as f:
        _save_ply(
            f,
            verts=verts,
            faces=faces,
            verts_normals=verts_normals,
            verts_colors=None,
            ascii=ascii,
            decimal_places=decimal_places,
        )
Exemple #5
0
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)
def save_ply(
    f,
    verts: torch.Tensor,
    faces: Optional[torch.LongTensor] = None,
    verts_normals: Optional[torch.Tensor] = None,
    ascii: bool = False,
    decimal_places: Optional[int] = None,
) -> None:
    """
    Save a mesh to a .ply file.

    Args:
        f: File (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.
        verts_normals: FloatTensor of shape (V, 3) giving vertex normals.
        ascii: (bool) whether to use the ascii ply format.
        decimal_places: Number of decimal places for saving if ascii=True.
    """

    verts_normals = (
        torch.tensor([], dtype=torch.float32, device=verts.device)
        if verts_normals is None
        else verts_normals
    )
    faces = torch.LongTensor([]) if faces is None else faces

    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 len(faces) and not (faces.dim() == 2 and faces.size(1) == 3):
        message = "Argument 'faces' should either be empty or of shape (num_faces, 3)."
        raise ValueError(message)

    if len(verts_normals) and not (
        verts_normals.dim() == 2
        and verts_normals.size(1) == 3
        and verts_normals.size(0) == verts.size(0)
    ):
        message = "Argument 'verts_normals' should either be empty or of shape (num_verts, 3)."
        raise ValueError(message)

    with _open_file(f, "wb") as f:
        _save_ply(f, verts, faces, verts_normals, ascii, decimal_places)
Exemple #7
0
    def save(
        self,
        data: Meshes,
        path: Union[str, Path],
        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 data.has_verts_normals():
            verts_normals = data.verts_normals_list()[0]
        else:
            verts_normals = None

        if isinstance(data.textures, TexturesVertex):
            mesh_verts_colors = data.textures.verts_features_list()[0]
            n_colors = mesh_verts_colors.shape[1]
            if n_colors == 3:
                verts_colors = mesh_verts_colors
            else:
                warnings.warn(
                    f"Texture will not be saved as it has {n_colors} colors, not 3."
                )
                verts_colors = None
        else:
            verts_colors = None

        with _open_file(path, path_manager, "wb") as f:
            _save_ply(
                f=f,
                verts=verts,
                faces=faces,
                verts_colors=verts_colors,
                verts_normals=verts_normals,
                ascii=binary is False,
                decimal_places=decimal_places,
            )
        return True
Exemple #8
0
def _parse_mtl(
        f: str,
        path_manager: PathManager,
        device: Device = "cpu") -> Tuple[MaterialProperties, TextureFiles]:
    material_properties = {}
    texture_files = {}
    material_name = ""

    with _open_file(f, path_manager, "r") as f:
        for line in f:
            tokens = line.strip().split()
            if not tokens:
                continue
            if tokens[0] == "newmtl":
                material_name = tokens[1]
                material_properties[material_name] = {}
            elif tokens[0] == "map_Kd":
                # Diffuse texture map
                # Account for the case where filenames might have spaces
                filename = line.strip()[7:]
                texture_files[material_name] = filename
            elif tokens[0] == "Kd":
                # RGB diffuse reflectivity
                kd = np.array(tokens[1:4]).astype(np.float32)
                kd = torch.from_numpy(kd).to(device)
                material_properties[material_name]["diffuse_color"] = kd
            elif tokens[0] == "Ka":
                # RGB ambient reflectivity
                ka = np.array(tokens[1:4]).astype(np.float32)
                ka = torch.from_numpy(ka).to(device)
                material_properties[material_name]["ambient_color"] = ka
            elif tokens[0] == "Ks":
                # RGB specular reflectivity
                ks = np.array(tokens[1:4]).astype(np.float32)
                ks = torch.from_numpy(ks).to(device)
                material_properties[material_name]["specular_color"] = ks
            elif tokens[0] == "Ns":
                # Specular exponent
                ns = np.array(tokens[1:4]).astype(np.float32)
                ns = torch.from_numpy(ns).to(device)
                material_properties[material_name]["shininess"] = ns

    return material_properties, texture_files
Exemple #9
0
def _load_ply_raw(f) -> Tuple[_PlyHeader, dict]:
    """
    Load the data from a .ply file.

    Args:
        f:  A binary or text file-like object (with methods read, readline,
            tell and seek), a pathlib path or a string containing a file name.
            If the ply file is binary, a text stream is not supported.
            It is recommended to use a binary stream.

    Returns:
        header: A _PlyHeader object describing the metadata in the ply file.
        elements: A dictionary of element names to values. If an element is
                  regular, in the sense of having no lists or being one
                  uniformly-sized list, then the value will be a 2D numpy array.
                  If not, it is a list of the relevant property values.
    """
    with _open_file(f, "rb") as f:
        header, elements = _load_ply_raw_stream(f)
    return header, elements
Exemple #10
0
def load_meshes(
    path: PathOrStr,
    path_manager: PathManager,
    include_textures: bool = True,
) -> List[Tuple[Optional[str], Meshes]]:
    """
    Loads all the meshes from the default scene in the given GLB file.
    and returns them separately.

    Args:
        path: path to read from
        path_manager: PathManager object for interpreting the path
        include_textures: whether to load textures

    Returns:
        List of (name, mesh) pairs, where the name is the optional name property
            from the GLB file, or None if it is absent, and the mesh is a Meshes
            object containing one mesh.
    """
    with _open_file(path, path_manager, "rb") as f:
        loader = _GLTFLoader(cast(BinaryIO, f))
    names_meshes_list = loader.load(include_textures=include_textures)
    return names_meshes_list
Exemple #11
0
def load_obj(
    f,
    load_textures=True,
    create_texture_atlas: bool = False,
    texture_atlas_size: int = 4,
    texture_wrap: Optional[str] = "repeat",
    device="cpu",
):
    """
    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 triange
        - 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 assummed 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: string or torch.device on which to return the new tensors.

    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, os.PathLike)):
        # pyre-fixme[6]: Expected `_PathLike[Variable[typing.AnyStr <: [str,
        #  bytes]]]` for 1st param but got `Union[_PathLike[typing.Any], bytes, str]`.
        data_dir = os.path.dirname(f)
    with _open_file(f, "r") as f:
        return _load_obj(
            f,
            data_dir,
            load_textures=load_textures,
            create_texture_atlas=create_texture_atlas,
            texture_atlas_size=texture_atlas_size,
            texture_wrap=texture_wrap,
            device=device,
        )
Exemple #12
0
def load_mtl(f_mtl, material_names: List, data_dir: str, device="cpu"):
    """
    Load texture images and material reflectivity values for ambient, diffuse
    and specular light (Ka, Kd, Ks, Ns).

    Args:
        f_mtl: a file like object of the material information.
        material_names: a list of the material names found in the .obj file.
        data_dir: the directory where the material texture files are located.

    Returns:
        material_colors: dict of properties for each material. If a material
                does not have any properties it will have an emtpy dict.
                {
                    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: {},
                    ...
                }
        texture_images: dict of material names and texture images
                {
                    material_name_1: (H, W, 3) image,
                    ...
                }
    """
    texture_files = {}
    material_colors = {}
    material_properties = {}
    texture_images = {}
    material_name = ""

    f_mtl, new_f = _open_file(f_mtl)
    lines = [line.strip() for line in f_mtl]
    for line in lines:
        if len(line.split()) != 0:
            if line.split()[0] == "newmtl":
                material_name = line.split()[1]
                material_colors[material_name] = {}
            if line.split()[0] == "map_Kd":
                # Texture map.
                texture_files[material_name] = line.split()[1]
            if line.split()[0] == "Kd":
                # RGB diffuse reflectivity
                kd = np.array(list(line.split()[1:4])).astype(np.float32)
                kd = torch.from_numpy(kd).to(device)
                material_colors[material_name]["diffuse_color"] = kd
            if line.split()[0] == "Ka":
                # RGB ambient reflectivity
                ka = np.array(list(line.split()[1:4])).astype(np.float32)
                ka = torch.from_numpy(ka).to(device)
                material_colors[material_name]["ambient_color"] = ka
            if line.split()[0] == "Ks":
                # RGB specular reflectivity
                ks = np.array(list(line.split()[1:4])).astype(np.float32)
                ks = torch.from_numpy(ks).to(device)
                material_colors[material_name]["specular_color"] = ks
            if line.split()[0] == "Ns":
                # Specular exponent
                ns = np.array(list(line.split()[1:4])).astype(np.float32)
                ns = torch.from_numpy(ns).to(device)
                material_colors[material_name]["shininess"] = ns

    if new_f:
        f_mtl.close()

    # Only keep the materials referenced in the obj.
    for name in material_names:
        if name in texture_files:
            # Load the texture image.
            filename = texture_files[name]
            filename_texture = os.path.join(data_dir, filename)
            if os.path.isfile(filename_texture):
                image = _read_image(filename_texture, format="RGB") / 255.0
                image = torch.from_numpy(image)
                texture_images[name] = image
            else:
                msg = f"Texture file does not exist: {filename_texture}"
                warnings.warn(msg)

        if name in material_colors:
            material_properties[name] = material_colors[name]

    return material_properties, texture_images
Exemple #13
0
def save_obj(
    f: PathOrStr,
    verts,
    faces,
    decimal_places: Optional[int] = None,
    path_manager: Optional[PathManager] = None,
    *,
    verts_uvs: Optional[torch.Tensor] = None,
    faces_uvs: Optional[torch.Tensor] = None,
    texture_map: Optional[torch.Tensor] = None,
) -> 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.
        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],
    """
    if len(verts) and (verts.dim() != 2 or verts.size(1) != 3):
        message = "'verts' should either be empty or of shape (num_verts, 3)."
        raise ValueError(message)

    if len(faces) and (faces.dim() != 2 or faces.size(1) != 3):
        message = "'faces' should either be empty or of shape (num_faces, 3)."
        raise ValueError(message)

    if faces_uvs is not None and (faces_uvs.dim() != 2 or faces_uvs.size(1) != 3):
        message = "'faces_uvs' should either be empty or of shape (num_faces, 3)."
        raise ValueError(message)

    if verts_uvs is not None and (verts_uvs.dim() != 2 or verts_uvs.size(1) != 2):
        message = "'verts_uvs' should either be empty or of shape (num_verts, 2)."
        raise ValueError(message)

    if texture_map is not None and (texture_map.dim() != 3 or texture_map.size(2) != 3):
        message = "'texture_map' should either be empty or of shape (H, W, 3)."
        raise ValueError(message)

    if path_manager is None:
        path_manager = PathManager()

    save_texture = all([t is not None for t in [faces_uvs, verts_uvs, texture_map]])
    output_path = Path(f)

    # Save the .obj file
    with _open_file(f, path_manager, "w") as f:
        if save_texture:
            # Add the header required for the texture info to be loaded correctly
            obj_header = "\nmtllib {0}.mtl\nusemtl mesh\n\n".format(output_path.stem)
            f.write(obj_header)
        _save(
            f,
            verts,
            faces,
            decimal_places,
            verts_uvs=verts_uvs,
            faces_uvs=faces_uvs,
            save_texture=save_texture,
        )

    # Save the .mtl and .png files associated with the texture
    if save_texture:
        image_path = output_path.with_suffix(".png")
        mtl_path = output_path.with_suffix(".mtl")
        if isinstance(f, str):
            # Back to str for iopath interpretation.
            image_path = str(image_path)
            mtl_path = str(mtl_path)

        # Save texture map to output folder
        # pyre-fixme[16] # undefined attribute cpu
        texture_map = texture_map.detach().cpu() * 255.0
        image = Image.fromarray(texture_map.numpy().astype(np.uint8))
        with _open_file(image_path, path_manager, "wb") as im_f:
            image.save(im_f)

        # Create .mtl file with the material name and texture map filename
        # TODO: enable material properties to also be saved.
        with _open_file(mtl_path, path_manager, "w") as f_mtl:
            lines = f"newmtl mesh\n" f"map_Kd {output_path.stem}.png\n"
            f_mtl.write(lines)