Skip to content
Closed
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
35 changes: 35 additions & 0 deletions monailabel/datastore/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,15 @@ def set_description(self, description: str):
self._update_datastore_file()

def _to_id(self, file: str) -> Tuple[str, str]:
"""
Derive an identifier and extension from a filename using the configured file extensions.

Parameters:
file (str): Filename to parse.

Returns:
Tuple[str, str]: The identifier and matched extension.
"""
ext = file_ext(file)
extensions = [e.replace("*", "") for e in self._extensions]
for e in extensions:
Expand All @@ -227,11 +236,28 @@ def _to_id(self, file: str) -> Tuple[str, str]:
return id, ext

def _to_label_id(self, file: str) -> Tuple[str, str]:
"""Convert a label filename to its identifier and extension.

Parameters:
file (str): Label filename to parse.

Returns:
Tuple[str, str]: The label identifier and file extension.
"""
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:
"""Construct a filename by concatenating an identifier with its extension.

Parameters:
id (str): The file identifier.
ext (str): The file extension.

Returns:
str: The resulting filename.
"""
return id + ext

def _to_bytes(self, file):
Expand Down Expand Up @@ -633,6 +659,15 @@ def _add_non_existing_images(self) -> int:
return invalidate

def _add_non_existing_labels(self, tag) -> int:
"""
Add label metadata for label files that exist on disk but are missing from the datastore.

Parameters:
tag (str): Label tag identifying the directory containing the label files.

Returns:
int: Number of labels added to the datastore.
"""
invalidate = 0
self._init_from_datastore_file()

Expand Down
153 changes: 146 additions & 7 deletions monailabel/endpoints/datastore_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@


def _safe_label_info(datastore, image_id: str, tag: str) -> Dict[str, Any]:
"""
Retrieve label metadata for an image and tag.

Parameters:
image_id (str): Identifier of the image.
tag (str): Label tag used to locate the metadata.

Returns:
Dict[str, Any]: The label metadata when it is a dictionary; otherwise, an empty dictionary.
"""
try:
info = datastore.get_label_info(image_id, tag)
return info if isinstance(info, dict) else {}
Expand All @@ -49,6 +59,18 @@ def _safe_label_info(datastore, image_id: str, tag: str) -> Dict[str, Any]:


def _parse_date_range(date_range: Optional[str]) -> Optional[Tuple[datetime, datetime]]:
"""
Parse an optional comma-separated ISO timestamp range.

Parameters:
date_range (Optional[str]): Range in the form ``start,end``.

Returns:
Optional[Tuple[datetime, datetime]]: The parsed start and end timestamps, or ``None`` when no range is provided.

Raises:
HTTPException: If the range is incomplete or contains invalid ISO timestamps.
"""
if not date_range:
return None

