Python integration

Python integration

Advanced

GA3 Python nodes support a wide range of Python packages, enabling advanced data analysis, visualization, and computational capabilities. By integrating scientific libraries directly into GA3 recipes, the platform’s functionality can be extended to meet specific research needs. Whether performing statistical analysis, processing images, analyzing large datasets, or applying machine learning techniques, Python nodes offer the flexibility to enhance workflows with customized scripts.

This section provides examples of how various scientific Python packages can be used within GA3, helping to streamline research and maximize the available tools.

For more GA3 examples, check the Laboratory Imaging GitHub repository GA3-examples.

NIS-Express comes with many scientific packages preinstalled. In order to install additional python modules use separate environments or managed environment in the Python node (see below).

NumPy for simple threshold

This workflows showcases a simple working example of using NumPy library for segmentation (see numpy.org).

Setup

Open the file that needs to be segmented.

In NIS Express GA3 Editor:

  • Add Python node and open its settings
    • Add color input
    • Add binary output
    • Insert code from Code section.
  • Connect the input pin to the Channels output to be segmented.
  • Connect the output pin to the SaveBinariesHDF5 node.

Code

GA3 Python editor
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# IMPORTANT: 'limnode' must be imported like this (not from nor as)
import limnode
import numpy as np

# defines output parameter properties
def output(inp: limnode.InDefTuple, out: limnode.OutDefTuple, par: limnode.UserParTuple) -> None:
    out[0].makeNew("Binary", "#00ff00")

# return Program for dimension reduction or two-pass processing
def build(par: limnode.UserParTuple, loops: limnode.LoopDefs) -> limnode.Program|None:
    return None

# called for each frame/volume
def run(inp: limnode.InDataTuple, out: limnode.OutDataTuple, par: limnode.UserParTuple, ctx: limnode.RunContext) -> None:
    img = inp[0].data
    bin = np.where((img >= 300) & (img <= 400), 1, 0)
    out[0].data[:] = bin.astype(np.uint8)

# child process initialization (when outproc is set)
if __name__ == '__main__':
    from limnode import print
    limnode.child_main(run, output, build)

The code:

  1. Imports NumPy. [line 3]
  2. Names binary output as “Binary” and sets its color green. [line 7]
  3. Get color data. [line 15]
  4. Segments values in range from 300 to 400 (edit as needed) [line 16]
  5. Stores data to output as unsigned 8bit integer. [line 17]

Results

scikit-image for segmentation

Scikit-image is “a collection of algorithms for image processing” (see scikit-image.org).

Setup

Open the file that needs to be segmented.

In NIS Express GA3 Editor:

  • Add Python node and open its settings
    • Add color input
    • Add binary output
    • Insert code from Code section.
  • Connect the input pin to the Channels output to be segmented.
  • Connect the output pin to the SaveBinariesHDF5 node.

Code

GA3 Python editor
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 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: limnode.InDefTuple, out: limnode.OutDefTuple, par: limnode.UserParTuple) -> None:
    out[0].makeNew("otsu", (0, 255, 255))

# return Program for dimension reduction or two-pass processing
def build(par: limnode.UserParTuple, loops: limnode.LoopDefs) -> limnode.Program|None:
    return None

# called for each frame/volume
def run(inp: limnode.InDataTuple, out: limnode.OutDataTuple, par: limnode.UserParTuple, 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__':
    from limnode import print
    limnode.child_main(run, output, build)
  1. Imports NumPy and filters from the scikit-image package [lines 2, 3].
  2. Defines the output to be a new binary named “otsu” and sets cyan color. [line 7].
  3. Takes the image from the inp array [line 15].
  4. Calculates the threshold calling threshold_otsu [line 16].
  5. Creates the binary by directly comparing the values from the image with the threshold value [line 17].
  6. Sets the binary data into the out array while converting to the proper format (uint8 or int32 for binary IDs) [line 18].

Results

BaSiCPy for shading removal

BaSiCPy is a library “for background and shading correction of optical microscopy images” (see BaSiCPy).

Setup

Open the file that needs to be processed.

In NIS Express GA3 Editor:

  • Add Channel input.
  • Add Channel output.
  • Set the python mode to “Managed environment”
  • Insert the environment from the Environment section.
  • Insert the code from Code section.
  • Connect the input pin to the Channels output to be processed.
  • Connect the output pin to the SavePictures node.

GA3 nodes

Environment

channels:
    - conda-forge
dependencies:
    - python=3.10
    - pip
    - pip:
        - "scipy<1.13"
        - "jax[cpu]==0.4.13"
        - "hyperactive<5"
        - "ml-dtypes==0.2.0"
        - basicpy
variables:
  PIP_FIND_LINKS: "https://whls.blob.core.windows.net/unstable/index.html"
  PIP_USE_DEPRECATED: "legacy-resolver"

The environment was created and tested in the November, 2025.

Consult with LLMs (e.g. ChatGPT) in case the versions do not work anymore.

GA3 nodes

Code

GA3 Python editor
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 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: limnode.InDefTuple, out: limnode.OutDefTuple, par: limnode.UserParTuple) -> None:
   pass

