Extending ImageJ: Data I/O

This notebook demonstrates how to write an IOPlugin, which handles reading and/or writing of external data to and from Java data structures.

In [1]:
%classpath config resolver scijava.public https://maven.scijava.org/content/groups/public
%classpath add mvn net.imagej imagej 2.0.0-rc-71
ij = new net.imagej.ImageJ()
"ImageJ v${ij.getVersion()} is ready to go."
Added new repo: scijava.public
Out[1]:
ImageJ v2.0.0-rc-71 is ready to go.

Creating a data reader

Suppose we have data stored in a file format, .char-table, which represents a table as a sequence of characters, along with metadata defining the number of rows and columns. We would like to write a plugin so that ImageJ can import these files via its File ▶ Open command.

The format of a .char-table file is key-value pairs, one per line. Valid keys are:

  • cols to define the column count
  • rows to define the row count
  • data to specify the actual character data: a sequence of $cols \times rows$ characters enumerated in row-major order, and bracketed by < and >.

Let's start by writing out an example .char-table file, which we will use for testing:

In [2]:
import java.io.File
import org.scijava.util.DigestUtils
import org.scijava.util.FileUtils
data = """
cols = 7
rows = 7
data = <@@@@@@@@      @ @ @ @@ @ @ @@ @ @ @@   @  @ @@@  >
"""
desktop = System.getProperty("user.home") + "/Desktop/"
tablePath = desktop + "example.char-table"
outFile = new File(tablePath)
FileUtils.writeFile(outFile, DigestUtils.bytes(data))
"Wrote ${outFile.length()} bytes to '$outFile'"
Out[2]:
Wrote 78 bytes to '/Users/curtis/Desktop/example.char-table'

Without further ado, here is the IOPlugin implementation:

In [3]:
import java.io.File
import org.scijava.io.AbstractIOPlugin
import org.scijava.io.IOPlugin
import org.scijava.plugin.Plugin
import org.scijava.table.DefaultGenericTable
import org.scijava.table.Table
import org.scijava.util.DigestUtils
import org.scijava.util.FileUtils

@Plugin(type = IOPlugin.class)
public class CharTableReader extends AbstractIOPlugin<Table> {

    @Override
    public Class<Table> getDataType() {
        // This is the type of object produced by the reader.
        return Table.class
    }

    @Override
    public boolean supportsOpen(final String source) {
        // Check whether the source is indeed a .char-table.
        // This check can be as shallow or as deep as you want,
        // but it is advised to keep it as fast as possible.
        // As such, it is not recommended to actually open and
        // interrogate the source unless you have no choice.
        return source.toLowerCase().endsWith(".char-table")
    }

    @Override
    public Table open(final String source) throws IOException {
        // This is where we read the data from its source,
        // and convert it into the destination data type.

        // Read in the file.
        String contents = DigestUtils.string(FileUtils.readFile(new File(source)))

        // Parse the contents line by line.
        int rows = 0, cols = 0
        String data = null
        for (line in contents.split("\n")) {
            int equals = line.indexOf("=")
            if (equals < 0) continue
            String key = line.substring(0, equals).trim()
            String val = line.substring(equals + 1).trim()
            switch (key) {
                case "rows":
                    rows = Integer.parseInt(val)
                    break
                case "cols":
                    cols = Integer.parseInt(val)
                    break
                case "data":
                    data = val
                    break
            }            
        }

        // Do some error checking.
        if (rows <= 0) throw new IOException("Missing or invalid rows")
        if (cols <= 0) throw new IOException("Missing or invalid cols")
        if (data == null || !data.startsWith("<") || !data.endsWith(">")) {
            throw new IOException("Missing or invalid data")
        }
        if (cols * rows != data.length() - 2) {
            throw new IOException("Expected data length ${cols * rows} but was ${data.length() - 2}")
        }

        // Build the resultant table.
        Table table = new DefaultGenericTable(cols, rows)
        int index = 1
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                table.set(c, r, data.charAt(index++))
            }
        }

        // HACK: Work around an SJJK bug when column headers are unspecified.
        for (int c = 0; c < cols; c++) table.setColumnHeader(c, "")

        return table
    }

    // HACK: Work around weird bug in Groovy(?).
    // It is normally not needed to override this method here.
    @Override
    public Class<String> getType() { return String.class }
}