Expand All @@ -63,6 +85,16 @@ def _parse_date_range(date_range: Optional[str]) -> Optional[Tuple[datetime, dat


def _matches_date_range(value: Optional[str], parsed: Optional[Tuple[datetime, datetime]]) -> bool:
"""
Determine whether a timestamp falls within an optional inclusive date range.

Parameters:
value (Optional[str]): ISO-formatted timestamp to evaluate.
parsed (Optional[Tuple[datetime, datetime]]): Inclusive start and end timestamps, or `None` to disable filtering.

Returns:
bool: `true` if filtering is disabled or the timestamp falls within the range, `false` otherwise.
"""
Comment on lines +95 to +97
if not parsed:
return True
if not value:
Expand All @@ -78,6 +110,17 @@ def _matches_date_range(value: Optional[str], parsed: Optional[Tuple[datetime, d


def _review_case(datastore, image_id: str, tag: str) -> Dict[str, Any]:
"""
Build a review record containing image metadata, review details, and label status.

Parameters:
datastore: Datastore used to retrieve image, label, and review information.
image_id (str): Identifier of the image.
tag (str): Label tag associated with the review.

Returns:
Dict[str, Any]: Review record with derived status, reviewer information, and label presence.
"""
image_info = datastore.get_image_info(image_id) or {}
label_info = _safe_label_info(datastore, image_id, tag)
labels = datastore.get_labels_by_image_id(image_id) or {}
Comment on lines 124 to 126
Expand Down Expand Up @@ -108,6 +151,22 @@ def _list_review_cases(
date_range: Optional[str],
tag: str,
) -> List[Dict[str, Any]]:
"""
Collect review cases that match the specified status, image, reviewer, date, and label tag filters.

Parameters:
status_filter (Optional[str]): Review status to match.
search (Optional[str]): Comma-separated image IDs to include.
reviewer (Optional[str]): Reviewer name to match.
date_range (Optional[str]): ISO timestamp range in the format ``start,end``.
tag (str): Label tag used to build each review case.

Returns:
List[Dict[str, Any]]: Review cases that satisfy all provided filters.

Raises:
HTTPException: If ``date_range`` is not a valid ISO timestamp range.
"""
datastore = app_instance().datastore()
image_ids = datastore.list_images()
parsed_range = _parse_date_range(date_range)
Expand All @@ -134,6 +193,15 @@ def _list_review_cases(


def _summary(items: List[Dict[str, Any]]) -> Dict[str, int]:
"""
Count review cases by overall total and status.

Parameters:
items (List[Dict[str, Any]]): Review case records containing a status field.

Returns:
Dict[str, int]: Counts for total, approved, flagged, pending, and unlabeled cases.
"""
return {
"total": len(items),
"approved": sum(1 for item in items if item.get("status") == "approved"),
Expand All @@ -144,6 +212,15 @@ def _summary(items: List[Dict[str, Any]]) -> Dict[str, int]:


def _report_stats(items: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Summarize review cases by status and difficulty level.

Parameters:
items (List[Dict[str, Any]]): Review case records to summarize.

Returns:
Dict[str, Any]: Status and difficulty counts with the current recording timestamp.
"""
return {
**_summary(items),
"easy": sum(1 for item in items if item.get("level") == "easy"),
Expand All @@ -154,14 +231,15 @@ def _report_stats(items: List[Dict[str, Any]]) -> Dict[str, Any]:


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
"""
Render review case records as CSV text.

Parameters:
items (List[Dict[str, Any]]): Review case records to include in the CSV output.

Returns:
str: CSV text containing a header row and one row for each review case.
"""
handle = io.StringIO()
writer = csv.writer(handle)
writer.writerow(["image_id", "status", "level", "reviewer", "comment", "last_reviewed", "tag"])
Expand All @@ -181,9 +259,27 @@ def sanitize_csv_value(value: Any) -> str:


def _render_html(stats: Dict[str, Any]) -> str:
"""
Render review statistics as an HTML report.

Parameters:
stats (Dict[str, Any]): Statistics and generation timestamp to include in the report.

Returns:
str: An HTML document containing review status and difficulty counts with percentages.
"""
total = stats["total"] or 1

def row(name: str, value: int) -> str:
"""Render an HTML table row containing a label, count, and percentage of the total.

Parameters:
name (str): The label displayed in the row.
value (int): The count used to calculate the percentage.

Returns:
str: An escaped HTML table row with the label, count, and percentage.
"""
return f"<tr><td>{escape(name)}</td><td>{value}</td>" f"<td>{(100.0 * value / total):.1f}%</td></tr>"

return (
Expand Down Expand Up @@ -215,6 +311,21 @@ async def api_review_cases(
tag: str = DefaultLabelTag.FINAL.value,
user: User = Depends(RBAC(settings.MONAI_LABEL_AUTH_ROLE_USER)),
):
"""
List filtered review cases with pagination and aggregate status counts.

Parameters:
offset (int): Number of matching cases to skip.
limit (int): Maximum number of cases to include in the results.
status_filter (Optional[str]): Status used to filter cases.
search (Optional[str]): Comma-separated image identifiers used to filter cases.
reviewer (Optional[str]): Reviewer name used to filter cases.
date_range (Optional[str]): ISO timestamp range in the format ``start,end``.
tag (str): Label tag used to build the review cases.

Returns:
Dict[str, Any]: A response containing summary counts, paginated results, and filter metadata.
"""
items = _list_review_cases(status_filter, search, reviewer, date_range, tag)
results = items[offset : offset + limit]

Expand All @@ -236,6 +347,18 @@ async def api_review_versions(
image: str,
user: User = Depends(RBAC(settings.MONAI_LABEL_AUTH_ROLE_USER)),
):
"""
Retrieve the available label versions for an image.

Parameters:
image (str): Identifier of the image whose label versions are requested.

Returns:
dict: A success response containing the image identifier and version metadata.

Raises:
HTTPException: If no labels are found for the image.
"""
datastore = app_instance().datastore()
labels = datastore.get_labels_by_image_id(image)
if not labels:
Expand Down Expand Up @@ -266,6 +389,22 @@ async def api_review_report(
tag: str = DefaultLabelTag.FINAL.value,
user: User = Depends(RBAC(settings.MONAI_LABEL_AUTH_ROLE_USER)),
):
"""
Generate a review report in JSON, CSV, or HTML format.

Parameters:
fmt (str): Output format: `"json"`, `"csv"`, or `"html"`.
reviewer (Optional[str]): Limits results to a specific reviewer.
date_range (Optional[str]): ISO timestamp range in `start,end` format.
tag (str): Label tag used to select review cases.

Returns:
dict: A formatted report containing review statistics and, for JSON output,
the applied filters and matching review cases.

Raises:
HTTPException: If `fmt` is not `"json"`, `"csv"`, or `"html"`.
"""
items = _list_review_cases(None, None, reviewer, date_range, tag)
stats = _report_stats(items)
fmt = fmt.lower()
Expand Down
Loading