Who doesn't love a good cookie? šŸŖ

Cookies and other technologies such as content caching and request analysis are used on this site to further improve the user experience and the content and services offered.
Detailed information can be found in the Privacy Policy.

Find this project on Github:USDZ converter.

Python pipeline for converting glTF files to the USDZ format.

Overview.

GlTF files have been around for some time and have a variety of applications in various fields. However, there is no standard way to convert them into the USDZ format, the new file type Apple introduced with iOS 12 and ARKit 2.0.

I will describe my experience creating a Python pipeline that converts glTF files to USDZ format using a Docker container. It aims to outline what difficulties I encountered and how I overcame them. Additionally, after reading this, you should feel able to understand, maybe improve or even develop your own approach in some way.

If you are already familiar with both file formats, you can skip the following section and jump directly into the pipeline implementation.

The two file types.

GLTF.

GLTF was introduced by the Khronos Group as the WebGL Transition Format (WebGL TF) and released in 2015. After evolving into the glTF 2.0 specification in 2017, support for the format grew steadily. Colloquially called the JPEG of 3D files, it allows for efficient representation of 3D data and quickly became a widely used file format.

Keep in mind the distinction between the ".gltf" and ".glb" files. Former consists of the actual ".gltf" file formatted in JSON, a directory containing the associated textures and a ".bin" file that describes the geometry.
While the ".glb" file is the composition of the components mentioned above and stands for the binary format of the specification.

USDZ.

Apple's USDZ format is a proprietary evolution of the original USD (Universal Scene Description) format developed by Disney. It is a compressed or zipped version of its ancestor and utilized for all 3D content on Apple devices powered by AR Kit.

The Problem.

USDZ files are (almost) only compatible with Apple devices and software. So the first difficulty as a non-IOS or OSX user already consists of opening and testing files. Conversely, glTF files cannot natively be opened and displayed on an Apple device without either having an app installed specifically for that purpose or using a web application with said capability.

My approach.

Initially, my approach to converting glTF to USDZ was to use the "USD-from_gltf" utility published by Google to get a USD file as an intermediate stage. I then wanted to "package" this into a USDZ file using the USD Toolkit developed by Pixar Animation Studios using its usdzip tool.
However, setting up the USD toolkit is first error-prone and additionally packed with functionalities for which I had no use. Therefore this idea soon turned out to be not very pragmatic.
Besides,"usd_from_gltf" is itself able to generate ā€œ.usdzā€ files, so the hassle of setting up Pixar's toolkit was not necessary in the first placeā€¦

Trying out various libraries and online tools, I finally came across a Docker image called "plattar/python-xrutils". Since not only Google's "usd_from_gltf" but many other useful libraries and toolkits were made available here, I ultimately decided to pursue this approach.

The implementation.

The script should of course be testable directly via the CLI, and in my case be integrated into a Django Python back-end afterwards. This requires four things to be taken care of:

  1. The handling of command line arguments

  2. Providing the converter as a reusable Python module

  3. The automatic fetching of the Docker image

  4. A way for providing the files to be converted inside the docker container

The setup

I use Microsoft's VS Code as my IDE and Pyenv to manage my Python versions. To create an isolated project that places all libraries within its own directory, versus a global installation of dependencies, run the following command:

1python -m venv .

This command runs Python and instructs the module "venv" (i.e. virtual environment) to run, which with the specification "." creates a virtual environment in the current working directory. After that I can use the following command to start the environment:

1. ./bin/activate

To deactivate this environment just type "deactivate" into the CLI. After that I install Docker as a dependency with the command:

1pip install docker

Now the development environment is set up and we can start writing the actual code.

The code

At first I create the file "main.py" and take care of handling the CLI arguments. For this I use the Python module "argparse". I decided on three arguments that can be used to configure the converter:

  • Input (optional)- To define the input files

  • Output (optional) - where to put the converted files

  • Read dir recursive (optional) - if sub-folders should be read as well, once a directory has been defined as input

The arguments are marked as optional because they are set to a default value to ease the testing process by not having to define the paths manually.

