Skip to content
Merged
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions src/mipview/control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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`
Expand Down Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion src/mipview/control/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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:]))
128 changes: 118 additions & 10 deletions src/mipview/control/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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:
Expand Down
Loading