Writing a block
A block is one step in a workflow — it takes typed inputs, does some work, and
hands typed outputs to the next block. This page walks you through writing one
from scratch and running it. We build a ProcessBlock that scales every image in
a batch by a number you set in the GUI. By the end you will know the four things
every block needs: a name, ports, parameters, and a body.
New to SciStudio? Read the overall getting-started.md first; this page is the block-authoring deep dive. You usually do not start from a blank file — the New custom block action and the in-app AI assistant both scaffold a block for you (see ai-assistant.md).
1. The shape of a block
A block is a Python class. Here is the whole thing; we explain each part below.
from typing import Any, ClassVar
from scistudio.blocks.process import ProcessBlock
from scistudio.blocks.base import BlockConfig, InputPort, OutputPort
from scistudio.core.types import Array
class ScaleImage(ProcessBlock):
"""Multiply every pixel of each image by a gain factor."""
# 1. Identity — shown in the palette and on the canvas node.
name: ClassVar[str] = "Scale Image"
description: ClassVar[str] = "Multiply each image by a gain factor."
# 2. Ports — typed connection points.
input_ports: ClassVar[list[InputPort]] = [
InputPort(name="input", accepted_types=[Array], description="Image to scale"),
]
output_ports: ClassVar[list[OutputPort]] = [
OutputPort(name="output", accepted_types=[Array], description="Scaled image"),
]
# 3. Parameters — a JSON Schema that renders the GUI panel.
config_schema: ClassVar[dict[str, Any]] = {
"type": "object",
"properties": {
"gain": {"type": "number", "default": 1.0, "title": "Gain"},
},
}
# 4. Body — runs once per item in the batch.
def process_item(self, item: Array, config: BlockConfig, state: Any = None) -> Array:
gain = config.get("gain", 1.0)
pixels = item.to_memory() # numpy ndarray
return Array(axes=list(item.axes), data=pixels * gain)
That is a complete, working block.
2. Identity
name: ClassVar[str] = "Scale Image"
description: ClassVar[str] = "Multiply each image by a gain factor."
name is what users see in the block palette and on the node header.
description shows up as help text. Everything is a ClassVar — a class-level
attribute, not something you set in __init__.
Give your block its own look (optional)
By default your block's node takes the color and icon of its category. You can override either with two more class attributes:
ui_color: ClassVar[str | None] = "#ff5733" # any CSS hex color
ui_icon: ClassVar[str | None] = "Microscope" # a Lucide icon name
ui_coloris a CSS hex string. The canvas derives the matching text and border shades from it, so you only pick one color.ui_iconis the name of a Lucide icon (the icon set SciStudio bundles), e.g."Microscope","FlaskConical","Waves". An unknown name simply falls back to the category icon — it never errors and never shows a broken glyph, so you can try a name and see.
Leave both unset (None) and the block looks exactly as it would by category
default. These hints are provisional — handy and safe to use, but the exact
color-derivation and icon resolution may still be refined.
3. Ports
input_ports = [InputPort(name="input", accepted_types=[Array], ...)]
output_ports = [OutputPort(name="output", accepted_types=[Array], ...)]
Ports are the plugs other blocks connect to. accepted_types controls what may
connect: always name a concrete type (Array, DataFrame, Series,
Text, Artifact, or a package type like Image or Spectrum) so the canvas
can type-check the wire and pick the right preview. Avoid accepted_types=[]
("accept anything") unless you truly mean it.
InputPort and OutputPort come from scistudio.blocks.base. See that module
in the API reference for the optional keywords (required=False, default=,
description=).
4. Parameters
config_schema = {"type": "object", "properties": {
"gain": {"type": "number", "default": 1.0, "title": "Gain"},
}}
config_schema is plain JSON Schema. SciStudio
turns it into the parameter panel users see when they click your block. Read the
values back in the body with config.get(name, default).
5. The body
def process_item(self, item, config, state=None):
gain = config.get("gain", 1.0)
pixels = item.to_memory() # the real value
return Array(axes=list(item.axes), data=pixels * gain)
We chose ProcessBlock because every image is transformed independently and the
number of images does not change. ProcessBlock loops over the incoming batch
for you and calls process_item once per item, so you only write the
single-item logic. (For full control over the whole batch — filtering, merging,
splitting — subclass Block and write run() instead; see
the process example and the API reference.)
Two things every body does:
- Read the value with
item.to_memory(). What you get back depends on the type (see the table below). - Build the result with the type's keyword constructor and
data=.
Reading and building each type
| Type | item.to_memory() returns |
Read pandas/numpy directly | Build one with |
|---|---|---|---|
Array |
numpy ndarray |
item.to_numpy() |
Array(axes=[...], data=arr) |
DataFrame |
pyarrow.Table |
item.to_pandas() / item.to_numpy() |
DataFrame(data=table) |
Series |
pyarrow.Table |
item.to_pandas() / item.to_numpy() |
Series(index_name=..., value_name=..., data=table) |
Text |
str |
— | Text(content="...") |
Artifact |
bytes (file contents; the path is item.file_path) |
— | Artifact(file_path=Path(...)) |
to_pandas() and to_numpy() are the ergonomic accessors: tables are
stored as Arrow (the fast, cross-language form), but most people think in pandas
or numpy, so these read straight out to what you know. They are read-only
conveniences — you still build a table back with data= and an Arrow table
(pyarrow.Table.from_pandas(df)).
6. Where the file goes
Save the class as a .py file under blocks/ in your project (the New custom
block action does this for you). SciStudio discovers it automatically and it
appears in the palette. There is one class per concept; you can put several
blocks in one file if they are related.
7. Run it
Drop the block on the canvas, wire an image source into its input port, set
Gain in the parameter panel, and run the workflow. The output port now
carries the scaled images, ready for the next block or a preview.
8. Testing (optional but recommended)
A block is a normal Python class, so you can test it without the GUI: build a
Collection of inputs, call run(...), and check the outputs.
import numpy as np
from scistudio.core.types import Array, Collection
from scistudio.blocks.base import BlockConfig
def test_scale_doubles():
img = Array(axes=["y", "x"], data=np.ones((2, 2)))
block = ScaleImage()
out = block.run({"input": Collection([img], item_type=Array)},
BlockConfig(params={"gain": 2.0}))
assert (out["output"].open_one().to_memory() == 2.0).all()
Collection is the batch container every port carries — an ordered group of
same-type items. A single value is a Collection of length 1. See Collection
in the API reference for the helper methods (open(), open_one(), iteration).
Next
- A different kind of block? Copy the matching example.
- The built-in types do not fit your data? See custom-types.md.
- The exact signature of anything here? Open the API reference.