From 2ffeaa941d73ddc2a33de24804274ace3924c3ac Mon Sep 17 00:00:00 2001 From: maurerv Date: Fri, 25 Sep 2026 10:48:10 +0200 Subject: [PATCH] Default to FreeDTS1.0_HMFF during DTS setup and consistent point scaling in animations --- docs/tutorial/workflows/iav.rst | 4 + src/mosaic/animation/_utils.py | 26 +++-- src/mosaic/animation/compose.py | 2 +- src/mosaic/dts/screening.py | 2 +- src/mosaic/geometry.py | 45 ++++++++- tests/gui/test_screenshot_manager.py | 141 +++++++++++++++++++++++++++ 6 files changed, 209 insertions(+), 11 deletions(-) diff --git a/docs/tutorial/workflows/iav.rst b/docs/tutorial/workflows/iav.rst index 079cef76..2cffd781 100644 --- a/docs/tutorial/workflows/iav.rst +++ b/docs/tutorial/workflows/iav.rst @@ -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 diff --git a/src/mosaic/animation/_utils.py b/src/mosaic/animation/_utils.py index 309bc95a..13ececf0 100644 --- a/src/mosaic/animation/_utils.py +++ b/src/mosaic/animation/_utils.py @@ -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 @@ -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 @@ -153,7 +164,8 @@ def scale(owner, attribute, transform): ) try: - yield + with render_scale(factor): + yield finally: for setter, original in restore: setter(original) @@ -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 ------- @@ -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: @@ -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] diff --git a/src/mosaic/animation/compose.py b/src/mosaic/animation/compose.py index c3aad0be..c5cae94c 100644 --- a/src/mosaic/animation/compose.py +++ b/src/mosaic/animation/compose.py @@ -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() diff --git a/src/mosaic/dts/screening.py b/src/mosaic/dts/screening.py index 3d623761..8a71f261 100644 --- a/src/mosaic/dts/screening.py +++ b/src/mosaic/dts/screening.py @@ -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: diff --git a/src/mosaic/geometry.py b/src/mosaic/geometry.py index e3122c04..40da332a 100644 --- a/src/mosaic/geometry.py +++ b/src/mosaic/geometry.py @@ -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 @@ -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. @@ -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)) @@ -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 diff --git a/tests/gui/test_screenshot_manager.py b/tests/gui/test_screenshot_manager.py index 8aacb26f..3a4b454d 100644 --- a/tests/gui/test_screenshot_manager.py +++ b/tests/gui/test_screenshot_manager.py @@ -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 ): @@ -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)