Change directory to NIS-Elements.
CD \Program Files\NIS Elements
Install package using pip.bat.
pip.bat install <package>
or (alternatively pip can be called using python.bat)
python.bat -m pip install <package>
Run the interpreter and try to import the package.
python.bat >>> import <package>
Call the bootstrappip.bat from NIS-Elements\Python folder.
Check pip.bat from C:\Program Files\NIS Elements\ and if successful end here. Otherwise continue with the steps below.
Go to NIS Elements Python folder C:\Program Files\NIS Elements\Python
Delete pip*.exe (pip.exe, pip3.exe, pip3.12.exe) from Scripts folder if present.
Delete pip and pip-XX.Y.Z.dist-info (e.g. pip-23.2.1.dist-info) from Lib\site-packages if present.
From C:\Program Files\NIS Elements\Python call
.\python.exe -m ensurepip --default-pip
Check pip.bat from C:\Program Files\NIS Elements\
what is the node output output(...),
how each frame/volume is processed run(...) and
how the run is called build(...)
stack reduction, and
two-pass algorithms
Drag the Python node (in the ND Processing & Conversions section) into the graph.
Open the node settings dialog by clicking on the
and add one color inputand one binary output
.
Connect the input pin to the blue channel.
Connect the output pin to the SaveBinaries node.
Paste the code below inside the dialog.
Imports filters from the skimage package.
In the output() function defines the output to be a new binary named “otsu” and cyan color (0, 255, 255) or “#00FFFF”.
In the run() function:
takes the image from the inp array,
calculates the threshold calling threshold_otsu,
creates the binary by directly comparing the values from the image with the threshold value (in this example objects are dark), and
sets the binary data into the out array while converting to the proper format (uint8 or int32 for binary IDs).
Reduces N source frames into one resulting frame. Each pixel value in the resulting frame is an average of corresponding pixels in N source frames.
Based on the type:
All frames in the loop are reduced into one frame (all to 1).
For each frame in the loop reduces surrounding N frames into the current one (all to all).
Reduces every consecutive N frames into one frame (all to all/N).
Loop to be reduced.
Number of frames to reduce. Disabled for All Frames.
Finds the best focus plane in the whole loop and effectively reduces the loop into single frame.
Select the method matching the acquisition modality.
Loop to be reduced.
In case there are more channels (All) select the one to use for criterion calculation.
(requires: EDF Module)
Creates an Extended Depth of Focus (EDF) image from the selected Z-Stack Loop similarly to the Applications > EDF > Create Focused Image . For more information about EDF, please see Extended Depth of Focus.
Integration uses multiple source frames to create one resulting image by integrating their pixel intensities. This is useful especially for low-signal (dark) scenes.
All frames in the loop are reduced into one frame (all to 1).
For each frame in the loop reduces surrounding N frames into the current one (all to all).
Reduces every consecutive N frames into one frame (all to all/N).
Loop to be reduced.
Number of frames to reduce. Disabled for All Frames.
Displays ND document in the maximum intensity projection. See ND Views for more information.
Reduces the specified loop by taking a pixel from Channel stack A from the frame where the Reference channel stack Ref has maximum intensity.
Performs median on the connected result based on the specified parameters.
All frames in the loop are reduced into one frame (all to 1).
For each frame in the loop reduces surrounding N frames into the current one (all to all).
Reduces every consecutive N frames into one frame (all to all/N).
Loop to be reduced.
Number of frames to reduce. Disabled for All Frames.
Performs quantile on the connected result based on the specified parameters.
Displays ND document in the minimum intensity projection. See ND Views for more information.
This action is used for selecting a specific frame in the source image.
Loop where the frame will be selected.
Specifies the selection from the current loop.
Starting frame.
Number of frames.
Step size.
Selects one frame (Index) from the specified dimension (Loop).
Returns binary from a selected frame (Index) chosen from the specified dimension (Loop).
Captures a shading image of the selected Type on the selected Loop. See also Image > Background > Shading Correction.
(requires: Stage)
Stitches frames of the connected image into a large image. Select the stitching method from the Stitching via drop-down menu (see Methods Used for Stitching Large Images). Optionally the Precise stitching (Image Registration) can be checked but be aware that it is more computationally demanding.
Optionally use the Automatic Shading Correction and choose the type which best represents your sample - Brightfield, DIC like or Fluorescence with offset enabling to heighten brightness level of the corrected image to make objects in dark areas more visible.
Reduces N source frames into one resulting frame. Each binary pixel in the resulting frame means that every frame has this binary pixel. The function behaves like Min IP but on the binary.
All frames in the loop are reduced into one frame (all to 1).
For each frame in the loop reduces surrounding N frames into the current one (all to all).
Reduces every consecutive N frames into one frame (all to all/N).
Loop to be reduced.
Number of frames to reduce. Feature is disabled for All Frames.
Reduces N source frames into one resulting frame. Each binary pixel in the resulting frame means that at least one frame has this binary pixel. The function behaves like Min IP but on the binary.
All frames in the loop are reduced into one frame (all to 1).
For each frame in the loop reduces surrounding N frames into the current one (all to all).
Reduces every consecutive N frames into one frame (all to all/N).
Loop to be reduced.
Number of frames to reduce. Feature is disabled for All Frames.
Moves the frames with respect to each other in order to stabilize the motion of the image.
Previous / First frame.
Loop to stabilize.
In case there are more channels (All) select the one to use for calculation.
This command enhances the dynamic range of generally any current ND2 file which contains at least Z dimension. It analyses the image, calculates values similar to auto-contrast values common for the whole time sequence and then processes all frames of the ND2 file. This method is robust to noise and preserves original trends of histogram.
You can select a method used to equalize the intensity.
Select which method is used to equalize the document. The histogram is stretched using selected methods. The shape of the histogram is preserved.
This method transforms the shape of the histogram of the whole document according to shape of the histogram of the selected area.
After you press the OK button, you will be prompted to select according to which area the image will be equalized. Draw the rectangle and confirm with a right-mouse click. The document is processed afterwards.
Connected binary image is converted into a color image. Pixels of the binary objects are equal to one and background pixels become zeros.
Creates a new channel with a binary filled with the measured object feature (Object value ).
Example 9.
Detect objects on a channel (with threshold) and measure circularity (or any other object feature). Then use this action on the thresholded result and connect the tabular input with the circularity result. Set Object value to Circularity. Each binary object now gets a circularity value.
Value added to the background (everything except the binary).
Object feature which is written to the object linked through Entity and ObjectId .