At first I define the function "main", which is invoked when the "main.py" script is executed via the CLI. This function mainly takes care of receiving the arguments and shall then call the converter with them.

1import argparse
2
3DEFAULT_INP = './_inp/'
4DEFAULT_OUT = './_out/'
5
6def main():
7    parser = argparse.ArgumentParser(add_help=True)
8
9    parser.add_argument('input', type=str, default=DEFAULT_INP)
10    parser.add_argument('-o', '--output', type=str, default=DEFAULT_OUT)
11    parser.add_argument('-r', '--read_dir_recursive', type=bool, default=False)
12
13    arguments = parser.parse_args()
14    
15    recursive = arguments.read_dir_recursive
16    input, output = check_io(arguments)
17
18
19if __name__ == '__main__':
20    main()

To validate the input and output values, I define the function "check_io", which checks if the input path exists and the output folder has already been created. For this the "arguments" object is passed into the function, where the necessary values are then accessed. If one of the paths does not exist, an error is thrown at the input or the output folder is created if it has not yet been created. If everything is valid the function returns "True" and we can continue.

1def check_io(args):
2    input, output = args.input, args.output
3    exists = os.path.exists
4
5    if not exists(input):
6        raise FileNotFoundError(f'The input {input} was not found.')
7    elif not exists(output):
8        os.mkdir(get_absolute_path(output))
9
10    return input, output

After that input, output and recursive (read_dir_recursive) can be used and we can start writing the docker util.
For this I create the file "docker_util.py" and start with the function "list_files", which takes the two values "input" and "recursive" to return a list of input files. This function shall check if the input is a single file or a directory.
For all path operations I use the python module "os.path" which has to be imported first.

If the input is already the path of a single file, the path is simply put into an array and returned afterwards. However, in case of a directory, another python module called "glob" is used. This provides a simple way to read directories and get a list of file paths from all files that match a certain pattern. Here the argument "recursive" is used to tell "glob" to also read the sub-folders if requested. Then the list of file paths is returned.

1def list_files(path: str, recursive: bool) -> 'list[str]':
2    files = []
3    if os.path.isfile(path):
4        files = [path]
5    else:
6        if recursive:
7            path = os.path.join(path, '**')
8        files = glob(path, recursive=recursive)
9    return files

Now the actual "Docker_util" can be taken care of. For this I create a class, which will contain all necessary values and methods. To start with, it is necessary to import the docker library.
To provide centralized access to important values like the image name, the release, as well as in- and output paths within the docker container, I define them directly at the beginning of my new class. I also create a variable named "instance", which connects to the Docker instance running on the host using the "docker.from_env()" method.
Now I can define the so-called constructor of the class, which provides values across methods within the "self" variable. These values are set when the class is initiated in the "main" function, and once again we are talking about the "input", "output" and "recursive" variables.

1class Docker_util:
2    docker_base_in = '/usr/src/app/_in/'
3    docker_base_out = '/usr/src/app/_out/'
4    image_name = 'plattar/python-xrutils'
5    image_release = 'release-1.108.3'
6    image_full_name = f'{image_name}:{image_release}'
7    
8    instance = docker.from_env()
9
10    def __init__(self, input, output, recursive):
11        self.input = input
12        self.output = output
13        self.recursive = recursive
14

Now we have to check if the image "Plattar/python-xrutils" is already present and if not, download it from the docker-hub. Here I can access the already created "instance" variable and list the available images named "plattar/python-xrutils". In case a list is returned which is longer than 0, the available image is retrieved, otherwise it is downloaded using the "pull" method and then returned.

1 def get_image(self):
2        image_list = self.instance.images.list(self.image_full_name)
3        if len(image_list) == 0:
4            self.instance.images.pull(self.image_name, tag=self.image_release)
5        return self.instance.images.get(self.image_full_name)

With this, three of the four important items of our checklist are already fulfilled. Yay.

