Skip to content

Analytics Data Format

This page builds from the simplest possible file up to the full format, one step at a time. Each step exists because the previous one runs out, and the reasoning is spelled out, so you can stop at whichever level your data actually needs.

Nothing is uploaded and nothing is converted. Your files stay where you wrote them, and the viewer reads them from the browser.

To see this format carrying a real screen rather than a toy example, read HCS Analytics in the Browser. It publishes BBBC013 as a bundle and loads it from one URL.

Unstable format, expect breaking changes

Published for transparency and tooling, not as a stable contract. It is still converging, and parts will change without preserving backward compatibility.

Build against it if you want, and please tell us what you are building. Pin a copy rather than assuming something that loads today will load next month. The schemas are versioned (v1.json), so a future v2 can appear alongside rather than replace it.

Step 1: the smallest thing that works, a CSV

One row per object, one column holding the object's ID:

csv
object_id,area_um2,mean_intensity
1,187.4,2145
2,203.1,1876
3,164.9,2903

Host it, paste the URL, done. Every other column becomes something you can colour, filter and group by.

There are only two ways in: pick a file or paste a URL. You never choose a format. The viewer works it out from what you gave it: .parquet loads as Parquet, a trailing slash loads as an analysis directory, anything else is parsed as a delimited table with the separator detected. So the steps below are not modes you switch between. They are how far you have chosen to go.

Why the object ID is the whole contract

The value in the ID column must equal the pixel value of that object in the label mask. Object 7 in the table is the region painted with value 7 in the mask.

That constraint does a lot of work, and it is deliberate. It makes the join a direct lookup rather than a spatial query: no polygon intersection, no nearest-centroid matching, no coordinate system to agree on. It is also what a segmentation tool already produces, because a mask's pixel values are the object IDs. The cheapest possible contract turns out to be the one your pipeline already satisfies.

The cost is that a table whose IDs do not line up with the mask is silently wrong rather than loudly broken. That is why this column matters more than anything else on this page.

The viewer looks for a column named, case-insensitively, one of object_id, objectid, object_number, objectnumber, label, label_id, labelid, id, index, object. If several match it asks which. If none match it falls back to row index, which is convenient and fragile, so name the column properly.

Carry centroid_x and centroid_y too if you have them, and the viewer can navigate to an object rather than only colour it. center_x, x, and the CellProfiler spellings location_center_x and areashape_center_x are recognised as well.

Step 2: when the table gets big, Parquet

A CSV has to be downloaded and parsed in full before anything appears, because there is no way to read part of one. Fine for a few thousand objects, painful for a plate.

Parquet is columnar and seekable. The viewer reads the file's footer to learn the column names, then fetches a single column's bytes only when you actually colour or filter by it. A forty-column table costs one small range request until you touch a column.

CSVParquet
LoadedWhole file, up frontColumn by column, on first use
Row ceiling1,000,0005,000,000
Size ceiling200 MBGoverned by the row count
Good forA few thousand objects, quick sharingA plate's worth of objects and dozens of columns

Those ceilings exist because a browser tab has to stay usable. They limit what the viewer will attempt, not what the format can describe.

Everything from Step 1 still holds: same ID rule, same column names. Swapping CSV for Parquet changes how the bytes arrive and nothing else.

Step 3: when the mask is not in the image, labels plus measurements

Steps 1 and 2 assume the segmentation already sits inside the OME-Zarr. Usually it does not. An analysis run produces both the mask and the numbers, and both are new files next to an image nobody wants to rewrite.

So declare them together. Put a manifest in a directory with the files it names:

nuclei/
├── manifest.json
├── measurements.parquet
└── labels/              ← the mask, declared below
json
{
  "$schema": "https://find-nuclei.github.io/schemas/analytics-bundle/v1.json",
  "version": "1",
  "format": "parquet",
  "target": { "labelName": "nuclei" },
  "measurements": {
    "path": "measurements.parquet",
    "objectIdColumn": "object_id",
    "centroidColumns": { "x": "centroid_x", "y": "centroid_y" }
  },
  "labels": {
    "nuclei": { "path": "labels", "scope": "per-field", "format": "ome-ngff-0.5", "rootType": "plate" }
  }
}

Point the viewer at …/nuclei/ and the mask appears in the Labels list with its measurements already attached. target.labelName is the name it takes there.

Why a directory and a manifest

Because an analysis result is more than one file, and the alternative to naming those files is guessing at them.

The manifest is an index, not a hint. The viewer fetches exactly what it names and nothing else. A file sitting in the directory that the manifest does not mention is invisible, however conventionally it is named.

That cuts both ways, on purpose. You can leave intermediate files beside the published ones without the viewer tripping over them or trying to parse them. And a file you forget to declare simply never loads, with no error to tell you so.

A trailing slash on the URL is what makes the viewer look for manifest.json. A URL ending in .csv or .parquet is a single file. A URL ending in manifest.json is not recognised as a bundle: point at the directory.

Optional sidecars are declared the same way, and a missing or malformed one degrades to a console warning rather than a failed load:

DeclaredWhat it buys you
histogramsPrecomputed per-column range and distribution, so sliders and filter histograms appear instantly instead of after a scan
phenotypesSaved phenotype groups, restored as if you had built them by hand
extendedMeasurementsExtra tables joined to the main one by a declared foreign key, their columns streaming lazily like any other

Step 4: when one run produces several compartments, the analysis bundle

Segmenting nuclei, then cells, then the cytoplasm between them is one analysis. It produces three masks and three tables, and asking a reader to paste three URLs would misrepresent that as three unrelated results.

So a run manifest lists them:

0b1e47a7-stardist-cellpose/
├── manifest.json          ← kind: "run", lists the children below
├── nuclei/                ← manifest.json, measurements.parquet, labels/
├── cells/                 ← same shape
└── cytoplasm/             ← same shape
json
{
  "$schema": "https://find-nuclei.github.io/schemas/analytics-run/v1.json",
  "version": 1,
  "kind": "run",
  "bundles": [
    { "path": "nuclei",    "targetLabel": "nuclei" },
    { "path": "cells",     "targetLabel": "cells" },
    { "path": "cytoplasm", "targetLabel": "cytoplasm" }
  ]
}

Each child is an ordinary Step 3 directory. Nothing about it is special, and that is the point: the run manifest adds one level of nesting and no new concepts. The children load in parallel, so one URL gives you three masks and three sets of measurements.

It is also why targetLabel lives in the run manifest instead of being inferred from the directory name. The directory is a filesystem detail; the label name is what the reader sees.

The tables above are the whole of what the viewer acts on. The published JSON schemas allow more fields, mostly provenance that is carried but not displayed, so treat this page rather than the schema as the answer to "will the viewer do something with this".

Hosting

The viewer runs entirely in the browser, so your storage has to answer the browser directly.

  • CORS. Access-Control-Allow-Origin must permit the viewer's origin, and Content-Length must be exposed, because the size gate reads it to reject an oversized CSV before downloading it.
  • Range requests. Required for Parquet. Content-Range must be exposed too.
  • HTTPS, unless you are serving from localhost.

S3, Cloudflare R2, GCS, Azure Blob and a plain nginx all work once CORS is configured. See the Data Server guide for running one locally.

A shared URL can carry the analytics alongside the image, so a reviewer opens the whole thing in one click:

?url=<image>&analyticsUrl=<directory-or-csv>&analyticsLabel=nuclei

analyticsLabel says which mask to attach to, and is needed for a single analysis because one image may carry several. A run manifest already names its targets inside, so it does not need the parameter. Add analyticsIdCol to pin the ID column when auto-detection would guess wrong.

Free. Private. Browser-based.