From 775d40bd6cdf75903eb0315580234a304c3a8873 Mon Sep 17 00:00:00 2001 From: Elanchezhian Date: Wed, 29 Jul 2026 15:56:00 +0000 Subject: [PATCH 1/3] Add lightweight reviewer app and review endpoints Fixes: - test_binary_to_image cleanup FileNotFoundError (guarded addCleanup) - highdicom Python 3.9 compatibility (version guard in requirements.txt) - build-docs (3.10) warnings (resolved in current HEAD) - DCO Signed-off-by compliance on all commits Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: CodeRabbit Signed-off-by: Elanchezhian --- .pre-commit-config.yaml | 2 +- README.md | 37 ++ monailabel/_version.py | 3 +- monailabel/app.py | 2 + monailabel/datastore/local.py | 37 +- monailabel/endpoints/datastore_review.py | 296 ++++++++++++ .../MONAILabelReviewer/MONAILabelReviewer.py | 43 +- .../ImageDataController.py | 9 + .../MONAILabelReviewerLib/MonaiServerREST.py | 24 +- requirements.txt | 3 +- sample-apps/reviewer/README.md | 306 +++++++++++++ sample-apps/reviewer/app.py | 133 ++++++ sample-apps/reviewer/client.py | 423 ++++++++++++++++++ sample-apps/reviewer/lib/__init__.py | 0 sample-apps/reviewer/lib/config.py | 126 ++++++ sample-apps/reviewer/main.py | 16 + scripts/manual_reviewer_smoke_test.py | 337 ++++++++++++++ tests/unit/datastore/test_convert.py | 2 +- tests/unit/datastore/test_local.py | 142 ++++++ tests/unit/endpoints/test_datastore_review.py | 57 +++ tests/unit/sample_apps/test_reviewer_app.py | 43 ++ .../unit/sample_apps/test_reviewer_client.py | 92 ++++ 22 files changed, 2117 insertions(+), 16 deletions(-) create mode 100644 monailabel/endpoints/datastore_review.py create mode 100644 sample-apps/reviewer/README.md create mode 100644 sample-apps/reviewer/app.py create mode 100644 sample-apps/reviewer/client.py create mode 100644 sample-apps/reviewer/lib/__init__.py create mode 100644 sample-apps/reviewer/lib/config.py create mode 100644 sample-apps/reviewer/main.py create mode 100644 scripts/manual_reviewer_smoke_test.py create mode 100644 tests/unit/datastore/test_local.py create mode 100644 tests/unit/endpoints/test_datastore_review.py create mode 100644 tests/unit/sample_apps/test_reviewer_app.py create mode 100644 tests/unit/sample_apps/test_reviewer_client.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 441dbe5c7..995e66725 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/README.md b/README.md index 240e79f20..9cc5d15b5 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 @@ -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 MONAI Label Bundles README. +### 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: sample-apps/reviewer/README.md. + ## Step 3 MONAI Label Supported Viewers ### Radiology diff --git a/monailabel/_version.py b/monailabel/_version.py index 551342ce7..0e55b9813 100644 --- a/monailabel/_version.py +++ b/monailabel/_version.py @@ -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) diff --git a/monailabel/app.py b/monailabel/app.py index a2714a616..f8b915db8 100644 --- a/monailabel/app.py +++ b/monailabel/app.py @@ -25,6 +25,7 @@ activelearning, batch_infer, datastore, + datastore_review, infer, info, login, @@ -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) diff --git a/monailabel/datastore/local.py b/monailabel/datastore/local.py index d8b0538aa..b6148921a 100644 --- a/monailabel/datastore/local.py +++ b/monailabel/datastore/local.py @@ -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 @@ -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) + def _filename(self, id: str, ext: str) -> str: return id + ext @@ -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}") @@ -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() @@ -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: diff --git a/monailabel/endpoints/datastore_review.py b/monailabel/endpoints/datastore_review.py new file mode 100644 index 000000000..dfeb57b14 --- /dev/null +++ b/monailabel/endpoints/datastore_review.py @@ -0,0 +1,296 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Reviewer-focused aggregate endpoints built on top of the standard datastore API. +""" + +import csv +import io +import logging +from datetime import datetime, timezone +from html import escape +from typing import Any, Dict, List, Optional, Tuple + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import HTMLResponse, PlainTextResponse + +from monailabel.config import RBAC_USER, settings +from monailabel.endpoints.user.auth import RBAC, User +from monailabel.interfaces.datastore import DefaultLabelTag +from monailabel.interfaces.exception import LabelNotFoundException +from monailabel.interfaces.utils.app import app_instance + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/review", + tags=["Review"], + responses={404: {"description": "Not found"}}, +) + + +def _safe_label_info(datastore, image_id: str, tag: str) -> Dict[str, Any]: + try: + info = datastore.get_label_info(image_id, tag) + return info if isinstance(info, dict) else {} + except LabelNotFoundException: + return {} + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def _parse_date_range(date_range: Optional[str]) -> Optional[Tuple[datetime, datetime]]: + if not date_range: + return None + + parts = [part.strip() for part in date_range.split(",", maxsplit=1)] + if len(parts) != 2 or not parts[0] or not parts[1]: + raise HTTPException(status_code=400, detail="date_range must be 'start,end' in ISO format") + + try: + return datetime.fromisoformat(parts[0]), datetime.fromisoformat(parts[1]) + except ValueError as exc: + raise HTTPException(status_code=400, detail="date_range must use ISO timestamps") from exc + + +def _matches_date_range(value: Optional[str], parsed: Optional[Tuple[datetime, datetime]]) -> bool: + if not parsed: + return True + if not value: + return False + + try: + current = datetime.fromisoformat(value) + except ValueError: + return False + + start, end = parsed + current = _as_utc(current) + start = _as_utc(start) + end = _as_utc(end) + return start <= current <= end + + +def _review_case(datastore, image_id: str, tag: str) -> Dict[str, Any]: + image_info = datastore.get_image_info(image_id) or {} + labels = datastore.get_labels_by_image_id(image_id) or {} + label_id = labels.get(tag) or image_id + label_info = _safe_label_info(datastore, label_id, tag) + + reviewer = label_info.get("reviewer_name") or label_info.get("reviewer") + raw_status = label_info.get("status") + status = raw_status.lower() if isinstance(raw_status, str) else ("pending" if labels.get(tag) else "unlabeled") + + return { + "id": image_id, + "name": image_info.get("name", image_id), + "path": image_info.get("path"), + "status": status, + "level": label_info.get("level"), + "comment": label_info.get("comment"), + "review_count": label_info.get("review_count", 0), + "last_reviewed": label_info.get("last_reviewed"), + "reviewer": reviewer, + "reviewer_name": reviewer, + "tag": tag, + "has_label": bool(labels.get(tag)), + } + + +def _list_review_cases( + status_filter: Optional[str], + search: Optional[str], + reviewer: Optional[str], + date_range: Optional[str], + tag: str, +) -> List[Dict[str, Any]]: + datastore = app_instance().datastore() + image_ids = datastore.list_images() + parsed_range = _parse_date_range(date_range) + + selected_ids = None + if search: + selected_ids = {item.strip() for item in search.split(",") if item.strip()} + + results: List[Dict[str, Any]] = [] + for image_id in image_ids: + if selected_ids and image_id not in selected_ids: + continue + + item = _review_case(datastore, image_id, tag) + if status_filter and item["status"] != status_filter.lower(): + continue + if reviewer and item.get("reviewer") != reviewer: + continue + if not _matches_date_range(item.get("last_reviewed"), parsed_range): + continue + results.append(item) + + return results + + +def _summary(items: List[Dict[str, Any]]) -> Dict[str, int]: + return { + "total": len(items), + "approved": sum(1 for item in items if item.get("status") == "approved"), + "flagged": sum(1 for item in items if item.get("status") == "flagged"), + "pending": sum(1 for item in items if item.get("status") in ("pending", "unapproved")), + "unlabeled": sum(1 for item in items if item.get("status") == "unlabeled"), + } + + +def _report_stats(items: List[Dict[str, Any]]) -> Dict[str, Any]: + return { + **_summary(items), + "easy": sum(1 for item in items if item.get("level") == "easy"), + "medium": sum(1 for item in items if item.get("level") == "medium"), + "hard": sum(1 for item in items if item.get("level") == "hard"), + "date_recorded": datetime.now().isoformat(), + } + + +def _render_csv(items: List[Dict[str, Any]]) -> str: + def sanitize_csv_value(value: Any) -> str: + """Sanitize CSV values to prevent formula injection.""" + text = str(value) if value else "" + # Prefix values that start with formula-like characters + if text and text[0] in ("=", "+", "-", "@"): + return "'" + text + return text + + handle = io.StringIO() + writer = csv.writer(handle) + writer.writerow(["image_id", "status", "level", "reviewer", "comment", "last_reviewed", "tag"]) + for item in items: + writer.writerow( + [ + sanitize_csv_value(item.get("id", "")), + sanitize_csv_value(item.get("status", "")), + sanitize_csv_value(item.get("level", "")), + sanitize_csv_value(item.get("reviewer", "")), + sanitize_csv_value(item.get("comment", "")), + sanitize_csv_value(item.get("last_reviewed", "")), + sanitize_csv_value(item.get("tag", "")), + ] + ) + return handle.getvalue() + + +def _render_html(stats: Dict[str, Any]) -> str: + total = stats["total"] or 1 + + def row(name: str, value: int) -> str: + return f"{escape(name)}{value}" f"{(100.0 * value / total):.1f}%" + + return ( + "MONAILabel Review Report" + f"

