From 7afdef8354677f997f810d5638642b69ea87698c Mon Sep 17 00:00:00 2001 From: MarshallXu Date: Sun, 17 May 2026 14:24:21 +1000 Subject: [PATCH 1/2] Add annotation preview option to projection save command --- src/mipview/control/README.md | 17 ++++ src/mipview/control/cli.py | 28 ++++++- src/mipview/control/controller.py | 128 +++++++++++++++++++++++++++--- 3 files changed, 162 insertions(+), 11 deletions(-) diff --git a/src/mipview/control/README.md b/src/mipview/control/README.md index 2c35de1..692e0e1 100644 --- a/src/mipview/control/README.md +++ b/src/mipview/control/README.md @@ -359,17 +359,21 @@ mipview-ctl projection mode minip ### `projection.save` Function: Save the selected patch projection image for one anatomical view. +With `--annotation-preview`, overlay the active annotation mask projection onto the +exported grayscale image projection. CLI usage: ```bash mipview-ctl projection save VIEW PATH +mipview-ctl projection save VIEW PATH --annotation-preview ``` Direct command: ```json {"command": "projection.save", "args": {"view": "axial", "path": "./patch_axial_minip.png"}} +{"command": "projection.save", "args": {"view": "axial", "path": "./patch_axial_minip.png", "annotation_preview": true}} ``` Parameters: @@ -378,11 +382,23 @@ Parameters: | --- | --- | --- | | `view` | `str` | Projection view: `axial`, `coronal`, or `sagittal`. | | `path` | `str` | Output image path. The parent directory must exist and be writable. | +| `annotation_preview` | `bool` | Optional. When true, project the active annotation patch with MIP and overlay it on the image projection. | + +The image projection uses the current projection mode (`MIP` or `MinIP`). The +annotation preview always uses MIP, even when the image projection mode is +`MinIP`. This command-layer preview is independent of the GUI patch-window view +export overlay path. + +If annotation preview is requested but there is no active annotation mask, or +the selected patch contains no nonzero annotation labels, the command still saves +the grayscale projection. The JSON response includes a warning in +`data.warnings`, and `mipview-ctl` prints the warning to stderr. Example: ```bash mipview-ctl projection save axial ./patch_axial_minip.png +mipview-ctl projection save axial ./patch_axial_minip.png --annotation-preview ``` ### `annotation.create` @@ -521,6 +537,7 @@ mipview-ctl patch select mipview-ctl patch export-raw ./patch_raw.npz mipview-ctl projection mode minip mipview-ctl projection save axial ./patch_axial_minip.png +mipview-ctl projection save axial ./patch_axial_minip.png --annotation-preview # add a flag to export png with current annotation overlayed mipview-ctl annotation create --label 1 mipview-ctl annotation paint-stroke --label 1 --radius 2 --view axial --points ./stroke_points.json mipview-ctl viewer screenshot ./after.png diff --git a/src/mipview/control/cli.py b/src/mipview/control/cli.py index f757f63..923148a 100644 --- a/src/mipview/control/cli.py +++ b/src/mipview/control/cli.py @@ -27,6 +27,8 @@ def main(argv: list[str] | None = None) -> int: ) if postprocess is not None and response.get("ok") is True: postprocess(response) + if response.get("ok") is True: + _print_response_warnings(response) print(json.dumps(response)) return 0 if response.get("ok") is True else 1 @@ -87,6 +89,11 @@ def _build_parser() -> argparse.ArgumentParser: projection_save = projection_subparsers.add_parser("save") projection_save.add_argument("view") projection_save.add_argument("path") + projection_save.add_argument( + "--annotation-preview", + action="store_true", + help="Overlay the active annotation MIP onto the exported projection.", + ) annotation_parser = subparsers.add_parser("annotation") annotation_subparsers = annotation_parser.add_subparsers( @@ -146,7 +153,15 @@ def _command_from_args(args: argparse.Namespace) -> tuple[str, dict[str, Any], A if args.projection_command == "mode": return "projection.mode", {"mode": args.mode}, None if args.projection_command == "save": - return "projection.save", {"view": args.view, "path": args.path}, None + return ( + "projection.save", + { + "view": args.view, + "path": args.path, + "annotation_preview": bool(args.annotation_preview), + }, + None, + ) if args.group == "annotation": if args.annotation_command == "create": @@ -263,5 +278,16 @@ def _write_response_data(path: Path, response: dict[str, Any]) -> None: ) +def _print_response_warnings(response: dict[str, Any]) -> None: + data = response.get("data") + if not isinstance(data, dict): + return + warnings = data.get("warnings") + if not isinstance(warnings, list): + return + for warning in warnings: + print(f"Warning: {warning}", file=sys.stderr) + + if __name__ == "__main__": raise SystemExit(main(sys.argv[1:])) diff --git a/src/mipview/control/controller.py b/src/mipview/control/controller.py index ae906d0..c607757 100644 --- a/src/mipview/control/controller.py +++ b/src/mipview/control/controller.py @@ -14,6 +14,7 @@ paint_stroke, save_annotation_mask, ) +from mipview.annotation.annotation_overlay import build_annotation_overlay_rgba from mipview.control.result import CommandResult from mipview.patch.extractor import extract_patch from mipview.patch.saver import save_patch_nifti @@ -294,7 +295,12 @@ def set_projection_mode(self, mode: str) -> CommandResult: {"mode": normalized_mode}, ) - def save_projection(self, view: str, path: str) -> CommandResult: + def save_projection( + self, + view: str, + path: str, + annotation_preview: bool = False, + ) -> CommandResult: orientation = _validate_orientation(view) if orientation is None: return CommandResult(False, "Projection view must be axial, coronal, or sagittal.") @@ -347,25 +353,92 @@ def save_projection(self, view: str, path: str) -> CommandResult: normalized_mode, ) image_data = normalize_slice_to_uint8(projection) - qimage = _grayscale_image_from_array(image_data) + + annotation_preview_requested = bool(annotation_preview) + annotation_overlay_applied = False + warnings: list[str] = [] + if annotation_preview_requested: + annotation_result = self._annotation_projection_for_selected_patch( + selected_bounds, + orientation, + ) + annotation_plane = annotation_result["plane"] + warnings.extend(annotation_result["warnings"]) + if annotation_plane is not None: + image_data = _blend_annotation_overlay( + image_data, + annotation_plane, + opacity=self.main_window.state.annotation.opacity, + active_label=self.main_window.state.annotation.active_label, + ) + annotation_overlay_applied = True + + qimage = ( + _rgb_image_from_array(image_data) + if image_data.ndim == 3 + else _grayscale_image_from_array(image_data) + ) if not qimage.save(str(output_path), format_name): return CommandResult( False, "Projection save failed. Check path permissions and file format.", - {"path": str(output_path), "view": orientation, "mode": normalized_mode}, + { + "path": str(output_path), + "view": orientation, + "mode": normalized_mode, + "annotation_preview_requested": annotation_preview_requested, + "annotation_overlay_applied": annotation_overlay_applied, + }, ) + data: dict[str, Any] = { + "path": str(output_path), + "view": orientation, + "mode": normalized_mode, + "patch_bounds": _patch_bounds_to_dict(selected_bounds), + "shape": [int(image_data.shape[0]), int(image_data.shape[1])], + "annotation_preview_requested": annotation_preview_requested, + "annotation_overlay_applied": annotation_overlay_applied, + } + if annotation_preview_requested: + data["annotation_projection_mode"] = "MIP" + if warnings: + data["warnings"] = warnings + return CommandResult( True, "Projection saved.", - { - "path": str(output_path), - "view": orientation, - "mode": normalized_mode, - "patch_bounds": _patch_bounds_to_dict(selected_bounds), - "shape": [int(image_data.shape[0]), int(image_data.shape[1])], - }, + data, + ) + + def _annotation_projection_for_selected_patch( + self, + selected_bounds: PatchBounds, + orientation: Orientation, + ) -> dict[str, Any]: + annotation_mask = self.main_window.state.annotation.active_mask + if annotation_mask is None: + return { + "plane": None, + "warnings": ["No annotation exists in the selected patch."], + } + + annotation_patch = extract_patch(annotation_mask, selected_bounds) + if not np.any(np.asarray(annotation_patch.data) > 0): + return { + "plane": None, + "warnings": ["No annotation exists in the selected patch."], + } + + annotation_plane = project_oriented_volume( + build_oriented_volume( + annotation_patch.data, + annotation_patch.affine, + ).display_data, + orientation, + "MIP", ) + return {"plane": annotation_plane, "warnings": []} def capture_screenshot(self, path: str | None = None) -> CommandResult: if path is None or not str(path).strip(): @@ -650,6 +723,41 @@ def _grayscale_image_from_array(image_data: np.ndarray) -> QImage: ).copy() +def _rgb_image_from_array(image_data: np.ndarray) -> QImage: + contiguous = np.ascontiguousarray(image_data, dtype=np.uint8) + height, width, channels = contiguous.shape + if channels != 3: + raise ValueError(f"RGB image export expects 3 channels, got {channels}.") + return QImage( + contiguous.data, + width, + height, + width * 3, + QImage.Format.Format_RGB888, + ).copy() + + +def _blend_annotation_overlay( + image_data: np.ndarray, + annotation_plane: np.ndarray, + *, + opacity: float, + active_label: int, +) -> np.ndarray: + overlay = build_annotation_overlay_rgba( + annotation_plane, + opacity=opacity, + active_label=active_label, + ) + base = np.repeat(np.asarray(image_data, dtype=np.uint8)[..., None], 3, axis=2) + alpha = overlay[..., 3:4].astype(np.float32) / 255.0 + blended = ( + base.astype(np.float32) * (1.0 - alpha) + + overlay[..., :3].astype(np.float32) * alpha + ) + return np.clip(np.rint(blended), 0, 255).astype(np.uint8) + + def _projection_mode(slice_viewer: Any) -> str | None: mode = getattr(slice_viewer, "_projection_mode", None) if mode is None: From 9b302700402e781698a0c19c9db82ee15aff6215 Mon Sep 17 00:00:00 2001 From: MarshallXu Date: Sun, 17 May 2026 14:24:33 +1000 Subject: [PATCH 2/2] bump version to 0.4.2 in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index befee3e..97aa4cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "mipview" -version = "0.4.1" +version = "0.4.2" description = "A lightweight Linux-first NIfTI viewer for patch-based inspection workflows." readme = "README.md" requires-python = ">=3.11"