Creates a new channel with a binary filled with the measured object feature (Object value).
Value added to the background (everything except the binary).
Object feature which is written to the object linked through Entity and Object3DId .

Changes the bit-depth of the connected result.
Sets a new color depth of your image. 8-bit/16-bit integer or a floating-point. Intensity values can be rescaled.
If checked the pixel values are mapped to the new range. E.g. when you convert an 8bit image to 16bit and you do not rescale the values, it results in a completely black image. If the check box was selected, the 8bit 255 value would become 65535 in 16bit etc.
Changes the bit-depth of the connected result to the bit-depth of the connected reference image. Intensity values can be rescaled.
Splits the connected results back to the state before the channel merge.
Creates an intensity channel from the connected color source.
Converts multiple binary inputs into a color class image where every pixel belongs to exactly one class defined by its value (zero being background). Binaries are taken one-by-one from A parameter each setting a value (A -> 1, B -> 2, ...) to pixels under the corresponding binary. If binaries overlap the later is preserved.
Converts class image into multiple binaries based on the pixel values.
Number of binaries created.
Connected color image is rendered into a single RGB image.
Connected binary image is rendered into a single RGB image.
Overlay of the connected color (bottom) and binary layer (top) is rendered into a single RGB image.
Renders Channel A and Binaries B1, ..., Bn and stores it in the table as a row. If Filter is connected to a table it renders only frames present in the table.
Defines the size (in microns if calibrated) of the center portion of the frame that is cropped and rendered.
Maximum Size (in pixels) is a limit to which the rendered image is stretched down if being bigger.
Size of the rendered image.
Specifies the Image file format (jpg or png) and image quality in [%].
Converts the connected RGB result into an intensity channel.
Converts the connected RGB result to its HSI representation.
Converts the connected HSI result to its standard RGB representation.
Creates a Volume Contrast image from the connected color image. Set the Wavelength of the connected sample image ( reset button returns the default value) and define the Well surface and Background level values. Recommended values can be added using the button on the right.
After connecting this node to the template image (Image) to be matched and the golden sample (Template), it can be used for adjusting the Template Matching parameters such as the minimal similarity, minimal/maximal angle, maximal number of matches, maximal overlap, or maximal downsampling.
Adjusts the parameters for the template matching binary layer such as the X and Y center position, width/height, angle, and units.