Review Statistics (Total: {stats['total']})

" + "" + "" + f"{row('Approved', stats['approved'])}" + f"{row('Flagged', stats['flagged'])}" + f"{row('Pending', stats['pending'])}" + f"{row('Unlabeled', stats['unlabeled'])}" + f"{row('Easy', stats['easy'])}" + f"{row('Medium', stats['medium'])}" + f"{row('Hard', stats['hard'])}" + "
StatusCountPercentage
" + f"

Generated: {escape(stats['date_recorded'])}

" + "" + ) + + +@router.get("/cases", summary=f"{RBAC_USER}List review cases") +async def api_review_cases( + offset: int = 0, + limit: int = 100, + status_filter: Optional[str] = None, + search: Optional[str] = None, + reviewer: Optional[str] = None, + date_range: Optional[str] = None, + tag: str = DefaultLabelTag.FINAL.value, + user: User = Depends(RBAC(settings.MONAI_LABEL_AUTH_ROLE_USER)), +): + items = _list_review_cases(status_filter, search, reviewer, date_range, tag) + results = items[offset : offset + limit] + + return { + "summary": _summary(items), + "results": results, + "metadata": { + "tag": tag, + "offset": offset, + "limit": limit, + "reviewer": reviewer, + "date_range": date_range, + }, + } + + +@router.get("/versions", summary=f"{RBAC_USER}List label versions for an image") +async def api_review_versions( + image: str, + user: User = Depends(RBAC(settings.MONAI_LABEL_AUTH_ROLE_USER)), +): + datastore = app_instance().datastore() + labels = datastore.get_labels_by_image_id(image) + if not labels: + raise HTTPException(status_code=404, detail=f"No labels found for image '{image}'") + + versions = [] + for tag, label_id in labels.items(): + info = _safe_label_info(datastore, label_id, tag) + versions.append( + { + "tag": tag, + "label": label_id, + "author": info.get("reviewer_name") or info.get("reviewer") or info.get("model"), + "created_at": info.get("created_at") or info.get("ts"), + "review_status": info.get("status", "pending"), + "review_count": info.get("review_count", 0), + } + ) + + return {"status": "success", "image_id": image, "versions": versions} + + +@router.get("/report", summary=f"{RBAC_USER}Generate review report") +async def api_review_report( + fmt: str = "json", + reviewer: Optional[str] = None, + date_range: Optional[str] = None, + tag: str = DefaultLabelTag.FINAL.value, + user: User = Depends(RBAC(settings.MONAI_LABEL_AUTH_ROLE_USER)), +): + items = _list_review_cases(None, None, reviewer, date_range, tag) + stats = _report_stats(items) + fmt = fmt.lower() + + if fmt == "csv": + return PlainTextResponse(_render_csv(items), media_type="text/csv") + if fmt == "html": + return HTMLResponse(_render_html(stats)) + if fmt != "json": + raise HTTPException(status_code=400, detail="fmt must be one of: json, csv, html") + + return { + "status": "success", + "fmt": "json", + "report": stats, + "date_generated": datetime.now().isoformat(), + "filters": {"reviewer": reviewer, "date_range": date_range, "tag": tag}, + "reviews": items, + } diff --git a/plugins/slicer/MONAILabelReviewer/MONAILabelReviewer.py b/plugins/slicer/MONAILabelReviewer/MONAILabelReviewer.py index 643598e7f..46ea856f6 100644 --- a/plugins/slicer/MONAILabelReviewer/MONAILabelReviewer.py +++ b/plugins/slicer/MONAILabelReviewer/MONAILabelReviewer.py @@ -18,7 +18,6 @@ import qt import requests -import SampleData import slicer from MONAILabelReviewerLib.ImageData import ImageData from MONAILabelReviewerLib.ImageDataController import ImageDataController, ImageDataStatistics @@ -430,7 +429,16 @@ def loadServerSelection(self): serverUrlHistory = settings.value("MONAILabel/serverUrlHistory") self.ui.comboBox_server_url.clear() - self.ui.comboBox_server_url.addItems(serverUrlHistory.split(";")) + if not serverUrlHistory: + return + + server_urls = [self.normalizeServerUrl(url) for url in serverUrlHistory.split(";") if url] + self.ui.comboBox_server_url.addItems(server_urls) + + def normalizeServerUrl(self, serverUrl: str) -> str: + if not serverUrl: + return "" + return serverUrl.strip().rstrip("/") def init_dicom_stream(self): """ @@ -439,7 +447,8 @@ def init_dicom_stream(self): """ # Check Connection self.cleanCache() - serverUrl: str = self.ui.comboBox_server_url.currentText + serverUrl: str = self.normalizeServerUrl(self.ui.comboBox_server_url.currentText) + self.ui.comboBox_server_url.setCurrentText(serverUrl) isConnected: bool = self.logic.connectToMonaiServer(serverUrl) if not isConnected: warningMessage = f"Connection to server failed \ndue to invalid ip '{serverUrl}'" @@ -1494,7 +1503,7 @@ def reloadImageAfterEditingLabel(self): self.fillComboBoxLabelVersions(self.currentImageData) def processDataStoreRecords(self): - serverUrl: str = self.ui.comboBox_server_url.currentText + serverUrl: str = self.normalizeServerUrl(self.ui.comboBox_server_url.currentText) result: bool = self.logic.initMetaDataProcessing() if result is False: warningMessage = ( @@ -1674,8 +1683,8 @@ def loadDicomAndSegmentation(self, imageData: ImageData, tag: str): ) ) - self.requestDicomImage(image_id, image_name, node_name) self.setTempFolderDir() + self.requestDicomImage(image_id, image_name, node_name) # Request segmentation if imageData.isSegemented(): @@ -1703,7 +1712,17 @@ def storeSegmentation( return destination def getPathToStore(self, segmentationFileName: str, tempDirectory: str) -> str: - return tempDirectory + "/" + segmentationFileName + temp_root = os.path.realpath(tempDirectory) + normalized_name = os.path.normpath(segmentationFileName.replace("\\", "/")) + safe_name = os.path.basename(normalized_name) + + if safe_name in ("", ".", ".."): + raise ValueError(f"Invalid temporary file name: {segmentationFileName}") + + destination = os.path.realpath(os.path.join(temp_root, safe_name)) + if os.path.commonpath([temp_root, destination]) != temp_root: + raise ValueError(f"Resolved path escapes temporary directory: {segmentationFileName}") + return destination def displaySegmention(self, destination: str): """ @@ -1712,8 +1731,16 @@ def displaySegmention(self, destination: str): segmentation = slicer.util.loadSegmentation(destination) def requestDicomImage(self, image_id: str, image_name: str, node_name: str): - download_uri = self.imageDataController.getDicomDownloadUri(image_id) - SampleData.SampleDataLogic().downloadFromURL(nodeNames=node_name, fileNames=image_name, uris=download_uri) + response = self.imageDataController.requestImage(image_id) + if response is None: + raise RuntimeError(f"Failed to download image '{image_id}' from MONAI Label server") + + destination = self.getPathToStore(image_name, self.temp_dir.name) + with open(destination, "wb") as img_file: + img_file.write(response.content) + + logging.info(f"{self.getCurrentTime()}: Image stored temporarily in: {destination}") + slicer.util.loadVolume(destination, properties={"name": node_name}) def setTempFolderDir(self): """ diff --git a/plugins/slicer/MONAILabelReviewer/MONAILabelReviewerLib/ImageDataController.py b/plugins/slicer/MONAILabelReviewer/MONAILabelReviewerLib/ImageDataController.py index 4dc9851b3..a8b0635bf 100644 --- a/plugins/slicer/MONAILabelReviewer/MONAILabelReviewerLib/ImageDataController.py +++ b/plugins/slicer/MONAILabelReviewer/MONAILabelReviewerLib/ImageDataController.py @@ -237,6 +237,15 @@ def reuqestSegmentation(self, image_id: str, tag: str) -> requests.models.Respon def getDicomDownloadUri(self, image_id: str) -> str: return self.monaiServerREST.getDicomDownloadUri(image_id) + def requestImage(self, image_id: str): + if img_blob is not None: + logging.info( + "{}: Image successfully requested from MONAIServer (image id: {})".format( + self.getCurrentTime(), image_id + ) + ) + return img_blob + def saveLabelInMonaiServer(self, image_in: str, label_in: str, tag: str, params: Dict): self.monaiServerREST.saveLabel(image_in, label_in, tag, params) diff --git a/plugins/slicer/MONAILabelReviewer/MONAILabelReviewerLib/MonaiServerREST.py b/plugins/slicer/MONAILabelReviewer/MONAILabelReviewerLib/MonaiServerREST.py index c75562e8d..aaa6dced4 100644 --- a/plugins/slicer/MONAILabelReviewer/MONAILabelReviewerLib/MonaiServerREST.py +++ b/plugins/slicer/MONAILabelReviewer/MONAILabelReviewerLib/MonaiServerREST.py @@ -26,7 +26,7 @@ class MonaiServerREST: def __init__(self, serverUrl: str): self.PARAMS_PREFIX_REST_REQUEST = "params" - self.serverUrl = serverUrl + self.serverUrl = serverUrl.rstrip("/") if serverUrl else serverUrl def getServerUrl(self) -> str: return self.serverUrl @@ -57,6 +57,28 @@ def getDicomDownloadUri(self, image_id: str) -> str: logging.info(f"{self.getCurrentTime()}: REST: request dicom image '{download_uri}'") return download_uri + def requestImage(self, image_id: str): + + try: + response = requests.get(download_uri, timeout=30) + except Exception as exception: + logging.warning( + "{}: Image request (image id: '{}') failed due to '{}'".format( + self.getCurrentTime(), image_id, exception + ) + ) + return None + + if response.status_code != 200: + logging.warning( + "{}: Image request (image id: '{}') failed due to response code: '{}'".format( + self.getCurrentTime(), image_id, response.status_code + ) + ) + return None + + return response + def requestSegmentation(self, image_id: str, tag: str) -> requests.models.Response: if tag == "": tag = "final" diff --git a/requirements.txt b/requirements.txt index d57ae0d0c..178bed922 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,8 @@ expiring_dict==1.1.0 cachetools==5.3.3 watchdog==4.0.0 pydicom==3.0.1 -highdicom==0.26.1 +highdicom==0.26.1 ; python_version >= '3.10' +highdicom==0.22.0 ; python_version < '3.10' pynetdicom==2.0.2 pynrrd==1.1.3 numpymaxflow==0.0.7 diff --git a/sample-apps/reviewer/README.md b/sample-apps/reviewer/README.md new file mode 100644 index 000000000..10efdd3d3 --- /dev/null +++ b/sample-apps/reviewer/README.md @@ -0,0 +1,306 @@ +# MONAILabel Reviewer - Lightweight Review App + +A minimal, GPU-free MONAILabel application designed specifically for image segmentation review workflows. + +## Overview + +MONAILabel Reviewer is a streamlined application that enables radiologists and clinicians to: +- **Review and validate** segmentation masks created by AI models +- **Rate difficulty levels** of segmentations (Easy/Medium/Hard) +- **Add comments** with version control for annotations +- **Generate reports** with review statistics +- **Review existing labels** without loading MONAI Label AI tasks + +**Key Features**: +- ✅ Zero AI model dependencies (no GPU required) +- ✅ Lightweight server with reviewer-specific aggregate endpoints under `/review` +- ✅ Version control for segmentations +- ✅ Support for multiple reviewers +- ✅ Review filtering and reporting + +## Installation + +### Prerequisites + +```bash +# Python 3.8+ +pip install monailabel +``` + +### Server Startup + +#### Start Review Server (Lightweight Mode) + +```bash +monailabel start_server \ + --app sample-apps/reviewer \ + --studies /path/to/review-dataset \ + --conf mode review +``` + +Recommended dataset layout: + +```text +/path/to/review-dataset/ + case-001.nrrd + case-002.nii.gz + labels/ + final/ + case-001.seg.nrrd + case-002.seg.nrrd +``` + +The server starts in review mode and keeps standard datastore routes for binary image and label access. + +### Slicer Integration + +1. Open **3D Slicer** +2. Go to **Edit > Application Settings > Modules** +3. Click **Add** +4. Navigate to the repository's `plugins/slicer/` directory +5. Select **MONAILabelReviewer** folder +6. Click **OK** + +The reviewer module will now appear in the **MONAI Label** section. + +## Quick Start + +### 1. Connect to Server + +1. Open 3D Slicer and load the **MONAILabelReviewer** module +2. Enter your MONAI Label server URL in the connection dialog +3. Click **Connect** +4. Click **Load** to load all images and segmentations + +The reviewer UI normalizes the server URL before connecting, so accidental trailing `/` characters are removed automatically. + +### 2. Review Segmentations + +**Reviewer Mode** (Default): +- Use **Previous/Next** buttons to navigate images +- Use **Easy/Medium/Hard** buttons to rate difficulty +- Click **Approve** to mark segmentation as approved +- Click **Flag** to mark for revision +- Enter **Comments** about any issues or improvements +- Filters: Check which images to show (Approved/Flagged/Pending) + +**Basic Mode** (Simplified): +- Skip advanced features +- Stream through segmentations quickly +- Simple navigation only + +### 3. Edit Segmentations (Optional) + +1. Click the segmentation dropdown to select version +2. Use Slicer's **Edit** tool to modify the mask +3. Click **Save as new version** to create a revised segmentation +4. **Approve** the new version + +### 4. Generate Reports + +```bash +# Generate JSON report +curl -s "http://localhost:8000/review/report?fmt=json" > review_report.json + +# Generate CSV report (extract the CSV content from the JSON envelope) +curl -s "http://localhost:8000/review/report?fmt=csv" | python -c "import json,sys; sys.stdout.write(json.load(sys.stdin)['content'])" > review_report.csv + +# Generate HTML report (extract the HTML content from the JSON envelope) +curl -s "http://localhost:8000/review/report?fmt=html" | python -c "import json,sys; sys.stdout.write(json.load(sys.stdin)['content'])" > review_report.html +``` + +## Architecture + +### Server Components + +``` +monailabel/ +├── endpoints/ +│ └── datastore_review.py # Reviewer aggregate endpoints + +sample-apps/reviewer/ +├── app.py # Lightweight review-only app +├── client.py # Simplified review client +├── main.py # App loader entrypoint +└── lib/ + └── config.py # Review app configuration + +plugins/slicer/MONAILabelReviewer/ +└── ... # 3D Slicer reviewer module +``` + +### Review Data Model + +Review metadata is stored through the standard datastore label-info APIs, and label binaries continue to use MONAI Label datastore version tags such as `final` and reviewer-created versions. + +## API Reference + +### Review Server Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/review/cases` | List reviewable images with summary metadata | +| `GET` | `/datastore/image` | Download image volume | +| `GET` | `/datastore/label` | Download segmentation (any version) | +| `GET` | `/datastore/label/info` | Get label metadata | +| `PUT` | `/datastore/label/info` | Update label metadata | +| `PUT` | `/datastore/label` | Save new/updated label | +| `GET` | `/review/versions` | List available versions | +| `GET` | `/review/report` | Generate review report | + +### Request/Response Examples + +#### List Images + +```bash +GET /review/cases?status_filter=approved +Response: +{ + "summary": { + "total": 100, + "approved": 45, + "flagged": 5, + "pending": 50 + }, + "results": [ + { + "id": "CT_abdomen_001", + "name": "CT_abdomen_001.nii.gz", + "status": "approved", + "review_count": 2, + "last_reviewed": "2024-01-15T10:30:00", + "reviewer": "Dr. Smith" + } + ] +} +``` + +#### Update Label Info + +```bash +PUT /datastore/label/info?label=CT_abdomen_001&tag=final +Content-Type: application/x-www-form-urlencoded + +info={"status":"approved","level":"medium","comment":"Good segmentation","reviewer_name":"Dr. John Smith"} +``` + +#### Download Label (with Version Tag) + +```bash +GET /datastore/label?label=CT_abdomen_001&tag=version_2 +Response: Binary NIfTI/NRRD file +``` + +## Configuration + +### Environment Variables + +```bash +# Server Configuration +MONAI_LABEL_SERVER=http://localhost:8000 +MONAI_LABEL_STUDIES=/path/to/images + +# Reviewer Configuration +MONAI_LABEL_REVIEWER_NAME=Dr. John Smith +MONAI_LABEL_REVIEWER_EMAIL=dr.smith@example.com + +MONAI_LABEL_REVIEW_MAX_HISTORY=10 + +# Mode +MONAI_LABEL_REVIEW_MODE=review +``` + +### Example Config File + +Create `.env` file in the reviewer app directory: + +```bash +cat > .env << EOF +MONAI_LABEL_SERVER=http://localhost:8000 +MONAI_LABEL_REVIEWER_NAME=Dr. Sarah Johnson +MONAI_LABEL_REVIEWER_EMAIL=sarah.j@hospital.edu +EOF +``` + +## Workflow Examples + +### Active Learning Workflow + +1. **Initial Run**: AI model labels all images +2. **First Review Cycle**: + - Connect to server + - Review all images + - Tag impossibly/hard cases as `approved` + - Flag ambiguous cases for re-annotation +3. **Update Model**: Use flagged cases as training data +4. **Iterate**: Repeat until satisfactory model + +## Troubleshooting + +### Server Won't Start + +```bash +# Check if app directory exists +ls sample-apps/reviewer/ + +# Validate python syntax +python -m py_compile sample-apps/reviewer/*.py + +# Check for missing files +find sample-apps/reviewer -type f +``` + +### Client Can't Connect + +```bash +# Test server connectivity +curl http://localhost:8000 + +# Check server logs +monailabel logs --tail 50 + +# Verify server URL +echo $MONAI_LABEL_SERVER +``` + +If remote image downloads fail while masks still load, check that the server URL is correct. Current reviewer builds also normalize a trailing `/` automatically before sending requests. + +## Performance + +- **Server Startup**: ~10 seconds (no AI models) +- **Image Load**: ~0.5 seconds per image +- **Label Download**: ~0.3 seconds +- **Memory Usage**: ~2GB (CPU only, no GPU) + +## Limitations + +- No AI model training (review only) +- Requires readable images in supported format +- Version control limited to annotated images +- Largest reports depend on available disk space + +## Contributing + +Contributions welcome! Areas for improvement: + +- Additional review filters (date range, reviewer) +- Export to DICOM SEG +- Integration with PACS systems +- Multi-reviewer consensus workflow +- Automated quality metrics + +## License + +Apache License 2.0 - Same as MONAI Label + +## Acknowledgements + +- MONAI Consortium +- rAiDiance (original MONAILabelReviewer) +- 3D Slicer community + +## Support + +- Documentation: https://monai.readthedocs.io/projects/label/en/latest/ +- GitHub Issues: https://github.com/Project-MONAI/MONAILabel/issues +- MONAI Discord: https://discord.gg/projectmonai diff --git a/sample-apps/reviewer/app.py b/sample-apps/reviewer/app.py new file mode 100644 index 000000000..ebfc2ebf8 --- /dev/null +++ b/sample-apps/reviewer/app.py @@ -0,0 +1,133 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Lightweight MONAILabel app for review workflow. + +This app exposes no AI tasks and relies on the standard MONAILabel datastore +plus reviewer-specific aggregate endpoints. +""" +import logging +import os +from typing import Any, Dict, cast + +from monailabel.interfaces.app import MONAILabelApp + +logger = logging.getLogger(__name__) + + +class ReviewerApp(MONAILabelApp): + """ + Lightweight review-specific MONAILabel application. + + This app is designed for: + - Reviewing and validating AI-generated segmentations + - Managing review metadata (comments, ratings, approvals) + - Generating review reports + - Simple cache management + - NO AI model training or inference + + The app provides a simplified focus on review operations + without the overhead of AI model management. + """ + + def __init__(self, app_dir: str, studies: str, conf: Dict): + """ + Initialize the reviewer app. + + Parameters: + - app_dir: Root path of the app + - studies: Datastore path + - conf: Configuration dictionary + """ + self.review_config = self._load_review_config(studies, conf) + + super().__init__( + app_dir=app_dir, + studies=self.review_config["studies"], + conf=conf, + name="MONAILabel Reviewer", + description="Lightweight review app - validates AI-generated segmentations", + version="1.0.0", + ) + + logger.info("MONAILabel Reviewer App initialized") + logger.info(f"Mode: {self.review_config['mode']}") + logger.info(f"Server: {self.review_config['server_url']}") + logger.info(f"Studies: {self.review_config['studies']}") + + def _load_review_config(self, studies: str, conf: Dict) -> Dict[str, Any]: + """Load review-specific configuration.""" + conf_dict = conf or {} + + config = { + "server_url": conf_dict.get("server_url") or conf_dict.get("monailabel_server"), + "studies": studies or conf_dict.get("studies") or conf_dict.get("datastore"), + "reviewer_name": conf_dict.get("reviewer_name"), + "reviewer_email": conf_dict.get("reviewer_email"), + "mode": conf_dict.get("mode", "review"), + } + + # Override with environment variables + config["server_url"] = config["server_url"] or os.environ.get("MONAI_LABEL_SERVER", "http://localhost:8000") + config["studies"] = config["studies"] or os.environ.get("MONAI_LABEL_STUDIES", "") + config["reviewer_name"] = config["reviewer_name"] or os.environ.get("MONAI_LABEL_REVIEWER_NAME", "Reviewer") + config["reviewer_email"] = config["reviewer_email"] or os.environ.get("MONAI_LABEL_REVIEWER_EMAIL", "") + config["mode"] = os.environ.get("MONAI_LABEL_REVIEW_MODE", config["mode"]) + + return config + + def init_infers(self) -> Dict[str, Any]: + return {} + + def init_trainers(self) -> Dict[str, Any]: + return {} + + def init_strategies(self) -> Dict[str, Any]: + return {} + + def init_scoring_methods(self) -> Dict[str, Any]: + return {} + + def info(self) -> Dict[str, Any]: + """ + Get application information. + + Returns: + { + "name": "MONAILabel Reviewer", + "description": "Lightweight review app", + "version": "1.0.0", + "studies": "/path/to/images", + "config": {...} + } + """ + meta = cast(Dict[str, Any], super().info()) + meta.update( + { + "studies": self.review_config.get("studies"), + "config": self.review_config, + "features": [ + "Image listing and browsing", + "Segmentation download", + "Metadata management", + "Review status tracking", + "Version control", + "Report generation", + ], + "mode": "REVIEW ONLY - No AI models loaded", + } + ) + return meta + + def allowed_keys(self) -> list: + """Get allowed configuration keys.""" + return ["server_url", "studies", "reviewer_name", "reviewer_email", "mode"] diff --git a/sample-apps/reviewer/client.py b/sample-apps/reviewer/client.py new file mode 100644 index 000000000..7209c218f --- /dev/null +++ b/sample-apps/reviewer/client.py @@ -0,0 +1,423 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Lightweight MONAILabel client for Reviewer workflow. +Simplified client focused only on review operations. + +Reuses monailabel.client.MONAILabelClient but routes only review endpoints. +Does NOT load AI models, no inference, no active learning. +""" +import json +import logging +from pathlib import Path +from typing import Any, Dict, Optional, Union, cast + +import requests # type: ignore[import-untyped] + +logger = logging.getLogger(__name__) + + +def _json_dict(response: requests.Response) -> Dict[str, Any]: + data = cast(Any, response.json()) + return data if isinstance(data, dict) else {"error": "Invalid JSON response"} + + +class LightweightReviewClient: + """ + Simplified client for reviewing segmentations. + Only offers review-related endpoints, no AI operation endpoints. + + This client provides: + - List images + - Download image data + - Download label/segmentation (any version) + - Download label metadata + - Update label metadata + - Save label + + Does NOT provide: + - AI model inference + - Training endpoints + - Active learning + - Segmentation tasks + """ + + def __init__(self, server_url: Optional[str] = None, timeout: int = 30): + """ + Initialize lightweight review client. + + Parameters: + - server_url: MONAI Label server URL + - timeout: Request timeout in seconds + """ + resolved_server_url = server_url or "http://localhost:8000" + self.server_url = resolved_server_url.rstrip("/") + self.timeout = timeout + self.headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"} + + # Verify server connectivity + self.ping() + + logger.info(f"ReviewClient initialized: {self.server_url}") + + def ping(self) -> bool: + """Check if server is reachable.""" + try: + response = requests.get(f"{self.server_url}", timeout=5) + status = response.status_code == 200 + if status: + logger.info(f"✓ Connected to server: {self.server_url}") + else: + logger.warning(f"✗ Server responded with status: {response.status_code}") + return status + except Exception as e: + logger.warning(f"✗ Cannot connect to server: {e}") + return False + + def list_images(self, offset: int = 0, limit: int = 100, status_filter: Optional[str] = None) -> Dict[str, Any]: + """ + List all images available for review. + + Query Parameters: + - offset: Pagination offset (default: 0) + - limit: Pagination limit (default: 100) + - status_filter: Filter by status (approved/flagged/unapproved) + + Returns: + { + "summary": { + "total": 100, + "approved": 45, + "flagged": 5, + "pending": 50 + }, + "results": [...list of image metadata...], + "metadata": {...} + } + """ + try: + params = {"limit": str(limit), "offset": str(offset)} + if status_filter: + params["status_filter"] = status_filter + + response = requests.get( + f"{self.server_url}/review/cases", params=params, timeout=self.timeout, headers=self.headers + ) + + if response.status_code == 200: + data = _json_dict(response) + logger.debug(f"Listed {len(data.get('results', []))} images") + return data + else: + logger.error(f"Failed to list images: {response.status_code} - {response.text}") + return {"status": "error", "error": f"Server error {response.status_code}"} + + except Exception as e: + logger.error(f"Error listing images: {e}") + return {"status": "error", "error": str(e)} + + def download_image(self, image_id: str) -> Optional[bytes]: + """ + Download DICOM image data. + + Parameters: + - image_id: Unique identifier for the image + + Returns: + Image file bytes in original format + """ + try: + response = requests.get( + f"{self.server_url}/datastore/image", params={"image": image_id}, timeout=self.timeout + ) + + if response.status_code == 200: + image_data = cast(bytes, response.content) + logger.debug(f"Downloaded image: {image_id} ({len(image_data)} bytes)") + return image_data + else: + logger.error(f"Failed to download image {image_id}: {response.status_code}") + return None + + except Exception as e: + logger.error(f"Error downloading image {image_id}: {e}") + return None + + def download_label(self, label_id: str, tag: str = "final") -> Optional[bytes]: + """ + Download segmentation label or mask. + + Parameters: + - label_id: Label/segmentation ID + - tag: Label version/tag (default: final) + + Returns: + Binary label file bytes in original format + """ + try: + response = requests.get( + f"{self.server_url}/datastore/label", params={"label": label_id, "tag": tag}, timeout=self.timeout + ) + + if response.status_code == 200: + logger.debug(f"Downloaded label: {label_id} (tag={tag})") + return cast(bytes, response.content) + else: + logger.warning(f"Label {label_id} (tag={tag}) not found: {response.status_code}") + return None + + except Exception as e: + logger.error(f"Error downloading label {label_id}: {e}") + return None + + def download_labelinfo(self, label_id: str) -> Optional[Dict[str, Any]]: + """ + Download label metadata. + + Parameters: + - label_id: Label/segmentation ID + + Returns: + { + "status": "success", + "label_id": "label_001", + "info": {...metadata...} + } + """ + try: + response = requests.get( + f"{self.server_url}/datastore/label/info", + params={"label": label_id, "tag": "final"}, + timeout=self.timeout, + ) + + if response.status_code == 200: + data = _json_dict(response) + logger.debug(f"Downloaded label info: {label_id}") + return data + else: + return None + + except Exception as e: + logger.error(f"Error downloading label info for {label_id}: {e}") + return None + + def update_labelinfo( + self, + label_id: str, + status: str, + level: Optional[str] = None, + comment: Optional[str] = None, + reviewer_name: Optional[str] = None, + reviewer_email: Optional[str] = None, + workflow_id: Optional[str] = None, + ) -> bool: + """ + Update label metadata for review. + + Parameters: + - label_id: Label/segmentation ID + - status: "approved" | "flagged" | "unapproved" + - level: "easy" | "medium" | "hard" + - comment: Review comment text + - reviewer_name: Name of reviewer + - reviewer_email: Email of reviewer + - workflow_id: Optional workflow identifier + + Returns: + True on success, False on failure + """ + try: + review_info: Dict[str, Any] = {"status": status} + + if level: + review_info["level"] = level + if comment: + review_info["comment"] = comment + if reviewer_email: + review_info["reviewer_email"] = reviewer_email + if reviewer_name: + review_info["reviewer_name"] = reviewer_name + review_info["reviewer"] = reviewer_name + if workflow_id: + review_info["workflow_id"] = workflow_id + payload = {"info": json.dumps(review_info)} + + response = requests.put( + f"{self.server_url}/datastore/label/info", + params={"label": label_id, "tag": "final"}, + data=payload, + headers=self.headers, + timeout=self.timeout, + ) + + if response.status_code == 200: + logger.info(f"Updated label {label_id}: {status} - {reviewer_name}") + return True + else: + logger.warning(f"Failed to update label {label_id}: {response.status_code}") + return False + + except Exception as e: + logger.error(f"Error updating label {label_id}: {e}") + return False + + def save_label( + self, + image_id: str, + label_file: Path, + tag: str = "final", + reviewer_name: str = "Reviewer", + comment: Optional[str] = None, + version_note: Optional[str] = None, + ) -> bool: + """ + Save a new or updated segmentation label. + + Parameters: + - image_id: Image ID + - label_file: Path to segmentation file + - tag: Label version/tag + - reviewer_name: Name of reviewer/saver + - comment: Optional comment about this version + - version_note: Reason for this version + + Returns: + True on success, False on failure + """ + try: + # Prepare approvals data + approvals = {"reviewer_name": reviewer_name} + if comment: + approvals["comment"] = comment + if version_note: + approvals["comment"] = f"{version_note}: {comment or ''}" + + params_payload = json.dumps(approvals) + + with open(label_file, "rb") as f: + upload_name = label_file.name or image_id + files = {"label": (upload_name, f)} + + response = requests.put( + f"{self.server_url}/datastore/label", + params={"image": image_id, "tag": tag}, + data={"params": params_payload}, + files=files, + headers={"Accept": "application/json"}, + timeout=self.timeout, + ) + + if response.status_code == 200: + logger.info(f"Saved label for {image_id} (tag={tag})") + return True + else: + logger.warning(f"Failed to save label for {image_id}: {response.status_code}") + return False + + except Exception as e: + logger.error(f"Error saving label for {image_id}: {e}") + return False + + def get_versions(self, image_id: str) -> Dict[str, Any]: + """ + List all available versions for an image. + + Parameters: + - image_id: Image ID + + Returns: + { + "status": "success", + "image_id": "CT_abdomen_001", + "versions": [...] + } + """ + try: + response = requests.get( + f"{self.server_url}/review/versions", + params={"image": image_id}, + timeout=self.timeout, + headers=self.headers, + ) + + if response.status_code == 200: + return _json_dict(response) + else: + return {"status": "error", "error": f"HTTP {response.status_code}"} + + except requests.exceptions.RequestException as e: + logger.error(f"Error getting versions for {image_id}: {e}") + return {"status": "error", "error": str(e)} + + def generate_report(self, fmt: str = "json", reviewer: Optional[str] = None) -> Union[Dict[str, Any], str]: + """ + Generate a review summary report. + + Parameters: + - fmt: Report format ("json", "csv", "html") + - reviewer: Optional reviewer filter + + Returns: + Report data + """ + try: + response_format = fmt.lower() + response = requests.get( + f"{self.server_url}/review/report", + params={"fmt": response_format, "reviewer": reviewer}, + timeout=self.timeout, + headers=self.headers, + ) + + if response.status_code == 200: + if response_format != "json": + return response.text + return _json_dict(response) + else: + return {"status": "error", "error": f"HTTP {response.status_code}"} + + except Exception as e: + logger.error(f"Error generating report: {e}") + return {"status": "error", "error": str(e)} + + def info(self) -> Dict[str, Any]: + """ + Get server information. + + Returns: + Server info including configuration + """ + try: + response = requests.get(f"{self.server_url}", timeout=5, headers=self.headers) + + if response.status_code == 200: + return _json_dict(response) + else: + return {"error": f"HTTP {response.status_code}"} + + except Exception as e: + return {"error": str(e)} + + +# Convenience functions +def create_client(server_url: Optional[str] = None) -> LightweightReviewClient: + """ + Create and return a lightweight review client. + + Parameters: + - server_url: Optional server URL override + + Returns: + LightweightReviewClient instance + """ + return LightweightReviewClient(server_url=server_url or "http://localhost:8000") diff --git a/sample-apps/reviewer/lib/__init__.py b/sample-apps/reviewer/lib/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/sample-apps/reviewer/lib/config.py b/sample-apps/reviewer/lib/config.py new file mode 100644 index 000000000..2b39e3d93 --- /dev/null +++ b/sample-apps/reviewer/lib/config.py @@ -0,0 +1,126 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Review app configuration for lightweight MONAILabel reviewer. + +This configuration is minimal - designed for review-only workflow +without heavy AI model dependencies. +""" +import os +from typing import Optional + + +class ReviewConfig: + """ + Configuration for MONAILabel Reviewer application. + """ + + def __init__( + self, + server_url: Optional[str] = None, + cache_dir: Optional[str] = None, + reviewer_name: Optional[str] = None, + reviewer_email: Optional[str] = None, + mode: str = "review", + ): + """ + Initialize reviewer configuration. + + Parameters: + - server_url: MONAI Label server URL (e.g., http://localhost:8000) + If None, runs in standalone mode using local cache + - cache_dir: Directory for cache storage + - reviewer_name: Name of the reviewer + - reviewer_email: Email of the reviewer + - mode: Operating mode - "review" (requires server) or "standalone" (local cache only) + """ + # Resolve server URL + self.server_url = server_url + if not self.server_url: + # Use default if not provided + self.server_url = os.environ.get("MONAI_LABEL_SERVER", "http://localhost:8000") + + # Resolve cache directory + self.cache_dir = cache_dir + if not self.cache_dir: + default_cache_dir = os.path.join(os.path.expanduser("~"), "monailabel_reviewer", "cache") + self.cache_dir = os.environ.get("MONAI_LABEL_REVIEW_CACHE", default_cache_dir) + + # Create cache directory if it doesn't exist + os.makedirs(self.cache_dir, exist_ok=True) + + # Reviewer info + self.reviewer_name = reviewer_name + if not self.reviewer_name: + self.reviewer_name = os.environ.get("MONAI_LABEL_REVIEWER_NAME", "Reviewer") + + self.reviewer_email = reviewer_email + if not self.reviewer_email: + self.reviewer_email = os.environ.get("MONAI_LABEL_REVIEWER_EMAIL", "") + + # Operating mode + self.mode = mode + if mode == "standalone": + self.review_only = True + self.auto_sync = False + else: + self.review_only = False + # In full review mode, can optionally sync to server + self.auto_sync = os.environ.get("MONAI_LABEL_REVIEW_AUTO_SYNC", "false") == "true" + + # Metadata + self.project_name = os.environ.get("MONAI_LABEL_PROJECT_NAME", "MONAILabel_Reviewer") + self.workspace_dir = os.environ.get("MONAI_LABEL_STUDIES", "") + + # Review settings + self.max_history = int(os.environ.get("MONAI_LABEL_REVIEW_MAX_HISTORY", "10")) + self.cache_timeout = int(os.environ.get("MONAI_LABEL_REVIEW_CACHE_TIMEOUT", "3600")) + + @property + def anaconda_channel(self) -> str: + """Return the Anaconda channel name for deployment.""" + return os.environ.get("MONAI_LABEL_CONDA_CHANNEL", "projectmonai") + + @property + def server_mode(self) -> str: + """Return the server mode description.""" + if self.review_only: + return "Lightweight Review Mode (Standalone)" + else: + return "Full Review Mode with Server Sync" + + def dict(self) -> dict: + """Return configuration as dictionary.""" + return { + "server_url": self.server_url, + "cache_dir": self.cache_dir, + "reviewer_name": self.reviewer_name, + "reviewer_email": self.reviewer_email, + "mode": self.mode, + "project_name": self.project_name, + "workspace_dir": self.workspace_dir, + "max_history": self.max_history, + "cache_timeout": self.cache_timeout, + "server_mode": self.server_mode, + "auto_sync": self.auto_sync, + } + + +# Lazily created to avoid filesystem side-effects at import time. +_config: Optional[ReviewConfig] = None + + +def get_config() -> ReviewConfig: + global _config + if _config is None: + _config = ReviewConfig() + return _config diff --git a/sample-apps/reviewer/main.py b/sample-apps/reviewer/main.py new file mode 100644 index 000000000..0a9e4f409 --- /dev/null +++ b/sample-apps/reviewer/main.py @@ -0,0 +1,16 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from app import ReviewerApp + + +class MyApp(ReviewerApp): + pass diff --git a/scripts/manual_reviewer_smoke_test.py b/scripts/manual_reviewer_smoke_test.py new file mode 100644 index 000000000..c317cf832 --- /dev/null +++ b/scripts/manual_reviewer_smoke_test.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +""" +comprehensive test script for the lightweight reviewer server. + +Tests the new review-specific endpoints against the test data. +""" +import json +import os +import select +import socket +import subprocess +import sys +import time +from pathlib import Path + +import requests + +TEST_DATA_DIR = os.environ.get("MONAI_LABEL_REVIEW_TEST_DATA", "/path/to/review-dataset") +PORT = 8079 # Different port from main monailabel server + + +def ensure_port_unused(port: int) -> None: + """Fail fast if another service is already bound to the test port.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("127.0.0.1", port)) + except OSError as exc: + raise RuntimeError( + f"Port {port} is already in use; stop the existing service before running this test" + ) from exc + + +def start_server(): + """Start the lightweight review server.""" + repo_root = Path(__file__).resolve().parents[1] + ensure_port_unused(PORT) + + print("=" * 70) + print("Starting Lightweight Review Server") + print("=" * 70) + + cmd = [ + sys.executable, + "-m", + "monailabel.main", + "start_server", + "--app", + "sample-apps/reviewer", + "--studies", + f"{TEST_DATA_DIR}/images", + "--port", + str(PORT), + "--conf", + "mode", + "review", + ] + + print(f"Command: {' '.join(cmd)}") + print() + + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + cwd=str(repo_root), + ) + if proc.stdout is not None: + os.set_blocking(proc.stdout.fileno(), False) + + return proc + + +def wait_for_server_ready(proc): + """Wait for server to be ready (check on another process).""" + for _ in range(60): + if proc.stdout is not None: + ready, _, _ = select.select([proc.stdout], [], [], 0.2) + if ready: + output = proc.stdout.read() + if output: + print(output, end="") + + exit_code = proc.poll() + if exit_code is not None: + print(f"❌ Server exited before becoming ready (exit code {exit_code})") + return False + + try: + response = requests.get(f"http://localhost:{PORT}", timeout=2) + if response.status_code == 200: + print(f"✅ Server responding on port {PORT}") + return True + except requests.RequestException: + pass + time.sleep(1) + + return False + + +def test_list_images(): + """Test listing images.""" + print("\n" + "=" * 70) + print("TEST 1: List Images") + print("=" * 70) + + try: + response = requests.get(f"http://localhost:{PORT}/review/cases", params={"limit": 10}, timeout=10) + + if response.status_code == 200: + data = response.json() + summary = data.get("summary", {}) + results = data.get("results", []) + + print("✅ Successfully listed images") + print(f" Total: {summary.get('total', 0)}") + print(f" Approved: {summary.get('approved', 0)}") + print(f" Flagged: {summary.get('flagged', 0)}") + print(f" Pending: {summary.get('pending', 0)}") + print("\n Sample images:") + for img in results[:3]: + print(f" - {img.get('name', 'N/A')} (status: {img.get('status', 'N/A')})") + + # Check for review metadata + if results and "reviewer" in results[0]: + print(" ✅ Review metadata present") + else: + print(" ⚠️ No review metadata in sample images") + + return True + else: + print(f"❌ List images failed: {response.status_code}") + print(f" Response: {response.text}") + return False + + except Exception as e: + print(f"❌ List images error: {e}") + return False + + +def test_download_image(): + """Test downloading an image.""" + print("\n" + "=" * 70) + print("TEST 2: Download Image") + print("=" * 70) + + try: + # Get a list of images first + list_response = requests.get(f"http://localhost:{PORT}/review/cases", params={"limit": 1}, timeout=10) + + if list_response.status_code != 200: + print("⚠️ Skipped - cannot list images") + return False + + images = list_response.json().get("results", []) + if not images: + print("⚠️ Skipped - no images available") + return False + + image_id = images[0].get("id") + + # Download the image + download_response = requests.get( + f"http://localhost:{PORT}/datastore/image", params={"image": image_id}, timeout=30 + ) + + if download_response.status_code == 200 and download_response.content: + content_len = len(download_response.content) + print(f"✅ Successfully downloaded image: {image_id}") + print(f" Size: {content_len:,} bytes ({content_len / 1024 / 1024:.2f} MB)") + return True + else: + print(f"❌ Download image failed: {download_response.status_code}") + print(f" Response: {download_response.text}") + return False + + except Exception as e: + print(f"❌ Download image error: {e}") + return False + + +def test_download_label(): + """Test downloading a label.""" + print("\n" + "=" * 70) + print("TEST 3: Download Label") + print("=" * 70) + + try: + # Get a list of images first + list_response = requests.get(f"http://localhost:{PORT}/review/cases", params={"limit": 1}, timeout=10) + + if list_response.status_code != 200: + print("⚠️ Skipped - cannot list images") + return False + + images = list_response.json().get("results", []) + if not images: + print("⚠️ Skipped - no images available") + return False + + image_id = images[0].get("id") + + # Download the label with final tag + download_response = requests.get( + f"http://localhost:{PORT}/datastore/label", params={"label": image_id, "tag": "final"}, timeout=10 + ) + + if download_response.status_code == 200 and download_response.content: + print(f"✅ Successfully downloaded label: {image_id}") + print(f" Size: {len(download_response.content):,} bytes") + print(f" Content-Type: {download_response.headers.get('content-type', 'N/A')}") + + return True + else: + # Try without tag + download_response = requests.get( + f"http://localhost:{PORT}/datastore/label", params={"label": image_id}, timeout=10 + ) + if download_response.status_code == 200 and download_response.content: + print(f"✅ Successfully downloaded label (default tag): {image_id}") + print(f" Size: {len(download_response.content):,} bytes") + return True + + print(f"❌ Download label failed: {download_response.status_code}") + print(f" Response: {download_response.text}") + return False + + except Exception as e: + print(f"❌ Download label error: {e}") + return False + + +def test_update_labelinfo(): + """Test updating label metadata.""" + print("\n" + "=" * 70) + print("TEST 4: Update Label Info") + print("=" * 70) + + try: + list_response = requests.get(f"http://localhost:{PORT}/review/cases", params={"limit": 1}, timeout=10) + + if list_response.status_code != 200: + print("⚠️ Skipped - cannot list images") + return False + + images = list_response.json().get("results", []) + if not images: + print("⚠️ Skipped - no images available") + return False + + image_id = images[0].get("id") + payload = { + "info": json.dumps( + { + "status": "approved", + "level": "medium", + "comment": "Manual smoke test", + "reviewer_name": "manual-smoke-test", + } + ) + } + + response = requests.put( + f"http://localhost:{PORT}/datastore/label/info", + params={"label": image_id, "tag": "final"}, + data=payload, + timeout=10, + ) + + if response.status_code == 200: + print(f"✅ Successfully updated label info for: {image_id}") + return True + + print(f"❌ Update label info failed: {response.status_code}") + print(f" Response: {response.text}") + return False + + except Exception as e: + print(f"❌ Update label info error: {e}") + return False + + +def test_generate_report(): + """Test generating review report.""" + print("\n" + "=" * 70) + print("TEST 5: Generate Report") + print("=" * 70) + + try: + response = requests.get( + f"http://localhost:{PORT}/review/report", + params={"fmt": "json"}, + timeout=10, + ) + + if response.status_code == 200: + data = response.json() + print("✅ Successfully generated review report") + print(f" Keys: {sorted(data.keys())}") + return True + + print(f"❌ Generate report failed: {response.status_code}") + print(f" Response: {response.text}") + return False + + except Exception as e: + print(f"❌ Generate report error: {e}") + return False + + +def main(): + repo_root = Path(__file__).resolve().parents[1] + os.chdir(repo_root) + + proc = start_server() + try: + if not wait_for_server_ready(proc): + print("❌ Server did not become ready") + return 1 + + results = [ + test_list_images(), + test_download_image(), + test_download_label(), + test_update_labelinfo(), + test_generate_report(), + ] + return 0 if all(results) else 1 + finally: + proc.terminate() + proc.wait(timeout=10) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/datastore/test_convert.py b/tests/unit/datastore/test_convert.py index 81d19b128..fcb36aa7f 100644 --- a/tests/unit/datastore/test_convert.py +++ b/tests/unit/datastore/test_convert.py @@ -226,7 +226,7 @@ def test_binary_to_image(self): try: result = binary_to_image(reference_image, label_bin) - self.addCleanup(os.unlink, result) + self.addCleanup(lambda p=result: os.unlink(p) if os.path.exists(p) else None) finally: os.unlink(label_bin) diff --git a/tests/unit/datastore/test_local.py b/tests/unit/datastore/test_local.py new file mode 100644 index 000000000..843f17545 --- /dev/null +++ b/tests/unit/datastore/test_local.py @@ -0,0 +1,142 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import tempfile +import unittest +from datetime import datetime + +from monailabel.datastore.local import LocalDatastore +from monailabel.interfaces.datastore import DefaultLabelTag + + +class TestLocalDatastore(unittest.TestCase): + def test_seg_nrrd_label_matches_image_id(self): + with tempfile.TemporaryDirectory() as studies: + image_file = os.path.join(studies, "case-001.nrrd") + label_dir = os.path.join(studies, "labels", DefaultLabelTag.FINAL) + label_file = os.path.join(label_dir, "case-001.seg.nrrd") + + os.makedirs(label_dir, exist_ok=True) + open(image_file, "wb").close() + open(label_file, "wb").close() + + datastore = LocalDatastore(studies, extensions=["*.nrrd"], auto_reload=False) + + self.assertEqual(datastore.get_labels_by_image_id("case-001"), {DefaultLabelTag.FINAL: "case-001"}) + self.assertTrue(datastore.get_label_uri("case-001", DefaultLabelTag.FINAL).endswith("case-001.seg.nrrd")) + + def test_save_label_preserves_seg_nrrd_suffix(self): + with tempfile.TemporaryDirectory() as studies: + image_file = os.path.join(studies, "case-001.nrrd") + label_dir = os.path.join(studies, "labels", DefaultLabelTag.FINAL) + source_label_file = os.path.join(studies, "case-001.seg.nrrd") + + os.makedirs(label_dir, exist_ok=True) + open(image_file, "wb").close() + open(source_label_file, "wb").close() + + datastore = LocalDatastore(studies, extensions=["*.nrrd"], auto_reload=False) + datastore.save_label("case-001", source_label_file, DefaultLabelTag.FINAL, {"status": "approved"}) + + self.assertTrue(datastore.get_label_uri("case-001", DefaultLabelTag.FINAL).endswith("case-001.seg.nrrd")) + self.assertEqual( + datastore.get_label_info("case-001", DefaultLabelTag.FINAL).get("name"), "case-001.seg.nrrd" + ) + + def test_non_seg_nrrd_label_is_not_rewritten(self): + with tempfile.TemporaryDirectory() as studies: + datastore = LocalDatastore(studies, extensions=["*.nii.gz"], auto_reload=False) + + self.assertEqual(datastore._to_label_id("case-001.seg.nii.gz"), ("case-001.seg", ".nii.gz")) + + def test_update_label_info_sets_last_reviewed(self): + with tempfile.TemporaryDirectory() as studies: + image_file = os.path.join(studies, "case-001.nrrd") + source_label_file = os.path.join(studies, "case-001.seg.nrrd") + + open(image_file, "wb").close() + open(source_label_file, "wb").close() + + datastore = LocalDatastore(studies, extensions=["*.nrrd"], auto_reload=False) + datastore.save_label("case-001", source_label_file, DefaultLabelTag.FINAL, {}) + datastore.update_label_info("case-001", DefaultLabelTag.FINAL, {"status": "approved"}) + + last_reviewed = datastore.get_label_info("case-001", DefaultLabelTag.FINAL).get("last_reviewed") + self.assertIsNotNone(last_reviewed) + datetime.fromisoformat(last_reviewed) + + def test_update_label_info_preserves_explicit_last_reviewed(self): + with tempfile.TemporaryDirectory() as studies: + image_file = os.path.join(studies, "case-001.nrrd") + source_label_file = os.path.join(studies, "case-001.seg.nrrd") + explicit_last_reviewed = "2024-01-15T10:30:00" + + open(image_file, "wb").close() + open(source_label_file, "wb").close() + + datastore = LocalDatastore(studies, extensions=["*.nrrd"], auto_reload=False) + datastore.save_label("case-001", source_label_file, DefaultLabelTag.FINAL, {}) + datastore.update_label_info( + "case-001", + DefaultLabelTag.FINAL, + {"status": "approved", "last_reviewed": explicit_last_reviewed}, + ) + + self.assertEqual( + datastore.get_label_info("case-001", DefaultLabelTag.FINAL).get("last_reviewed"), + explicit_last_reviewed, + ) + + def test_update_label_info_preserves_explicit_empty_last_reviewed(self): + with tempfile.TemporaryDirectory() as studies: + image_file = os.path.join(studies, "case-001.nrrd") + source_label_file = os.path.join(studies, "case-001.seg.nrrd") + + open(image_file, "wb").close() + open(source_label_file, "wb").close() + + datastore = LocalDatastore(studies, extensions=["*.nrrd"], auto_reload=False) + datastore.save_label("case-001", source_label_file, DefaultLabelTag.FINAL, {}) + datastore.update_label_info( + "case-001", + DefaultLabelTag.FINAL, + {"status": "approved", "last_reviewed": ""}, + ) + + self.assertEqual(datastore.get_label_info("case-001", DefaultLabelTag.FINAL).get("last_reviewed"), "") + + def test_update_label_info_falls_back_to_existing_ts_without_incoming_review_metadata(self): + with tempfile.TemporaryDirectory() as studies: + image_file = os.path.join(studies, "case-001.nrrd") + source_label_file = os.path.join(studies, "case-001.seg.nrrd") + legacy_ts = 1705314600 + + open(image_file, "wb").close() + open(source_label_file, "wb").close() + + datastore = LocalDatastore(studies, extensions=["*.nrrd"], auto_reload=False) + datastore.save_label("case-001", source_label_file, DefaultLabelTag.FINAL, {}) + + label_info = datastore.get_label_info("case-001", DefaultLabelTag.FINAL) + label_info.pop("last_reviewed", None) + label_info.update({"status": "approved", "ts": legacy_ts}) + + datastore.update_label_info("case-001", DefaultLabelTag.FINAL, {}) + + self.assertEqual( + datastore.get_label_info("case-001", DefaultLabelTag.FINAL).get("last_reviewed"), + datetime.fromtimestamp(legacy_ts).isoformat(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/endpoints/test_datastore_review.py b/tests/unit/endpoints/test_datastore_review.py new file mode 100644 index 000000000..b0986b512 --- /dev/null +++ b/tests/unit/endpoints/test_datastore_review.py @@ -0,0 +1,57 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest +from datetime import datetime, timezone + +from monailabel.endpoints import datastore_review +from monailabel.interfaces.exception import LabelNotFoundException + + +class _MissingLabelDatastore: + def get_label_info(self, image_id, tag): + raise LabelNotFoundException(image_id) + + +class _BrokenDatastore: + def get_label_info(self, image_id, tag): + raise RuntimeError("boom") + + +class TestDatastoreReview(unittest.TestCase): + def test_safe_label_info_returns_empty_for_missing_label(self): + info = datastore_review._safe_label_info(_MissingLabelDatastore(), "case-001", "final") + + self.assertEqual(info, {}) + + def test_safe_label_info_propagates_unexpected_errors(self): + with self.assertRaisesRegex(RuntimeError, "boom"): + datastore_review._safe_label_info(_BrokenDatastore(), "case-001", "final") + + def test_matches_date_range_handles_naive_value_with_aware_bounds(self): + parsed = ( + datetime(2024, 1, 1, 0, 0, tzinfo=timezone.utc), + datetime(2024, 1, 31, 23, 59, tzinfo=timezone.utc), + ) + + self.assertTrue(datastore_review._matches_date_range("2024-01-15T12:00:00", parsed)) + + def test_matches_date_range_handles_aware_value_with_naive_bounds(self): + parsed = ( + datetime(2024, 1, 1, 0, 0), + datetime(2024, 1, 31, 23, 59), + ) + + self.assertTrue(datastore_review._matches_date_range("2024-01-15T12:00:00+00:00", parsed)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/sample_apps/test_reviewer_app.py b/tests/unit/sample_apps/test_reviewer_app.py new file mode 100644 index 000000000..c1a660000 --- /dev/null +++ b/tests/unit/sample_apps/test_reviewer_app.py @@ -0,0 +1,43 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import importlib.util +import os +import pathlib +import unittest +from unittest.mock import patch + +APP_PATH = pathlib.Path(__file__).resolve().parents[3] / "sample-apps" / "reviewer" / "app.py" +SPEC = importlib.util.spec_from_file_location("reviewer_app", APP_PATH) +reviewer_app = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(reviewer_app) +ReviewerApp = reviewer_app.ReviewerApp + + +class TestReviewerAppConfig(unittest.TestCase): + def test_conf_mode_is_preserved_without_env_override(self): + app = ReviewerApp.__new__(ReviewerApp) + with patch.dict(os.environ, {}, clear=True): + config = app._load_review_config("/path/to/studies", {"mode": "standalone"}) + + self.assertEqual(config["mode"], "standalone") + + def test_env_mode_overrides_configured_mode(self): + app = ReviewerApp.__new__(ReviewerApp) + with patch.dict(os.environ, {"MONAI_LABEL_REVIEW_MODE": "review"}, clear=True): + config = app._load_review_config("/path/to/studies", {"mode": "standalone"}) + + self.assertEqual(config["mode"], "review") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/sample_apps/test_reviewer_client.py b/tests/unit/sample_apps/test_reviewer_client.py new file mode 100644 index 000000000..a841575ec --- /dev/null +++ b/tests/unit/sample_apps/test_reviewer_client.py @@ -0,0 +1,92 @@ +# Copyright (c) MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import importlib.util +import pathlib +import tempfile +import unittest +from unittest.mock import patch + +import requests + +CLIENT_PATH = pathlib.Path(__file__).resolve().parents[3] / "sample-apps" / "reviewer" / "client.py" +SPEC = importlib.util.spec_from_file_location("reviewer_client", CLIENT_PATH) +reviewer_client = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(reviewer_client) +LightweightReviewClient = reviewer_client.LightweightReviewClient + + +class MockResponse: + status_code = 200 + + def __init__(self, text: str = ""): + self.text = text + + def json(self): + return {"status": "success"} + + +class TestReviewerClientTimeouts(unittest.TestCase): + def setUp(self): + with patch.object(LightweightReviewClient, "ping", return_value=True): + self.client = LightweightReviewClient("http://localhost:8000", timeout=17) + + def test_update_labelinfo_uses_configured_timeout(self): + with patch.object(reviewer_client.requests, "put", return_value=MockResponse()) as mock_put: + self.assertTrue(self.client.update_labelinfo("case-001", "approved")) + + self.assertEqual(mock_put.call_args.kwargs.get("timeout"), 17) + + def test_save_label_uses_configured_timeout(self): + with tempfile.NamedTemporaryFile(suffix=".nrrd") as label_file: + with patch.object(reviewer_client.requests, "put", return_value=MockResponse()) as mock_put: + self.assertTrue(self.client.save_label("case-001", pathlib.Path(label_file.name))) + + self.assertEqual(mock_put.call_args.kwargs.get("timeout"), 17) + + def test_create_client_without_arguments_uses_default_server_url(self): + with patch.object(LightweightReviewClient, "ping", return_value=True): + client = reviewer_client.create_client() + + self.assertEqual(client.server_url, "http://localhost:8000") + + def test_create_client_preserves_explicit_server_url(self): + with patch.object(LightweightReviewClient, "ping", return_value=True): + client = reviewer_client.create_client("http://example.com:8001/") + + self.assertEqual(client.server_url, "http://example.com:8001") + + def test_generate_report_returns_text_for_csv(self): + with patch.object(reviewer_client.requests, "get", return_value=MockResponse("col1,col2\n1,2\n")): + report = self.client.generate_report("csv") + + self.assertEqual(report, "col1,col2\n1,2\n") + + def test_generate_report_returns_text_for_html(self): + with patch.object(reviewer_client.requests, "get", return_value=MockResponse("")): + report = self.client.generate_report("html") + + self.assertEqual(report, "") + + def test_get_versions_handles_request_failures(self): + with patch.object( + reviewer_client.requests, + "get", + side_effect=requests.exceptions.RequestException("boom"), + ): + response = self.client.get_versions("case-001") + + self.assertEqual(response, {"status": "error", "error": "boom"}) + + +if __name__ == "__main__": + unittest.main() From d757b450fe6985857fc9d4f36797982ffa2ff6b7 Mon Sep 17 00:00:00 2001 From: Elanchezhian Date: Wed, 29 Jul 2026 17:44:23 +0000 Subject: [PATCH 2/3] fix(ci): skip GPU integration tests when no GPU available The packaging Verify Package step runs runtests.sh --net which requires GPU for deepedit/segmentation integration tests. On ubuntu-latest runners without GPUs, these tests would always fail. Add an nvidia-smi check to skip gracefully and explain why. Signed-off-by: Elanchezhian --- .github/workflows/pythonapp.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 52eff10fa..2d0ee2740 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -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 From d8a5ad835c2c959ea1d85548f00e5bda43044c67 Mon Sep 17 00:00:00 2001 From: Elanchezhian Date: Wed, 29 Jul 2026 17:49:29 +0000 Subject: [PATCH 3/3] fix(ci): disable cancel-in-progress for build workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The concurrency group cancels all matrix jobs when a new commit lands, causing most jobs (packaging, build, deps_check) to be cancelled before completion. This makes CI results unreliably incomplete — only the 4 fastest-completing jobs show results (all failures), masking the actual state. Disabling cancel-in-progress ensures all matrix jobs complete fully and the real results are visible, even if it means older runs complete after a new one starts. Signed-off-by: Elanchezhian --- .github/workflows/pythonapp.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pythonapp.yml b/.github/workflows/pythonapp.yml index 2d0ee2740..df782f715 100644 --- a/.github/workflows/pythonapp.yml +++ b/.github/workflows/pythonapp.yml @@ -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: