Appearance
Slide workspace and annotation format v1
Status: slide annotations and project collections are implemented in the TypeScript viewer and Python package. Bounded binary overlay stacks now have a separate additive contract in docs/overlay-format-v1.md; annotation and project versions are unchanged. This is an experimental versioned contract; incompatible changes require a new version and a migration.
Files and ownership
text
slides/
study.microscope-project.json
study.microscope-project.json.previous.json
sample.svs
sample.svs.microscope/
slide.json
projects/
default/
annotations.json
annotations.previous.json # retained before replacing a saved current file
<project-id>/
annotations.jsonA sidecar uses the entire image filename plus .microscope. It does not contain a copy of the WSI. slide.json describes one slide; each project namespace owns its annotations. Opening does not create directories; saving does. No file represents a deck.gl layer class.
Without a collection, the viewer uses default, displayed as “Default workspace.” A selected collection uses its stable project ID instead. IDs contain 1–80 ASCII letters/digits/underscores/hyphens, begin with a letter/digit, and are independent of display names. default is reserved for standalone work. Never derive namespace paths from unvalidated display names. Removing collection membership leaves WSI and sidecar files intact.
Only annotations and their styles are saved now. Overlay previews, synthetic cells, view/camera state, drawing preferences, and unfinished geometry edits remain session state. Autosave uses these same files after a two-second pause in committed edits. There is no browser-private annotation recovery database or journal.
Slide manifest
json
{
"format": "microscope-slide",
"version": 1,
"id": "f00c73e7-76a4-4015-8590-83e17de282da",
"coordinateSystem": "level-0-pixels",
"source": {
"name": "sample.svs",
"size": 1234,
"lastModified": 1700000000000,
"width": 50000,
"height": 40000
},
"fingerprint": {
"algorithm": "sha256-first-last-64k-v1",
"digest": "<64 lowercase hexadecimal characters>"
}
}id is a stable workspace ID, not a hash of the image. source records the image metadata when the workspace was created. Size and timestamps are bytes and Unix milliseconds respectively. Dimensions are level-0 pixels. These numbers must be safe nonnegative integers; dimensions must be positive. The stored source identity remains unchanged when file timestamps change, and saved annotation documents use that stored identity.
The sampled fingerprint is SHA-256 of the concatenation of the first 65,536 bytes and the last 65,536 bytes, in that order. For shorter files each sample is the entire file, concatenated twice; overlapping samples are never deduplicated. Size is verified separately. This reads at most 128 KiB independent of WSI size. It detects many wrong-file associations, but is not a full-file content hash: middle-only changes can evade it. It must not be presented as cryptographic proof of full-slide identity.
The browser verifies size, decoded dimensions, and sampled fingerprint before restoring workspace data and rechecks sampled image content before each save. It also compares sampled content of the opened File and the selected folder's image before enabling writes. Python checks file size and fingerprint; it does not decode images or independently verify dimensions. Creating a workspace from Python requires caller-supplied level-0 dimensions. Opening an existing workspace requires no imaging dependencies or dimension arguments.
Moving a parent directory containing both WSI and sidecar preserves discovery. A timestamp change is tolerated. Renaming an image requires renaming its sidecar too; the stored source name stays as historical metadata. The browser's standalone annotation Import deliberately retains the existing stricter filename/size/timestamp/dimension match. Workspace loading is the portable path; legacy loose exports have no sampled fingerprint.
Project collection manifest
A project file is <name>.microscope-project.json within the chosen collection folder. The viewer creates an ASCII filename from the initial name; later label changes leave that filename unchanged. tests/fixtures/project-v1.json is the shared fixture.
json
{
"format": "microscope-project",
"version": 1,
"id": "study-a",
"name": "Research study",
"slides": [
{
"id": "member-a",
"name": "Sample A",
"path": "slides/sample.svs",
"size": 1234,
"fingerprint": {
"algorithm": "sha256-first-last-64k-v1",
"digest": "<64 lowercase hexadecimal characters>"
},
"workspaceId": null
}
]
}| Field | Meaning |
|---|---|
id | Stable project namespace; default is reserved |
name | Display label, independent of manifest filename and ID |
slides | Ordered members, at most 2,000; no image or annotation payloads |
Member id | Stable roster entry ID, unique within the collection |
Member name | Display alias; does not rename the image or sidecar |
Member path | Slash-separated relative image path from the manifest folder |
Member size, fingerprint | Byte size and the sampled identity defined above |
Member workspaceId | Existing sidecar ID, or explicit null before one is known |
Member IDs use the project-ID character rules. Names use the 1–500 UTF-16-unit nonblank rule. Paths are limited to 4,096 UTF-16 units and forbid absolute paths, empty components, ./.., backslash, colon, and NUL. Both member IDs and paths are unique. Byte sizes are nonnegative safe integers. Workspace IDs contain 1–80 ASCII letters/digits/hyphens. JSON is limited to 1 MiB UTF-8. Readers normalize supported fields and drop unknown fields.
The browser chooses one root per collection; members must be within it or its subfolders. It discovers top-level .microscope-project.json files (at most 200 per folder). Directory grants are not embedded in portable JSON. Moving the entire root preserves relative references. Moving individual images requires moving their sidecars together and using Locate; reassociation verifies size, sampled content, and any bound workspace ID. Missing/invalid references remain visible rather than silently disappearing.
Adding members reads size, two bounded file samples, and any existing slide manifest; it does not decode images or create sidecars. Only opening a selected member decodes its WSI and restores projects/<project-id>/annotations.json. A new namespace is empty; Copy Default annotations is an explicit, undoable copy that saves into the project while leaving Default intact. On an annotation save, the viewer records the committed workspace ID in its in-memory reference and marks the roster dirty. Python can leave a new reference unbound; sampled image identity remains checked.
Save project commits name and membership separately from annotation Save. It validates first, checks its exact opened baseline, retains <filename>.previous.json, rechecks, and replaces the document using the same native/Python commit mechanisms below. Changed baselines and failed commits produce errors. Renaming cannot change the namespace ID. Project and annotation saves are separate file commits, not a multi-file transaction. Opening a collection does not initialize all decoders or automatically select its first member.
Annotation document
annotations.json is the existing microscope-annotations version 1 document. The shared example is tests/fixtures/annotations-v1.json. Old documents without styles remain readable; no coordinate conversion or geometry simplification occurs while saving or loading.
Required top-level fields:
| Field | Meaning |
|---|---|
format | microscope-annotations |
version | numeric 1 |
coordinateSystem | level-0-pixels |
slide | complete stored source identity from slide.json |
classes | ordered array of class definitions |
annotations | FeatureCollection with an ordered features array |
Coordinates are Cartesian level-0 image pixels: top-left origin, X increases right, Y increases down. Fractional coordinates are retained as JSON numbers; writers must preserve IEEE-754 binary64 precision and must not quantize to Float32. This is a GeoJSON-like pixel document, not geographic RFC 7946 GeoJSON. Physical units are not inferred or embedded as longitude/latitude.
Classes require unique nonempty id, name, and six-digit hex color. An optional style contains any subset of lineWidth, lineColor, fillColor, and fillOpacity.
Each ROI is a Feature with a Polygon geometry and properties:
| Property | Meaning |
|---|---|
id | unique stable ROI ID |
name | user-facing region name |
classId | reference to a class in the same document |
visible | boolean; hidden ROIs are still saved |
shape | polygon or rectangle |
metadata | object of user-defined strings, finite numbers, and booleans |
style | optional per-ROI appearance overrides |
Polygons have one exterior ring followed by any holes. Each ring has at least three vertices plus the closing vertex, and first and last [x, y] must match exactly. Each point has exactly two finite numeric coordinates within [0, width] × [0, height]. Rings must produce a nonzero aggregate area. Rectangles have one ring with four axis-aligned corners and closure. This format does not repair self-intersections or validate complete polygon topology; avoid claiming topological validity from these structural checks.
IDs, names, source filename, and metadata keys are 1–500 UTF-16 code units and must contain non-whitespace text. Metadata keys __proto__, constructor, and prototype are reserved. Nested metadata, null values, and nonfinite numbers are unsupported. String metadata values may be empty. JSON numbers are interpreted as binary64 in the viewer; Python rejects integers that cannot be represented exactly in that type.
Appearance uses screen-pixel outline width (0.5–20), six-digit hex outline/fill colors, and fill opacity (0–1). Missing ROI values inherit class values. Missing class values use class color, 1.5 px width, and 0.08 fill opacity. These units describe rendering, not slide-space geometry.
Readers require known format/version/coordinate values and validate the fields above. They normalize supported fields and discard unknown fields. A new required semantic field needs a version decision before implementation; do not rely on unknown fields surviving a round trip.
Both languages enforce at most 1,000 classes, 50,000 ROIs, 100,000 vertices per ring, and one million total vertices including closure points. Annotation files are limited to 10 MiB of UTF-8 JSON; slide manifests to 16 KiB. These are bounds for editable annotation JSON, not the proposed large spatial-output format.
Save, conflict, and recovery behavior
The reader retains the exact current file text as its baseline. Saving:
- Validates and serializes the complete next annotation document before writing.
- Verifies the existing slide manifest and compares current annotations with the baseline. Changed files cause an explicit conflict; there is no force-overwrite path.
- Creates
slide.jsonif this is the first save. - Copies a valid current document to
annotations.previous.jsonbefore replacing it. - Rechecks the current file after the backup, then commits the complete new
annotations.json. - Marks the in-memory snapshot saved only after the commit succeeds. Edits made during a save remain dirty.
Native browser writes are committed on FileSystemWritableFileStream.close (API documentation); failed streams are aborted. Python writes a temporary file in the destination directory, flushes/fsyncs it, then calls os.replace. The current document remains in place if a replacement fails. Initial failed saves can leave directories or an empty newly created file; loading such a file produces an error, never a silently empty annotation set. A backup failure stops the save. If the backup succeeds but the new commit fails, the previous file can match the unchanged current file; it retains valid data rather than a complete version history.
This is optimistic conflict detection with one intended writer per project/slide. It is not an atomic compare-and-swap across Python processes and browser tabs. A competing write in the final check/commit interval can evade detection. Whole-workspace transactions, multiwriter locking, and guarantees against sudden power loss are not implemented. Do not run simultaneous writers against the same project data.
Missing or malformed slide manifests and malformed current data cause an explicit load error; it never automatically falls back to old data. Recovery is explicit through SlideWorkspace.recover in TypeScript or mio.open_slide(path, recover_previous=True) in Python. Recovery validates the previous document and current slide; save then commits the recovered state. A corrupt current file is not copied over the valid backup. The viewer offers Review previous save for a connected workspace and Review previous annotations when a malformed current annotation file prevents opening. Review opens the validated backup with autosave paused and no writes. Keep recovery or manual Save commits it; invalid backups preserve current work. An external change during review is still a conflict. There is one previous file, not a version archive.
Autosave is enabled by default for connected folders and is optional in the status bar. Committed annotation, overlay, and roster changes coalesce after two seconds; form edits commit on blur and unfinished geometry is excluded. Autosave never opens a picker, retries failed saves in a loop, or writes while reviewing a recovery. Failed saves retain dirty state and pause automation; Retry save explicitly retries transient failures. External changes retain the conflict baseline until reopening. Edits made during a save remain dirty and schedule a subsequent snapshot. Membership and annotation saves remain independent file commits.
Native handles are session-only unless the user enables Remember folder. The optional local microscope-folder IndexedDB database contains one directory handle and a small version-1 location hint: name, relative path, and project ID/member ID or workspace ID. It contains no annotation/image payloads and is not part of the portable on-disk contract. The autosave on/off preference uses localStorage. Forget folder deletes the hint/handle record without changing files or browser-managed permission grants.
After reload, Resume invokes the browser’s read/write permission request from a click, then opens the remembered project/member or standalone slide. Startup neither requests access nor decodes slides. Denied access and invalid identities produce visible errors; choosing the folder again remains available. Handle serialization and permission renewal follow the File System Access guidance. Actual permission persistence remains browser-controlled.
Without remembering, Open project reselects its root; standalone images reconnect through Save and offer existing work for loading before replacement. Browsers without directory picker support retain Import/Export. Picker cancellation leaves work in memory. Exit prompts remain best-effort: abrupt termination, unfinished forms/geometry, or edits inside the debounce window can be lost. No unload-time asynchronous save is promised.
Interoperability evidence
tests/workspace.test.ts writes a workspace from TypeScript to a real temporary directory, invokes the Python SDK to edit/save it, then reopens it in TypeScript. Python and TypeScript run the same valid fixture and invalid mutation cases. Tests cover fractional points, holes, rectangles, hidden ROIs, appearance inheritance/overrides, Unicode metadata, timestamp changes, project isolation, conflict, failed replacement, and explicit recovery.
tests/browser/persistence.spec.ts runs the actual viewer, writes through native browser filesystem handles, exchanges those files with the Python SDK, and reloads the edited annotations into the viewer. Its OS picker is substituted with a directory of real OPFS handles solely for automation; normal application saves use user-selected filesystem directories. Actual OS pickers and browser permission persistence still need manual compatibility testing on physical devices.
tests/projects.test.ts and python/tests/test_projects.py share collection fixtures and verify stable namespaces, metadata-only membership, relocation, conflict checks, backups, failed commits, and removal without disk deletion. A TypeScript → Python → TypeScript filesystem exchange checks collection and annotation isolation. tests/browser/projects.spec.ts covers the roster, per-project viewing/copying, Save/Discard/Cancel, failed saves, moved images, and reload through real native handles with test-only OS picker substitution. A 2,000-member synthetic document is approximately 636 KiB and parsed/validated in about 40 ms in Node on this machine; this is not a browser interaction benchmark.
Bounded scored-point/score-raster NPY arrays and layer inventories are implemented separately in docs/overlay-format-v1.md. Rich object metadata, packed imported geometry, Arrow/Zarr data, chunked rasters, and camera settings remain subsequent milestones.
tests/browser/autosave.spec.ts verifies coalescing, edits during a delayed native write, failure suspension, no retry loop, explicit retry, drawing suspension, project namespaces, remembered-handle reload, permission denial, conflicts, backup review/keep, and a bounded 6,000-ROI native-stream save. Tests substitute OS picker and permission UI while exercising real file/directory handles and IndexedDB; physical-device permission dialogs and disk latency remain manual compatibility checks.