Extending motile

motile ships with some basic tracking variables, constraints, and costs (see the API Reference for a complete list). In some situations, you might want to extend motile to fit your specific application. This tutorial will show you how to implement new constraints, variables, and costs.

Consider the following example track graph, where each node is a potential object and each edge a potential link of objects between frames:

from motile_toolbox.visualization import draw_track_graph

draw_track_graph(graph, alpha_attribute="score", label_attribute="score")

We will start with a simple model: the costs for selecting nodes and edges should depend on their score attribute, and the start of a track should have a positive cost as well. Furthermore, we are only interested in single-object tracks, i.e., tracks don’t merge or split. The solver for this model is:

from motile.constraints import MaxParents, MaxChildren
from motile.costs import NodeSelection, EdgeSelection, Appear

solver = motile.Solver(graph)

solver.add_constraint(MaxParents(1))
solver.add_constraint(MaxChildren(1))

solver.add_cost(NodeSelection(weight=-1, attribute="score"))
solver.add_cost(EdgeSelection(weight=-1, attribute="score"))
solver.add_cost(Appear(constant=1))

solver.solve()

…and our initial solution looks like this:

from motile_toolbox.visualization import draw_solution

draw_solution(graph, solver, label_attribute="score")

Adding Constraints

New constraints are introduced by subclassing Constraint and implementing the instantiate method. This method should return a list of ilpy.Constraint.

Imagine we know precisely that we want to track at most \(k\) objects, but we don’t know beforehand which of the many objects in the track graph those are. We are thus interested in finding at most \(k\) tracks with minimal costs.

This can be done with a constraint as follows:

import ilpy
from motile.variables import NodeAppear


class LimitNumTracks(motile.constraints.Constraint):

  def __init__(self, num_tracks):
      self.num_tracks = num_tracks

  def instantiate(self, solver):

      appear_indicators = solver.get_variables(NodeAppear)

      constraint = ilpy.Constraint()
      for appear_indicator, index in appear_indicators.items():
        constraint.set_coefficient(index, 1.0)
      constraint.set_relation(ilpy.Relation.LessEqual)
      constraint.set_value(self.num_tracks)

      return [constraint]

The instantiate method gets access to the solver the constraint is added to. Through the solver, we can then access variables to formulate constraints on them. Here, we make use of the NodeAppear variables: Simply counting how many nodes are marked as “appear” gives us the number of selected tracks. We then require that this number be less than or equal to num_tracks.

In other words, we formulated a constraint by enforcing a certain relation to hold between a subset of variables. Note that we didn’t have to do anything special to create the node appear variables: we simply ask the solver for them, and if they haven’t been added to the solver already, they will be added automatically.

Once implemented, the constraint can simply be added to the solver by instantiating the new class:

solver.add_constraint(LimitNumTracks(1))
solver.solve()
draw_solution(graph, solver, label_attribute="score")

Indeed, we have limited the solution to contain at most one track.

Adding Costs

We might want to add custom costs to motile that are not already covered by the existing ones. For the sake of this tutorial, let’s say we want to make the selection of nodes cheaper the higher up the node is in space. For obvious reasons, let’s call this new cost SillyCost.

Costs in motile are added by subclassing Cost and implementing the apply method:

from motile.variables import NodeSelected


class SillyCost(motile.costs.Cost):

    def __init__(self, position_attribute, weight=1.0):
        self.position_attribute = position_attribute
        self.weight = motile.costs.Weight(weight)

    def apply(self, solver):
        node_indicators = solver.get_variables(NodeSelected)

        for node_indicator, index in node_indicators.items():

            # x position of the node
            x = solver.graph.nodes[node_indicator][self.position_attribute]

            # costs can be negative (i.e., a reward)
            costs = -x

            solver.add_variable_cost(index, costs, weight=self.weight)

Similar to adding constraints, we simply ask the solver for the variables we want to add costs for and add them to the inference problem via Solver.add_variable_cost.

We can now add those costs in the same way others are added, i.e.:

print("Before adding silly cost:")
print(solver.get_variables(NodeSelected))

solver.add_cost(SillyCost('x', weight=0.02))

print("After adding silly cost:")
print(solver.get_variables(NodeSelected))
Before adding silly cost:
NodeSelected(0): cost=-0.800000011920929 value=1.0
NodeSelected(1): cost=-0.10000000149011612 value=0.0
NodeSelected(2): cost=-0.30000001192092896 value=1.0
NodeSelected(3): cost=-0.4000000059604645 value=0.0
NodeSelected(4): cost=-0.6000000238418579 value=1.0
NodeSelected(5): cost=-0.6000000238418579 value=0.0
NodeSelected(6): cost=-0.699999988079071 value=0.0
After adding silly cost:
NodeSelected(0): cost=-0.8199999928474426 value=1.0
NodeSelected(1): cost=-0.6000000238418579 value=0.0
NodeSelected(2): cost=-0.30000001192092896 value=1.0
NodeSelected(3): cost=-0.9199999570846558 value=0.0
NodeSelected(4): cost=-0.64000004529953 value=1.0
NodeSelected(5): cost=-1.0800000429153442 value=0.0
NodeSelected(6): cost=-1.399999976158142 value=0.0

As we can see, our new cost have been applied to each NodeSelected variable and nodes with a larger x value are now cheaper than others.

