Implementing the service
Now that we have everything in place, let’s start implementing the service.
Here, we will start small, with a working end-to-end service first that we will progressively enhance as we go. Of course, you are free to take the approach you feel most comfortable in, and jump directly to the end of this series to the completed project. In this tutorial series, we’ll take the approach we generally tend to recommend when designing services, where we progressively refine the specifications over multiple iterations. When possible to use this method, this approach generally tends to allow for greater success ratio, as it makes it possible to adapt to changing requirements over the lifespan of a project, and allows for greater flexibility to react and adapt to new information during the project. Greater transparency in communicating the status and progress of the project makes it easier for stakeholders to make informed decisions, which tend to promote efficiency and reduce waste in the project.
To illustrate humble beginnings, let’s start with a minimalistic design and implementation of our service so we can get a working end-to-end solution deployed and tested early.
Note
As we are walking through the process of defining and implementing a Service with you, we have annotated the code samples you will find in this guide with additional comments and insights about the design choices we made.
Please note these comments have been added for the benefit of providing context to our samples, as we will be iterating over the design and providing this information about where we are heading should help provide a sense of direction.
Defining the API
In order to iterate over our service, and have a functional prototype operating early, let’s define a minimal set of requirements that we would like to have in order to demonstrate the capabilities of our service.
We’ll start with a minimal API that will allow Users to capture images from the viewport of our Omniverse application. For this, we will define a REST API that will allow reception of payloads with the following JSON information:
As input:
usd_stage_path
: The full path of the USD stage that we’ll want to capture.As output:
success
: A flag indicating whether the operation completed successfully.
captured_image_path
: A string informing clients where captured images can be retrieved.
error_message
: A string informing clients of any potential error details in case the operation did not complete successfully.
In subsequent steps, after completing an initial proof of concept demonstrating our ability to capture an image from a given USD stage, we could consider adding support for additional options such as image resolution, timestamp, camera, viewport overlay information and other features for which we may also wish to provide controls to Users.
Initial implementation
Let’s define the extension’s main file, its TOML definition. If you already created Omniverse extensions before, there should be nothing surprising in this extension’s configuration file:
1[package]
2# Semantic versioning is used: https://semver.org
3version = "1.0.0"
4
5# The title and description fields are primarily for displaying extension info in UI:
6title = "Viewport capture service"
7description = "Sample service example demonstrating the creation of microservices using Omniverse."
8
9# Path (relative to the root), or content of the extension's "readme" file, in Markdown format:
10readme = "docs/README.md"
11
12# Path (relative to the root) of the extension's changelog file, in Markdown format:
13changelog = "docs/CHANGELOG.md"
14
15# URL of the extension source repository:
16repository = "https://github.com/NVIDIA-Omniverse/kit-extension-template"
17
18# Category of the extension:
19category = "services"
20
21# Keywords of the extension:
22keywords = ["kit", "service", "example"]
23
24# Icon to show in the extension manager:
25icon = "data/icon.png"
26
27# Preview to show in the extension manager:
28preview_image = "data/preview.png"
29
30# Use omni.ui to build simple UI
31[dependencies]
32"omni.kit.menu.edit" = {}
33"omni.kit.actions.core" = {}
34"omni.services.core" = {}
35"omni.services.transport.server.http" = {}
36"omni.usd" = {}
37
38# Main Python module this extension provides, it will be publicly available as
39# "import omni.services.example.viewport_capture.core":
40[[python.module]]
41name = "omni.services.example.viewport_capture.core"
42
43# Settings of our extension:
44[settings.exts."omni.services.example.viewport_capture.core"]
45# URL prefix where the service will be mounted, where our API will be available to handle incoming requests.
46#
47# Defining this as a setting makes it easy to change or rebrand the endpoint using only command-line or KIT-file
48# configuration instructions, should extensions ever feature conflicting endpoint naming conventions.
49url_prefix = "/viewport-capture"
50
51# Path from where the captured images will be served from, when exposed to clients.
52#
53# This path will be mounted as a child of the `url_prefix` setting, and expressed as a formatted join of the
54# `{url_prefix}{capture_path}` settings.
55capture_path = "/static"
56
57# Name of the directory on the server where captured images will be stored:
58capture_directory = "captured_stage_images"
Just as we did in the step before when confirming the extension was properly registered with our Omniverse host application, we will also confirm adequate end-to-end functionality of this development iteration by displaying debugging information to the console when receiving a request from the network. This will allow us to confirm that we properly received incoming requests, and that all components of our extension are correctly connected and cooperating together.
So let’s now define our extension’s API wrapper, which we’ll later register in our extension entrypoint:
1from typing import Optional
2
3from pydantic import BaseModel, Field
4
5import carb
6
7from omni.services.core import routers
8
9
10router = routers.ServiceAPIRouter()
11
12
13# Let's define a model to handle the parsing of incoming requests.
14#
15# Using `pydantic` to handle data-parsing duties makes it less cumbersome for us to do express types, default values,
16# minimum/maximum values, etc. while also taking care of documenting input and output properties of our service using
17# the OpenAPI specification format.
18class ViewportCaptureRequestModel(BaseModel):
19 """Model describing the request to capture a viewport as an image."""
20
21 usd_stage_path: str = Field(
22 ...,
23 title="Path of the USD stage for which to capture an image",
24 description="Location where the USD stage to capture can be found.",
25 )
26 # If required, add additional capture response options in subsequent iterations.
27 # [...]
28
29# We will also define a model to handle the delivery of responses back to clients.
30#
31# Just like the model used to handle incoming requests, the model to deliver responses will not only help define
32# default values of response parameters, but also in documenting the values clients can expect using the OpenAPI
33# specification format.
34class ViewportCaptureResponseModel(BaseModel):
35 """Model describing the response to the request to capture a viewport as an image."""
36
37 success: bool = Field(
38 default=False,
39 title="Capture status",
40 description="Status of the capture of the given USD stage.",
41 )
42 captured_image_path: Optional[str] = Field(
43 default=None,
44 title="Captured image path",
45 description="Path of the captured image, hosted on the current server.",
46 )
47 error_message: Optional[str] = Field(
48 default=None,
49 title="Error message",
50 description="Optional error message in case the operation was not successful.",
51 )
52 # If required, add additional capture response options in subsequent iterations.
53 # [...]
54
55
56# Using the `@router` annotation, we'll tag our `capture` function handler to document the responses and path of the
57# API, once again using the OpenAPI specification format.
58@router.post(
59 path="/capture",
60 summary="Capture a given USD stage",
61 description="Capture a given USD stage as an image.",
62 response_model=ViewportCaptureResponseModel,
63)
64async def capture(request: ViewportCaptureRequestModel,) -> ViewportCaptureResponseModel:
65 # For now, let's just print incoming request to the log to confirm all components of our extension are properly
66 # wired together:
67 carb.log_warn(f"Received a request to capture an image of \"{request.usd_stage_path}\".")
68
69 # Let's return a JSON response, indicating that the viewport capture operation failed to avoid misinterpreting the
70 # current lack of image output as a failure:
71 return ViewportCaptureResponseModel(
72 success=False,
73 captured_image_path=None,
74 error_message="Image not yet captured.",
75 )
For the final step of this iteration, let’s register our API in our extension entrypoint, whose principal responsibility will be to register the service that will receive incoming requests to capture images from USD stages from the network:
1import carb
2import omni.ext
3
4from omni.services.core import main
5
6# As most of the features of our API are implemented by the means of function handlers in the `/services` sub-folder,
7# the main duty of our extension entrypoint is to register our Service's `@router` and dictate when its capability
8# should be enabled or disabled under the guidance of the User or the dependency system.
9from .services.capture import router
10
11
12# Any class derived from `omni.ext.IExt` in the top level module (defined in the `python.module` section of the
13# `extension.toml` file) will be instantiated when the extension is enabled, and its `on_startup(ext_id)` method
14# will be called. When disabled or when the application is shut down, its `on_shutdown()` will be called.
15class ViewportCaptureCoreExtension(omni.ext.IExt):
16 """Sample extension illustrating registration of a service."""
17
18 # `ext_id` is the unique identifier of the extension, containing its name and semantic version number. This
19 # identifier can be used in conjunction with the Extension Manager to query for additional information, such
20 # as the extension's location on the filesystem.
21 def on_startup(self, ext_id: str) -> None:
22 ext_name = ext_id.split("-")[0]
23 carb.log_info("ViewportCaptureCoreExtension startup")
24
25 # At this point, we register our Service's `router` under the prefix we gave our API using the settings system,
26 # to facilitate its configuration and to ensure it is unique from all other extensions we may have enabled:
27 url_prefix = carb.settings.get_settings().get_as_string(f"exts/{ext_name}/url_prefix")
28 main.register_router(router=router, prefix=url_prefix, tags=["Viewport capture"],)
29
30 def on_shutdown(self) -> None:
31 carb.log_info("ViewportCaptureCoreExtension shutdown")
32
33 # When disabling the extension or shutting down the instance of the Omniverse application, let's make sure we
34 # also deregister our Service's `router` in order to avoid our API being erroneously advertised as present as
35 # part of the OpenAPI specification despite our handler function no longer being available:
36 main.deregister_router(router=router)
Next, we’ll confirm all is set and ready for us to iterate on the core feature by submitting a request to our service in its current early stage.
If you remember from the function handler we just defined in our Service layer, we should expect to see the path of the USD stage we expect to capture, along with a flag indicating whether the operation was successful.
« Previous section: Getting started | Next section: Exploring the API » |