Creating an Application

In this section, we’ll address:

Note

As of version 0.4.0, the Holoscan SDK only supports a single fragment by application. This means that the application can have only one workflow and work on a single machine. We plan to support multiple fragments per application in a future release.

Creating a new Application starts with inheriting from the Application (C++/Python) base class. At this time, it inherits from the Fragment (C++/Python) class to provide the functionalities of a Fragment (i.e., creating a workflow and running it).

The following code snippet shows an example Application code skeleton:

Copy
Copied!
            

#include <holoscan/holoscan.hpp> class App : public holoscan::Application { public: void compose() override { using namespace holoscan; // Define Operators // ... // Define the workflow // ... } }; int main() { auto app = holoscan::make_application<App>(); app->config("app_config.yaml"); app->run(); return 0; }

  • We define the App class that inherits from the Application base class, and create an instance of the App class in main() using the make_application() function.

  • The config() method provides parameters that will be used by the operators in the workflow.

  • The run() method starts the application which will execute its compose() method where the custom workflow will be defined.

Copy
Copied!
            

from holoscan.core import Application class App(Application): def compose(self): # Define Operators # ... # Define the workflow # ... def main(): app = App() app.config("app_config.yaml") app.run() if __name__ == "__main__": main()

  • We define the App class that inherits from the Application base class, and create an instance of the App class in __main__.

  • The config() method provides parameters that will be used by the operators in the workflow.

  • The run() method starts the application which will execute its compose() method where the custom workflow will be defined.

The config() function (C++/Python) takes the path to a YAML configuration file that contains:

  • the name of the GXF extensions to load (for C++ GXF operators)

  • the configuration parameters for the application

Note

If the path provided to config() is relative, it will be relative to the current working directory.

Note

With the Python API, calling the config() function is optional (default GXF extensions are registered) unless you want to provide parameters to the application from the configuration file. With the C++ API, calling the config() function is mandatory to provide the adequate GXF extensions that your application will need to load in order to run any GXF operator. We plan to provide a way to register GXF extensions without requiring a configuration file in a future release.

Let’s create a configuration file for the Holoscan Application (app_config.yaml):

Listing 5 app_config.yaml

Copy
Copied!
            

# For Python Application, Comment out the following lines (line 2-7) extensions: - libgxf_std.so - libgxf_cuda.so - libgxf_multimedia.so - libgxf_serialization.so # - (paths to other custom extensions ...) # Configuration for the application verbose: true app: input_config_path: "app_input.json" # Configuration for Operator 1 op1_params: param1: 0 param2: "hello" # Configuration for Operator 2 op2_params: param1: 2.0 param2: false


  • The extensions field in the YAML configuration file can specify a list of GXF extension names. With the C++ API, we need to register GXF extensions (line 2-7) in the configuration file. With the Python API, we need to comment out the lines (line 2-7) since all of the built-in GXF extensions are registered by default. In the above example, we have added the names of some of the GXF core extensions which would be used by some of the associated GXF operators. If you want to use other GXF operators, you’ll need to add the name to their associated GXF extensions.

  • The verbose and app.input_config_path fields are specified in this YAML file (as an example) for application specific parameters. The op1_params and op2_params fields are specified for the parameters of the operators.

To access the parameters in the application, we can use the from_config() (C++/Python) function which returns a value for a given key in the YAML file. The key can be a dot-separated string to access nested fields.

  • In the C++ API, the from_config() method returns an ArgList object, a container class that holds a list of Arg objects. The Arg object holds a name (key) and a value. If the ArgList object has only one item/Arg (when the key is pointing to a scalar item), it can be converted to the desired type using the as() method by passing the type as an argument.

  • In the Python API, the from_config() method returns an ArgList object if the key is pointing to a map item, or an Arg object if the key is pointing to a scalar item. An Arg object can be cast to the desired type (e.g., int(app.from_config("app_param"))). To get all the parameter key/values as a dictionary, the kwargs() method can be used instead.

The following example shows how to access parameter values from an application’s configuration file.

Copy
Copied!
            