# return Program for dimension reduction or two-pass processing
def build(par: limnode.UserParTuple, loops: limnode.LoopDefs) -> limnode.Program|None:
   return limnode.TwoPassProgram(loops).overMultiPoint()

# called for each frame/volume
def run(inp: limnode.InDataTuple, out: limnode.OutDataTuple, par: limnode.UserParTuple, 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__':
    from limnode import print
    limnode.child_main(run, output, build)
  1. Imports NumPy and basicpy from the BaSiC package [lines 2, 3].
  2. Initializes global variables for two pass processing [lines 5-7].
  3. Sets up two pass progam [line 15].
  4. Enables using global variables in run [line 19].
  5. Calculates shading correction (only once) and applies to every frame in second pass[lines 21-26].
  6. Collects data in first pass [lines 27-29].

Results

Matplotlib for data visualization

Matplotlib comes with NIS-Express. For node-level reference, see Matplotlib node.

This example will built on and modify the Object counting example.

Open the

  1. 02_count_in_t.nd2 time-lase nd2 image and
  2. 02_object_count_fluo.ga3 recipe.

Replace both graph nodes with a Python node. Both having Table as single input and single output.

Paste the code before connecting the output.

the recipe
Python nodes replacing standard GA3 graphs

Object count vs. time

  1. In the output() function [line 37] make the table result and provide it with input parameter (for taking the accumulation from the inp[0]),
  2. define the draw_graph() function [line 19] that actually draws the graph giving it the x, y data and background and foreground colors,
  3. call the draw_graph function twice for generating light and dark graphs [lines 48, 49] and finally
  4. call the withMplImage() method [line 52] to render the matplotlib figure into an image.

The withMplImage() takes following arguments:

  1. a tuple with ND loop indexes of the current frame or accumulated loop (ctx.inpParameterCoordinates[i] where i is the index of the input parameter is a good starting point) and
  2. a single figure or pyplot object or tuple of two such objects for light and dark color scheme.
Use the figure object as in the example below instead of matplotlib.pyplot global instance which may be in unexpected state when the node run function is called.
GA3 Python editor
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# IMPORTANT: 'limnode' must be imported like this (not from nor as)
import limnode

import numpy as np
from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
from matplotlib.figure import Figure
from matplotlib.ticker import FuncFormatter
import datetime, io, json

savefig_kwargs: dict = {
    'format': 'png',
    'bbox_inches': 'tight',
    'pad_inches': 0.25
}

def seconds_to_hms(x, _):
    return str(datetime.timedelta(seconds=int(x)))

def draw_graph(x, y, bgcolor, fgcolor):
    fig = Figure(figsize=(12, 4), facecolor=bgcolor)
    ax = fig.add_subplot(1, 1, 1, facecolor=bgcolor)
    ax.plot(x, y, marker='o', color='#77aadd', linewidth=2)
    ax.set_title("Object Count vs Time", fontsize=14, color=fgcolor)
    ax.set_xlabel("Time (hh:mm:ss)", fontsize=12, color=fgcolor)
    ax.set_ylabel("Count", fontsize=12, color=fgcolor)
    ax.grid(True, alpha=0.6, color='gray')
    ax.tick_params(axis='both', colors=fgcolor)
    for label in ax.get_xticklabels() + ax.get_yticklabels():
        label.set_color(fgcolor)
    for spine in ax.spines.values():
        spine.set_color(fgcolor)
    ax.xaxis.set_major_formatter(FuncFormatter(seconds_to_hms))
    return fig

# defines output parameter properties
def output(inp: limnode.InDefTuple, out: limnode.OutDefTuple, par: limnode.UserParTuple) -> None:
    out[0].makeResult("LineChart", inp[0])

# return Program for dimension reduction or two-pass processing
def build(par: limnode.UserParTuple, loops: limnode.LoopDefs) -> limnode.Program|None:
    return None

# called for each frame/volume
def run(inp: limnode.InDataTuple, out: limnode.OutDataTuple, par: limnode.UserParTuple, ctx: limnode.RunContext) -> None:
    Time = inp[0].colArray("Time")
    ObjectCount = inp[0].colArray("ObjectCount")

    canvasLight = draw_graph(Time, ObjectCount, '#fcfcfc', '#000000')
    canvasDark = draw_graph(Time, ObjectCount, '#444444', '#f0f0f0')


    out[0].withMplImage(ctx.inpParameterCoordinates[0], (canvasLight, canvasDark), savefig_kwargs=savefig_kwargs, iconres="line_common")

# child process initialization (when outproc is set)
if __name__ == '__main__':
    from limnode import print
    limnode.child_main(run, output, build)

The above python code produces following line chart.

Linechart light
Line chart by matplotlib in the light scheme
Linechart dark
Line chart by matplotlib in the dark scheme

Histogram of object sizes per frame

Similarly with histogram:

GA3 Python editor
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# IMPORTANT: 'limnode' must be imported like this (not from nor as)
import limnode

import numpy as np
from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
from matplotlib.figure import Figure
from matplotlib.ticker import FuncFormatter
import io, json

savefig_kwargs: dict = {
    'format': 'png',
    'bbox_inches': 'tight',
    'pad_inches': 0.25
}

def draw_graph(data, bgcolor, fgcolor):
    fig = Figure(figsize=(12, 4), facecolor=bgcolor)
    ax = fig.add_subplot(1, 1, 1, facecolor=bgcolor)
    ax.hist(data, bins=30, color='#77aadd', edgecolor='#77aadd22')
    ax.set_title("Histogram of Object Sizes", fontsize=14, color=fgcolor)
    ax.set_xlabel("Size (equivalent diameter)", fontsize=12, color=fgcolor)
    ax.set_ylabel("Frequency", fontsize=12, color=fgcolor)
    #ax.grid(True, alpha=0.6, color='gray')
    ax.tick_params(axis='y', colors=fgcolor)
    for label in ax.get_xticklabels() + ax.get_yticklabels():
        label.set_color(fgcolor)
    for spine in ax.spines.values():
        spine.set_color(fgcolor)
    return fig

# defines output parameter properties
def output(inp: limnode.InDefTuple, out: limnode.OutDefTuple, par: limnode.UserParTuple) -> None:
    out[0].makeResult("Histogram", inp[0])

# return Program for dimension reduction or two-pass processing
def build(par: limnode.UserParTuple, loops: limnode.LoopDefs) -> limnode.Program|None:
    return None

# called for each frame/volume
def run(inp: limnode.InDataTuple, out: limnode.OutDataTuple, par: limnode.UserParTuple, ctx: limnode.RunContext) -> None:
    ObjectSizes = inp[0].colArray("EqDiameter")

    canvasLight = draw_graph(ObjectSizes, '#fcfcfc', '#000000')
    canvasDark = draw_graph(ObjectSizes, '#444444', '#f0f0f0')

    out[0].withMplImage(ctx.inpParameterCoordinates[0], (canvasLight, canvasDark), savefig_kwargs=savefig_kwargs, iconres="histo_common")

# child process initialization (when outproc is set)
if __name__ == '__main__':
    from limnode import print
    limnode.child_main(run, output, build)

The above python code produces following histogram.

Histogram
Histogram by matplotlib

Conclusion

These two examples are not the fanciest plots. They lack interactivity compared to native GA3 plots because they are rendered into a static image (or two: light and dark). However, based on aggregation of inputs and loop coordinates, behavior still changes with context. For example, the histogram changes with frame, while the counts-vs-time chart stays fixed when built from one accumulated table.

Matplotlib: Per-well subplot

This section focuses on the dedicated Matplotlib node, separate from the generic Python-node examples above.

You can also use matplotlib to generate per-well graphs.

When the input table contains a Well column (for example A1, B3), data can be rendered in a plate-style layout where each well is drawn in its own square cell. This is useful when spatial arrangement across the plate matters.

Download recipe file: 08_live_dead_wellplate_matplotlib.ga3

The example uses the image from Wellplate assays: Live/dead discrimination block. Use the downloaded .ga3 recipe from this page (not the .ga3 recipe from the example).

Scatterplot

Each well contains a small scatter cloud. Points are scaled with one global X/Y range (shared across all wells) and drawn inside each plate cell.

Code
GA3 Matplotlib node editor
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.figure import Figure

well_col = ct("A:Well")
x_col = ct("A:LiveFillArea")
y_col = ct("A:LiveMeanOfBF")

required = [well_col, x_col, y_col]
missing = [c for c in required if c not in df.columns]
if missing:
    raise ValueError(f"Missing required columns: {missing}")

data = df[required].copy()
data = data.replace([np.inf, -np.inf], np.nan).dropna()

parsed = data[well_col].astype(str).str.strip().str.upper().str.extract(r"^([A-D])([1-6])$")
ok = parsed.notna().all(axis=1)
data = data.loc[ok].copy()
parsed = parsed.loc[ok]

if data.empty:
    raise ValueError("No valid rows to plot.")

row_map = {"A": 0, "B": 1, "C": 2, "D": 3}
data["_row"] = parsed[0].map(row_map).astype(int)
data["_col"] = parsed[1].astype(int) - 1

x_all = data[x_col].to_numpy(dtype=float)
y_all = data[y_col].to_numpy(dtype=float)

x_min = np.min(x_all)
x_max = np.max(x_all)
y_min = np.min(y_all)
y_max = np.max(y_all)

fig = Figure((10, 5))
fig.set_size_inches(15, 8, forward=True)
fig.set_dpi(400)
ax = fig.add_subplot(111)

pad = 0.12
cell_w = 1.0 - 2 * pad
cell_h = 1.0 - 2 * pad

for (row_idx, col_idx), g in data.groupby(["_row", "_col"], sort=True):
    x = g[x_col].to_numpy(dtype=float)
    y = g[y_col].to_numpy(dtype=float)

    if np.isclose(x_min, x_max):
        x_norm = np.full_like(x, 0.5)
    else:
        x_norm = (x - x_min) / (x_max - x_min)

    if np.isclose(y_min, y_max):
        y_norm = np.full_like(y, 0.5)
    else:
        y_norm = (y - y_min) / (y_max - y_min)

    x_plot = col_idx + 0.5 + pad + x_norm * cell_w
    y_plot = row_idx + 0.5 + pad + (1.0 - y_norm) * cell_h

    ax.scatter(x_plot, y_plot, s=10)

ax.set_xlim(0.5, 6.5)
ax.set_ylim(4.5, 0.5)
ax.set_aspect("equal")

ax.set_xticks(np.arange(1, 7))
ax.set_xticklabels([str(i) for i in range(1, 7)])
ax.set_yticks(np.arange(1, 5))
ax.set_yticklabels(list("ABCD"))

ax.set_xticks(np.arange(0.5, 7.0, 1.0), minor=True)
ax.set_yticks(np.arange(0.5, 5.0, 1.0), minor=True)
ax.grid(False)
ax.grid(which="minor")

ax.tick_params(axis="both", which="major", length=0)
ax.tick_params(axis="both", which="minor", length=0)

ax.set_title("Per-well scatter: LiveFillArea vs LiveMeanOfBF")

style_figure(fig, icon="scatter")
Per-well scatter light
Per-well scatter example (light)
Per-well scatter dark
Per-well scatter example (dark)

Boxplot

Each well shows a compact boxplot inside the well cell.

Code
GA3 Matplotlib node editor
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
    import numpy as np
    import pandas as pd
    import matplotlib.pyplot as plt
    from matplotlib.figure import Figure
    from matplotlib.patches import Rectangle

    well_col = ct("A:Well")
    value_col = ct("A:LiveCircularity")

    required = [well_col, value_col]
    missing = [c for c in required if c not in df.columns]
    if missing:
        raise ValueError(f"Missing required columns: {missing}")

    data = df[required].copy()
    data = data.replace([np.inf, -np.inf], np.nan).dropna()

    parsed = data[well_col].astype(str).str.strip().str.upper().str.extract(r"^([A-D])([1-6])$")
    ok = parsed.notna().all(axis=1)
    data = data.loc[ok].copy()
    parsed = parsed.loc[ok]

    if data.empty:
        raise ValueError("No valid rows to plot.")

    row_map = {"A": 0, "B": 1, "C": 2, "D": 3}
    data["_row"] = parsed[0].map(row_map).astype(int)
    data["_col"] = parsed[1].astype(int) - 1

    v_all = data[value_col].to_numpy(dtype=float)
    global_min = np.min(v_all)
    global_max = np.max(v_all)

    fig = Figure((10, 5))
    fig.set_size_inches(15, 8, forward=True)
    fig.set_dpi(400)
    ax = fig.add_subplot(111)

    pad_y = 0.12
    inner_h = 1.0 - 2 * pad_y
    box_w = 0.28

    def map_y(row_idx, value):
        if np.isclose(global_min, global_max):
            norm_value = 0.5
        else:
            norm_value = (value - global_min) / (global_max - global_min)
        return row_idx + 0.5 + pad_y + (1.0 - norm_value) * inner_h

    for (row_idx, col_idx), g in data.groupby(["_row", "_col"], sort=True):
        v = g[value_col].to_numpy(dtype=float)
        if v.size == 0:
            continue

        q1 = np.percentile(v, 25)
        q2 = np.percentile(v, 50)
        q3 = np.percentile(v, 75)
        vmin = np.min(v)
        vmax = np.max(v)

        y0 = map_y(row_idx, vmin)
        y1 = map_y(row_idx, q1)
        y2 = map_y(row_idx, q2)
        y3 = map_y(row_idx, q3)
        y4 = map_y(row_idx, vmax)

        cx = col_idx + 1.0
        x_left = cx - box_w / 2
        x_right = cx + box_w / 2

        ax.plot([cx, cx], [y0, y1], linewidth=1.0, color="black")
        ax.plot([cx, cx], [y3, y4], linewidth=1.0, color="black")
        ax.plot([x_left, x_right], [y0, y0], linewidth=1.0, color="black")
        ax.plot([x_left, x_right], [y4, y4], linewidth=1.0, color="black")

        rect = Rectangle((x_left, y3), box_w, y1 - y3, fill=False, linewidth=1.0, edgecolor="black")
        ax.add_patch(rect)

        ax.plot([x_left, x_right], [y2, y2], linewidth=1.0, color="black")

    ax.set_xlim(0.5, 6.5)
    ax.set_ylim(4.5, 0.5)
    ax.set_aspect("equal")

    ax.set_xticks(np.arange(1, 7))
    ax.set_xticklabels([str(i) for i in range(1, 7)])
    ax.set_yticks(np.arange(1, 5))
    ax.set_yticklabels(list("ABCD"))

    ax.set_xticks(np.arange(0.5, 7.0, 1.0), minor=True)
    ax.set_yticks(np.arange(0.5, 5.0, 1.0), minor=True)
    ax.grid(False)
    ax.grid(which="minor")

    ax.tick_params(axis="both", which="major", length=0)
    ax.tick_params(axis="both", which="minor", length=0)

    ax.set_title("Per-well boxplot: LiveCircularity")

    # Read themed axis color, then apply it to custom artists.
    style_figure(fig, icon="histo")

    mono_color = ax.spines["left"].get_edgecolor()
    if mono_color is None or mono_color == "none":
        mono_color = plt.rcParams.get("axes.edgecolor", plt.rcParams.get("text.color", "black"))

    for line in ax.lines:
        line.set_color(mono_color)

    for patch in ax.patches:
        if isinstance(patch, Rectangle):
            patch.set_edgecolor(mono_color)
Per-well distribution light
Per-well distribution example (light)
Per-well distribution dark
Per-well distribution example (dark)

Heatmap

Each well is colored by one per-well value, with the value shown as text.

Code
GA3 Matplotlib node editor
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.figure import Figure
import matplotlib.patheffects as pe

well_col = ct("A:Well")
value_col = ct("A:LiveRatio")   # try DeadCells / LiveCells / AllCells too

required = [well_col, value_col]
missing = [c for c in required if c not in df.columns]
if missing:
    raise ValueError(f"Missing required columns: {missing}")

data = df[required].copy()
data = data.replace([np.inf, -np.inf], np.nan).dropna()

parsed = data[well_col].astype(str).str.strip().str.upper().str.extract(r"^([A-D])([1-6])$")
ok = parsed.notna().all(axis=1)
data = data.loc[ok].copy()
parsed = parsed.loc[ok]

if data.empty:
    raise ValueError("No valid rows to plot.")

row_map = {"A": 0, "B": 1, "C": 2, "D": 3}
data["_row"] = parsed[0].map(row_map).astype(int)
data["_col"] = parsed[1].astype(int) - 1

per_well = data.groupby([well_col, "_row", "_col"], as_index=False)[value_col].first()

grid = np.full((4, 6), np.nan, dtype=float)
for _, row in per_well.iterrows():
    grid[int(row["_row"]), int(row["_col"])] = float(row[value_col])

fig = Figure((10, 5))
fig.set_size_inches(15, 8, forward=True)
fig.set_dpi(400)
ax = fig.add_subplot(111)

ax.imshow(grid, origin="upper", aspect="equal")

ax.set_xticks(np.arange(6))
ax.set_xticklabels([str(i) for i in range(1, 7)])
ax.set_yticks(np.arange(4))
ax.set_yticklabels(list("ABCD"))

ax.set_xticks(np.arange(-0.5, 6, 1), minor=True)
ax.set_yticks(np.arange(-0.5, 4, 1), minor=True)
ax.grid(False)
ax.grid(which="minor")

ax.tick_params(axis="both", which="major", length=0)
ax.tick_params(axis="both", which="minor", length=0)

for r in range(4):
    for c in range(6):
        if np.isfinite(grid[r, c]):
            txt = ax.text(
                c,
                r,
                f"{grid[r, c]:.3f}",
                ha="center",
                va="center",
                color="white",
                fontsize=14,
                fontweight="bold"
            )
            txt.set_path_effects([
                pe.Stroke(linewidth=1.6, foreground="black", alpha=0.65),
                pe.Normal()
            ])

ax.set_title("LiveRatio by well")

style_figure(fig, icon="histo")
Per-well heatmap light
Per-well heatmap example (light)
Per-well heatmap dark
Per-well heatmap example (dark)

Ordered boxplot (non-grid)

This is also a per-well plot, but it is not rendered in plate-grid cells. Each well is shown as one category on the X axis (A1, A2, …), with one boxplot per well.

Code
GA3 Matplotlib node editor
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.figure import Figure

well_col = ct("A:Well")
circ_col = ct("A:LiveCircularity")

required_columns = [well_col, circ_col]
missing_columns = [col for col in required_columns if col not in df.columns]
if missing_columns:
    raise ValueError(f"Missing required columns: {missing_columns}")

plot_df = df[[well_col, circ_col]].copy()
plot_df = plot_df.replace([np.inf, -np.inf], np.nan)
plot_df = plot_df.dropna(subset=[well_col, circ_col])

if plot_df.empty:
    raise ValueError("No valid rows available for plotting after removing NaN/inf values.")

# Natural plate ordering, e.g. B2, B4, C2, C4, D2, D4
well_parts = plot_df[well_col].astype(str).str.strip().str.upper().str.extract(r"^([A-Z]+)(\d+)$")
plot_df["_well_row"] = well_parts[0]
plot_df["_well_num"] = pd.to_numeric(well_parts[1], errors="coerce")

plot_df = plot_df.dropna(subset=["_well_row", "_well_num"])

if plot_df.empty:
    raise ValueError("No valid well names found for ordering.")

well_order = (
    plot_df[[well_col, "_well_row", "_well_num"]]
    .drop_duplicates()
    .sort_values(by=["_well_row", "_well_num", well_col])[well_col]
    .tolist()
)

boxplot_data = []
final_well_order = []

for well in well_order:
    vals = plot_df.loc[plot_df[well_col] == well, circ_col].to_numpy(dtype=float)
    vals = vals[np.isfinite(vals)]
    if vals.size > 0:
        final_well_order.append(well)
        boxplot_data.append(vals)

if not boxplot_data:
    raise ValueError("No non-empty well groups available for boxplot.")

fig = Figure((10, 5))
fig.set_size_inches(15, 8, forward=True)
fig.set_dpi(400)
ax = fig.add_subplot(111)

bp = ax.boxplot(
    boxplot_data,
    tick_labels=final_well_order,
    whis=1.5,
    widths=0.55,
    patch_artist=True,
    boxprops=dict(facecolor="none", linewidth=1.2),
    medianprops=dict(color="#ff7f0e", linewidth=1.6),
    whiskerprops=dict(linewidth=1.2),
    capprops=dict(linewidth=1.2),
    flierprops=dict(
        marker="o",
        markerfacecolor="none",
        markersize=4.5,
        linestyle="none",
        markeredgewidth=1.1,
    ),
)

ax.set_title("Live Circularity by Well")
ax.set_xlabel("Well")
ax.set_ylabel("Live Circularity")
ax.set_axisbelow(True)
ax.grid(True, axis="y")

style_figure(fig, icon="histo")

# Read the themed axis color and apply it to custom boxplot artists.
mono_color = ax.spines["left"].get_edgecolor()
if mono_color is None or mono_color == "none":
    mono_color = plt.rcParams.get("axes.edgecolor", plt.rcParams.get("text.color", "black"))

for box in bp["boxes"]:
    box.set_edgecolor(mono_color)
    box.set_facecolor("none")

for whisker in bp["whiskers"]:
    whisker.set_color(mono_color)

for cap in bp["caps"]:
    cap.set_color(mono_color)

for flier in bp["fliers"]:
    flier.set_markeredgecolor(mono_color)
    flier.set_markerfacecolor("none")

for median in bp["medians"]:
    median.set_color("#ff7f0e")
Per-well ordered boxplot light
Per-well ordered boxplot (light)
Per-well ordered boxplot dark
Per-well ordered boxplot (dark)

Omnipose for bacterias

Omnipose is a pupular general image segmentation package that builds on Cellpose.

Setup

In the GA3 node add

  • one channel input,
  • one binary output,
  • one channel output and
  • set the Execution mode to “Managed environment”
  • insert the environment from the Environment section.
  • insert the code from Code section.
  • connect the input pin to the Channels output to be processed.
  • connect the binary output pin to the SavePictures node.
  • connect the channel output pin to the SaveBinaries node.

GA3 nodes

Environment

Edit the evironment and insert following definition:

channels:
    - conda-forge
dependencies:
    - python=3.10.12
    - pip
    - pip:
        - omnipose==1.1.4
channels:
    - conda-forge
dependencies:
    - python=3.10.12
    - pip
    - pip:
        - --index-url https://pypi.org/simple
        - --extra-index-url https://download.pytorch.org/whl/cu126
        - omnipose==1.1.4
        - torch==2.9.1
        - torchvision==0.24.1
        - torchaudio==2.9.1

Then install it by clicking the “Install managed environment” icon. Be patient it takes time.

The environment was created and tested in the January, 2026.

Consult with LLMs (e.g. ChatGPT) in case the versions do not work anymore.

Managed environment
Omnipose: Managed environment

Code

Insert the code as follows:

GA3 Python editor
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# IMPORTANT: 'limnode' must be imported like this (not from nor as)
import limnode

model = None
model_name = None

# defines output parameter properties
def output(inp: limnode.InDefTuple, out: limnode.OutDefTuple, par: limnode.UserParTuple) -> None:
    out[0].makeNew("Mask", (0, 255, 0)).makeUInt8()
    out[1].makeNewRgb("Flow").makeUInt8()

# return Program for dimension reduction or two-pass processing
def build(par: limnode.UserParTuple, loops: limnode.LoopDefs) -> limnode.Program|None:
    return None

# called for each frame/volume
def run(inp: limnode.InDataTuple, out: limnode.OutDataTuple, par: limnode.UserParTuple, ctx: limnode.RunContext) -> None:
    import numpy as np
    from omnipose.gpu import use_gpu
    from omnipose.utils import normalize99
    from cellpose_omni import models

    img = inp[0].data[0, :, :, 0]
    img = normalize99(img)

    global model, model_name
    new_model_name = 'bact_fluor_omni'
    if model is None or model_name != new_model_name:
        model_name = new_model_name
        gpu_device, is_gpu = use_gpu()
        if is_gpu:
            model = models.CellposeModel(gpu=(gpu_device, is_gpu), model_type=model_name)
        else:
            model = models.CellposeModel(model_type=model_name)

    params = {
        'channels':None,
        'rescale': None,
        'mask_threshold': -2,
        'flow_threshold': 0,
        'transparency': False,
        'omni': True,
        'cluster': True,
        'resample': True,
        'verbose': False,
        'tile': False,
        'niter': None,
        'augment': False,
        'affinity_seg': True
    }

    mask, flow, style = model.eval(img,**params)
    mask = limnode.separateLabeledImage(mask)
    out[0].data[0,:,:,0] = mask
    out[1].data[0,:,:,:] = np.where(mask[..., None], flow[0][:, :, 0:3], 0)[...,[2,1,0]]

# child process initialization (when outproc is set)
if __name__ == '__main__':
    from limnode import print
    limnode.child_main(run, output, build)

The code:

  1. Initializes the global variable model so the model is created only once. [line 4]
  2. Initializes the global variable model_name to track the currently selected model type. [line 5]
  3. Defines a binary output named “Mask” and sets its display color to green. [line 8]
  4. Defines a channel output named “Flow” and sets its format to 8-bit RGB. [line 9]
  5. Checks whether a GPU device is enabled [line 30]
  6. Creates the CPU model. [line 32]
  7. Creates the GPU model. [line 34]
  8. Runs the model on the current frame using the specified parameters. [line 52]
  9. Splits the labeled mask into separate binary objects. [line 53]
  10. Writes the resulting binary mask to the binary output [line 54]
  11. Applies the binary mask to the flow image, converts RGB to BGR, and writes the result to the channel output. [line 55]

Results

Segmented result
Omnipose: Binary Mask & RGB Flow

Export table to CSV

This workflow demonstrates a simple table export to CSV with customizable export options using an underlying pandas DataFrame (see pandas.pydata.org). It also shows how to add parameters for User Mode configuration.

Setup

Open the file that needs to be segmented.

In NIS Express GA3 Editor:

  • Add Python node and open its settings
    • Add table input
    • Insert code from Code section.
  • Connect the input pin to the Table output to be exported.

Code

GA3 Python editor
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# IMPORTANT: 'limnode' must be imported like this (not from nor as)
import limnode
from pathlib import Path

# defines output parameter properties
def output(inp: limnode.InDefTuple, out: limnode.OutDefTuple, par: limnode.UserParTuple) -> None:
    pass

# return Program for dimension reduction or two-pass processing
def build(par: limnode.UserParTuple, loops: limnode.LoopDefs) -> limnode.Program|None:
    return None

# called for each frame/volume
def run(inp: limnode.InDataTuple, out: limnode.OutDataTuple, par: limnode.UserParTuple, ctx: limnode.RunContext) -> None:
    par_output = par[0] if len(par) > 0 else ""
    par_sep = par[1] if len(par) > 1 else ";"
    par_decimal = par[2] if len(par) > 2 else "."

    img_p = Path(ctx.outFilename)
    img_stem = img_p.stem
    img_dir = img_p.parent

    p = Path(par_output)
    if not p.is_absolute():
        p = img_dir / p
    if not p.suffix:
        p = p / f"{img_stem}.csv"
    p = p.resolve(strict=False)

    output_stem = p.stem if p.stem else img_stem
    output_suffix = p.suffix if p.suffix else ".csv"
    output_dir = p.parent

    output_dir.mkdir(parents=True, exist_ok=True)

    coords = ctx.inpParameterCoordinates[0]
    file_idxs = ("_" + "_".join(map(str, coords))) if coords else ""
    file_path = output_dir / f"{output_stem}{file_idxs}{output_suffix}"

    inp[0].data.pandasDataFrame().to_csv(file_path, index=False, sep=par_sep, decimal=par_decimal)

# child process initialization (when outproc is set)
if __name__ == '__main__':
    from limnode import print
    limnode.child_main(run, output, build)

The code:

  1. Imports Path from pathlib [line 3]
  2. Reads GUI parameters [lines 7-9]
  3. Extracts output image filepath info for use as default [lines 19-21]
  4. Resolves output path, adds default CSV name if missing, and normalizes it [lines 23-28]
  5. Extracts final filename, extension, and target directory [lines 30-32]
  6. Creates the output directory if it does not exist [line 34]
  7. Appends loop indices to filename to ensure unique files per iteration. [lines 36-38]
  8. Exports the input table to CSV with selected separator and decimal format [line 40]

User parameters

  1. Open the User parameters in the top toolbar of the node.
  2. In the opened window:
    • fill the description: Select options for CSV export
    • add parameters
      1. Output
        • Type: str
        • Definition: {"control":"Path","mode":"save","type":"file"}
      2. Delimiter
        • Type: str
        • Initial value: ;
        • Definition: {"control":"Selection","list":[{"text":"Semicolon ';'","value":";"},{"text":"Comma ','","value":","},{"text":"Tab '\\t'","value":"\t"}]}
      3. Decimal separator
        • Type: str
        • Initial value: .
        • Definition: {"control":"Selection","list":[{"text":"Dot '.'","value":"."},{"text":"Comma ','","value":","}]}

  1. Close the User parameters window
  2. Switch to the User mode in the top toolbar of the node.
  3. In the newly opened window click “Skip”
  4. Window should switch to User mode.
  5. If needed, return back by clicking Developer mode.

Results

CSV files are exported to the selected output directory, and export options can be easily modified via the custom GUI.