Data formats¶
Every file SMINT reads or writes, with its columns, an example and the code that produces or consumes it. Use this page to prepare inputs from your own instruments and to find the column you need in an output. All tables are plain CSV with a header row; coordinates are in the units of the file they came from (pixels for segmentation output, microns for Xenium).
flowchart LR
subgraph IN[Inputs]
IMG[OME-TIFF / OME-Zarr image]
XEN[Xenium cells CSV]
SM[SM matrix CSV]
ST[ST annotation CSV]
end
subgraph SEG[Segmentation]
OUT1[outlines + summary CSVs<br/>Zarr label masks]
end
subgraph REG[Registration]
PRE[.transform.json]
LM[landmark .npy pair]
JOB[job_spec.json<br/>status.json]
RES[centroids_transformed.csv<br/>sm_transformed.csv]
end
IMG --> OUT1
OUT1 -->|centroids| RES
XEN --> RES
SM --> PRE
SM --> LM
ST --> LM
LM --> RES
JOB -.- RES
| File | Read or written by | Section |
|---|---|---|
| Any table with coordinates | auto-detection | Coordinate columns |
| Centroid CSV (nuclei, Xenium cells) | smint-alignment centroid, register_centroid_files |
Centroid CSVs |
| SM matrix CSV | smint-alignment st-sm / pretransform, read_sm_matrix |
SM matrix CSV |
| ST annotation CSV | smint-alignment st-sm, read_st_annotations |
ST annotation CSV |
*_points.npy |
napari plugin, point annotator, load_landmarks |
Landmarks |
*.npz raster |
prepare_landmark_inputs, save_rasterized |
Landmarks |
<output>.transform.json |
smint-alignment pretransform, save_transform |
Pre-registration transform |
job_spec.json, status.json, logs |
smint.alignment.jobs, smint-register |
Registration jobs |
centroids_transformed.csv, sm_transformed.csv |
registration worker | Registration outputs |
cells_outlines.csv, *_summary.csv, *.zarr |
distributed segmentation | Segmentation outputs |
<base>_cells.csv, <base>_nuclei.csv |
single-process segmentation | Segmentation outputs |
current_tile_info.txt, live_view.png |
segmentation, smint-viewer |
Live viewer files |
Coordinate columns¶
Tables from different sources name their coordinates differently. Wherever SMINT
auto-detects them, it tries these names in order, case-insensitively, and takes
the first that exists (smint.alignment.columns.X_CANDIDATES / Y_CANDIDATES):
| Priority | x column | y column | Typical source |
|---|---|---|---|
| 1 | x_final |
y_final |
ST annotation tables |
| 2 | x_centroid |
y_centroid |
Xenium cells.csv; SMINT segmentation summaries |
| 3 | centroid_x |
centroid_y |
Nucleus centroids from calculate_centroids |
| 4 | x_transformed |
y_transformed |
Output of an earlier SMINT registration |
| 5 | x |
y |
Metabolomics matrices |
| 6 | X |
Y |
Other tools |
So a table with both x_final and x resolves to x_final. Check what SMINT
will pick with smint-alignment columns my_table.csv.
| Where | Auto-detected? |
|---|---|
smint-alignment pretransform (both files), smint-alignment columns |
yes |
ST table in st-sm / register_sm_to_st (--st-x-col / --st-y-col override) |
yes |
| napari plugin loaders | yes |
SM matrix in st-sm / read_sm_matrix |
no: the columns named x and y (override with --sm-x-col / --sm-y-col) |
smint-alignment centroid / register_centroid_files |
yes: the first pair present of x_centroid y_centroid, centroid_x centroid_y, x y (override with --source-cols / --target-cols) |
Several coordinate pairs in one table
ST tables often carry several plausible pairs from successive processing steps. A wrong pick misregisters silently instead of failing, so name the columns explicitly whenever more than one pair exists.
Inputs¶
Centroid CSVs¶
Two tables of (x, y) centroids from the same section: the source is moved
onto the target. Any other columns are carried through to the output unchanged.
Source, e.g. nuclei from SMINT segmentation. cells_summary.csv /
nuclei_summary.csv from the distributed pipeline already hold x_centroid,
y_centroid; centroids computed from single-process outlines with
smint.segmentation.postprocess.calculate_centroids are named centroid_x,
centroid_y (here from the synthetic test image used further down this page):
Target, e.g. Xenium cells.csv; only the two coordinate columns are read
(synthetic values):
Both must be in the same units and roughly the same frame: registration
corrects residual misalignment of the order of --max-distance (default 100),
not a different scale or orientation. Bring them together first with
pretransform if needed.
SM matrix CSV¶
A spatial metabolomics matrix: one row per pixel, its grid position and one
column per m/z feature. The delimiter is sniffed from the header line (, ;
tab or |), so semicolon- and tab-separated exports work too.
| Column | Required | Meaning |
|---|---|---|
x, y |
yes | Pixel grid position (any case; or name others with --sm-x-col / --sm-y-col) |
is_edge |
no | Edge flag; kept as is |
72.0796, 88.0399, … |
no | One intensity column per m/z value |
When read for registration (read_sm_matrix):
- numeric headers are prefixed with
X(72.0796→X72.0796), so they are valid identifiers and survive a round trip through R; xandyare multiplied byscale_xy(--scale-xy, default 10, the pixel-to-micron factor for the MALDI data SMINT was developed on; set it for your own pixel size, or 1 if the grid is already in microns);- with
rotate_left_90,(x, y) → (−y, x); - with
keep_positive=True(default), an axis with negative values is shifted so its minimum is 0; non-negative coordinates are left unchanged (--no-keep-positiveturns this off).
The registration output keeps these modified x / y and X… columns. Exports
named *information_matrix_tissue_only_full_with_xy.csv under
<root>/layer_<n>/matrix/ are found by find_sm_matrix_files.
Tip
These matrices can be hundreds of MB. Read only what you need:
pd.read_csv(path, usecols=["x", "y"]), or read_sm_matrix(path, usecols=["x", "y"]).
ST annotation CSV¶
The spatial transcriptomics side of a sequential-section registration: one row per cell with its coordinates, auto-detected as above, and anything else you like (annotations are carried along but not used). Illustrative rows:
Landmarks¶
Landmark .npy¶
Landmarks for st-sm come as a pair of files, one per modality, with the same
number of points (at least 3) in the same order. The format is the one the point
annotator writes and the napari plugin reproduces: a Python dict pickled into a
.npy, mapping a label ("1", "2", … in click order) to a one-element list
holding an (x, y) tuple in the coordinates of the data you clicked on.
import numpy as np
points = {"1": [(1520.4, 880.2)], "2": [(2210.0, 1460.7)], "3": [(980.5, 2105.3)]}
np.save("landmarks_st_points.npy", points, allow_pickle=True)
np.load("landmarks_st_points.npy", allow_pickle=True).item()
# {'1': [(1520.4, 880.2)], '2': [(2210.0, 1460.7)], '3': [(980.5, 2105.3)]}
load_landmarks orders the points
by label (numerically for "1"…"10") and returns them in STalign's row–column
order, (y, x):
from smint.alignment import load_landmarks
load_landmarks("landmarks_st_points.npy")
# array([[ 880.2, 1520.4],
# [1460.7, 2210. ],
# [2105.3, 980.5]])
Name the pair <prefix>_st_points.npy and <prefix>_sm_points.npy; that is what
the napari plugin and prepare_landmark_inputs expect.
Raster .npz¶
prepare_landmark_inputs
rasterises each modality with STalign and saves <prefix>_st.npz and
<prefix>_sm.npz for the point annotator, with arrays x and y (pixel
positions along each axis, in coordinate units, dx apart) and I (the density
image, channels first). The annotator writes <prefix>_st_points.npy /
<prefix>_sm_points.npy beside them.
Pre-registration transform¶
.transform.json¶
smint-alignment pretransform --output sm_pre.csv writes the transformed table and,
beside it, sm_pre.csv.transform.json
(save_transform). This one is
from a synthetic metabolomics grid stored rotated by −90° at one tenth of the
target's scale:
{
"matrix": [
[6.135756699848347e-16, -10.020451127819548, 3988.613063047138],
[10.020451127819548, 6.135756699848347e-16, 0.2399003878131225],
[0.0, 0.0, 1.0]
],
"described": {
"scale_x": 10.020451127819548,
"scale_y": 10.020451127819548,
"rotation_deg": 90.0,
"shear_deg": 0.0,
"translation": [3988.613063047138, 0.2399003878131225],
"determinant": 100.40944080502001,
"reflects": false
},
"source": "sm.csv",
"target": "cells.csv",
"rotation": 90.0,
"flip_x": false,
"flip_y": false,
"scale_mode": "extent"
}
| Key | Meaning |
|---|---|
matrix |
3×3 homogeneous affine; [x', y', 1] = matrix @ [x, y, 1] |
described |
Its decomposition (describe_pretransform). reflects: true means a flip is included |
source, target, rotation, flip_x, flip_y, scale_mode |
The settings that produced it, as passed |
Replay it on other coordinates with
apply_pretransform(xy, load_transform("sm_pre.csv.transform.json")).
Registration jobs¶
smint-alignment st-sm|centroid, the napari plugin and
submit all create one directory per
job (--output-dir):
| File | Written by | When |
|---|---|---|
job_spec.json |
caller | always: what to run |
status.json |
caller, then worker | always: current state, rewritten as the job progresses |
submit.sh |
caller | sbatch backend: the generated batch script |
<job_name>_<jobid>.out / .err |
SLURM | sbatch backend: worker stdout / stderr |
worker.log |
worker | local backend: worker stdout and stderr |
centroids_transformed.csv or sm_transformed.csv |
worker | on success; see Registration outputs |
job_spec.json¶
The serialised JobSpec. This is the
spec of the Sample 2 z5 same-section run (file paths replaced by placeholders):
{
"round": "centroid",
"inputs": {
"source_file": "data/sample2_z5_nuclei.csv",
"target_file": "data/sample2_z5_xenium_cells.csv"
},
"output_dir": "runs/z5_refine",
"params": {
"method": "multiround",
"max_distance": 100.0,
"validation_fraction": 0.25,
"n_control": 1000,
"tps_regularization": null,
"source_cols": ["x_centroid", "y_centroid"],
"target_cols": ["x_centroid", "y_centroid"],
"refine": true,
"refine_kwargs": {"snap_radius": 10.0, "assign_remaining": true}
},
"backend": "local",
"resources": {
"partition": "regular",
"cpus_per_task": 8,
"memory": "64G",
"time_limit": "04:00:00",
"job_name": "smint_centroid",
"gpus": 0,
"gpu_type": "A30"
},
"worker_python": "/path/to/env/bin/python",
"created": "2026-10-26T15:11:52"
}
| Key | Meaning |
|---|---|
round |
centroid (same section) or st_sm (sequential sections, STalign) |
inputs |
centroid: source_file, target_file. st_sm: st_file, sm_file, st_points, sm_points. All must exist when the job is submitted |
output_dir |
The job directory. For sbatch it must be on storage the compute node sees (not /tmp) |
params |
Keyword arguments for register_centroid_files or register_sm_to_st; output_path overrides the output file name |
backend |
local (detached subprocess) or sbatch |
resources |
SlurmResources; ignored for local |
worker_python |
Interpreter that runs -m smint.cli.register; for st_sm it needs STalign (numpy < 2) |
created |
Timestamp; reset when a spec is read back |
status.json¶
Rewritten at each step, so a GUI or --watch can poll it without parsing logs.
poll also asks SLURM (squeue, then
sacct) and reports a job SLURM killed (out of memory, time limit, cancelled) as
failed, which the worker itself could not record.
stateDiagram-v2
[*] --> submitted
submitted --> queued: sbatch
submitted --> running: local (pid)
submitted --> failed: sbatch error
queued --> running: worker starts
queued --> failed: killed by SLURM
running --> completed
running --> failed: error, or killed by SLURM
completed --> [*]
failed --> [*]
state |
Meaning |
|---|---|
unknown |
No readable status file yet (returned by read_status, never written) |
submitted |
Spec written, worker not launched yet |
queued |
Accepted by sbatch; job_id is set |
running |
Worker started (pid for local jobs) |
completed |
Finished; outputs and, for centroid, metrics are set |
failed |
message holds the exception; traceback the full traceback |
cancelled |
Treated as final by poll; SMINT itself reports SLURM cancellations as failed |
Every status has state, message and updated. This is the status.json of the
same Sample 2 z5 run, a completed centroid round (path shortened):
{
"state": "completed",
"message": "centroid round finished",
"updated": "2026-10-26T15:12:13",
"outputs": {
"transformed_csv": "runs/z5_refine/centroids_transformed.csv"
},
"metrics": {
"method": "multiround",
"n_matched": 49206,
"tre_initial": 10.957773145566303,
"tre_fit": 4.447931821449571,
"tre_validation": 4.925670066673518,
"inlier_fraction": null,
"rounds": [
{"stage": "initial", "median_nn": 12.399709824113891},
{"stage": "affine", "max_distance": 100.0, "block_size": 500.0, "blocks": 300,
"consistent_blocks": 0.91, "median_nn": 5.376158600755868, "accepted": true},
{"stage": "tps", "max_distance": 25.0, "block_size": 250.0, "blocks": 1013,
"consistent_blocks": 0.7680157946692991, "median_nn": 5.4244016618258435,
"accepted": false}
],
"validation": {
"before": {"median_nn": 12.437970058717603, "mutual_within_5": 0.10054078413699864,
"mutual_within_10": 0.3390716538981523},
"after": {"median_nn": 5.382032714808998, "mutual_within_5": 0.4511491662911221,
"mutual_within_10": 0.7585849481748536}
},
"refinement": {
"n_assigned": 8266,
"median_deviation": {"snapped": 4.140545587695355, "assigned": 8.544943093267737},
"n_snapped": 57083,
"fraction_snapped": 0.6420601534204665,
"fraction_targets_matched": 0.8801567740110712,
"snapped_per_pass": [55208, 56923, 57069, 57083],
"n_filled": 9147,
"consistency_tol": 6.955109026100267,
"validation": {"n": 5594, "median_error": 3.9995483181583147,
"within_2": 0.1803718269574544, "within_5": 0.6603503754022166}
}
}
}
Reading it: the held-out target registration error fell from 11.0 to 4.9 µm
(tre_initial → tre_validation); round 1 (affine) was accepted and round 2
(TPS) rejected because it did not improve the median nearest-neighbour distance;
refinement snapped 57,083 nuclei onto their cells and assigned 8,266 more.
metrics key |
Meaning |
|---|---|
method |
Registration model used |
n_matched |
multiround: mutual nearest-neighbour pairs among the fit points after the transform. Single-round methods: pairs matched before fitting |
tre_initial, tre_fit, tre_validation |
Mean target registration error before, on fitted points, and on held-out points. Only tre_validation is a fair accuracy estimate |
inlier_fraction |
RANSAC inlier fraction (ransac, ransac+tps); null otherwise |
rounds |
multiround only: per round, block size, number of blocks, fraction consistent with the RANSAC fit, median nearest-neighbour distance and whether it was kept |
validation |
multiround only: alignment_quality of the held-out points before and after (median_nn, fraction mutual within 5 / 10 units) |
refinement |
Refinement summary, null with --no-refine: counts per status, fraction_targets_matched, median_deviation per status, and a held-out check of the snapped offset field (validation.median_error, within_2, within_5) |
An st_sm round reports outputs only. A failed job, here a centroid run whose
source file had no centroid_x / centroid_y columns:
{
"state": "failed",
"message": "ValueError: Columns ['centroid_x', 'centroid_y'] not found in cells.csv. Available: cell_id, x_centroid, y_centroid",
"updated": "2026-10-26T16:33:25",
"traceback": "Traceback (most recent call last):\n ...\nValueError: Columns ['centroid_x', 'centroid_y'] not found in cells.csv. ..."
}
Registration outputs¶
centroids_transformed.csv¶
Written by the centroid round (register_centroid_files): every source row,
with all its original columns, plus:
| Column | Meaning |
|---|---|
x_transformed_smooth, y_transformed_smooth |
Position after the fitted transform alone (only with refinement, the default) |
registration_status |
How the final position was obtained; see below (only with refinement) |
target_index |
0-based row number in the target CSV of the partner cell; -1 if none (only with refinement) |
neighbour_deviation |
For placed points: distance between this point's offset to its partner and the median offset of its snapped neighbours. Small (about the nucleus-in-cell scatter) for right pairs, about a cell spacing for wrong ones; empty for filled / unmoved (only with refinement) |
x_transformed, y_transformed |
Final position: refined when refinement ran, otherwise the transform alone |
registration_status |
Meaning | Final position |
|---|---|---|
snapped |
Mutual nearest neighbour within --snap-radius, consistent with its neighbourhood |
exactly on its target cell |
assigned |
Left over after snapping, then paired one-to-one with a free cell by minimum total distance; skipped the consistency test, so less certain | exactly on its target cell |
filled |
No partner; moved by the median offset of its snapped neighbours | smooth position + local offset |
unmoved |
No partner and no snapped neighbours in range | smooth position |
Rows from the Sample 2 z5 run, one per status (coordinates in µm, rounded; the source file's own columns are omitted except the centroids):
| x_centroid | y_centroid | x_transformed_smooth | y_transformed_smooth | registration_status | target_index | neighbour_deviation | x_transformed | y_transformed |
|---|---|---|---|---|---|---|---|---|
| 626.63 | 6097.85 | 666.11 | 6121.17 | unmoved | -1 | 666.11 | 6121.17 | |
| 471.74 | 7763.32 | 519.21 | 7794.19 | snapped | 11371 | 2.17 | 519.76 | 7793.32 |
| 927.07 | 7817.44 | 973.26 | 7846.87 | assigned | 29742 | 9.59 | 966.42 | 7839.82 |
| 1389.68 | 4330.35 | 1418.68 | 4343.47 | filled | -1 | 1418.95 | 4342.49 |
To join nuclei to their Xenium cells:
import pandas as pd
nuclei = pd.read_csv("runs/centroid/centroids_transformed.csv")
cells = pd.read_csv("xenium_cells.csv")
paired = nuclei[nuclei.registration_status == "snapped"]
joined = paired.merge(cells, left_on="target_index", right_index=True, suffixes=("", "_cell"))
Keep only the confident pairs
Filter on registration_status == "snapped", or keep assigned pairs whose
neighbour_deviation is close to that of the snapped ones. With
--no-refine, only x_transformed / y_transformed are added.
sm_transformed.csv¶
Written by the st_sm round (register_sm_to_st): the SM matrix as read for
registration (x, y scaled, rotated and shifted; m/z columns X-prefixed) plus
x_transformed, y_transformed, the position of every SM pixel in the ST frame.
Record the orientation
The CSV does not say which orientation produced it. With stalign, the
columns are true x / y in the ST frame and overlay the ST cells; with
dataset (the default) they are transposed relative to ST. The job's
job_spec.json (params.orientation) is the record.
Segmentation outputs¶
Distributed segmentation outputs¶
run_distributed_segmentation
writes, into the output directory:
| File | Contents |
|---|---|
cells_outlines.csv, nuclei_outlines.csv |
One row per outline vertex, after stitching: global_cell_id, x, y |
cells_summary.csv |
One row per cell: global_cell_id, x_centroid, y_centroid, area, n_nuclei |
nuclei_summary.csv |
One row per nucleus: global_cell_id, x_centroid, y_centroid, area, cell_id |
cells.zarr, nuclei.zarr |
Label masks of the whole image |
distributed_seg_<YYYYmmdd-HHMMSS>.log |
Run log, including per-tile counts and stitching statistics |
dask_logs/ |
SLURM backend only: worker job logs, in the current directory |
Coordinates are pixels of the full-resolution image (x = column, y = row);
area is in pixels². IDs start at 1 and are shared between a cell's outline and
summary rows. In nuclei_summary.csv, cell_id is the global_cell_id of the
cell containing the nucleus. With reconciliation off (reconcile_nuclei: false)
the n_nuclei / cell_id columns are absent.
These rows are from a 768 × 768 px synthetic two-channel image (225 cells) run in four 384 px tiles with a 64 px overlap:
Each outline is a closed ring: its last vertex repeats the first.
Zarr label masks. uint32 arrays the size of the image ((height, width)),
chunked by tile, 0 for background, in the Zarr format of the installed zarr
package (v3 with zarr 3, v2 with zarr 2). Each tile writes only its core region,
with labels offset by tile_id × 100000, so IDs are unique across tiles but are
not the stitched global_cell_ids: use the CSVs for analysis and the masks for
display or pixel lookups.
import zarr
masks = zarr.open_array("results/dist/cells.zarr", mode="r")
print(masks.shape, masks.dtype, masks.chunks) # (768, 768) uint32 (384, 384)
labels = masks[0:384, 0:384] # NumPy array of one tile
Single-process segmentation outputs¶
process_large_image
(and run_segmentation.py without --distributed, which uses
<output-dir>/cell_outlines as the base path) writes:
| File | Contents |
|---|---|
<base>_cells.csv, <base>_nuclei.csv |
One row per outline vertex: global_cell_id, chunk_id, x, y (integer pixels) |
<base>.log |
Run log |
visualizations/chunk_<row>_<col>.png |
Outline overlays (cells red, nuclei cyan) for randomly chosen chunks, when visualisation is on; <row>, <col> are chunk indices |
chunk_id is "<y>_<x>", the pixel origin of the chunk the object was found in.
IDs run consecutively over the whole image. Chunks do not overlap and are not
stitched, so a cell crossing a chunk edge appears once per chunk: on the same
synthetic image as above, 384 px chunks gave 234 cells for 225 real ones.
Get one centroid per object with
smint.segmentation.postprocess.calculate_centroids(pd.read_csv("<base>_nuclei.csv")),
which returns cell_id, centroid_x, centroid_y, chunk_id (the mean of each
outline's vertices), ready to use as a registration source.
Live viewer files¶
When a run is given tile_info_path and live_update_image_path
(run_segmentation.py --live-viewer sets them to <output-dir>/current_tile_info.txt
and <output-dir>/live_view.png), each finished tile rewrites:
- the PNG: the tile's latest segmentation (single process: the chunk with outlines; distributed: the tile's cell boundaries);
- the tile-info file: one line,
y,x,tile_height,tile_width,scan_height,scan_width,png_name.
smint-viewer reads both to show progress.
Next steps¶
- Configuration: the YAML keys behind the segmentation outputs.
- Command line: the commands that read and write these files.
- Alignment API and Segmentation API.