Example: {page:int}
Defines a parameter 'page' as an integral number.
Parses e.g.: 5
Evaluates to: Java: java.lang.Integer
, Python: class 'int'
, JavaScript: number
Graphical user interfaces can easily become complex and confusing as the number of user input parameters increases. This is particularly true if a workflow needs to be configured, where (i) each step has its own set of parameters, (ii) steps can occur in any order and (iii) steps can be repeated arbitrarily. Consider the configuration of an image pre-processing workflow, which consists of the following algorithms, each having its own set of parameters:
A traditional graphical user interface (GUI) could e.g. look like this:
where the user can activate the various algorithms and specify their parameters as necessary. This user interface however does not take into account that different algorithms could occur repeatedly, and it does not allow to change the order.
Using Natural Language Scripting, we want to implement a text-based interface which reads and executes text like:
Apply Gaussian blurring with a standard deviation of 3 pixel(s).
Subtract the background with a window readius of 30 pixel(s).
Apply Median filtering with a window radius of 1 pixel(s).
Normalize intensities.
Apply Gaussian blurring with a standard deviation of 1 pixel(s).
First of all, we'll implement a backend which does the actual processing. We therefore create a class that implements the actual algorithms. It uses Fiji as an underlying image processing library:
Preprocessing.java
import ij.IJ;
import ij.ImagePlus;
import ij.process.ImageProcessor;
public class Preprocessing {
private ImagePlus image;
public Preprocessing(ImagePlus image) {
this.image = image;
}
public void gaussianBlur(float stdDev) {
IJ.run(image, "Gaussian Blur...", "sigma=" + stdDev);
}
public void medianFilter(int radius) {
IJ.run(image, "Median...", "radius=" + radius);
}
public void subtractBackground(float radius) {
IJ.run(image, "Subtract Background...", "rolling=50");
}
public void convertToGray() {
if(image.getType() == ImagePlus.COLOR_RGB)
IJ.run(image, "8-bit", "");
}
public void intensityNormalization() {
convertToGray();
ImageProcessor ip = image.getProcessor();
double min = ip.getMin();
double max = ip.getMax();
ip = ip.convertToFloat();
ip.subtract(min);
ip.multiply(1 / (max - min));
image.setProcessor(ip);
}
}
The Natural Language Scripting framework offers a convenient way to define the sentences your interface should understand, and provides an auto-completion enabled text editor for users to enter their instructions. The following code snippet shows how to create a parser, how to define a pattern for a sentence for it to parse, and how to display the editor:
Parser parser = new Parser();
parser.defineSentence(
"Apply Gaussian blurring with a standard deviation of {stddev:float} pixel(s).",
null);
new ACEditor(parser).setVisible(true);
parser = Parser();
parser.defineSentence(
"Apply Gaussian blurring with a standard deviation of {stddev:float} pixel(s).",
None);
ACEditor(parser).show();
parser = nlScript.Parser();
parser.defineSentence(
"Apply Gaussian blurring with a standard deviation of {stddev:float} pixel(s).",
undefined);
new nlScript.ACEditor(parser, document.getElementById("nls-container"));
Find the full code here: Java
Python
JavaScript
.
In this example we state that we expect a literal "Apply Gaussian blurring with a standard deviation of ", followed by a floating point number, which we name "stddev" for later reference, followed by the literal "pixel(s).". There is a second parameter to defineSentence()
, which we'll discover next.
The code snippet is sufficient to provide the means for user input, but nothing happens yet when the user clicks the Run
button.
Find more information about How to specify variables.
The second argument to parser.defineSentence()
, which was omitted above, is of type Evaluator
. Evaluator
is an interface with a single function
interface Evaluator {
Object evaluate(ParsedNode pn);
}
class IEvaluator(ABC):
@abstractmethod
def evaluate(self, pn: ParsedNode) -> object:
pass
class Evaluator(IEvaluator):
def __init__(self, evaluate: Callable[[ParsedNode], object]):
self._evaluate = evaluate
def evaluate(self, pn: ParsedNode) -> object:
return self._evaluate(pn)
export type Evaluator = (pn: ParsedNode) => any;
The task of the evaluator is to evaluate the expression (the sentence) we defined on the parser. In the example, it is responsible for the actual blurring. The argument to evaluate()
, pn
, is of type ParsedNode
, which can be used to retrieve the parsed value for the standard deviation.
Variables in nlScript are defined hierarchically (see the paragraph "Custom types and type hierarchy" below). The result of parsing is also a hierarchical tree-like structure consisting of nodes of type ParsedNode
. The ParsedNode
has child ParsedNode
s representing the variables the current type consists of. The sentence above consists of a string literal "Apply Gaussian blurring with a standard deviation of", a floating-point variable called "stddev", and a string literal "pixel(s).". Therefore, the ParsedNode
given to the sentence's Evaluator
has three child ParsedNode
s, which can be accessed, e.g. by name, in the Evaluator
and evaluated recursively.
ImagePlus image = IJ.openImage("http://imagej.net/images/clown.jpg");
Preprocessing preprocessing = new Preprocessing(image);
Parser parser = new Parser();
parser.defineSentence(
"Apply Gaussian blurring with a standard deviation of {stddev:float} pixel(s).",
pn -> {
double stdDev = (double)pn.evaluate("stddev");
preprocessing.gaussianBlur((float)stdDev);
return null;
});
new ACEditor(parser).setVisible(true);
preprocessing = Preprocessing(None)
preprocessing.open('http://imagej.net/images/clown.jpg')
preprocessing.show()
parser = Parser()
def evaluateSentence(pn):
stddev = pn.evaluate("stddev")
preprocessing.gaussianBlur(stddev)
parser.defineSentence(
"Apply Gaussian blurring with a standard deviation of {stddev:float} pixel(s).",
evaluateSentence)
editor = ACEditor(parser)
editor.show()
let preprocessing = new Preprocessing("output");
let parser = new nlScript.Parser();
parser.defineSentence(
"Apply Gaussian blurring with a standard deviation of {stddev:float} pixel(s).",
pn => {
let stdDev = pn.evaluate("stddev");
preprocessing.gaussianBlur(stdDev);
preprocessing.show("output");
return undefined;
});
new nlScript.ACEditor(parser, document.getElementById("nls-container"));
Find the full code here: Java
Python
JavaScript
.
This is the first fully working example:
Above, we specified the type of the standard deviation parameter stddev
to be a floating point number, using {stddev:float}
.
The following tables show other available built-in types:
int
: An integral numberExample: {page:int}
Defines a parameter 'page' as an integral number.
Parses e.g.: 5
Evaluates to: Java: java.lang.Integer
, Python: class 'int'
, JavaScript: number
float
: A floating-point numberExample: {sigma:float}
Defines a parameter 'sigma' as a floating-point number.
Parses e.g.: 5.3
Evaluates to: Java: java.lang.Double
, Python: class 'float'
, JavaScript: number
digit
: A character from '0' to '9'Example: {ch:digit}
Defines a parameter 'ch' as a digit.
Parses e.g.: 3
Evaluates to: Java: java.lang.Character
, Python: class 'str'
, JavaScript: string
letter
: A character A-Z or a-zExample: {ch:letter}
Defines a parameter 'ch' as a letter.
Parses e.g.: b
Evaluates to: Java: java.lang.Character
, Python: class 'str'
, JavaScript: string
path
: A path within the local file systemExample: {inputfile:path}
Defines a parameter 'inputfile' as a file system path.
Parses e.g.: 'C:\Program Files'
Evaluates to: Java: java.lang.String
, Python: class 'str'
, JavaScript: not implemented
color
: A colorExample: {text-color:color}
Defines a parameter 'text-color' as a color.
Parses e.g.: (25, 0, 233)
as an RGB value or one of the pre-defined colors below.
Evaluates to: Integral number representing the RGB value of the color. Java: int
(Conversion between int
and java.awt.Color
is possible using new Color(int)
and Color.toRGB()
) Python: class 'int'
JavaScript: number
Pre-defined colors:
weekday
: Day of the weekExample: {meeting-day:weekday}
Defines a parameter 'meeting-day' as a weekday.
Parses e.g.: Monday
one of the pre-defined weekdays below.
Evaluates to: Integral number, starting with 0
for Monday
. Java: int
Python: class 'int'
JavaScript: number
Pre-defined weekdays:
month
: A monthExample: {month-of-start:month}
Defines a parameter 'month-of-start' as a month.
Parses e.g.: January
one of the pre-defined months below.
Evaluates to: Integral number, starting with 0
for January
. Java: int
Python: class 'int'
JavaScript: number
Pre-defined months:
date
: A dateExample: {date-of-birth:date}
Defines a parameter 'date-of-birth' as a date.
Parses e.g.: 01 January 2020
Evaluates to: Java: java.time.LocalDate
Python: class 'datetime.date'
JavaScript: Date
time
: A timeExample: {alarm:time}
Defines a parameter 'alarm' as a time.
Parses e.g.: 6:30
Evaluates to: Java: java.time.LocalTime
Python: class 'datetime.time'
JavaScript: Date
datetime
: A date and timeExample: {meeting-start:datetime}
Defines a parameter 'meeting-start' as a date and time.
Parses e.g.: 01 January 2020 14:30
Evaluates to: Java: java.time.LocalDateTime
Python: class 'datetime.datetime'
JavaScript: Date
tuple<type,n1,n2,...>
: An n-dimensional tuple of comma-separated elements surrounded by (
, )
Example: {point:tuple<int,x,y>}
Defines a parameter 'point' as a 2-tuple with entries named 'x' and 'y', the type of which is int
Parses e.g.: (15, 30)
Evaluates to: Java: java.lang.Object[]
. The actual type of each entry depends on type
(In the example, the type is int
, so each entry in the array is a java.lang.Integer
).Python: class 'list'
JavaScript: Array
type
can also be a custom-defined type (see below)
list<type>
: An (unbound) comma-separated listExample: {colors:list<color>}
Defines a parameter 'colors' as a list with entries of type color
.
Parses e.g.: green, blue, yellow, (255, 30, 20)
Evaluates to: Java: java.lang.Object[]
. The actual type of each entry depends on type
(In the example, the type is color
, so each entry in the array is a java.lang.Integer
)Python: class 'list'
JavaScript: Array
type
can also be a custom-defined type (see below)
Furthermore, the cardinality of a list can be constrained:
{colors:list<color>:5}
accepts only lists with exactly 5 colors
{colors:list<color>:3-5}
accepts only lists with 3 to 5 colors
{colors:list<color>:\*}
accepts lists with 0 to infinity colors
{colors:list<color>:+}
accepts lists with 1 to infinity colors
{colors:list<color>:?}
accepts lists with 0 or 1 colors
[a-zA-Z]
: A definable characterExample: (1) {ch1:[a-z0-9]}
or (2) {ch2:[^0-9]}
Parses: (1) Any lower-case letter or digit and (2) any character that is not a digit
Evaluates to: Java: java.lang.Character
. Python: class 'str'
JavaScript: string
. If a quantifier is used, it evaluates to java.lang.Object[]
(Java), where each entry is of type java.lang.Character
, class 'list'
(Python) or Array
(JavaScript). If you want to get the parsed string instead, you can use ParsedNode.getParsedString()
instead of ParsedNode.evaluate()
.
Some more examples:
[a-zAB567]
matches a character 'a' - 'z', 'A', 'B', '5', '6' or '7'.
[^1-35]
matches any character which is not '1', '2', '3' or '5'.
Furthermore, character classes can be extended to specify strings, by specifying the number of characters to match. In this case, it evaluates to java.lang.String
.
{identifier:[a-z]:5}
accepts a string consisting of 5 lower-case letters.
{identifier:[a-z]:3-5}
accepts a string consisting of 3-5 lower-case letters.
{identifier:[a-z]:\*}
accepts a string consisting of 0 to infinity lower-case letters.
{identifier:[a-z]:+}
accepts a string consisting of 1 to infinity lower-case letters.
{identifier:[a-z]:?}
accepts a string consisting of 0 or 1 lower-case letters.
It is possible and also common to define custom types. We could e.g. define a type filter-size
which consists of a floating point number and a unit (e.g. pixel(s)
). In defineSentence
we could then use filter-size
as type for the standard deviation parameter:
parser.defineType(
"filter-size",
"{stddev:float} pixel(s)",
pn -> pn.evaluate("stddev"));
parser.defineSentence(
"Apply Gaussian blurring with a standard deviation of {stddev:filter-size}.",
pn -> {
double stdDev = (double)pn.evaluate("stddev");
preprocessing.gaussianBlur((float)stdDev);
return null;
});
def evaluateFilterSize(pn):
return pn.evaluate("stddev")
# Create a custom type 'filter-size'
parser.defineType(
"filter-size",
"{stddev:float} pixel(s)",
evaluator=evaluateFilterSize)
def evaluateSentence(pn):
stddev = pn.evaluate("stddev")
preprocessing.gaussianBlur(stddev)
parser.defineSentence(
"Apply Gaussian blurring with a standard deviation of {stddev:filter-size}.",
evaluator=evaluateSentence)
parser.defineType(
"filter-size",
"{stddev:float} pixel(s)",
pn => pn.evaluate("stddev"));
parser.defineSentence(
"Apply Gaussian blurring with a standard deviation of {stddev:filter-size}.",
pn => {
let stdDev = pn.evaluate("stddev");
preprocessing.gaussianBlur(stdDev);
preprocessing.show("output");
return undefined;
});
Find the full code here: Java
Python
JavaScript
.
Autocompletion and evaluation will work as before, but the type filter-size
can be re-used, e.g. for other filters like the median filter.
Custom types can be defined using built-in types and other custom types. In this way, an entire hierarchy of types can be built up. Here are more Details about type hierarchy.
Defining custom types has an additional advantage: It allows to customize autocompletion. In the example above, with default autocompletion in place, autocompletion looks like
The user sees that a value for the stddev
parameter is required, but doesn't know in which units the value needs to be entered. Usage is much clearer if we use inline, or parameterized autocompletion:
This is accomplished by specifying a 4th parameter to defineType
, a boolean that specifies whether to use parameterized autocompletion or not (false
is the default):
parser.defineType("filter-size",
"{stddev:float} pixel(s)",
pn -> pn.evaluate("stddev"),
true);
parser.defineType("filter-size",
"{stddev:float} pixel(s)",
lambda pn: pn.evaluate("stddev"),
True);
parser.defineType("filter-size",
"{stddev:float} pixel(s)",
pn => pn.evaluate("stddev"),
true);
Find the full code here: Java
Python
JavaScript
.
filter-size
Microscope images commonly have a calibrated pixel size, and it is beneficial to specify algorithm parameters in real-world units instead of pixels. This facilitates the re-use of processing workflows, even if images were acquired at different resolutions. To extend the script in this regard, we define another type units
. To take different units into account, we can re-define units
. Here we will define it as pixel(s)
and calibrated units
(i.e. the units in which the image is calibrated). If the user selects the first option, we let the units
type evaluate to false
, otherwise to true
.
parser.defineType("units", "pixel(s)", pn -> false);
parser.defineType("units", "calibrated units", pn -> true);
parser.defineType("units", "pixel(s)", lambda pn: false)
parser.defineType("units", "calibrated units", lambda pn: true)
parser.defineType("units", "pixel(s)", pn => false);
parser.defineType("units", "calibrated units", pn => true);
The definition of filter-size
now becomes
parser.defineType("filter-size", "{stddev:float} {units:units}", pn -> {
double stddev = (Double) pn.evaluate("stddev");
boolean units = (Boolean) pn.evaluate("units");
if(units)
stddev /= image.getCalibration().pixelWidth;
return stddev;
}, true);
def evaluateFilterSize(pn):
stddev = pn.evaluate("stddev")
units = pn.evaluate("units")
if units:
stddev /= preprocessing.getPixelWidth()
return stddev
parser.defineType("filter-size",
"{stddev:float} {units:units}",
evaluateFilterSize,
True);
parser.defineType("filter-size", "{stddev:float} {units:units}", pn -> {
let stddev = pn.evaluate("stddev");
let units = pn.evaluate("units");
if(units)
stddev /= preprocessing.getPixelWidth();
return stddev;
}, true);
Find the full code here: Java
Python
JavaScript
.
In evaluate()
we use pn
to retrieve the values for stddev
and units
. If units
evaluates to true
(indicating that the user chose calibrated units
), we calculate the filter-size in pixels, dividing by the pixel size.
Autocompletion will now look as follows:
Then, after typing 5
, followed by tab
:
Autocompleter
The completion option calibrated units
obviously does not read very nice. Preferably we would replace it with the actual units the image was calibrated with, e.g. microns. This is runtime dependent, i.e. it depends on the image on which the script is executed. In the code example above, the image is opened before creating the language, but in a more realistic setting, we would like to run our script on any user-selected image.
To replace calibrated units
with the real units, we use a custom Autocompleter
, which needs to implement the Autocompleter
interface:
public interface Autocompleter {
Autocompletion[] getAutocompletion(ParsedNode pn, boolean justCheck);
}
class IAutocompleter(ABC):
@abstractmethod
def getAutocompletion(self, pn: DefaultParsedNode, justCheck: bool) -> List[Autocompletion] or None:
pass
class Autocompleter(IAutocompleter):
def __init__(self, getAutocompletion: Callable[[ParsedNode, bool], List[Autocompletion]]):
self._getAutocompletion = getAutocompletion
def getAutocompletion(self, pn: ParsedNode, justCheck: bool) -> List[Autocompletion] or None:
return self._getAutocompletion(pn, justCheck)
interface Autocompleter {
getAutocompletion(n: DefaultParsedNode, justCheck: boolean): Autocompletion[] | undefined;
}
getAutocompletion()
returns an array of possible completions. As arguments, it takes a ParsedNode
, which can be queried for what's already entered (pn.getParsedString()
). A second parameter justCheck
indicates that the actual completions are not needed and the function should just return whether it does autocomplete itself (return Autocompletion.doesAutocomplete()
) or it leaves autocompletion to its children (return null
). This is useful if the calculation of possible completion options is computationally expensive. Normally, getAutocompletion()
returns an array of Autocompletion
s. There are different subclasses for Autocompletion
: Autocompletion.Literal
for string constants, Autocompletion.Parameterized
for a (single) parametric completion, Autocompletion.EntireSequence
for a sequence of literal and parameterized completions, Autocompletion.Veto
, which is a special completion which, if present in the list of options, prohibits autocompletion totally. There are several convenience functions to create the different kinds of autocompletions in the Autocompletion
class.
In the example at hand, instead of multiple definitions of the type units
, we define units
to be an arbitrary string that may contain characters a
-z
, A
-Z
, (
and )
. Then, we use a custom Autocompleter
as a 4th parameter to defineType
, which returns as possible autocompletions pixel(s)
and the actual string representing the units the open image is calibrated in (imageUnits
).
parser.defineType(
"units", // the type name
"{unitstring:[a-zA-Z()]:+}", // the pattern to parse
pn -> !pn.getParsedString().equals("pixel(s)"), // the evaluator
(pn, justCheck) -> Autocompletion.literal(pn, "pixel(s)", imageUnits)); // the autocompleter
def getAutocompletion(pn, justCheck):
return Autocompletion.literal(pn, "pixel(s)", imageUnits)
parser.defineType(
"units", // the type name
"{unitstring:[a-zA-Z()]:+}", // the pattern to parse
lambda pn: pn.getParsedString() != "pixel(s)", // the evaluator
getAutocompletion) // the autocompleter
parser.defineType(
"units", // the type name
"{unitstring:[a-zA-Z()]:+}", // the pattern to parse
pn => pn.getParsedString() !== "pixel(s)", // the evaluator
(pn, justCheck) => Autocompletion.literal(pn, "pixel(s)", imageUnits)); // the autocompleter
Autocompletion.literal
creates an array of literal completions from a ParsedNode
and a variable number of String
arguments.
So where does imageUnits
come from? We need to catch it at runtime, so we register a ParseStartListener
on the parser
, and read the image calibration units at the time parsing is started:
StringBuilder imageUnits = new StringBuilder();
parser.addParseStartListener(() -> {
imageUnits.setLength(0);
imageUnits.append(image.getCalibration().getUnits());
});
imageUnits = ""
def parsingStarted():
global imageUnits
imageUnits = preprocessing.getUnits()
parser.addParseStartListener(listener=ParseStartListener(parsingStarted))
let imageUnits = "";
parser.addParseStartListener(() => {
imageUnits = preprocessing.getUnits();
});
Find the full code here: Java
Python
JavaScript
.
Once autocompletion hits the units
phrase, it displays a dropdown menu with the 2 options pixel(s)
and mm
, as desired.
Autocompleter.VETO
However, when we start typing a value for units
, e.g. we manually enter cm
, it still shows pixel(s)
, mm
and additionally .
. That's because our Autocompleter
always returns the two options. We could decide to just skip autocompletion once the user starts to type. We can do this in the Autocompleter
, by checking if the user already entered something, and in case s/he did, we return Autocompletion.veto()
:
parser.defineType(
"units",
"{unitstring:[a-zA-Z()]:+}",
pn -> !pn.getParsedString().equals("pixel(s)"),
(pn, justCheck) -> pn.getParsedString().isEmpty()
? Autocompletion.literal(pn, "pixel(s)", imageUnits)
: Autocompletion.veto(pn));
def getAutocompletion(pn, justCheck):
if len(pn.getParsedString()) == 0:
return Autocompletion.literal(pn, ["pixel(s)", imageUnits])
return Autocompletion.veto(pn)
parser.defineType(
"units",
"{unistring:[a-zA-Z()]:+}",
lambda pn: pn.getParsedString() != "pixel(s)",
getAutocompletion)
parser.defineType(
"units",
"{unitstring:[a-zA-Z()]:+}",
pn => pn.getParsedString() !== "pixel(s)",
(pn, justCheck) => pn.getParsedString().length === 0
? nlScript.Autocompletion.literal(pn, ["pixel(s)", imageUnits])
: nlScript.Autocompletion.veto(pn));
Find the full code here: Java
Python
JavaScript
.
The above solution works, but the meaning is a little different from what we wanted to achieve originally. Although it shows pixel(s)
and mm
as completion options, it accepts any string (we defined the pattern to parse to be a-zA-Z()
). In fact, we'd ideally like to restrict possible types to pixel(s)
and mm
. And we can implement this by dynamically changing the parser, i.e. re-defining the type units
. We do this again with a ParseStartListener
:
parser.addParseStartListener(() -> {
String unitsString = image.getCalibration().getUnits();
parser.undefineType("units");
// Re-define the 'units' type
parser.defineType("units", "pixel(s)", pn -> false);
parser.defineType("units", unitsString, pn -> true);
});
def parsingStarted():
unitsString = preprocessing.getUnits()
parser.undefineType("units")
# Re-define the 'units' type
parser.defineType("units", "pixel(s)", lambda pn: False)
parser.defineType("units", unitsString, lambda pn: True)
parser.addParseStartListener(listener=ParseStartListener(parsingStarted))
parser.addParseStartListener(() => {
imageUnits = preprocessing.getUnits();
parser.undefineType("units");
// Re-define the 'units' type
parser.defineType("units", "pixel(s)", pn => false);
parser.defineType("units", imageUnits, pn => true);
});
Find the full code here: Java
Python
JavaScript
.
This does now exactly what we wanted, i.e. it acts like the units
type was defined from the beginning on with the calibration unit of the image.
This is indeed a very powerful feature, as it allows for dynamic re-definition of the parsed language, based on current runtime circumstances.