#include <holoscan/holoscan.hpp> class App : public holoscan::Application { public: void config_path(std::string config_dir) { input_config_path_ = config_dir; } void compose() override { using namespace holoscan; fmt::print("input_config_path: {}\n", input_config_path_); fmt::print("op1_params: size: {}\n", from_config("op1_params").size()); auto op1_params = from_config("op1_params"); auto& param1 = op1_params.args()[1]; auto node = std::any_cast<YAML::Node>(param1.value()); fmt::print("op1_params[1]: name: {} value: {}\n", param1.name(), node.as<std::string>()); fmt::print("op2_params.param1: {}\n", from_config("op2_params.param1").as ()); // Define Operators // ... // Define the workflow // ... } private: std::string input_config_path_; }; int main() { holoscan::load_env_log_level(); auto app = holoscan::make_application<App>(); app->config("app_config.yaml"); // Get the value of 'verbose' field (as 'bool') auto verbose = app->from_config("verbose").as (); // Get the value of the 'app.input_config_path' field (as 'std::string') auto input_config_path = app->from_config("app.input_config_path").as<std::string>(); app->config_path(input_config_path); fmt::print("verbose: {}\n", verbose); app->run(); return 0; } // The output is as follows: // verbose: true // input_config_path: app_input.json // op1_params: size: 2 // op1_params[1]: name: param2 value: hello // op2_params.param1: 2

Copy
Copied!
            

from holoscan.core import Application from holoscan.logger import load_env_log_level class App(Application): def config_path(self, config_dir): self.input_config_path = config_dir def compose(self): print("input_config_path:", self.input_config_path) # Define Operators # ... print ("op1_params:", self.kwargs("op1_params")) print ("op2_params:", self.kwargs("op2_params")) print ("op2_params.param1:", float(self.from_config("op2_params.param1"))) # Define the workflow # ... def main(): load_env_log_level() app = App() app.config("app_config.yaml") # Get the value of 'verbose' field (as 'bool') verbose = bool(app.from_config("verbose")) # Get the value of the 'app.input_config_path' field (as 'str') input_config_path = str(app.from_config("app.input_config_path")) app.config_path(input_config_path) print("verbose:", verbose) app.run() if __name__ == "__main__": main() # The output is as follows: # verbose: True # input_config_path: app_input.json # op1_params: {'param1': 0, 'param2': 'hello'} # op2_params: {'param1': 2.0, 'param2': False} # op2_params.param1: 2.0

ArgList, Arg, or standard Python keyword arguments (e.g. as expanded from the Python dictionary object returned by Fragment.kwargs) can be used to provide arguments to an operator. We will see how to use them in the Creating Operators (C++/Python) section.

One-operator Workflow

The simplest form of a workflow would be a single operator.

%%{init: {"theme": "base", "themeVariables": { "fontSize": "16px"}} }%% classDiagram direction LR class MyOp { }

Fig. 7 A one-operator workflow

The graph above shows an Operator (C++/Python) (named MyOp) that has neither inputs nor output ports.

  • Such an operator may accept input data from the outside (e.g., from a file) and produce output data (e.g., to a file) so that it acts as both the source and the sink operator.

  • Arguments to the operator (e.g., input/output file paths) can be passed as parameters as described in the section above.

We can add an operator to the workflow by calling add_operator (C++/Python) method in the compose() method.

The following code shows how to define a one-operator workflow in compose() method of the App class (assuming that the operator class MyOp is declared/defined in the same file).

Copy
Copied!
            

