Creating an Application
In this section, we’ll address:
how to define an Application class
how to configure an Application
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:
#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 theApplication
base class, and create an instance of theApp
class inmain()
using themake_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 itscompose()
method where the custom workflow will be defined.
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 theApplication
base class, and create an instance of theApp
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 itscompose()
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
If the path provided to config()
is relative, it will be relative to the current working directory.
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
# 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
andapp.input_config_path
fields are specified in this YAML file (as an example) for application specific parameters. Theop1_params
andop2_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 anArgList
object, a container class that holds a list ofArg
objects. TheArg
object holds a name (key) and a value. If theArgList
object has only one item/Arg
(when the key is pointing to a scalar item), it can be converted to the desired type using theas()
method by passing the type as an argument.In the Python API, the
from_config()
method returns anArgList
object if the key is pointing to a map item, or anArg
object if the key is pointing to a scalar item. AnArg
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, thekwargs()
method can be used instead.
The following example shows how to access parameter values from an application’s configuration file.
#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<float>());
// 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<bool>();
// 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
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.
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).
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);
}
};
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:
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).
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, {{"", ""}});`
}
};
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:
Fig. 9 A complex workflow (multiple inputs and outputs)
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"}});
}
};
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")})