A domain representation is what the histo pipeline produces for one H&E whole-slide image: five label maps that describe the slide down to single cells. This page is for teams that want to use them.
There are three ways in:
- Read the published outputs from the data platform. This is the normal
route. You need
vxdata-sdkandzarr, but not thehistopackage. - Production runs on the cluster, which publish new outputs to the platform.
- Local runs, which write outputs to shared storage for your own experiments.
1. Read published outputs
What is published
Each processed scan has one HistoDomainRep bundle in the histo_domain_reps
namespace. The bundle points to five HistoMaps.
| Bundle field | HistoMap task | Map |
|---|---|---|
tissue_segmentation |
TISSUE_SEGMENTATION |
tissue_seg.zarr |
cell_segmentation |
CELL_SEGMENTATION |
cell_seg.zarr |
nuclei_segmentation |
NUCLEI_SEGMENTATION |
nuclei_seg.zarr |
gleason_segmentation |
GLEASON_SEGMENTATION |
gleason_seg.zarr |
structure_tensor |
STRUCTURE_TENSOR |
structure_tensor.zarr |
Identifiers carry the pipeline version, so several versions can exist side by side:
histodomainrep/<scan path>/v2.0
histomap/<scan path>/tissue_seg-v2.0<scan path> is the source HistoScan identifier without the histoscan/
prefix. The bundle’s parent_identifier is that HistoScan, so you can find the
H&E the maps were made from.
Which scans have a bundle changes with every run, so read it from the platform. For example, bundles per dataset:
reps = client.histo_domain_reps.query().filter(F.histo_version == "v2.0")
reps.select("datasource_id").collect()["datasource_id"].value_counts()Load one slide
from pathlib import Path
import zarr
from vxdata.sdk import Client, F
client = Client()
# Pick bundles: by dataset, by scan, or by version.
reps = (
client.histo_domain_reps.query()
.filter(F.datasource_id == "datasource/essen01", F.histo_version == "v2.0")
.collect()
)
rep = reps.row(0, named=True)
# Resolve the five maps to storage urls.
fields = ["tissue_segmentation", "cell_segmentation", "nuclei_segmentation",
"gleason_segmentation", "structure_tensor"]
maps = client.histo_maps.query().filter(F.identifier.is_in([rep[f] for f in fields])).collect()
urls = dict(zip(maps["task"], maps["url"]))
# Download the maps you need (each url is a zarr directory) and open them.
dest = Path("domain_reps")
tissue_path = Path(client.storage.download(urls["TISSUE_SEGMENTATION"], dest))
cell_path = Path(client.storage.download(urls["CELL_SEGMENTATION"], dest))
tissue_group = zarr.open_group(tissue_path, mode="r")
cell_group = zarr.open_group(cell_path, mode="r")
tissue_seg = tissue_group["scale_1"] # (H, W) uint8
cell_seg = cell_group["scale_1"] # (H, W) uint32
cell_lut = cell_group["cell_lut"][:] # (n_cells, 3) uint32
classes = tissue_group.attrs["virdx_attrs"]["misc"] # class names, ids, coloursThe H&E comes from the parent scan: client.histo_scans.query() .filter(F.identifier == rep["parent_identifier"]). Its url is the
preprocessed pyramidal TIFF.
The maps
Every map is an OME-Zarr (zarr v3) group. The image is the array scale_1;
there is no pyramid. cell_seg.zarr also holds the cell_lut array. The four
label maps share one pixel grid: the H&E pyramid level closest to 1 µm/px. The
structure tensor is on a 4x coarser grid.
The pipeline does not resample, so the pixel size follows the component’s pyramid, which depends on the scanner. It ranges from 0.88 to 1.10 µm/px:
| Datasets | µm/px of the label maps |
|---|---|
| Essen01 (most), Leopard, Chimera, PESO | 0.97 |
| Essen01 (giga-block slides) | 0.88 |
| AGGC | 1.00 (KFBio: 0.96) |
| PANDA | 0.97 or 1.01 |
| HistAI | 1.04 (some slides: 0.97) |
| Essen02 | 1.05 |
| Frankfurt | 1.10 |
| ICESP (components at 0.25, from 0.172 µm/px scans) | 1.00 |
Always read the pixel size of each map; never assume one value. It is stored in three places that agree:
- the
scalein the zarr’s OME metadata, in micrometers (the axes of maps written so far sayunit: null; read that as micrometers too), - the
affinein itsvirdx_attrs, generation_outputs["mpp"]on the map’sHistoMaprecord (the bundle’smppis that of the label maps).
Do not derive it from the parent HistoScan: its resolution is often empty,
rounded, or (for Frankfurt) 2x too small.
Each map’s generation_outputs also says where its grid sits on the parent
HistoScan:
component_level: the TIFF level of the scan the maps were made from,level_downsample: how many times that level is downsampled from level 0, as a whole number (4 for a 0.25 µm/px component read at 1 µm/px). It is the nominal factor, not the ratio of the TIFF level shapes, which rounding makes slightly off,stride: 1 for the label maps, 4 for the structure tensor.
Map pixel i is pixel stride * i of level component_level. The
translation in the zarr’s OME metadata (and in its affine) is the offset of
the map’s first pixel centre from the centre of level-0 pixel (0, 0), on both
axes: (d - 1) / 2 · mpp / d µm, where d is level_downsample and mpp is
the label maps’ µm/px. For d = 4 at 1 µm/px that is 0.375 µm. The structure
tensor has the same translation: its first sample sits on the label maps’ first
pixel. Maps written before histo 0.0.162 have translation 0 and lack these
three fields.
| Map | Shape | dtype | Pixel value |
|---|---|---|---|
tissue_seg |
(H, W) | uint8 | tissue class, with Gleason grade on epithelium |
cell_seg |
(H, W) | uint32 | cell id on cell pixels, tissue class elsewhere |
nuclei_seg |
(H, W) | uint32 | nucleus id (= its cell id) on nucleus pixels, tissue class elsewhere |
gleason_seg |
(H, W) | uint8 | 0 background, 1 benign, 3/4/5 Gleason grade |
structure_tensor |
(⌈H/4⌉, ⌈W/4⌉, 2) | uint8 | fibre orientation and coherence, 4x subsampled |
One id space
tissue_seg, cell_seg and nuclei_seg use the same id space:
| Ids | Meaning |
|---|---|
| 0–27 | base tissue classes (the fine28 taxonomy; 0 = background) |
| 128–139 | Gleason-graded epithelium, e.g. 132 = luminal_epithelium_g4 |
| 255 | unknown |
| ≥ 256 | cell instance ids (only in cell_seg and nuclei_seg) |
Do not hard-code the class table. tissue_seg, cell_seg and nuclei_seg
store it in attrs["virdx_attrs"]["misc"]: region_ids, region_classes,
region_palette_rgb and id_space. The same table is in their HistoMap’s
labels column. gleason_seg stores its own five-class table in the same
place, without id_space. The source config is config/domain_representation/taxonomy/fine28.yaml.
Cells
cell_seg.zarr/cell_lut has one row per cell: [cell_id, base_class, gleason_grade]. base_class is a 0–27 id. gleason_grade is 3, 4 or 5 for
graded epithelium cells and 0 for all others.
The maps agree by construction:
- On cell pixels,
tissue_segis the cell’s class, graded if the cell has a grade. - Off cell pixels,
cell_seg == tissue_seg. - Every nucleus id in
nuclei_segis also a cell id incell_seg. - Every cell id in the map has a LUT row. A few LUT rows (about 0.3 % on a test slide) have no pixels, so join from the map to the LUT, not the reverse.
Only these classes hold cells: epithelia (1, 2, 22, 23), stroma (3, 4), inflammation, nerve, vessels and arteries. Lumen, fat, blood and artefact classes have none.
Structure tensor
Channel 0 is the dominant gradient orientation in radians, in [-π/2, π/2]. The
fibres run perpendicular to it. Channel 1 is coherence in [0, 1]. Decode with
value * scale + offset. The scale and offset are in
attrs["virdx_attrs"]["misc"]["structure_tensor_encoding"]. Pixel
(r, c) samples full-grid pixel (4r, 4c). With histo installed,
histo.domain_representation.representation.decode_structure_tensor(array)
does the decode.
Provenance and versions
Each bundle records:
histo_version: the pipeline version (v2.0). It changes whenever the outputs change meaning.tissue_model_id,gleason_model_id,cell_model_id: the ClearML model ids.generation_parameters: the full pipeline config, including the Docker image tag and the taxonomy.generation_outputs: µm/px (mpp),component_level,level_downsample, cell count, timings, and urls of the run log (run.json) and event log (events.jsonl).
A bundle is written only after its five maps are uploaded. If a bundle exists, the scan is complete.
Which tissue is covered
A bundle does not cover the whole slide. Its maps are made from a preprocessed
HistoScan (the bundle’s parent_identifier), and preprocessing keeps only
part of the tissue:
- A tissue detector finds the tissue on the raw slide.
- The mask is split into connected pieces. Pieces that are too small or look like artefacts are dropped.
- Each kept piece is cropped, straightened and published as its own scan,
histoscan/<raw scan path>/preprocessed/component_<i>, whoseparent_identifieris the raw scan.
Dropped tissue is in no bundle, so small fragments are missing. One slide can also have several components, each with its own bundle; group by the raw scan for slide-level numbers.
Current settings. The preprocessing factory (mono
apps/factory/src/factory/registry.py) runs histo’s tissue detection
(histo.tissue_detection.params) with:
| Step | Setting |
|---|---|
| Detector | GrandQC tissue model at 10 µm/px |
| Pen marks | a piece is dropped if ink is at least 50 % of its coloured pixels |
| Minimum area | 5 mm² (min_component_area_um2). The biopsy rule (Essen02, AGGC and PANDA biopsies) uses 0.1 mm². |
| Content check, pieces below 50 mm² only | dropped if blurry (median Laplacian variance < 150), pale (< 15 % stained pixels) or pen (ink ≥ 40 %), measured at 2.2 µm/px |
| Size relative to the largest piece | off (min_area_frac_of_largest = 0) |
| Pixel size | the scan’s own, if a 2x step of it lies in 0.85-1.15 µm/px; otherwise the scan is area-averaged onto the finest power-of-two step of 1 µm/px that needs no upsampling (0.25 µm/px for a 0.172 µm/px scan), so the component’s pyramid holds 1 µm/px |
Older components used other settings. Components are not redone when the settings change, so the published bundles come from these setups, and one dataset can mix several:
| Preprocessing | method |
Minimum area | Other filters |
|---|---|---|---|
| Otsu (oldest) | None |
100,000 px at 5x (about 0.4 mm², on a strongly dilated mask) | on RPE slides, every piece below 30 % of the largest piece is dropped |
| GrandQC | grandqc |
2.56 mm² | content check in later runs only |
| GrandQC (current) | grandqc |
5 mm² (biopsy rule: 0.1 mm²) | content check |
The 30 % rule matters most: on those RPE slides only the main piece and pieces close to its size were kept.
Read the settings of a bundle from its component scan:
import json
scan = (
client.histo_scans.query()
.filter(F.identifier == rep["parent_identifier"])
.collect()
.row(0, named=True)
)
params = json.loads(scan["preprocessing_parameters"])
params = params["tissue_detection_params"]
params.get("method") # "grandqc"; None for Otsu
params.get("min_component_area_um2") # µm²; Otsu: min_component_area (px)
params["min_area_frac_of_largest"] # 0.3 on Otsu RPE runs, else 0
params.get("content") # content check settings, if it ranGrandQC runs also publish the whole-slide mask as the FOREGROUND_MASK map
histomap/<raw scan path>/preprocessing_tissue_mask. It is in raw slide
coordinates and holds only the kept pieces, so it shows what preprocessing
kept, but not what it dropped.
Size and practical notes
- Biopsy slides are small (for example 27k × 6k px). Frankfurt whole-mounts are
about 47k × 47k px. There,
tissue_segis about 100 MB andcell_segseveral times more. Read windows (tissue_seg[y0:y1, x0:x1]) instead of whole arrays when you can. - Label maps are stored in 1344 × 1344 px chunks inside shards of 5376 rows × one super-window (26880 px); the structure tensor uses a quarter of each. Every pyramid level is sharded too. All are compressed with zstd.
storage.download(url, dest)keeps the key path underdest, without the bucket name.
2. Production runs: publish to the data platform
A production run processes HistoScans on the cluster and registers the results
on the platform, as described in section 1. The run is the Argo WorkflowTemplate
histo-domain-representation-v2, kept in the mono repo
(apps/factory/argo/_histo-domain-representation-v2.workflowtemplate.yaml) and
deployed from there. It runs the histo image: the image holds the code, the
environment and the config, so the image tag decides the pipeline version and
the models. The pods read everything from S3, Hugging Face and ClearML, with
credentials from cluster secrets; they need no shared storage.
Weekly schedule. The CronWorkflow histo-domain-representation-v2-rpe (same
folder) runs the template every Sunday on every preprocessed prostate RPE scan
(organ=prostate, so a new prostate datasource is picked up without a change),
in production. Only scans without a bundle are
processed, so a week without new scans costs one short list step. It pins a
histo image tag; change it in a reviewed mono PR. A tag with a new
pipeline_version reprocesses every scan (about 300 GPU-hours) and its bundles
sit next to the old ones. New model ids need a new pipeline_version: the
platform does not replace a bundle, so scans that already have one of the same
version are skipped with a warning.
Manual runs. You need argo access to the argo-workflows namespace and a
histo image (kuberun build on a clean checkout builds the current commit as
histo:<branch>-<sha>). Test on staging first: run_environment=staging (the
default) registers the bundles on the staging platform and uploads to the
domrep_v2_testing group. Staging holds the same HistoScans as production.
run_environment=production writes the official v2.0 bundles that other teams
read (upload group domainrep_v2.0). Agree production runs with the histo team.
argo submit -n argo-workflows --from workflowtemplate/histo-domain-representation-v2 \
-p image_tag=<tag> -p run_environment=staging \
-p datasources=essen01 -p specimen_types=RPE
# or explicit scans
argo submit -n argo-workflows --from workflowtemplate/histo-domain-representation-v2 \
-p image_tag=<tag> -p scans="histoscan/<a> histoscan/<b>"The template calls paths inside the image. If a histo change moves or changes the
scripts it calls (scripts/domain_representation/list_domain_representation_worklists.py
and generate_domain_representation.py), update the template in the same step.
Platform token. The platform does not let anonymous callers read
pseudonymized or CC-licensed scans or create maps. The template sends the
VXDATA_TOKEN of the secret vxdata-credentials-<run_environment>. Use an
image of histo 0.0.141 or newer: older images lack the list step options the
template passes.
| Parameter | What it sets |
|---|---|
image_tag |
The histo image. Required. |
datasources |
Space-separated datasource shorthands, e.g. essen01 histai. Every preprocessed scan, crops left out. |
organ |
Only scans of this organ, e.g. prostate. Alone or together with datasources. |
specimen_types |
With datasources or organ: only these specimen types, e.g. RPE. Empty takes all, biopsies included. |
scans |
Instead of datasources and organ: space-separated HistoScan identifiers. |
run_environment |
staging (default) or production. |
gpu_class |
rtx-6000-pro (Blackwell) or rtx-6000 (Ada). Empty takes either. |
worklist_mpx |
Slide area per GPU pod, in megapixels at 10x. The default 40000 takes about 2 h on Blackwell. |
overrides |
Extra Hydra overrides for the pipeline. |
cpu |
CPUs per GPU pod. Default 16. |
local_cache |
Node-local scratch per GPU pod at /cache (outputs, weights, kernel caches). Default 100Gi. |
What happens:
- A list step finds the scans that have no bundle of this pipeline version, reads each slide’s size from its TIFF header on S3, and packs them into worklists. It fails if no slide can be sized. Resubmitting only processes what is left.
- One GPU pod (128 Gi) per worklist downloads the models, then processes its scans one by one. A failed scan does not stop the others; the pod then exits with code 13.
- Inputs are preprocessed HistoScans. A scan’s
FOREGROUND_MASKmap is used when it has one; without it every window is embedded, which is slower.
A manual run goes to your own Kueue queue (user-yourname), which may hold only
one GPU. To also use idle GPUs elsewhere on the cluster, add
-l virdx.dev/preemptible=true to argo submit. Those pods can be stopped and
rerun, which is safe because done scans are skipped.
Watch the run with argo list -n argo-workflows, argo get -n argo-workflows <workflow> and argo logs -n argo-workflows <workflow>. Finished scans appear
as bundles on the platform.
3. Local runs: outputs for your own experiments
A local run writes the same five maps to shared storage and never touches the platform. Use it to try other settings, or slides that are not on the platform.
You need:
- A GPU. The coder pod’s 24 GB GPU slice works with the
localprofiles. - A histo checkout with its environment:
pixi install --manifest-path packages/histo/pyproject.toml -e cuda-dev. - ClearML credentials in
~/clearml.conf. The three heads are downloaded fromclearml.fra.virdx.devon the first run and cached in~/.clearml/cache. /mnt/artifactsmounted. The backbone weights are read from there, so no Hugging Face token is needed.- A vxData token only if your inputs are platform scans.
Run from the repo root:
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
pixi run --manifest-path packages/histo/pyproject.toml -e cuda-dev \
python scripts/domain_representation/generate_domain_representation.py \
compute=local 'inputs.paths=[/path/to/slide.tiff]'The two certificate variables let Python trust the internal CA of
clearml.fra.virdx.dev. On the coder pod the system bundle already contains
it.
Inputs, one of:
inputs.paths=[...]: preprocessed pyramidal TIFFs. If<name>_tissue_mask.ome.zarrsits next to<name>.tiff, only tissue is embedded.inputs.list_file=slides.json: a JSON list of{"path": ..., "identifier": ...}.identifieris optional and names the output folder.inputs.scans=[histoscan/...]: platform scans, downloaded first.
inputs.limit=N processes only the first N slides.
Profiles: compute=local (fits the coder pod) and compute=local_small
(smaller windows, for quick checks).
Each slide gets a folder
/mnt/artifacts/$USER/histo/domain_representation_v2/<slide>/ with the five
maps (tissue_seg.zarr, cell_seg.zarr, …), run.json (settings, models,
timings), events.jsonl, and links to the input slide and its tissue mask, so a
viewer can open the folder as one case. Set output.scratch_dir=... to write
elsewhere.
Open the maps with zarr as in section 1; their layout is the same.
A slide whose folder already has a run.json is skipped. Delete the folder, or
use a new output.scratch_dir, to compute it again.
Any setting in config/domain_representation/generate.yaml can be changed on
the command line, for example cell_postprocess.nucleus_threshold=0.6. Settings are checked
on load, and a bad combination stops the run with a message.
Python API
The same run from Python. The public API is what histo.domain_representation
exports:
| Name | What it does |
|---|---|
load_settings(cfg) -> PipelineSettings |
Types and checks the composed Hydra config. |
load_models(settings, device=None) -> Models |
Loads H-optimus-0 and the tissue, Gleason and cell heads. |
process_scans(scans, settings, models, *, platform) -> ScansReport |
Processes a list of slides. Skips slides already done, and one failure does not stop the rest. report.done, report.failed. |
process_scan(scan, settings, models, *, output_dir, ...) |
Processes one slide into output_dir. |
local_scans(settings.inputs) -> list[ScanInput] |
Slides from local paths or a JSON list file. |
ScanInput, PlatformScan |
One slide on disk / one HistoScan on the platform. |
Platform |
Resolves scans and publishes outputs. Pass platform=None to write locally only. |
from hydra import compose, initialize_config_dir
from histo.domain_representation import load_models, load_settings, local_scans, process_scans
with initialize_config_dir(version_base=None, config_dir="/abs/path/to/histo/config/domain_representation"):
cfg = compose("generate", overrides=["compute=local", "inputs.paths=[/data/slide.tiff]"])
settings = load_settings(cfg)
report = process_scans(local_scans(settings.inputs), settings, load_models(settings), platform=None)The config is in the repo, not in the histo package, so this also needs a
histo checkout. The other modules of histo are internal; their interfaces may
change without notice.
Contact
Ask the histo team before you depend on anything not listed on this page.