class App : public holoscan::Application { public: void compose() override { // Define Operators auto my_op = make_operator<MyOp>("my_op"); // Define the workflow add_operator(my_op); } };

Copy
Copied!
            

class App(Application): def compose(self): # Define Operators my_op = MyOp(self, name="my_op") # Define the workflow self.add_operator(my_op)

Linear Workflow

Here is an example workflow where the operators are connected linearly:

%%{init: {"theme": "base", "themeVariables": { "fontSize": "16px"}} }%% classDiagram direction LR SourceOp --|> ProcessOp : output...input ProcessOp --|> SinkOp : output...input class SourceOp { output(out) Tensor } class ProcessOp { [in]input : Tensor output(out) Tensor } class SinkOp { [in]input : Tensor }

Fig. 8 A linear workflow

In this example, SourceOp produces a message and passes it to ProcessOp. ProcessOp produces another message and passes it to SinkOp.

We can connect two operators by calling the add_flow() method (C++/Python) in the compose() method.

The add_flow() method (C++/Python) takes the source operator, the destination operator, and the optional port name pairs. The port name pair is used to connect the output port of the source operator to the input port of the destination operator. The first element of the pair is the output port name of the upstream operator and the second element is the input port name of the downstream operator. An empty port name (“”) can be used for specifying a port name if the operator has only one input/output port. If there is only one output port in the upstream operator and only one input port in the downstream operator, the port pairs can be omitted.

The following code shows how to define a linear workflow in the compose() method of the App class (assuming that the operator classes SourceOp, ProcessOp, and SinkOp are declared/defined in the same file).

Copy
Copied!
            

class App : public holoscan::Application { public: void compose() override { // Define Operators auto source = make_operator<SourceOp>("source"); auto process = make_operator<ProcessOp>("process"); auto sink = make_operator<SinkOp>("sink"); // Define the workflow add_flow(source, process); // same as `add_flow(source, process, {{"output", "input"}});` add_flow(process, sink); // same as `add_flow(process, sink, {{"", ""}});` } };

Copy
Copied!
            

class App(Application): def compose(self): # Define Operators source = SourceOp(self, name="source") process = ProcessOp(self, name="process") sink = SinkOp(self, name="sink") # Define the workflow self.add_flow(source, process) # same as `self.add_flow(source, process, {("output", "input")})` self.add_flow(process, sink) # same as `self.add_flow(process, sink, {("", "")})`

Complex Workflow (Multiple Inputs and Outputs)

You can design a complex workflow like below where some operators have multi-inputs and/or multi-outputs:

%%{init: {"theme": "base", "themeVariables": { "fontSize": "16px"}} }%% classDiagram direction TB Reader1 --|> Processor1 : image...{image1,image2}\nmetadata...metadata Reader2 --|> Processor2 : roi...roi Processor1 --|> Processor2 : image...image Processor2 --|> Processor3 : image...image Processor2 --|> Notifier : image...image Processor1 --|> Writer : image...image Processor3 --|> Writer : seg_image...seg_image class Reader1 { image(out) metadata(out) } class Reader2 { roi(out) } class Processor1 { [in]image1 [in]image2 [in]metadata image(out) } class Processor2 { [in]image [in]roi image(out) } class Processor3 { [in]image seg_image(out) } class Writer { [in]image [in]seg_image } class Notifier { [in]image }

Fig. 9 A complex workflow (multiple inputs and outputs)

Copy
Copied!
            

class App : public holoscan::Application { public: void compose() override { // Define Operators auto reader1 = make_operator<Reader1>("reader1"); auto reader2 = make_operator<Reader2>("reader2"); auto processor1 = make_operator<Processor1>("processor1"); auto processor2 = make_operator<Processor2>("processor2"); auto processor3 = make_operator<Processor3>("processor3"); auto writer = make_operator<Writer>("writer"); auto notifier = make_operator<Notifier>("notifier"); // Define the workflow add_flow(reader1, processor1, {{"image", "image1"}, {"image", "image2"}, {"metadata", "metadata"}}); add_flow(reader1, processor1, {{"image", "image2"}}); add_flow(reader2, processor2, {{"roi", "roi"}}); add_flow(processor1, processor2, {{"image", "image"}}); add_flow(processor1, writer, {{"image", "image"}}); add_flow(processor2, notifier); add_flow(processor2, processor3); add_flow(processor3, writer, {{"seg_image", "seg_image"}}); } };

Copy
Copied!
            

class App(Application): def compose(self): # Define Operators reader1 = Reader1Op(self, name="reader1") reader2 = Reader2Op(self, name="reader2") processor1 = Processor1Op(self, name="processor1") processor2 = Processor2Op(self, name="processor2") processor3 = Processor3Op(self, name="processor3") notifier = NotifierOp(self, name="notifier") writer = WriterOp(self, name="writer") # Define the workflow self.add_flow(reader1, processor1, {("image", "image1"), ("image", "image2"), ("metadata", "metadata")}) self.add_flow(reader2, processor2, {("roi", "roi")}) self.add_flow(processor1, processor2, {("image", "image")}) self.add_flow(processor1, writer, {("image", "image")}) self.add_flow(processor2, notifier) self.add_flow(processor2, processor3) self.add_flow(processor3, writer, {("seg_image", "seg_image")})

© Copyright 2022, NVIDIA. Last updated on Apr 27, 2023.