As the last important point (besides the actual conversion of course) we still have to take care of providing the files inside the container. For this we create the following method called "bind_volume". At the same time many path operations are necessary now, which are stored for reuse in another file called "path_operations.py". This is where I also put the "check_io" function.
First I want to get the absolute paths of the input and output paths in the "bind_volume" method to prevent possible errors when running the converter. Also the "get_absolute_path" function removes a possible file at the end of the input path.

Now this is the full "path_operations.py" file:

1import os
2
3
4def get_absolute_path(path):
5    return os.path.abspath(os.path.dirname(path))
6
7
8def get_base(path):
9    return os.path.basename(path)
10
11
12def change_extension(file, new_extension):
13    return os.path.splitext(file)[0] + new_extension
14
15
16def new_file_path(new_path, file):
17    return os.path.join(new_path, get_base(file))
18
19
20def check_io(args):
21    input, output = args.input, args.output
22    exists = os.path.exists
23
24    if not exists(input):
25        raise FileNotFoundError(f'The input {input} was not found.')
26    elif not exists(output):
27        os.mkdir(get_absolute_path(output))
28
29    return input, output
30
31
32def is_gltf(file):
33    extension = os.path.splitext(file)[1]
34    for ext in ['.gltf', '.glb']:
35        if ext == extension:
36            return True
37

Now I use these paths and the docker paths defined at the beginning of the class to connect the in- and output host paths to the locations accessible in the docker container. For this, the python docker api needs an object that represents this connection accordingly. This object can now be inserted into the "docker.containers.run()". Additionally I set the "tty" flag, which ensures that the container is not stopped right after it is executed. I also set the "detach" flag to run the container in the background.
All together this results in the following code for the "bind_volume" method:

1 def bind_volume(self):
2        absolute_in = get_absolute_path(self.input)
3        absolute_out = get_absolute_path(self.output)
4
5        bind_obj = {
6            absolute_in: {'bind': self.docker_base_in, 'mode': 'rw'},
7            absolute_out: {'bind': self.docker_base_out, 'mode': 'rw'}
8        }
9
10        self.container = self.instance.containers.run(
11            self.image_full_name, detach=True, volumes=bind_obj, tty=True)
12

Now to execute the command which starts the "usd_from_gltf" tool inside the container, I define the method "call_converter". (almost done hehe)
This method will iterate over the list of input paths and therefore accepts only one parameter, a string pointing to a ".gltf" input file.
At the beginning of the function, I define a little helper function called "create_args", which uses two more functions to generate the input and output filenames for inside the docker container.
First the function "new_file_path" and then "change_extension", where the first one appends a filename to a new path and the second one replaces one file extension with another. These functions are also added to the "path_operations.py" file.
I now use the "create_args" function to create the input and output arguments for the command that will be executed inside the running Docker container.

1def call_converter(self, file: str):
2        def create_args(f: str):
3            inp = new_file_path(self.docker_base_in, f)
4            out = new_file_path(self.docker_base_out,
5                                change_extension(f, '.usdz'))
6            return {'inp': inp, 'out': out}
7
8        args = create_args(file)
9        cmd = f'usd_from_gltf {args["inp"]} {args["out"]}'
10        self.container.exec_run(cmd)

And now, to tie everything together, two final methods are defined: The "stop" and "start" methods. The "stop" method is only two lines long and invokes first the "docker.containers.kill()" method of our docker instance and then the "docker.containers.remove()" method. This then removes the container created at the beginning, including volumes (i.e. the connection for the file exchange).

1def stop(self):
2        self.container.kill()
3        self.container.remove(v=True)

The "start" method now simply calls all previously defined methods one after the other, then iterates through the list of input files with the "call_converter" method and catches potential errors. Last but not least, in case of an error as well as after finishing the conversion, our little "stop" method is called.

1def start(self):
2        self.get_image()
3        self.bind_volume()
4        files = list_files(self.input, self.recursive)
5
6        try:
7            for file in files:
8                if is_gltf(file):
9                    print(f'\nConverting {file}... \n')
10                    self.call_converter(file)
11        except:
12            print('Something went wrong while converting.')
13            self.stop()
14            raise
15
16        self.stop()
17        print('Successfully finished converting files!')
18

