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.
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:
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.