Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/pythonapp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ on:
concurrency:
# automatically cancel the previously triggered workflows when there's a newer version
group: build-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
cancel-in-progress: false

jobs:
deps_check:
Expand Down Expand Up @@ -136,7 +136,12 @@ jobs:
python -m pip install pytest

# start the monailabel server in the background and run the integration tests
./runtests.sh --net
# Note: --net tests require GPU. Skip on non-GPU runners.
if nvidia-smi &>/dev/null; then
./runtests.sh --net
else
echo "Skipping integration tests -- GPU not available (requires self-hosted GPU runner)"
fi

# cleanup
python -m pip uninstall -y monailabel
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ repos:
additional_dependencies: [types-PyYAML,types-filelock,types-requests,types-docutils,types-cachetools]

- repo: https://github.com/asottile/pyupgrade
rev: v3.20.0
rev: v3.21.2
hooks:
- id: pyupgrade
args: [--py37-plus]
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Refer to [MONAI Label Tutorial](https://github.com/Project-MONAI/tutorials/tree/
- [Getting Started with MONAI Label](#getting-started-with-monai-label)
- [Step 1. Installation](#step-1-installation)
- [Step 2. MONAI Label Sample Applications](#step-2-monai-label-sample-applications)
- [Reviewer App](#reviewer-app)
- [Step 3. MONAI Label Supported Viewers](#step-3-monai-label-supported-viewers)
- [Step 4. Data Preparation](#step-4-data-preparation)
- [Step 5. Start MONAI Label Server and Start Annotating!](#step-5-start-monai-label-server-and-start-annotating)
Expand All @@ -58,6 +59,7 @@ MONAI Label aims to fill the gap between developers creating new annotation appl
- Customizable labeling app design for varying user expertise
- Annotation support via [3DSlicer](https://github.com/Project-MONAI/MONAILabel/tree/main/plugins/slicer)
& [OHIF](https://github.com/Project-MONAI/MONAILabel/tree/main/plugins/ohif) for radiology
- Lightweight review workflow via the reviewer sample app and 3D Slicer reviewer plugin
- Annotation support via [QuPath](https://github.com/Project-MONAI/MONAILabel/tree/main/plugins/qupath), [Digital Slide Archive](https://github.com/Project-MONAI/MONAILabel/tree/main/plugins/dsa), and [CVAT](https://github.com/Project-MONAI/MONAILabel/tree/main/plugins/cvat) for
pathology
- Annotation support via [CVAT](https://github.com/Project-MONAI/MONAILabel/tree/main/plugins/cvat) for Endoscopy
Expand Down Expand Up @@ -259,6 +261,41 @@ To use [SAM-2.1](https://huggingface.co/facebook/sam2.1-hiera-large) use one of

For a full list of supported bundles, see the <a href="https://github.com/Project-MONAI/MONAILabel/tree/main/sample-apps/monaibundle">MONAI Label Bundles README</a>.

### Reviewer App

The reviewer sample app provides a lightweight, CPU-friendly review workflow for existing segmentations without loading AI inference or training tasks. It is intended for validation, approval, flagging, comments, and version inspection from the MONAILabel reviewer plugin in 3D Slicer.

Typical startup:

```bash
monailabel start_server \
--app sample-apps/reviewer \
--studies /path/to/review-dataset \
--conf mode review
```

Dataset layout:

```text
/path/to/review-dataset/
case-001.nrrd
case-002.nrrd
labels/
final/
case-001.seg.nrrd
case-002.seg.nrrd
```

Reviewer-specific aggregate APIs are exposed under `/review`:

- `/review/cases` for case listing and summary
- `/review/versions` for label version metadata
- `/review/report` for JSON, CSV, or HTML review reports

Image and label binaries continue to use the standard datastore APIs such as `/datastore/image`, `/datastore/label`, and `/datastore/label/info`.

See the reviewer sample app guide for usage details: <a href="https://github.com/Project-MONAI/MONAILabel/tree/main/sample-apps/reviewer">sample-apps/reviewer/README.md</a>.

## Step 3 MONAI Label Supported Viewers

### Radiology
Expand Down
3 changes: 1 addition & 2 deletions monailabel/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,7 @@ def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False, env=
print(f"unable to find command, tried {commands}")
return None, None
stdout = p.communicate()[0].strip()
if sys.version_info[0] >= 3:
stdout = stdout.decode()
stdout = stdout.decode()
if p.returncode != 0:
if verbose:
print("unable to run %s (error)" % dispcmd)
Expand Down
2 changes: 2 additions & 0 deletions monailabel/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
activelearning,
batch_infer,
datastore,
datastore_review,
infer,
info,
login,
Expand Down Expand Up @@ -91,6 +92,7 @@ async def lifespan(app: FastAPI):
app.include_router(activelearning.router, prefix=settings.MONAI_LABEL_API_STR)
app.include_router(scoring.router, prefix=settings.MONAI_LABEL_API_STR)
app.include_router(datastore.router, prefix=settings.MONAI_LABEL_API_STR)
app.include_router(datastore_review.router, prefix=settings.MONAI_LABEL_API_STR)
app.include_router(logs.router, prefix=settings.MONAI_LABEL_API_STR)
app.include_router(ohif.router, prefix=settings.MONAI_LABEL_API_STR)
app.include_router(proxy.router, prefix=settings.MONAI_LABEL_API_STR)
Expand Down
37 changes: 35 additions & 2 deletions monailabel/datastore/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import tempfile
import time
import zipfile
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple

from filelock import FileLock
Expand Down Expand Up @@ -225,6 +226,11 @@ def _to_id(self, file: str) -> Tuple[str, str]:
id = file.replace(ext, "")
return id, ext

def _to_label_id(self, file: str) -> Tuple[str, str]:
if file.lower().endswith(".seg.nrrd"):
return file[: -len(".seg.nrrd")], ".seg.nrrd"
return self._to_id(file)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
def _filename(self, id: str, ext: str) -> str:
return id + ext

Expand Down Expand Up @@ -488,7 +494,7 @@ def save_label(self, image_id: str, label_filename: str, label_tag: str, label_i
if not obj:
raise ImageNotFoundException(f"Image {image_id} not found")

_, label_ext = self._to_id(os.path.basename(label_filename))
_, label_ext = self._to_label_id(os.path.basename(label_filename))
label_id = image_id

logger.info(f"Adding Label: {image_id} => {label_tag} => {label_filename}")
Expand Down Expand Up @@ -540,11 +546,38 @@ def update_label_info(self, label_id: str, label_tag: str, info: Dict[str, Any])
:param label_id: the id of the label we want to add/update info
:param label_tag: the matching label tag
:param info: a dictionary of custom label information Dict[str, Any]

The `last_reviewed` field is preserved if the caller already provides the key,
otherwise we keep the existing value when present. For older reviewer
metadata that predates `last_reviewed`, we fall back to the label's
existing `ts` only when the label already carries review metadata;
otherwise we stamp the current server-side update time.
"""
label = self._datastore.label(label_id, label_tag)
if not label:
raise LabelNotFoundException(f"Label: {label_id} Tag: {label_tag} not found")

info = dict(info) if info else {}
if "last_reviewed" not in info:
has_incoming_review_metadata = any(
info.get(field) for field in ("status", "level", "comment", "reviewer", "reviewer_name")
)
if has_incoming_review_metadata:
info["last_reviewed"] = datetime.now().isoformat()
else:
existing_last_reviewed = label.info.get("last_reviewed")
if existing_last_reviewed:
info["last_reviewed"] = existing_last_reviewed
else:
has_existing_review_metadata = any(
label.info.get(field) for field in ("status", "level", "comment", "reviewer", "reviewer_name")
)
existing_ts = label.info.get("ts")
if has_existing_review_metadata and isinstance(existing_ts, (int, float)):
info["last_reviewed"] = datetime.fromtimestamp(existing_ts).isoformat()
else:
info["last_reviewed"] = datetime.now().isoformat()

label.info.update(info)
self._update_datastore_file()

Expand Down Expand Up @@ -613,7 +646,7 @@ def _add_non_existing_labels(self, tag) -> int:

image_ids = list(self._datastore.objects.keys())
for label_file in local_labels:
label_id, label_ext = self._to_id(label_file)
label_id, label_ext = self._to_label_id(label_file)

obj = self._datastore.objects.get(label_id)
if not obj or label_id not in image_ids:
Expand Down
Loading
Loading