Here is the full "docker_util.py" code for you:

1import os
2import docker
3from glob import glob
4
5from utils.path_operations import get_absolute_path, change_extension, new_file_path, is_gltf
6
7
8def list_files(path: str, recursive: bool) -> 'list[str]':
9    files = []
10    if os.path.isfile(path):
11        files = [path]
12    else:
13        if recursive:
14            path = os.path.join(path, '**')
15        files = glob(path, recursive=recursive)
16    return files
17
18
19class Docker_util:
20    docker_base_in = '/usr/src/app/_in/'
21    docker_base_out = '/usr/src/app/_out/'
22    image_name = 'plattar/python-xrutils'
23    image_release = 'release-1.108.3'
24    image_full_name = f'{image_name}:{image_release}'
25    
26    instance = docker.from_env()
27
28    def __init__(self, input, output, recursive):
29        self.input = input
30        self.output = output
31        self.recursive = recursive
32
33    def get_image(self):
34        image_list = self.instance.images.list(self.image_full_name)
35        if len(image_list) == 0:
36            self.instance.images.pull(self.image_name, tag=self.image_release)
37        return self.instance.images.get(self.image_full_name)
38
39    def bind_volume(self):
40        absolute_in = get_absolute_path(self.input)
41        absolute_out = get_absolute_path(self.output)
42
43        bind_obj = {
44            absolute_in: {'bind': self.docker_base_in, 'mode': 'rw'},
45            absolute_out: {'bind': self.docker_base_out, 'mode': 'rw'}
46        }
47
48        self.container = self.instance.containers.run(
49            self.image_full_name, detach=True, volumes=bind_obj, tty=True)
50
51    def call_converter(self, file: str):
52        def create_args(f: str):
53            inp = new_file_path(self.docker_base_in, f)
54            out = new_file_path(self.docker_base_out,
55                                change_extension(f, '.usdz'))
56            return {'inp': inp, 'out': out}
57
58        args = create_args(file)
59        cmd = f'usd_from_gltf {args["inp"]} {args["out"]}'
60        self.container.exec_run(cmd)
61
62    def stop(self):
63        self.container.kill()
64        self.container.remove(v=True)
65
66    def start(self):
67        self.get_image()
68        self.bind_volume()
69        files = list_files(self.input, self.recursive)
70
71        try:
72            for file in files:
73                if is_gltf(file):
74                    print(f'\nConverting {file}... \n')
75                    self.call_converter(file)
76        except:
77            print('Something went wrong while converting.')
78            self.stop()
79            raise
80
81        self.stop()
82        print('Successfully finished converting files!')
83

All that is missing now is to import this new class, initialize it in our "main" function with the input and output values from the CLI arguments and then call the "start" method.

1import argparse
2
3from utils.path_operations import check_io
4from utils.docker_util import Docker_util
5
6DEFAULT_INP = './_in/'
7DEFAULT_OUT = './_out/'
8
9
10def main():
11    parser = argparse.ArgumentParser(add_help=True)
12
13    parser.add_argument('input', type=str, default=DEFAULT_INP)
14    parser.add_argument('-o', '--output', type=str, default=DEFAULT_OUT)
15    parser.add_argument('-r', '--read_dir_recursive', type=bool, default=True)
16
17    arguments = parser.parse_args()
18
19    recursive = arguments.read_dir_recursive
20    input, output = check_io(arguments)
21
22    Converter = Docker_util(input, output, recursive)
23    Converter.start()
24
25
26if __name__ == '__main__':
27    main()
28

And that's it! Now we have a converter that can convert any glTF 2.0 file into the USDZ format. This class can now be used in other projects, e.g. to extend a larger 3D pipeline or to make an online tool out of it.

With this I hope I was able to help you if you are currently working on such a project, or maybe I just gave you some ideas. Regardless, I'd love to hear your feedback and hear what you thought of this article.

Created with šŸ§  and next.js | Ā© 2022 Moritz Becker