Building a Block
This tutorial walks through building a complete Spade block from scratch. You will create a block that computes the Normalized Difference Vegetation Index (NDVI) from satellite imagery -- a common remote sensing operation that measures vegetation health. NDVI is defined as:
NDVI = (NIR - Red) / (NIR + Red)where NIR is the near-infrared band and Red is the visible red band. The result ranges from -1 to +1, with higher values indicating healthier vegetation.
By the end of this tutorial you will have:
- Planned the block's inputs and outputs
- Written the block manifest (YAML)
- Implemented the handler function (Python)
- Validated the block with
spade check - Installed the block locally
- Used the block in a pipeline
Prerequisites🔗
Before starting, make sure you have:
- The Spade CLI installed (Installation guide)
- Python 3.12 or later
uvpackage manager (recommended) orpipnumpyandrasterioPython packages (we will add these as dependencies)
Step 1: Plan the block's interface🔗
Before writing any code, decide what the block accepts and what it produces. This is the most important design step -- it determines how the block fits into pipelines.
For an NDVI computation, we need:
Inputs:
- A red band raster file (GeoTIFF) -- the visible red reflectance
- A NIR band raster file (GeoTIFF) -- the near-infrared reflectance
Outputs:
- An NDVI raster file (GeoTIFF) -- a single-band raster with NDVI values
We also want a parameter to control what value to assign to pixels where the computation is undefined (both bands are zero). This is the nodata value, and we will make it a number parameter.
Parameters (via args):
nodata_value(number) -- the value to write where NDVI is undefined (default:-9999)
Step 2: Create the block collection🔗
A block collection is a repository that groups related blocks together. All blocks in a collection share the same programming language.
Create a new collection for raster processing blocks:
mkdir raster-tools && cd raster-tools
spade init --language pythonThis scaffolds a Python project:
raster-tools/
pyproject.toml
src/
raster_tools/
__init__.py
blocks/Step 3: Add the block🔗
Use spade add to create the manifest and source file:
spade add ndviThis creates two files:
Created blocks/ndvi.yaml
Created src/raster_tools/ndvi.pyStep 4: Write the block manifest🔗
Open blocks/ndvi.yaml and replace its contents with the interface you planned in Step 1:
id: raster-tools.ndvi
version: 0.1.0
kind: standard
network: false
description: >
Computes the Normalized Difference Vegetation Index (NDVI) from
red and near-infrared raster bands. NDVI = (NIR - Red) / (NIR + Red).
entrypoint: ndvi
inputs:
red_band:
type: file
format: GeoTIFF
description: Visible red reflectance band
nir_band:
type: file
format: GeoTIFF
description: Near-infrared reflectance band
nodata_value:
type: number
description: Value to assign where NDVI is undefined (both bands zero)
outputs:
ndvi_raster:
type: file
format: GeoTIFF
description: Single-band NDVI raster with values from -1 to 1Let's walk through each field:
id: raster-tools.ndvi-- Unique identifier following the<collection>.<block>convention. This is how pipelines reference the block.version: 0.1.0-- Semantic version. Changing this invalidates cached results.kind: standard-- This is a regular processing block, not a map or reduce block.network: false-- The block does not need internet access. It only reads local files.entrypoint: ndvi-- The name used to locate the source file. For Python, Spade looks forsrc/raster_tools/ndvi.py.inputs-- Two file inputs (red_bandandnir_band) and one numeric parameter (nodata_value). File inputs come from upstream blocks; the numeric parameter comes from the pipeline'sargs.outputs-- One file output (ndvi_raster) containing the computed NDVI.
Step 5: Implement the handler🔗
Open src/raster_tools/ndvi.py and replace the stub with the actual implementation:
import numpy as np
import rasterio
from spade import run, RasterFile
def handler(red_band: RasterFile, nir_band: RasterFile, nodata_value: float) -> RasterFile:
"""Compute NDVI from red and NIR raster bands.
NDVI = (NIR - Red) / (NIR + Red)
"""
# Read the input raster bands
with rasterio.open(red_band.path) as red_src:
red = red_src.read(1).astype(np.float64)
profile = red_src.profile.copy()
with rasterio.open(nir_band.path) as nir_src:
nir = nir_src.read(1).astype(np.float64)
# Compute NDVI, handling division by zero
denominator = nir + red
ndvi = np.where(
denominator != 0,
(nir - red) / denominator,
nodata_value,
)
# Update the raster profile for single-band float output
profile.update(dtype=rasterio.float64, count=1, nodata=nodata_value)
# Write the output
output_path = "outputs/ndvi_raster/ndvi.tif"
with rasterio.open(output_path, "w", **profile) as dst:
dst.write(ndvi, 1)
return RasterFile(path=output_path)
if __name__ == "__main__":
run(handler)There are several important things to understand about this code:
How inputs are loaded🔗
The function signature tells the Spade library how to load each argument:
red_band: RasterFile-- Spade looks ininputs/red_band/for a file and creates aRasterFileobject with a.pathattribute pointing to it.nir_band: RasterFile-- Same pattern, reads frominputs/nir_band/.nodata_value: float-- Spade reads this fromparams.yaml(which is generated from the pipeline'sargs).
The parameter names in the function signature must match the input names in the manifest. red_band in the manifest maps to red_band in the function.
How outputs are written🔗
The block writes its output file to outputs/ndvi_raster/ndvi.tif. The directory name ndvi_raster must match the output name in the manifest. Spade expects to find the output at outputs/<output_name>/ after the block finishes.
The function returns a RasterFile pointing to the output path. The run() function uses this to verify the output exists.
The entry point🔗
The if __name__ == "__main__": run(handler) block at the bottom is what Spade calls when it executes the block. The run() function:
- Reads
params.yamlfor scalar parameters - Scans
inputs/for file-based inputs - Calls your handler with the loaded arguments
- Verifies the output was written
Step 6: Add dependencies🔗
Your block uses numpy and rasterio. Add them to pyproject.toml:
[project]
name = "raster-tools"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"spade",
"numpy",
"rasterio",
]If you are using uv:
uv add numpy rasterioStep 7: Validate with spade check🔗
Run the validation command from the collection root:
spade checkIf everything is correct, you will see:
Collection is valid. 1 block(s) checked.spade check verifies:
- The manifest has all required fields (
id,version,inputs,outputs) - Input and output types are valid
- The entrypoint file exists at
src/raster_tools/ndvi.py - The block ID follows the
<collection>.<block>convention
If there is a problem -- for example, a missing field or a typo in the entrypoint -- the error message will tell you exactly what to fix.
Step 8: Install locally🔗
Install the collection from the local directory:
spade install file://.This runs uv sync to install dependencies, then copies the built artifacts and manifests to ~/.spade/blocks/raster-tools/0.1.0/. The block is now available for use in pipelines.
Cloning file://.
Detected language: python
Collection: raster-tools v0.1.0
Building...
Installed 1 block(s) to /home/user/.spade/blocks/raster-tools/0.1.0Step 9: Use in a pipeline🔗
Now create a pipeline that uses your NDVI block. In a real scenario, the red and NIR bands would come from upstream blocks (such as a band-splitting block). Here is a three-block pipeline that downloads a satellite image, splits it into bands, and computes NDVI:
Create ndvi-pipeline.yaml:
name: ndvi-computation
version: "1.0"
description: >
Download satellite imagery, extract red and NIR bands,
and compute NDVI.
blocks:
# Step 1: Download satellite imagery
- id: "@source"
name: data.sentinel2
inputs: []
args:
region: "POLYGON((-105.5 40.0, -105.0 40.0, -105.0 40.5, -105.5 40.5, -105.5 40.0))"
date_range: "2025-06-01/2025-09-01"
# Step 2: Split into individual bands
- id: "@split"
name: raster.split-bands
inputs:
- "@source"
args:
red_band: 4
nir_band: 8
# Step 3: Compute NDVI using our new block
- id: "@ndvi"
name: raster-tools.ndvi
inputs:
- block: "@split"
output: red
as: red_band
- block: "@split"
output: nir
as: nir_band
args:
nodata_value: -9999Notice that the NDVI block uses explicit references for its inputs. This is necessary because raster.split-bands produces two outputs of the same type (both file with format GeoTIFF). Without explicit references, Spade could not determine which output goes to red_band and which goes to nir_band. The output key names the specific upstream output, and the as key names the input on the downstream block.
Validate the pipeline:
spade check ndvi-pipeline.yamlThen run it:
spade run ndvi-pipeline.yamlNotes for other languages🔗
The workflow above uses Python, but the same pattern applies to all supported languages. The manifest YAML is identical regardless of language -- only the handler implementation differs.
Go:
package main
import spade "github.com/spade-dev/spade"
func handler(args *spade.Args) (*spade.RasterFile, error) {
redBand, err := spade.Input[*spade.RasterFile](args, "red_band")
if err != nil {
return nil, err
}
nirBand, err := spade.Input[*spade.RasterFile](args, "nir_band")
if err != nil {
return nil, err
}
nodata, err := spade.Param[float64](args, "nodata_value")
if err != nil {
return nil, err
}
// ... compute NDVI using redBand.Path and nirBand.Path ...
result := spade.NewRasterFile("outputs/ndvi_raster/ndvi.tif")
return &result, nil
}
func main() {
spade.Run(handler)
}Rust:
use spade::{run, Args, RasterFile};
fn handler(args: Args) -> Result<RasterFile, Box<dyn std::error::Error + Send + Sync>> {
let red_band: RasterFile = args.input("red_band")?;
let nir_band: RasterFile = args.input("nir_band")?;
let nodata: f64 = args.param("nodata_value")?;
// ... compute NDVI using red_band.path and nir_band.path ...
Ok(RasterFile::new("outputs/ndvi_raster/ndvi.tif"))
}
fn main() {
run(handler);
}R:
library(yaml)
library(terra)
library(spade)
params <- read_yaml("params.yaml")
red <- rast("inputs/red_band")
nir <- rast("inputs/nir_band")
ndvi <- (nir - red) / (nir + red)
ndvi[is.nan(ndvi)] <- params$nodata_value
writeRaster(ndvi, "outputs/ndvi_raster/ndvi.tif", overwrite = TRUE)TypeScript:
import { run, RasterFile } from "spade";
function handler(red_band: RasterFile, nir_band: RasterFile, nodata_value: number): RasterFile {
// ... compute NDVI using red_band.path and nir_band.path ...
return new RasterFile("outputs/ndvi_raster/ndvi.tif");
}
run(handler);Summary🔗
Building a Spade block follows a consistent process:
- Plan -- Decide on inputs, outputs, and parameters before writing code.
- Scaffold -- Use
spade initandspade addto create the collection and block files. - Manifest -- Declare the block's interface in the YAML manifest. This is the contract between your block and the rest of the pipeline.
- Implement -- Write the handler function. Read from
inputs/, read parameters from the function arguments, and write results tooutputs/. - Validate -- Run
spade checkto catch errors early. - Install -- Run
spade install file://.to build and register the block. - Use -- Reference the block in a pipeline YAML file and run it with
spade run.
Next steps🔗
- Learn how to write multi-step pipelines using your blocks
- Explore testing strategies for blocks during development
- Read about map/reduce for parallel processing of large datasets