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
4 changes: 4 additions & 0 deletions docs/tutorial/workflows/iav.rst
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ HMFF Simulation

The edge length parameter is related to mesh quality, and FreeDTS will inform you if any edges fall outside this range. The ideal range is 1.0 - 3.0, but upper bounds of 5.0 can still yield stable simulations. Beyond that input mesh quality should be improved. When using meshes created without the *Equilibrate* functionality, you need to set HMFF scale and offset parameter yourself (see ``mosaic.meshing.hmff`` equilibrate_fit for how we compute them).

.. note::

Depending on your FreeDTS version the HMFF energy may be called differently. From v2.2+, only ``FreeDTS1.0_HMFF`` is supported, whereas older scripts may use ``FreeDTS1.0_MDFF`` and needs to be adapted.

After the background job has completed, navigate to your chosen output directory and from within run_1 execute:

.. code-block:: bash
Expand Down
26 changes: 19 additions & 7 deletions src/mosaic/animation/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from vtkmodules.util import numpy_support
from vtkmodules.vtkRenderingCore import vtkWindowToImageFilter

from ..geometry import render_scale


def read_frame(
window_to_image, target_width, target_height, magnification=1, transparent_bg=False
Expand Down Expand Up @@ -115,7 +117,16 @@ def scaled_device_pixel_attributes(render_window, factor: float):
render_window : vtkRenderWindow
The render window whose props are rescaled.
factor : float
Ratio of the capture size to the on-screen window size.
Ratio of the capture height to the on-screen window height.

Yields
------
None

See Also
--------
:py:func:`mosaic.geometry.render_scale` : keeps actors rebuilt mid-capture,
such as trajectory frames, at the same scale.
"""
if factor == 1:
yield
Expand Down Expand Up @@ -153,7 +164,8 @@ def scale(owner, attribute, transform):
)

try:
yield
with render_scale(factor):
yield
finally:
for setter, original in restore:
setter(original)
Expand Down Expand Up @@ -194,8 +206,8 @@ def capture_frame(
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.
width. Defaults to the ratio between capture and window height; pass
it explicitly when the window was already resized by the caller.

Returns
-------
Expand All @@ -217,7 +229,7 @@ def capture_frame(

size_changed = target_width != original_size[0] or target_height != original_size[1]
if pixel_scale is None:
pixel_scale = target_width / original_size[0] if original_size[0] else 1
pixel_scale = target_height / original_size[1] if original_size[1] else 1

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

fh = frame.shape[0]
Expand Down
2 changes: 1 addition & 1 deletion src/mosaic/animation/compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,7 @@ def export_animation(self):
render_window.SetSize(render_width, render_height)

pixel_scale = (
render_width / original_size[0] if original_size[0] else 1
render_height / original_size[1] if original_size[1] else 1
)
with scaled_device_pixel_attributes(render_window, pixel_scale):
render_window.Render()
Expand Down
2 changes: 1 addition & 1 deletion src/mosaic/dts/screening.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ def _build_dts_template(
else invert_contrast
)
energy_line = (
f"EnergyMethod = FreeDTS1.0_MDFF {volume_path} {xi} 0 "
f"EnergyMethod = FreeDTS1.0_HMFF {volume_path} {xi} 0 "
f"{mesh_scale} {mesh_offset} {invert_flag} 0"
)
else:
Expand Down
45 changes: 43 additions & 2 deletions src/mosaic/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import warnings
from uuid import uuid4
from contextlib import contextmanager
from typing import Dict, List, Optional, Tuple

import numpy as np
Expand All @@ -26,11 +27,51 @@
"SegmentationGeometry",
"GeometryTrajectory",
"merge_geometries",
"get_render_scale",
"render_scale",
]


BASE_COLOR = (0.7, 0.7, 0.7)

_RENDER_SCALE = 1.0


def get_render_scale() -> float:
"""Return the factor applied to pixel-denominated actor attributes.

Returns
-------
float
One during interactive rendering, the capture-to-window height ratio
while a screenshot or animation frame is being rendered.
"""
return _RENDER_SCALE


@contextmanager
def render_scale(factor: float):
"""Scale pixel-denominated actor attributes for the duration of a capture.

Parameters
----------
factor : float
Ratio of the capture height to the on-screen window height, the axis
the rendered scene scales along. Nested scopes compound.

Yields
------
None
"""
global _RENDER_SCALE

previous = _RENDER_SCALE
_RENDER_SCALE = previous * factor
try:
yield
finally:
_RENDER_SCALE = previous


class GeometryData:
"""Canonical store for geometry data.
Expand Down Expand Up @@ -852,7 +893,7 @@ def _set_appearance(self):
if not self._appearance.get("render_spheres", True):
prop.SetRenderPointsAsSpheres(False)

prop.SetPointSize(self._appearance.get("size", 8))
prop.SetPointSize(self._appearance.get("size", 8) * get_render_scale())
prop.SetOpacity(self._appearance.get("opacity", 1.0))
prop.SetAmbient(self._appearance.get("ambient", 0.3))
prop.SetDiffuse(self._appearance.get("diffuse", 0.7))
Expand Down Expand Up @@ -1191,7 +1232,7 @@ def change_representation(self, representation: str = "pointcloud") -> int:
scale = 15 * np.max(self.sampling_rate)
mapper, prop = self._actor.GetMapper(), self._actor.GetProperty()
prop.SetOpacity(self._appearance["opacity"])
prop.SetPointSize(self._appearance["size"])
prop.SetPointSize(self._appearance["size"] * get_render_scale())
prop.SetRenderPointsAsSpheres(self._appearance["render_spheres"])

self._representation = representation
Expand Down
141 changes: 141 additions & 0 deletions tests/gui/test_screenshot_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,25 @@ def test_point_size_keeps_its_share_of_the_frame(
assert scaled_separation == pytest.approx(2 * base_separation, rel=0.05)
assert scaled_diameter == pytest.approx(2 * base_diameter, rel=0.2)

def test_point_size_follows_the_scene_when_aspect_changes(
self, require_vtk_render_window
):
"""VTK keeps the vertical field of view when a window is resized, so the
scene grows with the capture height alone.
"""
render_window = _offscreen_window((400, 300))

diameter, separation = _measure(
_utils.capture_frame(render_window, width=400, height=300)
)
wide_diameter, wide_separation = _measure(
_utils.capture_frame(render_window, width=1920, height=1080)
)

assert wide_diameter / wide_separation == pytest.approx(
diameter / separation, rel=0.15
)

def test_cropped_capture_keeps_point_proportions(
self, require_vtk_render_window
):
Expand Down Expand Up @@ -258,3 +277,125 @@ def test_text_keeps_its_share_of_the_frame(self, require_vtk_render_window):
)

assert scaled == pytest.approx(2 * base, rel=0.3)


def _geometry_window(size=(400, 300)):
"""Render window holding a :py:class:`Geometry` point cloud."""
import vtk

from mosaic.geometry import Geometry

points = np.array([[-5.0, -4.0, 0.0], [5.0, 4.0, 0.0]])
geometry = Geometry(points=points, sampling_rate=np.ones(3))
geometry.set_color((1, 1, 1))

renderer = vtk.vtkRenderer()
renderer.AddActor(geometry.actor)
renderer.SetBackground(0, 0, 0)

render_window = vtk.vtkRenderWindow()
render_window.SetOffScreenRendering(1)
render_window.AddRenderer(renderer)
render_window.SetSize(*size)
render_window.Render()
renderer.ResetCamera()
render_window.Render()
return render_window, geometry


class TestRenderScale:
"""Actors rebuilt during a capture keep the capture's pixel scale."""

def test_rebuilt_actor_keeps_scaled_point_size(self):
from mosaic.geometry import Geometry, get_render_scale, render_scale

geometry = Geometry(points=np.zeros((2, 3)), sampling_rate=np.ones(3))
on_screen = geometry.actor.GetProperty().GetPointSize()

with render_scale(4.0):
geometry.swap_data(points=np.ones((2, 3)))
assert geometry.actor.GetProperty().GetPointSize() == 4 * on_screen

assert get_render_scale() == 1.0
geometry.swap_data(points=np.zeros((2, 3)))
assert geometry.actor.GetProperty().GetPointSize() == on_screen

def test_nested_scales_compound(self):
from mosaic.geometry import Geometry, render_scale

geometry = Geometry(points=np.zeros((2, 3)), sampling_rate=np.ones(3))
on_screen = geometry.actor.GetProperty().GetPointSize()

with render_scale(2.0):
with render_scale(3.0):
geometry.swap_data(points=np.ones((2, 3)))
assert geometry.actor.GetProperty().GetPointSize() == 6 * on_screen

geometry.swap_data(points=np.zeros((2, 3)))
assert geometry.actor.GetProperty().GetPointSize() == 2 * on_screen


@pytest.mark.gui
class TestAnimationFrameScaling:
"""Trajectory frames swap geometry data while the export is in flight."""

def test_point_size_survives_a_frame_update(self, require_vtk_render_window):
render_window, geometry = _geometry_window()
points = np.array([[-5.0, -4.0, 0.0], [5.0, 4.0, 0.0]])

base_diameter, _ = _measure(
_utils.capture_frame(render_window, width=400, height=300, magnification=1)
)

with _utils.scaled_device_pixel_attributes(render_window, 2.0):
geometry.swap_data(points=points)
frame = _utils.capture_frame(
render_window,
width=400,
height=300,
magnification=2,
pixel_scale=1,
)

scaled_diameter, _ = _measure(frame)
assert scaled_diameter == pytest.approx(2 * base_diameter, rel=0.2)

def test_exported_frames_keep_the_on_screen_point_size(
self, require_vtk_render_window
):
import vtk

render_window, geometry = _geometry_window()
points = np.array([[-5.0, -4.0, 0.0], [5.0, 4.0, 0.0]])

width, height, magnification = 400, 300, 3
on_screen_diameter, _ = _measure(
_utils.capture_frame(render_window, width=width, height=height)
)

original_size = render_window.GetSize()
render_window.SetSize(width * magnification, height * magnification)
pixel_scale = width * magnification / original_size[0]

diameters = []
with _utils.scaled_device_pixel_attributes(render_window, pixel_scale):
render_window.Render()

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

for _ in range(3):
geometry.swap_data(points=points)
render_window.Render()
frame = _utils.read_frame(
window_to_image, width, height, magnification, False
)
diameters.append(_measure(frame)[0])

render_window.SetSize(*original_size)
for diameter in diameters:
assert diameter == pytest.approx(on_screen_diameter, rel=0.2)
Loading