El compilador de gráficos C++ es una herramienta potente para realizar las siguientes tareas:
- Cómo crear gráficos complejos
- Parametrización de gráficos (p.ej., configurar un delegado en
InferenceCalculator
, habilitar o inhabilitar partes del gráfico) - Anulación de duplicación de gráficos (p.ej., en lugar de gráficos dedicados de CPU y GPU en pbtxt) puedes tener un solo código que cree los gráficos necesarios y comparta tantos como sea posible)
- Compatibilidad con entradas y salidas opcionales de grafos
- Personalización de gráficos por plataforma
Uso básico
Veamos cómo se puede usar el compilador de gráficos C++ para un gráfico simple:
# Graph inputs.
input_stream: "input_tensors"
input_side_packet: "model"
# Graph outputs.
output_stream: "output_tensors"
node {
calculator: "InferenceCalculator"
input_stream: "TENSORS:input_tensors"
input_side_packet: "MODEL:model"
output_stream: "TENSORS:output_tensors"
options: {
[drishti.InferenceCalculatorOptions.ext] {
# Requesting GPU delegate.
delegate { gpu {} }
}
}
}
La función para compilar el CalculatorGraphConfig
anterior puede verse de la siguiente manera:
CalculatorGraphConfig BuildGraph() {
Graph graph;
// Graph inputs.
Stream<std::vector<Tensor>> input_tensors =
graph.In(0).SetName("input_tensors").Cast<std::vector<Tensor>>();
SidePacket<TfLiteModelPtr> model =
graph.SideIn(0).SetName("model").Cast<TfLiteModelPtr>();
auto& inference_node = graph.AddNode("InferenceCalculator");
auto& inference_opts =
inference_node.GetOptions<InferenceCalculatorOptions>();
// Requesting GPU delegate.
inference_opts.mutable_delegate()->mutable_gpu();
input_tensors.ConnectTo(inference_node.In("TENSORS"));
model.ConnectTo(inference_node.SideIn("MODEL"));
Stream<std::vector<Tensor>> output_tensors =
inference_node.Out("TENSORS").Cast<std::vector<Tensor>>();
// Graph outputs.
output_tensors.SetName("output_tensors").ConnectTo(graph.Out(0));
// Get `CalculatorGraphConfig` to pass it into `CalculatorGraph`
return graph.GetConfig();
}
Breve resumen:
- Usa
Graph::In/SideIn
para obtener entradas del gráfico comoStream/SidePacket
. - Usa
Node::Out/SideOut
para obtener resultados de nodo comoStream/SidePacket
. - Usa
Stream/SidePacket::ConnectTo
para conectar transmisiones y paquetes laterales a entradas de nodo (Node::In/SideIn
) y salidas de grafo (Graph::Out/SideOut
)- Hay un "atajo" el operador
>>
que puedes usar en lugar del FunciónConnectTo
(p.ej.,x >> node.In("IN")
).
- Hay un "atajo" el operador
Stream/SidePacket::Cast
se usa para transmitir una transmisión o un paquete complementario deAnyType
. (p.ej.,Stream<AnyType> in = graph.In(0);
) a un tipo determinado- Usar tipos reales en lugar de
AnyType
te orientará mejor a liberar las capacidades del compilador de grafos y mejorar tus grafos la legibilidad.
- Usar tipos reales en lugar de
Uso avanzado
Funciones de utilidad
Extraigamos el código de construcción de la inferencia en una función de utilidad dedicada para para facilitar la legibilidad y reutilizar el código:
// Updates graph to run inference.
Stream<std::vector<Tensor>> RunInference(
Stream<std::vector<Tensor>> tensors, SidePacket<TfLiteModelPtr> model,
const InferenceCalculatorOptions::Delegate& delegate, Graph& graph) {
auto& inference_node = graph.AddNode("InferenceCalculator");
auto& inference_opts =
inference_node.GetOptions<InferenceCalculatorOptions>();
*inference_opts.mutable_delegate() = delegate;
tensors.ConnectTo(inference_node.In("TENSORS"));
model.ConnectTo(inference_node.SideIn("MODEL"));
return inference_node.Out("TENSORS").Cast<std::vector<Tensor>>();
}
CalculatorGraphConfig BuildGraph() {
Graph graph;
// Graph inputs.
Stream<std::vector<Tensor>> input_tensors =
graph.In(0).SetName("input_tensors").Cast<std::vector<Tensor>>();
SidePacket<TfLiteModelPtr> model =
graph.SideIn(0).SetName("model").Cast<TfLiteModelPtr>();
InferenceCalculatorOptions::Delegate delegate;
delegate.mutable_gpu();
Stream<std::vector<Tensor>> output_tensors =
RunInference(input_tensors, model, delegate, graph);
// Graph outputs.
output_tensors.SetName("output_tensors").ConnectTo(graph.Out(0));
return graph.GetConfig();
}
Como resultado, RunInference
proporciona una interfaz clara que indica cuáles son las
las entradas y salidas
y sus tipos.
Se puede reutilizar fácilmente, p.ej., solo tendrás unas pocas líneas si quieres ejecutar una consulta Inferencia de modelo:
// Run first inference.
Stream<std::vector<Tensor>> output_tensors =
RunInference(input_tensors, model, delegate, graph);
// Run second inference on the output of the first one.
Stream<std::vector<Tensor>> extra_output_tensors =
RunInference(output_tensors, extra_model, delegate, graph);
Y no necesitas duplicar nombres y etiquetas (InferenceCalculator
,
TENSORS
o MODEL
) o introduce constantes dedicadas aquí y allá, esas
los detalles se localizan para la función RunInference
.
Clases de utilidad
Y, sin duda, no se trata solo de funciones, sino que, en algunos casos, presentar clases de utilidades que pueden ayudar a crear el código de construcción de tu gráfico sean más fáciles de leer y menos propensos a errores.
MediaPipe ofrece la calculadora de PassThroughCalculator
, que simplemente pasa
a través de sus entradas:
input_stream: "float_value"
input_stream: "int_value"
input_stream: "bool_value"
output_stream: "passed_float_value"
output_stream: "passed_int_value"
output_stream: "passed_bool_value"
node {
calculator: "PassThroughCalculator"
input_stream: "float_value"
input_stream: "int_value"
input_stream: "bool_value"
# The order must be the same as for inputs (or you can use explicit indexes)
output_stream: "passed_float_value"
output_stream: "passed_int_value"
output_stream: "passed_bool_value"
}
Veamos el código de construcción de C++ directo para crear el gráfico anterior:
CalculatorGraphConfig BuildGraph() {
Graph graph;
// Graph inputs.
Stream<float> float_value = graph.In(0).SetName("float_value").Cast<float>();
Stream<int> int_value = graph.In(1).SetName("int_value").Cast<int>();
Stream<bool> bool_value = graph.In(2).SetName("bool_value").Cast<bool>();
auto& pass_node = graph.AddNode("PassThroughCalculator");
float_value.ConnectTo(pass_node.In("")[0]);
int_value.ConnectTo(pass_node.In("")[1]);
bool_value.ConnectTo(pass_node.In("")[2]);
Stream<float> passed_float_value = pass_node.Out("")[0].Cast<float>();
Stream<int> passed_int_value = pass_node.Out("")[1].Cast<int>();
Stream<bool> passed_bool_value = pass_node.Out("")[2].Cast<bool>();
// Graph outputs.
passed_float_value.SetName("passed_float_value").ConnectTo(graph.Out(0));
passed_int_value.SetName("passed_int_value").ConnectTo(graph.Out(1));
passed_bool_value.SetName("passed_bool_value").ConnectTo(graph.Out(2));
// Get `CalculatorGraphConfig` to pass it into `CalculatorGraph`
return graph.GetConfig();
}
Mientras que la representación pbtxt
puede ser propensa a errores (cuando tenemos muchas entradas para pasar
a través de él), el código C++ se ve aún peor: etiquetas vacías repetidas y llamadas Cast
. Vamos a
Para ver cómo podemos mejorarlo, presentamos un PassThroughNodeBuilder
:
class PassThroughNodeBuilder {
public:
explicit PassThroughNodeBuilder(Graph& graph)
: node_(graph.AddNode("PassThroughCalculator")) {}
template <typename T>
Stream<T> PassThrough(Stream<T> stream) {
stream.ConnectTo(node_.In(index_));
return node_.Out(index_++).Cast<T>();
}
private:
int index_ = 0;
GenericNode& node_;
};
Ahora, el código de construcción del grafo puede verse de la siguiente manera:
CalculatorGraphConfig BuildGraph() {
Graph graph;
// Graph inputs.
Stream<float> float_value = graph.In(0).SetName("float_value").Cast<float>();
Stream<int> int_value = graph.In(1).SetName("int_value").Cast<int>();
Stream<bool> bool_value = graph.In(2).SetName("bool_value").Cast<bool>();
PassThroughNodeBuilder pass_node_builder(graph);
Stream<float> passed_float_value = pass_node_builder.PassThrough(float_value);
Stream<int> passed_int_value = pass_node_builder.PassThrough(int_value);
Stream<bool> passed_bool_value = pass_node_builder.PassThrough(bool_value);
// Graph outputs.
passed_float_value.SetName("passed_float_value").ConnectTo(graph.Out(0));
passed_int_value.SetName("passed_int_value").ConnectTo(graph.Out(1));
passed_bool_value.SetName("passed_bool_value").ConnectTo(graph.Out(2));
// Get `CalculatorGraphConfig` to pass it into `CalculatorGraph`
return graph.GetConfig();
}
Ahora no puedes tener un orden o índice incorrecto en el pase durante la construcción
adivina el tipo de Cast
desde PassThrough
entrada.
Sugerencias y precauciones
Si es posible, define las entradas del grafo desde el principio.
En el siguiente código:
- Puede ser difícil adivinar cuántas entradas tienes en el gráfico.
- Puede ser propenso a errores en general y difícil de mantener en el futuro (p.ej., ¿es una ¿El índice es correcto? nombre? ¿qué sucede si se quitan algunas entradas o se vuelven opcionales? etcétera).
- La reutilización de
RunSomething
está limitada porque otros gráficos pueden tener diferentes entradas
NO DEBES: ejemplo de código incorrecto.
Stream<D> RunSomething(Stream<A> a, Stream<B> b, Graph& graph) {
Stream<C> c = graph.In(2).SetName("c").Cast<C>(); // Bad.
// ...
}
CalculatorGraphConfig BuildGraph() {
Graph graph;
Stream<A> a = graph.In(0).SetName("a").Cast<A>();
// 10/100/N lines of code.
Stream<B> b = graph.In(1).SetName("b").Cast<B>() // Bad.
Stream<D> d = RunSomething(a, b, graph);
// ...
return graph.GetConfig();
}
En cambio, define las entradas del gráfico al comienzo de tu compilador:
SÍ: Ejemplo de buen código.
Stream<D> RunSomething(Stream<A> a, Stream<B> b, Stream<C> c, Graph& graph) {
// ...
}
CalculatorGraphConfig BuildGraph() {
Graph graph;
// Inputs.
Stream<A> a = graph.In(0).SetName("a").Cast<A>();
Stream<B> b = graph.In(1).SetName("b").Cast<B>();
Stream<C> c = graph.In(2).SetName("c").Cast<C>();
// 10/100/N lines of code.
Stream<D> d = RunSomething(a, b, c, graph);
// ...
return graph.GetConfig();
}
Usa std::optional
si tienes una transmisión de entrada o un paquete lateral que no
siempre definido y ponerlo al principio:
SÍ: Ejemplo de buen código.
std::optional<Stream<A>> a;
if (needs_a) {
a = graph.In(0).SetName(a).Cast<A>();
}
Cómo definir los resultados del grafo al final
En el siguiente código:
- Puede ser difícil adivinar cuántos resultados tienes en el gráfico.
- Puede ser propenso a errores en general y difícil de mantener en el futuro (p.ej., ¿es una ¿El índice es correcto? nombre? ¿qué pasa si se quitan algunos desechos o se vuelven opcionales? etcétera).
- La reutilización de
RunSomething
está limitada, ya que otros gráficos pueden tener resultados diferentes.
NO DEBES: ejemplo de código incorrecto.
void RunSomething(Stream<Input> input, Graph& graph) {
// ...
node.Out("OUTPUT_F")
.SetName("output_f").ConnectTo(graph.Out(2)); // Bad.
}
CalculatorGraphConfig BuildGraph() {
Graph graph;
// 10/100/N lines of code.
node.Out("OUTPUT_D")
.SetName("output_d").ConnectTo(graph.Out(0)); // Bad.
// 10/100/N lines of code.
node.Out("OUTPUT_E")
.SetName("output_e").ConnectTo(graph.Out(1)); // Bad.
// 10/100/N lines of code.
RunSomething(input, graph);
// ...
return graph.GetConfig();
}
En su lugar, define los resultados del grafo al final de tu compilador de grafos:
SÍ: Ejemplo de buen código.
Stream<F> RunSomething(Stream<Input> input, Graph& graph) {
// ...
return node.Out("OUTPUT_F").Cast<F>();
}
CalculatorGraphConfig BuildGraph() {
Graph graph;
// 10/100/N lines of code.
Stream<D> d = node.Out("OUTPUT_D").Cast<D>();
// 10/100/N lines of code.
Stream<E> e = node.Out("OUTPUT_E").Cast<E>();
// 10/100/N lines of code.
Stream<F> f = RunSomething(input, graph);
// ...
// Outputs.
d.SetName("output_d").ConnectTo(graph.Out(0));
e.SetName("output_e").ConnectTo(graph.Out(1));
f.SetName("output_f").ConnectTo(graph.Out(2));
return graph.GetConfig();
}
Mantén los nodos separados entre sí
En MediaPipe, las transmisiones de paquetes y los paquetes laterales son tan significativos como el procesamiento nodos. Y cualquier requisito de entrada de nodo y producto de salida se expresa de manera clara e independiente en cuanto a las transmisiones y los paquetes laterales que consume y produce.
NO DEBES: ejemplo de código incorrecto.
CalculatorGraphConfig BuildGraph() {
Graph graph;
// Inputs.
Stream<A> a = graph.In(0).Cast<A>();
auto& node1 = graph.AddNode("Calculator1");
a.ConnectTo(node1.In("INPUT"));
auto& node2 = graph.AddNode("Calculator2");
node1.Out("OUTPUT").ConnectTo(node2.In("INPUT")); // Bad.
auto& node3 = graph.AddNode("Calculator3");
node1.Out("OUTPUT").ConnectTo(node3.In("INPUT_B")); // Bad.
node2.Out("OUTPUT").ConnectTo(node3.In("INPUT_C")); // Bad.
auto& node4 = graph.AddNode("Calculator4");
node1.Out("OUTPUT").ConnectTo(node4.In("INPUT_B")); // Bad.
node2.Out("OUTPUT").ConnectTo(node4.In("INPUT_C")); // Bad.
node3.Out("OUTPUT").ConnectTo(node4.In("INPUT_D")); // Bad.
// Outputs.
node1.Out("OUTPUT").SetName("b").ConnectTo(graph.Out(0)); // Bad.
node2.Out("OUTPUT").SetName("c").ConnectTo(graph.Out(1)); // Bad.
node3.Out("OUTPUT").SetName("d").ConnectTo(graph.Out(2)); // Bad.
node4.Out("OUTPUT").SetName("e").ConnectTo(graph.Out(3)); // Bad.
return graph.GetConfig();
}
En el código anterior:
- Los nodos están acoplados entre sí, p.ej.,
node4
sabe dónde están sus entradas que provienen de (node1
,node2
,node3
) y complica la refactorización. el mantenimiento y la reutilización del código- .
- Este patrón de uso es un cambio a una versión inferior de la representación proto, en la que los nodos están desacoplados de forma predeterminada.
node#.Out("OUTPUT")
llamadas están duplicadas y la legibilidad se ve afectada a medida que tú podría usar nombres más limpios y también proporcionar un tipo real.
Por lo tanto, para solucionar los problemas anteriores, puedes escribir el siguiente código de construcción del grafo:
SÍ: Ejemplo de buen código.
CalculatorGraphConfig BuildGraph() {
Graph graph;
// Inputs.
Stream<A> a = graph.In(0).Cast<A>();
// `node1` usage is limited to 3 lines below.
auto& node1 = graph.AddNode("Calculator1");
a.ConnectTo(node1.In("INPUT"));
Stream<B> b = node1.Out("OUTPUT").Cast<B>();
// `node2` usage is limited to 3 lines below.
auto& node2 = graph.AddNode("Calculator2");
b.ConnectTo(node2.In("INPUT"));
Stream<C> c = node2.Out("OUTPUT").Cast<C>();
// `node3` usage is limited to 4 lines below.
auto& node3 = graph.AddNode("Calculator3");
b.ConnectTo(node3.In("INPUT_B"));
c.ConnectTo(node3.In("INPUT_C"));
Stream<D> d = node3.Out("OUTPUT").Cast<D>();
// `node4` usage is limited to 5 lines below.
auto& node4 = graph.AddNode("Calculator4");
b.ConnectTo(node4.In("INPUT_B"));
c.ConnectTo(node4.In("INPUT_C"));
d.ConnectTo(node4.In("INPUT_D"));
Stream<E> e = node4.Out("OUTPUT").Cast<E>();
// Outputs.
b.SetName("b").ConnectTo(graph.Out(0));
c.SetName("c").ConnectTo(graph.Out(1));
d.SetName("d").ConnectTo(graph.Out(2));
e.SetName("e").ConnectTo(graph.Out(3));
return graph.GetConfig();
}
Ahora, si es necesario, puedes quitar fácilmente node1
y hacer que b
sea una entrada de gráfico y no
Se necesitan actualizaciones para node2
, node3
y node4
(igual que en la representación proto).
porque están desacoplados entre sí.
En general, el código anterior replica el grafo proto más estrechamente:
input_stream: "a"
node {
calculator: "Calculator1"
input_stream: "INPUT:a"
output_stream: "OUTPUT:b"
}
node {
calculator: "Calculator2"
input_stream: "INPUT:b"
output_stream: "OUTPUT:C"
}
node {
calculator: "Calculator3"
input_stream: "INPUT_B:b"
input_stream: "INPUT_C:c"
output_stream: "OUTPUT:d"
}
node {
calculator: "Calculator4"
input_stream: "INPUT_B:b"
input_stream: "INPUT_C:c"
input_stream: "INPUT_D:d"
output_stream: "OUTPUT:e"
}
output_stream: "b"
output_stream: "c"
output_stream: "d"
output_stream: "e"
Además de eso, ahora puedes extraer funciones de utilidad para volver a usarlas en otros gráficos:
SÍ: Ejemplo de buen código.
Stream<B> RunCalculator1(Stream<A> a, Graph& graph) {
auto& node = graph.AddNode("Calculator1");
a.ConnectTo(node.In("INPUT"));
return node.Out("OUTPUT").Cast<B>();
}
Stream<C> RunCalculator2(Stream<B> b, Graph& graph) {
auto& node = graph.AddNode("Calculator2");
b.ConnectTo(node.In("INPUT"));
return node.Out("OUTPUT").Cast<C>();
}
Stream<D> RunCalculator3(Stream<B> b, Stream<C> c, Graph& graph) {
auto& node = graph.AddNode("Calculator3");
b.ConnectTo(node.In("INPUT_B"));
c.ConnectTo(node.In("INPUT_C"));
return node.Out("OUTPUT").Cast<D>();
}
Stream<E> RunCalculator4(Stream<B> b, Stream<C> c, Stream<D> d, Graph& graph) {
auto& node = graph.AddNode("Calculator4");
b.ConnectTo(node.In("INPUT_B"));
c.ConnectTo(node.In("INPUT_C"));
d.ConnectTo(node.In("INPUT_D"));
return node.Out("OUTPUT").Cast<E>();
}
CalculatorGraphConfig BuildGraph() {
Graph graph;
// Inputs.
Stream<A> a = graph.In(0).Cast<A>();
Stream<B> b = RunCalculator1(a, graph);
Stream<C> c = RunCalculator2(b, graph);
Stream<D> d = RunCalculator3(b, c, graph);
Stream<E> e = RunCalculator4(b, c, d, graph);
// Outputs.
b.SetName("b").ConnectTo(graph.Out(0));
c.SetName("c").ConnectTo(graph.Out(1));
d.SetName("d").ConnectTo(graph.Out(2));
e.SetName("e").ConnectTo(graph.Out(3));
return graph.GetConfig();
}
Nodos separados para una mejor legibilidad
NO DEBES: ejemplo de código incorrecto.
CalculatorGraphConfig BuildGraph() {
Graph graph;
// Inputs.
Stream<A> a = graph.In(0).Cast<A>();
auto& node1 = graph.AddNode("Calculator1");
a.ConnectTo(node1.In("INPUT"));
Stream<B> b = node1.Out("OUTPUT").Cast<B>();
auto& node2 = graph.AddNode("Calculator2");
b.ConnectTo(node2.In("INPUT"));
Stream<C> c = node2.Out("OUTPUT").Cast<C>();
auto& node3 = graph.AddNode("Calculator3");
b.ConnectTo(node3.In("INPUT_B"));
c.ConnectTo(node3.In("INPUT_C"));
Stream<D> d = node3.Out("OUTPUT").Cast<D>();
auto& node4 = graph.AddNode("Calculator4");
b.ConnectTo(node4.In("INPUT_B"));
c.ConnectTo(node4.In("INPUT_C"));
d.ConnectTo(node4.In("INPUT_D"));
Stream<E> e = node4.Out("OUTPUT").Cast<E>();
// Outputs.
b.SetName("b").ConnectTo(graph.Out(0));
c.SetName("c").ConnectTo(graph.Out(1));
d.SetName("d").ConnectTo(graph.Out(2));
e.SetName("e").ConnectTo(graph.Out(3));
return graph.GetConfig();
}
En el código anterior, puede ser difícil comprender dónde comienza cada nodo y hasta el final. Para mejorar esto y ayudar a los lectores de código, solo tienes que dejar un espacio en blanco líneas antes y después de cada nodo:
SÍ: Ejemplo de buen código.
CalculatorGraphConfig BuildGraph() {
Graph graph;
// Inputs.
Stream<A> a = graph.In(0).Cast<A>();
auto& node1 = graph.AddNode("Calculator1");
a.ConnectTo(node1.In("INPUT"));
Stream<B> b = node1.Out("OUTPUT").Cast<B>();
auto& node2 = graph.AddNode("Calculator2");
b.ConnectTo(node2.In("INPUT"));
Stream<C> c = node2.Out("OUTPUT").Cast<C>();
auto& node3 = graph.AddNode("Calculator3");
b.ConnectTo(node3.In("INPUT_B"));
c.ConnectTo(node3.In("INPUT_C"));
Stream<D> d = node3.Out("OUTPUT").Cast<D>();
auto& node4 = graph.AddNode("Calculator4");
b.ConnectTo(node4.In("INPUT_B"));
c.ConnectTo(node4.In("INPUT_C"));
d.ConnectTo(node4.In("INPUT_D"));
Stream<E> e = node4.Out("OUTPUT").Cast<E>();
// Outputs.
b.SetName("b").ConnectTo(graph.Out(0));
c.SetName("c").ConnectTo(graph.Out(1));
d.SetName("d").ConnectTo(graph.Out(2));
e.SetName("e").ConnectTo(graph.Out(3));
return graph.GetConfig();
}
Además, la representación anterior coincide con el protocolo CalculatorGraphConfig
.
una mejor representación.
Si extraes nodos en funciones de utilidad, su alcance se define dentro de las funciones. y está claro dónde empiezan y terminan, así que está bien tienen:
SÍ: Ejemplo de buen código.
CalculatorGraphConfig BuildGraph() {
Graph graph;
// Inputs.
Stream<A> a = graph.In(0).Cast<A>();
Stream<B> b = RunCalculator1(a, graph);
Stream<C> c = RunCalculator2(b, graph);
Stream<D> d = RunCalculator3(b, c, graph);
Stream<E> e = RunCalculator4(b, c, d, graph);
// Outputs.
b.SetName("b").ConnectTo(graph.Out(0));
c.SetName("c").ConnectTo(graph.Out(1));
d.SetName("d").ConnectTo(graph.Out(2));
e.SetName("e").ConnectTo(graph.Out(3));
return graph.GetConfig();
}