// Register the plugin with the existing SciJava context.
import org.scijava.plugin.PluginInfo
info = new PluginInfo(CharTableReader.class, IOPlugin.class)
ij.plugin().addPlugin(info)
info
Out[3]:
priority=0.0, enabled=true, pluginType=IOPlugin

Now that we have an IOPlugin registered to handle the reading of .char-table files, let's give it a spin on the example data we wrote earlier:

In [4]:
table = ij.io().open(tablePath)
ij.notebook().display((Object) table)
Out[4]:
@@@@@@@
@
@ @ @ @
@ @ @ @
@ @ @ @
@ @
@ @@@

Creating a data writer

Similarly, IOPlugins also extend ImageJ's capabilities when writing data to external sources. In the future, ImageJ will have a unified File ▶ Save As... command which offers all available export options in a unified UI. But for the moment, the routine must be called programmatically via the IOService.

Let's write an exporter for tables to another custom file format: .ascii-table. This format writes each table cell as a single readable ASCII character (32 - 126 inclusive); characters outside this range are written as - (45). All columns of a row are written on the same line, with a newline between each row. Of course, this can be a lossy export.

Here is the exporter implementation for .ascii-table:

In [5]:
import java.io.File
import org.scijava.Priority
import org.scijava.io.AbstractIOPlugin
import org.scijava.io.IOPlugin
import org.scijava.plugin.Plugin
import org.scijava.table.DefaultGenericTable
import org.scijava.table.Table
import org.scijava.util.DigestUtils
import org.scijava.util.FileUtils

@Plugin(type = IOPlugin.class)
public class AsciiTableWriter extends AbstractIOPlugin<Table> {

    @Override
    public Class<Table> getDataType() {
        // This is the type of object exported by the writer.
        return Table.class
    }

    @Override
    public boolean supportsSave(final String destination) {
        // Check whether the destination should be a .ascii-table.
        // This is typically a format extension check.
        return destination.toLowerCase().endsWith(".ascii-table")
    }

    @Override
    public void save(final Table table, final String destination) throws IOException {
        // This is where we write the data to its destination,
        // converting it from the source data type.

        // Define the default character.
        byte other = (byte) '-'

        // Build up the output bytes.
        int cols = table.getColumnCount()
        int rows = table.getRowCount()
        byte[] bytes = new byte[(cols + 1) * rows]
        int index = 0
        for (int r = 0; r < rows; r++) {
            for (int c = 0; c < cols; c++) {
                Object cell = table.get(c, r)
                String s = cell == null ? null : cell.toString()
                if (s == null || s.length() == 0) {
                    bytes[index++] = other
                    continue
                }
                int v = s.charAt(0)
                bytes[index++] = v >= 32 && v <= 126 ? (byte) v : other
            }
            bytes[index++] = '\n'
        }

        // Write out the file.
        FileUtils.writeFile(new File(destination), bytes)
    }

    // HACK: Work around weird bug in Groovy(?).
    // It is normally not needed to override this method here.
    @Override
    public Class<String> getType() { return String.class }
}

// Register the plugin with the existing SciJava context.
import org.scijava.plugin.PluginInfo
info = new PluginInfo(AsciiTableWriter.class, IOPlugin.class)
ij.plugin().addPlugin(info)

// HACK: Refresh the IOService. (This bug is fixed on scijava-common master.)
import org.scijava.util.ClassUtils
ClassUtils.setValue(ClassUtils.getField(org.scijava.plugin.AbstractSingletonService.class, "instances"), ij.io(), null)

info
Out[5]:
priority=0.0, enabled=true, pluginType=IOPlugin
In [6]:
outPath = desktop + "fiji.ascii-table"
ij.io().save(table, desktop + "fiji.ascii-table")
outFile = new File(outPath)
"Wrote ${outFile.length()} bytes to '$outFile'"
Out[6]:
Wrote 56 bytes to '/Users/curtis/Desktop/fiji.ascii-table'

Check that it did what we wanted:

In [7]:
import org.scijava.util.DigestUtils
import org.scijava.util.FileUtils
DigestUtils.string(FileUtils.readFile(outFile))
Out[7]:
@@@@@@@
@      
@ @ @ @
@ @ @ @
@ @ @ @
@   @  
@ @@@  

Supporting both reading and writing

If you wish to support both reading and writing to/from the same format, you can include both open and save implementations in the same IOPlugin.