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 = "poetry.core.masonry.api"

[project]
name = "mosaic-gui"
version = "1.3.2dev1"
version = "1.3.2"
description = "mosaic"
authors = [
{name = "Valentin Maurer", email = "valentin.maurer@embl-hamburg.de"},
Expand Down
150 changes: 132 additions & 18 deletions src/mosaic/animation/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
Author: Valentin Maurer <valentin.maurer@embl-hamburg.de>
"""

from contextlib import contextmanager
from os.path import splitext

import numpy as np
Expand Down Expand Up @@ -72,13 +73,101 @@ def read_frame(
return arr


_TEXT_PROPERTY_GETTERS = (
"GetTextProperty",
"GetTitleTextProperty",
"GetLabelTextProperty",
"GetLegendTitleProperty",
"GetLegendLabelProperty",
)


def _iter_props(render_window):
"""Yield every prop in every renderer, assembly parts included."""
from vtkmodules.vtkRenderingCore import vtkPropCollection

renderers = render_window.GetRenderers()
renderers.InitTraversal()
for _ in range(renderers.GetNumberOfItems()):
renderer = renderers.GetNextItem()
view_props = renderer.GetViewProps()
view_props.InitTraversal()
for _ in range(view_props.GetNumberOfItems()):
prop = view_props.GetNextProp()
yield prop

leaves = vtkPropCollection()
prop.GetActors(leaves)
prop.GetActors2D(leaves)
leaves.InitTraversal()
for _ in range(leaves.GetNumberOfItems()):
leaf = leaves.GetNextProp()
if leaf is not prop:
yield leaf


@contextmanager
def scaled_device_pixel_attributes(render_window, factor: float):
"""Scale pixel-denominated render attributes for the duration of a capture.

Parameters
----------
render_window : vtkRenderWindow
The render window whose props are rescaled.
factor : float
Ratio of the capture size to the on-screen window size.
"""
if factor == 1:
yield
return None

restore = []
scaled = set()

def scale(owner, attribute, transform):
if owner is None or (owner, attribute) in scaled:
return

getter = getattr(owner, f"Get{attribute}", None)
if getter is None:
return

scaled.add((owner, attribute))
original = getter()
setter = getattr(owner, f"Set{attribute}")
restore.append((setter, original))
setter(transform(original))
return

for prop in _iter_props(render_window):
appearance = prop.GetProperty() if hasattr(prop, "GetProperty") else None
for attribute in ("PointSize", "LineWidth"):
scale(appearance, attribute, lambda value: max(value * factor, 1.0))

for getter in _TEXT_PROPERTY_GETTERS:
text_property = getattr(prop, getter, None)
scale(
text_property() if text_property is not None else None,
"FontSize",
lambda value: round(value * factor),
)

try:
yield
finally:
for setter, original in restore:
setter(original)
return None


def capture_frame(
render_window,
transparent_bg: bool = False,
magnification: int = 1,
multisamples: int = None,
width: int = None,
height: int = None,
pixel_scale: float = None,
) -> np.ndarray:
"""Capture the current frame from a VTK render window.

Expand All @@ -103,6 +192,10 @@ def capture_frame(
Custom target width, uses current window width by default.
height : int, optional
Custom target height, uses current window height by default.
pixel_scale : float, optional
Factor applied to device-pixel attributes such as point size and line
width. Defaults to the ratio between capture and window size; pass it
explicitly when the window was already resized by the caller.

Returns
-------
Expand All @@ -123,20 +216,24 @@ def capture_frame(
target_height = (height if height is not None else original_size[1]) * magnification

size_changed = target_width != original_size[0] or target_height != original_size[1]
if size_changed:
render_window.SetOffScreenRendering(1)
render_window.SetSize(target_width, target_height)
render_window.Render()

window_to_image = vtkWindowToImageFilter()
window_to_image.SetInput(render_window)
window_to_image.SetInputBufferTypeToRGBA()
window_to_image.SetScale(1)
window_to_image.ReadFrontBufferOff()

arr = read_frame(
window_to_image, target_width, target_height, transparent_bg=transparent_bg
)
if pixel_scale is None:
pixel_scale = target_width / original_size[0] if original_size[0] else 1

with scaled_device_pixel_attributes(render_window, pixel_scale):
if size_changed:
render_window.SetOffScreenRendering(1)
render_window.SetSize(target_width, target_height)
render_window.Render()

window_to_image = vtkWindowToImageFilter()
window_to_image.SetInput(render_window)
window_to_image.SetInputBufferTypeToRGBA()
window_to_image.SetScale(1)
window_to_image.ReadFrontBufferOff()

arr = read_frame(
window_to_image, target_width, target_height, transparent_bg=transparent_bg
)

render_window.SetAlphaBitPlanes(original_alpha_bit_planes)

Expand Down Expand Up @@ -245,7 +342,10 @@ def capture_cropped(render_window, ctx):
Cropped RGB image of exactly ``ctx["width"]`` x ``ctx["height"]``.
"""
rw, rh = ctx["render_size"]
frame = capture_frame(render_window, width=rw, height=rh)
ow = ctx["original_size"][0]
frame = capture_frame(
render_window, width=rw, height=rh, pixel_scale=rw / ow if ow else 1
)

fh = frame.shape[0]
x, y, w, h = ctx["crop_x"], ctx["crop_y"], ctx["width"], ctx["height"]
Expand Down Expand Up @@ -310,9 +410,9 @@ def capture(
transparent_bg : bool, optional
Whether to keep transparent background.
width : int, optional
Custom width, uses current window width by default.
Custom width in pixels, uses the widget width by default.
height : int, optional
Custom height, uses current window height by default.
Custom height in pixels, uses the widget height by default.
magnification : int, optional
Output resolution multiplier (1-8). Rendered off-screen so it is not
bounded by the on-screen window size.
Expand All @@ -325,6 +425,13 @@ def capture(
Screenshot image.
"""
render_window = self.vtk_widget.GetRenderWindow()

# Avoid shortfalls of high-DPI screens.
if width is None:
width = self.vtk_widget.width()
if height is None:
height = self.vtk_widget.height()

arr = capture_frame(
render_window,
transparent_bg=transparent_bg,
Expand Down Expand Up @@ -353,7 +460,14 @@ def capture_window(self):
window_arr = np.frombuffer(ptr, np.uint8).reshape(height, width, 3).copy()
window_img = Image.fromarray(window_arr, "RGB")

vtk_img = self.capture(transparent_bg=False, magnification=1, multisamples=0)
render_size = self.vtk_widget.GetRenderWindow().GetSize()
vtk_img = self.capture(
transparent_bg=False,
width=render_size[0],
height=render_size[1],
magnification=1,
multisamples=0,
)
vtk_pos = self.vtk_widget.mapTo(top_window, self.vtk_widget.rect().topLeft())

dpr = top_window.devicePixelRatio()
Expand Down
58 changes: 34 additions & 24 deletions src/mosaic/animation/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@
from .timeline import TimelineWidget
from .animations import AnimationType, BaseAnimation
from .settings import AnimationSettings, ExportDialog
from ._utils import FrameWriter, capture_frame, read_frame
from ._utils import (
FrameWriter,
capture_frame,
read_frame,
scaled_device_pixel_attributes,
)

from ..icons import icon
from ..utils import Throttle
Expand Down Expand Up @@ -639,33 +644,38 @@ def export_animation(self):
render_width = width * magnification
render_height = height * magnification
render_window.SetSize(render_width, render_height)
render_window.Render()

window_to_image = vtkWindowToImageFilter()
window_to_image.SetInput(render_window)
window_to_image.SetInputBufferTypeToRGBA()
window_to_image.SetScale(1)
window_to_image.ReadFrontBufferOff()
window_to_image.ShouldRerenderOff()

if indicator is not None:
indicator.show_progress("Exporting Animation", total=n_frames)

for i, frame_idx in enumerate(frames):
self.set_current_frame(frame_idx)
pixel_scale = (
render_width / original_size[0] if original_size[0] else 1
)
with scaled_device_pixel_attributes(render_window, pixel_scale):
render_window.Render()

frame = read_frame(
window_to_image,
width,
height,
magnification,
transparent_bg,
)
writer.append_data(frame)
window_to_image = vtkWindowToImageFilter()
window_to_image.SetInput(render_window)
window_to_image.SetInputBufferTypeToRGBA()
window_to_image.SetScale(1)
window_to_image.ReadFrontBufferOff()
window_to_image.ShouldRerenderOff()

if indicator is not None:
indicator.update_progress(i + 1, n_frames)
QApplication.processEvents()
indicator.show_progress("Exporting Animation", total=n_frames)

for i, frame_idx in enumerate(frames):
self.set_current_frame(frame_idx)

frame = read_frame(
window_to_image,
width,
height,
magnification,
transparent_bg,
)
writer.append_data(frame)

if indicator is not None:
indicator.update_progress(i + 1, n_frames)
QApplication.processEvents()

writer.close()

Expand Down
13 changes: 12 additions & 1 deletion src/mosaic/interactor.py
Original file line number Diff line number Diff line change
Expand Up @@ -789,11 +789,22 @@ def visibility(self, geometries, visible: bool = True):
self.render()

def duplicate(self, geometries):
"""Copy each geometry into the container as a single undoable step."""
copies = []
for geometry in geometries:
if geometry is None:
continue
self.add(geometry[...])
index = self.add(geometry[...])
if (copy := self.container.get(index)) is not None:
copies.append(copy)

if copies:
self._push_swap(
"Duplicate",
[GeometrySwap(c.uuid, before=None, after=c[...]) for c in copies],
)
self.render()
return None

def update(self, tree_state=None):
"""Resync the tree widget after the session swapped containers.
Expand Down
62 changes: 62 additions & 0 deletions tests/gui/test_op_undo.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,65 @@ def test_replace_style_place_removes_source_and_undo_restores(interactor):
assert interactor.container.get(src.uuid) is not None
assert interactor.container.get(child_a.uuid) is None
assert interactor.container.get(child_b.uuid) is None


def test_duplicate_is_undoable(interactor):
source = _geom(12, 5)
interactor.add(source)
interactor.update()
n_before = len(interactor.container.data)

interactor.duplicate([interactor.container.get(source.uuid)])
assert len(interactor.container.data) == n_before + 1
copy_uuid = interactor.container.data[-1].uuid

STACK.undo()
assert len(interactor.container.data) == n_before
assert interactor.container.get(copy_uuid) is None
assert interactor.container.get(source.uuid) is not None

STACK.redo()
assert interactor.container.get(copy_uuid) is not None


def test_duplicate_records_one_entry_for_a_multi_selection(interactor):
first, second = _geom(6, 7), _geom(7, 8)
interactor.add(first)
interactor.add(second)
interactor.update()
n_before = len(interactor.container.data)

interactor.duplicate(
[interactor.container.get(first.uuid), interactor.container.get(second.uuid)]
)
assert len(interactor.container.data) == n_before + 2

STACK.undo()
assert len(interactor.container.data) == n_before


def test_duplicate_keeps_its_own_color_across_redo(interactor):
source = _geom(10, 9)
interactor.add(source)
interactor.set_coloring_mode("entity")
interactor.update()

interactor.duplicate([interactor.container.get(source.uuid)])
copy = interactor.container.data[-1]
copy_uuid, copy_color = copy.uuid, copy._appearance.get("base_color")
assert copy_color != interactor.container.get(source.uuid)._appearance.get(
"base_color"
)

STACK.undo()
STACK.redo()
restored = interactor.container.get(copy_uuid)
assert restored._appearance.get("base_color") == copy_color


def test_duplicate_of_nothing_records_no_entry(interactor):
interactor.add(_geom(5, 10))
interactor.update()

interactor.duplicate([None])
assert STACK.undo() is None
Loading
Loading