Python 3.12 is embedded inside NIS-Elements in such a way that NIS-Elements can call the python interpreter and pass python code to it.
The python program and all its modules live in NIS-Elements folder so that it does not contaminate host environment that may be using different version of Python:
C:\Program Files\NIS Elements\Python\
Furthermore, two bat files pip.bat and python.bat exist in NIS-Elements folder to enable interacting with the python.
Installing packages using pip
Python packages can be managed using pip from the NIS-Elements folder.
Upgrading pip
Be careful and call .\python.exe from the NIS-Elements Python folder to be sure to upgrade the local NIS-Elements pip not the system pip.
CD \Program Files\NIS Elements\Python .\python.exe -m pip install --upgrade pip
Troubleshooting pip
When pip.bat is not working:
Solution 1: instead use
python.bat -m pip install <package>
Solution 2: bootstrap pip
Python stdout - where print() text goes
NIS-Elements installs hook to both python stderr and stdout and routes it to the Log file. To see it go to Help > Open Log File).
There are two Python nodes. 2D node operates on frame data and a 3D node operates on volume data. They share the same look and behavior and appear empty when inserted into GA3.
The nodes allow for any number and type of inputs and outputs. Inputs and outputs can be added and removed by buttons on the toolbar. The tooltip on both inputs and outputs suggests how to access it in the code (e.g. inp[0], out[1]).
The default script looks like this:
# IMPORTANT: 'limnode' must be imported like this (not from nor as) import limnode # defines output parameter properties def output(inp: tuple[limnode.AnyInDef], out: tuple[limnode.AnyOutDef]) -> None: pass # return Program for dimension reduction or two-pass processing def build(loops: list[limnode.LoopDef]) -> limnode.Program|None: return None # called for each frame/volume def run(inp: tuple[limnode.AnyInData], out: tuple[limnode.AnyOutData], ctx: limnode.RunContext) -> None: pass # child process initialization (when outproc is set) if __name__ == '__main__': limnode.child_main(run, output, build)
There are three functions that define
All important details can be seen in the limnode.py inside C:\Program Files\NIS Elements\Python\Lib\site-packages.
The purpose of this function is to define all aspects of output parameters by modifying out tuple items.
Output() is typically called before the run of GA3 after some user change upstream the graph. The information provided is used by the downstream nodes in GUI and run (for example table columns defined here are listed dependent nodes).
In GA3 output parameters can have assigned input or can be create new output. When they have assigned input, they inherit name, color and the number of components from that input. The output changes as the input gets reconnected. New outputs however, must be fully defined.
For assigning all output items have assign(...) method which takes input of the same type. For creating new output there are dedicated makeNew*(...) methods. All these methods return self and can be safely concatenated.
By default the node tries to assign to every output the first input of the same type. It this is not possible a new output is made.
Here is a summary of the methods:
class OutputChannelDef: def assign(self, param: InputChannelDef) -> OutputChannelDef: ... def makeNewMono(self, name: str, color: AnyColorDef) -> OutputChannelDef: ... def makeNewRgb(self, name: str|None = None) -> OutputChannelDef: ... # change bitdepth def makeFloat(self) -> OutputChannelDef: ... def makeUInt16(self) -> OutputChannelDef: ... def makeUInt8(self) -> OutputChannelDef: ... class OutputBinaryDef: def assign(self, param: InputBinaryDef) -> OutputBinaryDef: ... def makeNew(self, name: str, color: AnyColorDef) -> OutputBinaryDef: ... # change bitdepth def makeInt32(self) -> OutputBinaryDef: ... def makeUInt8(self) -> OutputBinaryDef: ... class OutputTableDef: def assign(self, param: InputTableDef) -> OutputTableDef: ... # new empty table def makeEmpty(self, name: str) -> OutputTableDef: ... # optional InputTableDef to copy columns from or if None add just loopCols def makeNew(self, name: str, param: InputTableDef|None = None) -> OutputTableDef: ... # to add all necessary loop columns def withLoopCols(self) -> OutputTableDef: ... # to add Entity and ObjectID columns def withObjectCols(self) -> OutputTableDef: ... # and arbitrary data columns based on type def withIntCol(self, title: str, unit: str|None = None, feature:str|None = None, id: str|None = None) -> OutputTableDef: ... def withFloatCol(self, title: str, unit: str|None = None, feature:str|None = None, id: str|None = None) -> OutputTableDef: ... def withStringCol(self, title: str, unit: str|None = None, feature:str|None = None, id: str|None = None) -> OutputTableDef: ...
Example of assigning an input channel to output and changing it to float:
def output(inp: tuple[limnode.AnyInDef], out: tuple[limnode.AnyOutDef]) -> None: out[0].assign(inp[0]).makeFloat()
Example of creating a new YFP (yellow) 16bit channel:
def output(inp: tuple[limnode.AnyInDef], out: tuple[limnode.AnyOutDef]) -> None: out[0].makeNewMono('YFP', "#FFFF00").makeUInt16() # color can be a RGB tuple too: (255, 255, 0)
Example of adding two columns to an output table:
# by making new the table has only loop columns and the two columns added to it def output(inp: tuple[limnode.AnyInDef], out: tuple[limnode.AnyOutDef]) -> None: unit_x, unit_y, _ = inp[0].units # inp[0] is InputChannelDef or InputBinaryDef out[0].makeNew("Records").withFloatCol("X coord", unit_x).withFloatCol("Y coord", unit_y) # by assigning the table gets all columns from inp[0] and a new Ratio column of type float def output(inp: tuple[limnode.AnyInDef], out: tuple[limnode.AnyOutDef]) -> None: out[0].assign(inp[0]).withFloatCol("Ratio")
It is called before run to enquire hew to call the run method. By default it returns None to indicate that run(...) will be called for each frame/volume once.
Other two possibilities are for:
For stack reduction return ReduceProgram(...) with appropriate loop overZStack() or overTimeLapse() method.
def build(loops: list[limnode.LoopDef]) -> limnode.Program|None: return limnode.ReduceProgram(loops).overZStack()
For two-pass algorithms return TwoPassProgram(...) with appropriate loop overZStack() or overTimeLapse() method.
def build(loops: list[limnode.LoopDef]) -> limnode.Program|None: return limnode.TwoPassProgram(loops).overZStack()
It is called for every frame/volume (depending on build(...) return value) to fill the data of each out item.
For OutputChannelData and OutputBinaryData the data property is a numpy ndarry of shape (z, y, x, comps) and dtype defined by output() function. By default it is filled with zeros.
# to simply multiply the input by factor of two def run(inp: tuple[limnode.AnyInData], out: tuple[limnode.AnyOutData], ctx: limnode.RunContext) -> None: out[0].data[:] = inp[0].data[:] * 2
For OutputTableData the data property is a table (type LimTableDataBase defined in limtabledata.py). Table columns are defined by output function(). By default the table is empty (there is no data in any column).
# for more columns at once def run(inp: tuple[limnode.AnyInData], out: tuple[limnode.AnyOutData], ctx: limnode.RunContext) -> None: out[0].withColData(["X coord", "Y coord"], [ [0.0, 0.5], [0.0, 0.5] ]) # list, list[list] # for inp[0] being an assigned (has same columns) InputTableData def run(inp: tuple[limnode.AnyInData], out: tuple[limnode.AnyOutData], ctx: limnode.RunContext) -> None: a, b = inp[0].colArray(["MeanOfDiO", "MeanOfDiI"]) out[0].withColDataFrom(inp[0]).withColData("Ratio", a/b)
Each item has prefilled recdata (of type LimTableDataBase) with per-frame recorded data (e.g. AcqTime, X, Y, Z, ...) which can be altered.
Note
The columns in colArray() and withColData() are regular experssions (columns are looked up using re.search(colname)). Thus, for exact match use "^colname$" (see colIndexByPattern()in limtabledata.py).
The context ctx provides more information about the current run:
@dataclass(kw_only=True) class RunContext: inpFilename: str # full input filename outFilename: str # full output filename (typically same as inpFilename except in Cluster Computing) inpParameterCoordinates: tuple[tuple[int]] # loop coordinates for every input parameter outCoordinates: tuple[int] # output loop coordinates reducingCoordinateIndexes: tuple[int]|None # loop of loops being reduced or None when not reducing finalCall: bool # when out should be filled (when false out.data and recdata are set to None) @property def shouldAbort(self) -> bool: # abort is requested ...
ctx.shouldAbort should be checked whenever possible. However, it is not possible to abort calls into libraries. In such cases switch out of process execution ON.
When ReduceProgram was returned in build() the run() function must fill the out items only when ctx.lastCall == True.
Before lastCall it is expected to accumulate the result like in this maximum intensity example:
# IMPORTANT: 'limnode' must be imported like this (not from nor as) import limnode, numpy as np tmp = None # defines output parameter properties def output(inp: tuple[limnode.AnyInDef], out: tuple[limnode.AnyOutDef]) -> None: out[0].makeNewMono("DiY", (0, 0, 255)) # return Program for dimension reduction or two-pass processing def build(loops: list[limnode.LoopDef]) -> limnode.Program|None: return limnode.ReduceProgram(loops).overZStack() # called for each frame/volume def run(inp: tuple[limnode.AnyInData], out: tuple[limnode.AnyOutData], ctx: limnode.RunContext) -> None: global tmp if tmp is None: tmp = np.zeros(inp[0].data.shape, inp[0].data.dtype) tmp = np.maximum(tmp, inp[0].data) if ctx.finalCall: out[0].data[:] = tmp # child process initialization (when outproc is set) if __name__ == '__main__': limnode.child_main(run, output, build)
When TwoPassProgram was returned in build() the run() function must fill the out items only when ctx.lastCall == True. The run can monitor the frame/volume being processed in ctx.inpParameterCoordinates and ctx.reducingCoordinateIndexes. During first pass the node analyses data and then during second pass if fills the out items.
In the example below the node accumulates a maximum intensity projection in the first pass and during the second pass it subtracts current frame from it:
import limnode, numpy as np tmp = None # defines output parameter properties def output(inp: tuple[limnode.AnyInDef], out: tuple[limnode.AnyOutDef]) -> None: out[0].assign(inp[0]) # return Program for dimension reduction or two-pass processing def build(loops: list[limnode.LoopDef]) -> limnode.Program|None: return limnode.TwoPassProgram(loops).overZStack() # called for each frame/volume def run(inp: tuple[limnode.AnyInData], out: tuple[limnode.AnyOutData], ctx: limnode.RunContext) -> None: global tmp if tmp is None: tmp = np.zeros(inp[0].data.shape, inp[0].data.dtype) if ctx.finalCall: out[0].data[:] = tmp - inp[0].data[:] else: tmp = np.maximum(tmp, inp[0].data) # child process initialization (when outproc is set) if __name__ == '__main__': limnode.child_main(run, output, build)
Important
All numpy.ndarray data properties are valid only during the particular run. If the algorithm has to to store it in a variable it has to make a copy of it.
tmp = numpy.copy(unp[0].data[0, :, :, 0])
Many times the required python modules are incompatible with NIS-Elements or lengthy operation dictate the need for out of process execution. In such scenario all three functions output, build and run are called in a separate process. Specifically the C:\Program Files\NIS Elements\Python\pythonw.exe is and the script is passed to it. All calls are forwarded to that process.
For details see limnode.py.
This example will show how to install an additional package (skimage) and use it for basic segmentation.
Install the package using pip.bat and check that it is installed and can be imported using python.bat:
CD \Program Files\NIS Elements pip.bat install scikit-image python.bat >>> import skimage
Open the C:\Program Files\NIS-Elements\Images\agnor.tif.
In NIS-Elements GA3 Editor:
# IMPORTANT: 'limnode' must be imported like this (not from nor as) import limnode, numpy from skimage import filters # defines output parameter properties def output(inp: tuple[limnode.AnyInDef], out: tuple[limnode.AnyOutDef]) -> None: out[0].makeNew("otsu", (0, 255, 255)) # return Program for dimension reduction or two-pass processing def build(loops: list[limnode.LoopDef]) -> limnode.Program|None: return None # called for each frame/volume def run(inp: tuple[limnode.AnyInData], out: tuple[limnode.AnyOutData], ctx: limnode.RunContext) -> None: image = inp[0].data[0, :, :, 0] threshold = filters.threshold_otsu(image) binary = image < threshold out[0].data[0, :, :, 0] = binary.astype(numpy.uint8) # child process initialization (when outproc is set) if __name__ == '__main__': limnode.child_main(run, output, build)
The code:
The resulting thresholded binary image should look like this:
Cellpose is well known package for cellular segmentation (see cellpose.org).
First install the cellpose module:
C:\Program Files\NIS Elements>pip.bat install cellpose[gui]
In NIS-Elements open an image, insert python node and set it up for segmentation (one channel input and one binary output) like so:
Because the libraries used by cellpose collide with NIS-Elements, make sure to switch ON the Run out of process.
Then edit the python code as follow:
# IMPORTANT: 'limnode' must be imported like this (not from nor as) import limnode, numpy from cellpose import models model = models.Cellpose(model_type='cyto3') # defines output parameter properties def output(inp: tuple[limnode.AnyInDef], out: tuple[limnode.AnyOutDef]) -> None: out[0].makeNew("cell", (0, 255, 255)) # return Program for dimension reduction or two-pass processing def build(loops: list[limnode.LoopDef]) -> limnode.Program|None: return None # called for each frame/volume def run(inp: tuple[limnode.AnyInData], out: tuple[limnode.AnyOutData], ctx: limnode.RunContext) -> None: masks, flows, styles, diams = model.eval(inp[0].data[0, :, :, 0]) out[0].data[0, :, :, 0] = masks.astype(numpy.uint8) # child process initialization (when outproc is set) if __name__ == '__main__': limnode.child_main(run, output, build)
And see the results:
All imports and processing defined in global scope is often run by GA3 editor and can result in frequent freezing of GA3 editor. To make GA3 editor more responsible, try to hide these in functions.
Here is short example how to rewrite Cellpose segmentation to make editor more responsive.
# IMPORTANT: 'limnode' must be imported like this (not from nor as) import limnode model = None # defines output parameter properties def output(inp: tuple[limnode.AnyInDef], out: tuple[limnode.AnyOutDef]) -> None: out[0].makeNew("cell", (0, 255, 255)) # return Program for dimension reduction or two-pass processing def build(loops: list[limnode.LoopDef]) -> limnode.Program|None: return None # called for each frame/volume def run(inp: tuple[limnode.AnyInData], out: tuple[limnode.AnyOutData], ctx: limnode.RunContext) -> None: import numpy from cellpose import models global model if model is None: model = models.Cellpose(model_type='cyto3') masks, flows, styles, diams = model.eval(inp[0].data[0, :, :, 0]) out[0].data[0, :, :, 0] = masks.astype(numpy.uint8) # child process initialization (when outproc is set) if __name__ == '__main__': limnode.child_main(run, output, build)
All imports except limnode are moved to function 'run' as its only place where they are needed. Global variable “model” is initially assigned to None and is reassigned in “run”.
BaSiCPy is a library "for background and shading correction of optical microscopy images" (see BaSiCPy @ github.com).
First install the basicpy module:
C:\Program Files\NIS Elements>pip.bat install basicpy
Setup the node for processing by adding Channel input and output. It is not necessary to run BaSiC in different process.
The python con be written like so:
# IMPORTANT: 'limnode' must be imported like this (not from nor as) import limnode, numpy from basicpy import BaSiC basic = BaSiC(get_darkfield=True) fitted: bool = False imgcache: list[numpy.ndarray] = [] # defines output parameter properties def output(inp: tuple[limnode.AnyInDef], out: tuple[limnode.AnyOutDef]) -> None: pass # return Program for dimension reduction or two-pass processing def build(loops: list[limnode.LoopDef]) -> limnode.Program|None: return limnode.TwoPassProgram(loops).overMultiPoint() # called for each frame/volume def run(inp: tuple[limnode.AnyInData], out: tuple[limnode.AnyOutData], ctx: limnode.RunContext) -> None: global imgcache, fitted, basic if ctx.finalCall: if not fitted: stk = numpy.stack(imgcache, axis=0) basic.fit(stk) imgcache = [] fitted = True out[0].data[0, :, :, 0] = basic.transform(inp[0].data[0, :, :, 0])[0] else: imgcache.append(numpy.copy(inp[0].data[0, :, :, 0])) fitted = False # child process initialization (when outproc is set) if __name__ == '__main__': limnode.child_main(run, output, build)
The result looks like this: