Skip to content

Images and uploads

An image is a 3D array that belongs to a team. You upload it in three steps. The file goes straight to object storage, so a 4 GiB volume never has to fit through a web request.

1. POST /uploads/2. POST the file to the form's url3. POST /uploads/{id}/complete/4. GET /images/{image_id}/every few secondsreadyfailedObject storageIngestion 201: url + fieldsfile204starts202: processing
The three-step upload: the file goes straight to object storage, then PoreStore ingests it.

Ingestion decodes the file, checks it, writes the array to chunked storage unchanged, and measures basic properties such as porosity. It doesn't binarise, rescale or reorient anything. Supported formats says how each file type is read.

Supported formats

Format How it's read Notes
.npy numpy.load, no pickles The best choice. Any dtype and memory order NumPy supports.
.tif / .tiff tifffile A multi-page TIFF becomes (pages, rows, columns), so the page index is x. A stack with a channel axis is averaged over its channels.
.raw numpy.fromfile, reshaped Needs raw_metadata. See RAW files.
.npz each .npy member Every member becomes a separate image, in name order, up to 32 per archive.
.zip each .npy, .tif or .tiff member Every member becomes a separate image, in name order, up to 32 per archive.

Files are limited to 4 GiB each. Ingestion stores the array exactly as uploaded: same values, same dtype, no rescaling and no binarisation. If the file can't be read, the image is marked failed with the reason in processing_error.

0 is solid, and the image's other value is pore. Every analysis takes a segmented image: False / 0 is solid, and True, 1 or 255, whichever the image holds, is pore, so a bool, a 0/1 and a 0/255 array give the same results. Greyscale images, labels, and two-valued images without a 0 are refused. See Segmented images. Axes are NumPy axes: x, y and z are axes 0, 1 and 2 of the array as uploaded, so "flow in x" means along im[:, j, k]. Nothing is transposed or flipped.

RAW files

A .raw file is a headerless block of voxel values. Describe it in raw_metadata:

{"width": 512, "height": 512, "depth": 300, "dtype": "uint8"}

The file is read in C order and reshaped to (depth, height, width), so depth becomes x, height becomes y and width becomes z. The byte count must equal width × height × depth × bytes per voxel exactly. Supported dtypes are uint8, uint16, uint32, float32 and float64, little-endian.

Create an upload session

POST /api/v1/teams/{team_id}/uploads/

Field Type Required Notes
filename string yes Up to 255 characters. Its extension picks the decoder: .npy, .npz, .zip, .raw, .tif or .tiff.
name string no Display name, up to 200 characters. Defaults to the filename.
description string no Free text.
voxel_size number no Edge length of one voxel in micrometres, a positive finite number. Results are reported in physical units only when this is set. You can also set it later in the app, but not through the API yet.
raw_metadata object for .raw {"width": …, "height": …, "depth": …, "dtype": "uint8"}. See RAW files.

Response 201

{
  "id": "3f0c1d2e-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
  "url": "https://…/",
  "fields": {
    "Content-Type": "application/octet-stream",
    "key": "media/image_staging/7k3mf8q2/3f0c1d2e-5a6b-4c7d-8e9f-0a1b2c3d4e5f.npy",
    "policy": "…",
    "…": "…"
  },
  "storage_key": "image_staging/7k3mf8q2/3f0c1d2e-5a6b-4c7d-8e9f-0a1b2c3d4e5f.npy",
  "expires_in_seconds": 3600,
  "max_file_size": 4294967296
}

fields holds the signed form fields. Their names vary, so send whatever comes back.

Errors

Status Where Why
400 details.<field> A name or filename contains a control character (such as a NUL or a line break), or voxel_size is zero, negative, NaN or infinite.

Upload the file

POST a multipart/form-data body to url, with every entry of fields as a form field, exactly as given, followed by the file in a field called file. The file must come last. Don't send your API key here.

with open("berea.npy", "rb") as f:
    r = requests.post(target["url"], data=target["fields"], files={"file": ("berea.npy", f)})
r.raise_for_status()  # 204 No Content

The form is valid for one hour and accepts one file of 1 byte to 4 GiB (4,294,967,296 bytes). Storage rejects anything else with 403 (an expired or altered form) or 400 (a file that's too large). If the form has expired, create a new session.

Complete an upload

POST /api/v1/teams/{team_id}/uploads/{upload_id}/complete/

Send the same filename you used to create the session, plus any of name, description, voxel_size and raw_metadata. PoreStore checks that the object exists, creates the image, and starts ingestion.

Complete the upload within 6 days of uploading the file. A later completion is refused. An uploaded file that never became an image can be deleted any time after 7 days: a daily clean-up removes such files, oldest first.

The request also takes an optional compute_tier: auto (the default), or a tier your team has been given for ingestion. Most teams have none, and auto is the only value they can send.

Response 202: an image with status: "processing".

Errors

Status Where Why
400 details.upload "The uploaded object was not found or has expired." The direct upload didn't succeed, or the session's filename extension doesn't match.
400 details.upload The file was uploaded more than 6 days ago, or is larger than 4 GiB.
400 details.filename Unsupported extension.

Archives make several images

Each array inside a .zip or .npz becomes its own image, up to 32 per archive; once 32 images are made, the remaining members are skipped without an error. The images are grouped into a batch named after the archive, which you can use in the app; the API can't list batches or run jobs on them yet, so submit one job per image. Through the API, a zip of TIFF slices isn't stacked into one volume: stack the slices into a 3D .npy or a multi-page .tif first. The completion response describes the first image created. List images afterwards to find the others.

List images

GET /api/v1/teams/{team_id}/images/

Response 200: {"results": [image, …]}, with every image in the team. Not paginated, and the order isn't guaranteed.

Get an image

GET /api/v1/teams/{team_id}/images/{image_id}/

Response 200: an image. Poll this after completing an upload, until status is ready or failed.

Delete an image

DELETE /api/v1/teams/{team_id}/images/{image_id}/

Deletes the image and every file stored for it: the original upload, the chunked store, and the fields saved in it. This can't be undone.

The image's jobs and their results are kept. Each job's image_id becomes null, and its numbers can still be read from /result/. The 3D fields those jobs saved with the image are gone.

Response 204: no body.

The image object

{
  "id": "9d2b6f0e-1c3a-4e5b-8f7d-2a4c6e8b0d1f",
  "name": "Berea sandstone",
  "description": "",
  "status": "ready",
  "file_size": 64000128,
  "dimensions": [400, 400, 400],
  "voxel_size": 5.345,
  "metrics": {
    "shape": [400, 400, 400],
    "data_type": "bool",
    "range": [0, 1],
    "porosity": 0.1964,
    "segmentation_phase_state": "mixed",
    "image_kind": "binary",
    "binary_values": [0, 1]
  },
  "processing_error": "",
  "created_at": "2026-10-10T14:03:22.418193+00:00",
  "updated_at": "2026-10-10T14:04:01.902114+00:00"
}
Field Meaning
status processing while ingestion runs, then ready or failed.
file_size Bytes in the uploaded file.
dimensions The array shape as [x, y, z], which are NumPy axes 0, 1 and 2. null until ingestion finishes.
voxel_size Voxel edge in µm, or null.
metrics What ingestion measured. Always shape, data_type and range (min and max). image_kind is binary for an array of two values, such as 0 and 1 or 0 and 255, with those values in binary_values, and grayscale otherwise. For a segmented image, such as a bool, 0/1 or 0/255 array, also porosity (the pore fraction) and segmentation_phase_state: mixed, all_pore or all_solid. Other keys may appear.
processing_error Why ingestion failed, as a short message. It never names the services that ran it. Empty otherwise.

An image that is all_pore or all_solid can't be used for permeability. Most other analyses also need a mixed segmented image.