Solving again will now select the upper one of the possible tracks in the track graph:

solver.solve()
draw_solution(graph, solver, label_attribute="score")

Adding Variables

Variables in motile are added by subclassing the Variable class. Subclasses need to implement at least a static method instantiate. This method should return keys for what kind of things we would like to create a variable for. This should be a list of anything that is hashable (the keys will be used in a dictionary).

If, for example, we would like to create a new variable for each edge in the track graph, we would simply return a list of tuples, each tuple representing an edge (graph.edges would fit that, in this case).

Variables can also be introduced for other things that are not nodes or edges. We could create variables only for a subset of nodes, for pairs of edges, or the number of division events.

Let’s say we want to add new variables for pairs of edges that leave and enter the same node. Having such a variable might be useful to, for example, measure the curvature of a track and put a cost on that.

To create our new variables, we simply return a list of all pairs of edges we wish to have a variable for in instantiate. However, declaring those variables alone is not sufficient. To give them semantic meaning, we also have to make sure that our edge-pair variables are set to 1 if the two edges they represent have been selected, and 0 otherwise. To that end, we also add constraints that are specific to our variables by overriding the instantiate_constraints method, such that our variables are linked to the already existing EdgeSelected variables.

The complete variable declaration looks like this:

import ilpy
from motile.variables import EdgeSelected


class EdgePairs(motile.variables.Variable):

  @staticmethod
  def instantiate(solver):

      edge_pairs = [
          (in_edge, out_edge)
          # for each node
          for node in solver.graph.nodes
          # for each pair of incoming and outgoing edge
          for in_edge in solver.graph.prev_edges[node]
          for out_edge in solver.graph.next_edges[node]
      ]

      return edge_pairs

  @staticmethod
  def instantiate_constraints(solver):

      edge_indicators = solver.get_variables(EdgeSelected)
      edge_pair_indicators = solver.get_variables(EdgePairs)

      constraints = []
      for (in_edge, out_edge), pair_index in edge_pair_indicators.items():

          in_edge_index = edge_indicators[in_edge]
          out_edge_index = edge_indicators[out_edge]

          # edge pair indicator = 1 <=> in edge = 1 and out edge = 1
          constraint = ilpy.Constraint()
          constraint.set_coefficient(pair_index, 2)
          constraint.set_coefficient(in_edge_index, -1)
          constraint.set_coefficient(out_edge_index, -1)
          constraint.set_relation(ilpy.Relation.LessEqual)
          constraint.set_value(0)
          constraints.append(constraint)

          constraint = ilpy.Constraint()
          constraint.set_coefficient(pair_index, -1)
          constraint.set_coefficient(in_edge_index, 1)
          constraint.set_coefficient(out_edge_index, 1)
          constraint.set_relation(ilpy.Relation.LessEqual)
          constraint.set_value(1)
          constraints.append(constraint)

      return constraints

Variables on their own, however, don’t do anything yet. They only start to affect the solution if they are involved in constraints or have a cost.

The following defines a cost on our new variables, which loosely approximate the local curvature of the track:

class CurvatureCost(motile.costs.Cost):

    def __init__(self, position_attribute, weight=1.0):
        self.position_attribute = position_attribute
        self.weight = motile.costs.Weight(weight)

    def apply(self, solver):

        # get edge pair variables
        edge_pair_indicators = solver.get_variables(EdgePairs)

        for (in_edge, out_edge), index in edge_pair_indicators.items():

            in_offset = self.get_edge_offset(solver.graph, in_edge)
            out_offset = self.get_edge_offset(solver.graph, out_edge)

            curvature_cost = abs(out_offset - in_offset)

            solver.add_variable_cost(index, curvature_cost, self.weight)

    def get_edge_offset(self, graph, edge):

        pos_v = graph.nodes[edge[1]][self.position_attribute]
        pos_u = graph.nodes[edge[0]][self.position_attribute]

        return pos_v - pos_u

As before, we don’t have to do anything special to add the variables: we simply ask the solver for them, and if they don’t exist yet, they will be instantiated.

We can now add those costs to our solvers, which in turn makes use of our newly added variables:

solver.add_cost(CurvatureCost('x', weight=0.1))
solver.solve()

Let’s inspect the solution!

print(solver.get_variables(EdgePairs))
draw_solution(graph, solver, label_attribute="score")
EdgePairs(((0, 2), (2, 4))): cost=0.30000001192092896 value=0.0
EdgePairs(((0, 2), (2, 5))): cost=2.5 value=0.0
EdgePairs(((1, 2), (2, 4))): cost=2.700000047683716 value=0.0
EdgePairs(((1, 2), (2, 5))): cost=4.900000095367432 value=0.0
EdgePairs(((0, 3), (3, 5))): cost=2.700000047683716 value=0.0
EdgePairs(((0, 3), (3, 4))): cost=4.900000095367432 value=0.0
EdgePairs(((0, 3), (3, 6))): cost=1.600000023841858 value=0.0
EdgePairs(((1, 3), (3, 5))): cost=0.30000001192092896 value=1.0
EdgePairs(((1, 3), (3, 4))): cost=2.5 value=0.0
EdgePairs(((1, 3), (3, 6))): cost=0.800000011920929 value=0.0