trackmate-sc / TrackMate

TrackMate is your buddy for your everyday tracking.
https://imagej.net/plugins/trackmate
GNU General Public License v3.0
169 stars 76 forks source link

Utilities to simplify the integration of command-line tools in TrackMate modules #299

Closed tinevez closed 5 months ago

tinevez commented 5 months ago

This PR ships a series of classes meant to simplify and accelerate the integration of command-lines in TrackMate modules. Typically such tools are Python tools that can be installed in a conda environment, can be called and configured from the command line, and yield results as files that can be imported in TrackMate. Good examples are Python scientific tools like cellpose or Trackastra.

These tools are in the fiji.plugin.trackmate.util.cli package.

An example implementation is in https://github.com/trackmate-sc/TrackMate-Trackastra/tree/main/src/main/java/fiji/plugin/trackmate/tracking/trackastra

A simple example is included in src/test/java/fiji/plugin/trackmate/util/cli/ExampleCommandCLI.java

Simple example.

Each tool is represented by one class, inheriting from CLIConfigurator. If the tool you want to run corresponds to an executable on disk, subclass CommandCLIConfigurator. If it based on a Python tool installed in a conda environment, subclass CondaCLIConfigurator.

Creating a CLI config.

For instance, a tool that depends on an executable with 3 arguments, 2 optional, 1 required, would look like this:

public class ExampleCommandCLI extends CommandCLIConfigurator
{
    ...

    public ExampleCommandCLI()
    {
        executable
                .name( "Path to the executable." )
                .help( "Browse to the executable location on your computer." )
                .key( "PATH_TO_EXECUTABLE" );

executable is the executable that will be called by the TrackMate module.

CLI configs that inherit from 'CommandCLIConfigurator' are all based on an actual executable, that is a file with exec rights somewhere on the user computer. They need to set it themselves, so the config part only specifies the name, the help and the key of this command. The help and name will be used in the UI.

In this example we will assume that the tool we want to run accepts a few arguments.

The first one is the --nThreads argument, that accept integer larger than 1 and smaller than 24.

        this.nThreads = addIntArgument()
                .argument( "--nThreads" ) // arg in the command line
                .name( "N threads" ) // convenient name
                .help( "Sets the number of threads to use for computation." ) // help
                .defaultValue( 1 )
                .min( 1 )
                .max( 24 ) // will be used to create an adequate UI widget
                .key( "N_THREADS" ) // use to serialize to Map<String, Object>
                .get();

The 'argument()' part must be something the tool can understand. This is what is passed to it before the value. This example argument is not required, but has a default value of 1. The default value is used only in the command line. If an argument is not required, is not set, but has a default value, then the argument will appear in the command line with this default value.

Adding arguments is done via 'adder' methods, that are only visible in inhering classes. The get() method of the adder returns the created argument. It also adds it to the inner parts of the mother class, so that it is handled automatically when creating a GUI or a command line. But it is a good idea to expose it in this concrete class so that you can expose it to the user and let them set it.

The second argument is a double. It is required, which means that an error will be thrown when making a command line from this config if the user forgot to set a value. It also has a unit, which is only used in the UI.

Because it does not specify a min or a max, any numerical value can be entered in the GUI. The implementation will have to add an extra check to verify consistency of values.

        this.diameter = addDoubleArgument()
                .argument( "--diameter" )
                .name( "Diameter" )
                .help( "The diameter of objects to process." )
                .key( "DIAMETER" )
                .defaultValue( 1.5 )
                .units( "microns" )
                .required( true ) // required flag
                .get();

The third argument is a a double argument that has a min and a max will generate another widget in the UI: a slider.

        this.time = addDoubleArgument()
                .argument( "--time" )
                .name( "Time" )
                .help( "Time to wait after processing." )
                .key( "TIME" )
                .min( 1. )
                .max( 100. )
                .units( "seconds" )
                .get();

Using a CLI config to yield a command line.

Here is how this CLI config could be used to generate a command line. The class CommandBuilder contains a static method that generates a command line as a list of tokens, so that they can be used directly by Java ProcessBuilder.

final ExampleCommandCLI cli = new ExampleCommandCLI();

//Configure the CLI.
cli.getCommandArg().set( "/path/to/my/executable" );

The following will generate an error because 'diameter' is required and not set.

The argument 'nThreads' is not set either, but it has a default value and is not required -> no error, the command line will use the default value.

The argument 'time' is not set either, does not have a default, but it is not required -> no error, the command line will just miss the 'time' argument.

// Play with the command line.
// This will generate an error because 'diameter' is not set and is required.
try
{
    final List< String > cmd = CommandBuilder.build( cli );
    System.out.println( "To run: " + cmd ); // error
}
catch ( final IllegalArgumentException e )
{
    System.err.println( e.getMessage() );
}

This prints:

Required argument 'Diameter' is not set.

We can fix this by actually setting the diameter:

// Set the diameter. Now it should be ok.
cli.diameter().set( 2.5 );
try
{
    final List< String > cmd = CommandBuilder.build( cli );
    System.out.println( "To run: " + cmd );
}
catch ( final IllegalArgumentException e )
{
    System.err.println( e.getMessage() );
}

This prints:

To run: [/path/to/my/executable, --nThreads, 1, --diameter, 2.5]

Generating a UI from a CLI config.

A CLI object can also be used to generate a UI that will configure it. This is done with the CliGuiBuilder class. There is one important point: the UI builder requires all arguments and commands to have a value set:

// The UI cannot be created wit arguments that do not have a value. This
// will generate an error:
try
{
    CliGuiBuilder.build( cli );
}
catch ( final IllegalArgumentException e )
{
    System.err.println( e.getMessage() );
}

This prints:

The GUI builder requires all arguments and commands to have a value set. The following miss one: [N threads, Time]

To fix this we can set a value for all arguments:

cli.time().set( 5. );
cli.nThreads().set( 2 );
// This should be ok now.
final CliConfigPanel panel = CliGuiBuilder.build( cli );
final JFrame frame = new JFrame( "Demo CLI tool" );
frame.getContentPane().add( panel, BorderLayout.CENTER );
final JButton btn = new JButton( "echo" );
btn.addActionListener( e -> System.out.println( CommandBuilder.build( cli ) ) );
frame.getContentPane().add( btn, BorderLayout.SOUTH );
frame.setLocationRelativeTo( null );
frame.pack();
frame.setVisible( true );
frame.setDefaultCloseOperation( JFrame.EXIT_ON_CLOSE );

And it yields this: Screenshot 2024-06-25 at 15 07 43

The UI directly modifies the values of the arguments. When pressing the echo button we get this:

[/path/to/my/executable-trololo, --nThreads, 19, --diameter, 55.0, --time, 88.0]