From e8a3d588052295c31c111b1923235826ec3048c1 Mon Sep 17 00:00:00 2001 From: user27182 <89109579+user27182@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:49:00 -0600 Subject: [PATCH 1/2] fix: return columns then rows from VtkRemoteControlledArea img_cols_rows unpacked vtkImageData.GetDimensions(), which is (width, height, depth), as rows, cols, so it returned the height as cols and the width as rows, unlike the protocol and the vanilla example. The array shape was right, but the encoders' meta reported w and h swapped for non-square windows. Add a test with a non-square window that checks cols, rows and every encoder's meta w/h. --- src/trame_rca/rca/vtk_rca.py | 4 ++-- tests/test_rca_image.py | 41 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/trame_rca/rca/vtk_rca.py b/src/trame_rca/rca/vtk_rca.py index 1e8a936..534dcaa 100644 --- a/src/trame_rca/rca/vtk_rca.py +++ b/src/trame_rca/rca/vtk_rca.py @@ -36,10 +36,10 @@ def img_cols_rows(self): self._window_to_image.Update() image_data = self._window_to_image.GetOutput() - rows, cols, _ = image_data.GetDimensions() + cols, rows, _ = image_data.GetDimensions() scalars = image_data.GetPointData().GetScalars() np_image = vtk_to_numpy(scalars) - np_image = np_image.reshape((cols, rows, -1)) + np_image = np_image.reshape((rows, cols, -1)) np_image[:] = np_image[::-1, :, :] return np_image, cols, rows diff --git a/tests/test_rca_image.py b/tests/test_rca_image.py index d9af038..04ab4c6 100644 --- a/tests/test_rca_image.py +++ b/tests/test_rca_image.py @@ -9,6 +9,11 @@ import pytest from PIL import Image +from vtkmodules.vtkRenderingCore import ( + vtkRenderer, + vtkRenderWindow, + vtkRenderWindowInteractor, +) from trame_rca.encoders import RcaImageEncoder from trame_rca.schedulers import RcaImageRenderScheduler from trame_rca.rca import VtkRemoteControlledArea @@ -159,3 +164,39 @@ def test_scheduler_is_compatible_with_string_encoder_format(encoder, a_render_wi interactive_quality=1, rca_encoder=encoder, ) + + +# Not square, so a transposed frame fails too +WIDTH, HEIGHT = 96, 64 + +# VTK viewports are (xmin, ymin, xmax, ymax) with the origin at the bottom left +QUADRANTS = { + "top-left": ((0.0, 0.5, 0.5, 1.0), (255, 0, 0)), + "top-right": ((0.5, 0.5, 1.0, 1.0), (0, 255, 0)), + "bottom-left": ((0.0, 0.0, 0.5, 0.5), (0, 0, 255)), + "bottom-right": ((0.5, 0.0, 1.0, 0.5), (255, 255, 255)), +} + + +@pytest.fixture +def quadrants_window(): + render_window = vtkRenderWindow() + render_window.SetOffScreenRendering(1) + render_window.SetSize(WIDTH, HEIGHT) + for viewport, color in QUADRANTS.values(): + renderer = vtkRenderer() + renderer.SetViewport(*viewport) + renderer.SetBackground(*(c / 255 for c in color)) + render_window.AddRenderer(renderer) + interactor = vtkRenderWindowInteractor() + interactor.SetRenderWindow(render_window) + yield render_window + render_window.Finalize() + + +@pytest.mark.parametrize("encoder", list(RcaImageEncoder)) +def test_frame_size_is_columns_then_rows(quadrants_window, encoder): + image, cols, rows = VtkRemoteControlledArea(quadrants_window).img_cols_rows + assert (cols, rows) == (WIDTH, HEIGHT) + _, meta, _ = encoder.encode(image, cols, rows, 90) + assert (meta["w"], meta["h"]) == (WIDTH, HEIGHT) From ae12d337b2ec1f03029065b138f39762bf7ada97 Mon Sep 17 00:00:00 2001 From: user27182 <89109579+user27182@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:51:04 -0600 Subject: [PATCH 2/2] test: test turbo-jpeg on CI, benchmark encoders and check VTK frames - CI: install libjpeg-turbo 3.2.0 (checksum pinned) on Linux and Homebrew's jpeg-turbo on macOS. Ubuntu's libturbojpeg 2.1.5 lacks the tj3* API PyTurboJPEG 2 needs, so turbo-jpeg silently fell back to Pillow; the job now fails if it does. README's Ubuntu instructions point to the official package too. - CI: select OSMesa directly on Linux (VTK_DEFAULT_OPENGL_WINDOW), so render windows no longer log "Failed to load EGL!" first. - Benchmark VtkRemoteControlledArea.img_cols_rows on a 1920x1080 frame separately from encoding it with each encoder (pytest-benchmark, dev extra); CI saves the results as a Linux artifact. - Check each quadrant's color in the img_cols_rows array and in every encoder's decoded output, that encoded frames have no alpha channel, and that a frame isn't overwritten by the next capture. - Check that every encoder gives the same bytes for non-contiguous RGB arrays (views of RGBA, bottom-up, mirrored, transposed, strided, Fortran order) as for a contiguous copy; these need no rendering and also run on macOS and Windows CI. --- .github/workflows/test_and_release.yml | 30 ++++++- README.rst | 6 +- pyproject.toml | 1 + tests/test_benchmark.py | 105 +++++++++++++++++++++++++ tests/test_image_encoders.py | 28 +++++++ tests/test_rca_image.py | 46 +++++++++++ 6 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 tests/test_benchmark.py create mode 100644 tests/test_image_encoders.py diff --git a/.github/workflows/test_and_release.yml b/.github/workflows/test_and_release.yml index c11733e..f315d9f 100644 --- a/.github/workflows/test_and_release.yml +++ b/.github/workflows/test_and_release.yml @@ -51,7 +51,21 @@ jobs: if: matrix.config.os == 'ubuntu-latest' run: | sudo apt update - sudo apt install -y libosmesa6-dev libturbojpeg + sudo apt install -y libosmesa6-dev + echo "VTK_DEFAULT_OPENGL_WINDOW=vtkOSOpenGLRenderWindow" >> "$GITHUB_ENV" + + # PyTurboJPEG 2 needs libjpeg-turbo >= 3.0. Ubuntu's libturbojpeg is 2.x and, + # if installed, is found first, so use the official package in /opt instead. + - name: Install libjpeg-turbo for Linux + if: matrix.config.os == 'ubuntu-latest' + run: | + curl -fsSL -o libjpeg-turbo.deb https://github.com/libjpeg-turbo/libjpeg-turbo/releases/download/3.2.0/libjpeg-turbo-official_3.2.0_amd64.deb + echo "21297da4a4eb34ebefc54afca5d8dd86c0fdd6a9dfe49b1b962c5d1eeeafd8ec libjpeg-turbo.deb" | sha256sum -c + sudo apt install -y ./libjpeg-turbo.deb + + - name: Install libjpeg-turbo for macOS + if: matrix.config.os == 'macos-latest' + run: brew install jpeg-turbo - name: Set Up Node uses: actions/setup-node@v7 @@ -78,12 +92,24 @@ jobs: - name: Install and Run Tests run: | pip install ".[turbo, avif, vtkstreaming, dev]" + # Fail instead of silently testing the Pillow fallback as turbo-jpeg + if [ "${{ matrix.config.os }}" != "windows-latest" ]; then + python -c "from trame_rca.encoders.image_encoder import encode_pil, encode_turbo; assert encode_turbo is not encode_pil, 'turbo-jpeg fell back to Pillow'" + python -c "from trame_rca.encoders.turbo_jpeg import jpeg; print('turbo-jpeg uses', jpeg._TurboJPEG__find_turbojpeg())" + fi # Install requirements for playwright playwright install - pytest -s ./tests + pytest -s ./tests --benchmark-json=benchmark.json env: CI: true + - name: Upload benchmark results + if: matrix.config.os == 'ubuntu-latest' + uses: actions/upload-artifact@v7 + with: + name: benchmark-linux + path: benchmark.json + vitest: name: Vitest (JS Core Unit Tests) runs-on: ubuntu-latest diff --git a/README.rst b/README.rst index de662de..76132bd 100644 --- a/README.rst +++ b/README.rst @@ -79,8 +79,10 @@ Download and install from Github: https://github.com/libjpeg-turbo/libjpeg-turbo # RHEL/CentOS/Fedora # YUM doc: https://libjpeg-turbo.org/Downloads/YUM - # Ubuntu - apt-get install libturbojpeg + # Ubuntu: its libturbojpeg package is 2.x (2.1.5 on 24.04), but PyTurboJPEG 2 + # needs libjpeg-turbo >= 3.0, so install the official package from + # https://github.com/libjpeg-turbo/libjpeg-turbo/releases + sudo apt install ./libjpeg-turbo-official__.deb # amd64 or arm64 Once your system is ready, you can try our code example: diff --git a/pyproject.toml b/pyproject.toml index 8e9284e..603c05a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dev = [ "pre-commit", "pytest", "pytest-asyncio", + "pytest-benchmark", "pytest-playwright", "pytest-xprocess", "ruff", diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py new file mode 100644 index 0000000..9f67be4 --- /dev/null +++ b/tests/test_benchmark.py @@ -0,0 +1,105 @@ +import os +import sys + +import pytest +from vtkmodules.vtkFiltersCore import vtkElevationFilter +from vtkmodules.vtkFiltersSources import vtkSphereSource +from vtkmodules.vtkRenderingCore import ( + vtkActor, + vtkPolyDataMapper, + vtkRenderer, + vtkRenderWindow, + vtkRenderWindowInteractor, +) + +from trame_rca.encoders import RcaImageEncoder +from trame_rca.rca import VtkRemoteControlledArea + + +if os.environ.get("CI") is not None and sys.platform != "linux": + pytest.skip( + "Rendering tests are disabled on CI for non Linux platforms.", + allow_module_level=True, + ) + + +@pytest.fixture(scope="module") +def full_hd_scene(): + # A colored mesh filling most of the frame over a gradient, so encoding + # sees detail rather than mostly flat background + sphere = vtkSphereSource() + sphere.SetThetaResolution(120) + sphere.SetPhiResolution(120) + elevation = vtkElevationFilter() + elevation.SetInputConnection(sphere.GetOutputPort()) + elevation.SetLowPoint(0, 0, -0.5) + elevation.SetHighPoint(0, 0, 0.5) + mapper = vtkPolyDataMapper() + mapper.SetInputConnection(elevation.GetOutputPort()) + actor = vtkActor() + actor.SetMapper(mapper) + actor.GetProperty().EdgeVisibilityOn() + renderer = vtkRenderer() + renderer.AddActor(actor) + renderer.GradientBackgroundOn() + renderer.SetBackground(0.1, 0.2, 0.3) + renderer.SetBackground2(0.8, 0.8, 0.9) + renderer.ResetCamera() + renderer.GetActiveCamera().Zoom(2.4) + render_window = vtkRenderWindow() + render_window.SetOffScreenRendering(1) + render_window.AddRenderer(renderer) + render_window.SetSize(1920, 1080) + interactor = vtkRenderWindowInteractor() + interactor.SetRenderWindow(render_window) + + rca = VtkRemoteControlledArea(render_window) + rca_render = rca._render + + def render(): + rca_render() + # Render() only queues GPU work; finish it so capture doesn't time it + render_window.WaitForCompletion() + + # img_cols_rows renders first; render in the untimed setup instead + rendered = [] + + def recording_render(): + rendered.append(True) + render() + + rca._render = recording_render + rca.img_cols_rows + assert rendered, "img_cols_rows no longer renders through _render" + rca._render = lambda: None + yield rca, render, renderer.GetActiveCamera() + render_window.Finalize() + + +def test_benchmark_capture(benchmark, full_hd_scene): + rca, render, camera = full_hd_scene + + def next_frame(): + camera.Azimuth(1) + render() + + benchmark.pedantic( + lambda: rca.img_cols_rows, setup=next_frame, rounds=50, warmup_rounds=5 + ) + + +@pytest.mark.parametrize("encoder", list(RcaImageEncoder)) +def test_benchmark_encode(benchmark, full_hd_scene, encoder): + rca, render, _ = full_hd_scene + render() + image, cols, rows = rca.img_cols_rows + + encoded, *_ = benchmark.pedantic( + encoder.encode, + args=(image, cols, rows, 80), + # JPEG encodes in milliseconds and needs more rounds for a stable median; + # the other formats are 20-150x slower, so fewer rounds keep this short + rounds=50 if "JPEG" in encoder.name else 10, + warmup_rounds=2, + ) + benchmark.extra_info["frame_bytes"] = len(encoded) diff --git a/tests/test_image_encoders.py b/tests/test_image_encoders.py new file mode 100644 index 0000000..17d027d --- /dev/null +++ b/tests/test_image_encoders.py @@ -0,0 +1,28 @@ +import numpy as np +import pytest + +from trame_rca.encoders import RcaImageEncoder + +RGBA = np.random.default_rng(0).integers(0, 256, (48, 64, 4), dtype=np.uint8) +SQUARE_RGBA = np.random.default_rng(1).integers(0, 256, (48, 48, 4), dtype=np.uint8) +# RGB arrays an RCA could return, none of them C-contiguous RGB +LAYOUTS = { + "rgb-view-of-rgba": RGBA[..., :3], + "bottom-up": RGBA[::-1, :, :3], + "mirrored": RGBA[:, ::-1, :3], + "transposed": RGBA.transpose(1, 0, 2)[..., :3], + "transposed-square": SQUARE_RGBA.transpose(1, 0, 2)[..., :3], + "channel-offset": RGBA[..., 1:], + "every-other-pixel": RGBA[::2, ::2, :3], + "fortran-order": np.asfortranarray(RGBA[..., :3]), +} + + +@pytest.mark.parametrize("layout", LAYOUTS) +@pytest.mark.parametrize("encoder", list(RcaImageEncoder)) +def test_encoders_read_any_rgb_layout(encoder, layout): + image = LAYOUTS[layout] + rows, cols = image.shape[:2] + expected, *_ = encoder.encode(np.ascontiguousarray(image), cols, rows, 90) + encoded, *_ = encoder.encode(image, cols, rows, 90) + assert encoded == expected diff --git a/tests/test_rca_image.py b/tests/test_rca_image.py index 04ab4c6..9a135c6 100644 --- a/tests/test_rca_image.py +++ b/tests/test_rca_image.py @@ -2,11 +2,13 @@ import os import sys import time +from io import BytesIO from multiprocessing import Pool from pathlib import Path from unittest.mock import MagicMock +import numpy as np import pytest from PIL import Image from vtkmodules.vtkRenderingCore import ( @@ -176,6 +178,12 @@ def test_scheduler_is_compatible_with_string_encoder_format(encoder, a_render_wi "bottom-left": ((0.0, 0.0, 0.5, 0.5), (0, 0, 255)), "bottom-right": ((0.5, 0.0, 1.0, 0.5), (255, 255, 255)), } +CENTERS = { + "top-left": (HEIGHT // 4, WIDTH // 4), + "top-right": (HEIGHT // 4, 3 * WIDTH // 4), + "bottom-left": (3 * HEIGHT // 4, WIDTH // 4), + "bottom-right": (3 * HEIGHT // 4, 3 * WIDTH // 4), +} @pytest.fixture @@ -194,9 +202,47 @@ def quadrants_window(): render_window.Finalize() +def decode(encoded): + return Image.open(BytesIO(encoded)) + + +def test_img_cols_rows_is_a_top_down_rgb_array(quadrants_window): + image, *_ = VtkRemoteControlledArea(quadrants_window).img_cols_rows + assert image.dtype == np.uint8 + assert image.shape == (HEIGHT, WIDTH, 3) + for name, (row, col) in CENTERS.items(): + assert tuple(image[row, col]) == QUADRANTS[name][1], name + + @pytest.mark.parametrize("encoder", list(RcaImageEncoder)) def test_frame_size_is_columns_then_rows(quadrants_window, encoder): image, cols, rows = VtkRemoteControlledArea(quadrants_window).img_cols_rows assert (cols, rows) == (WIDTH, HEIGHT) _, meta, _ = encoder.encode(image, cols, rows, 90) assert (meta["w"], meta["h"]) == (WIDTH, HEIGHT) + + +def test_frames_are_not_overwritten_by_later_captures(quadrants_window): + rca = VtkRemoteControlledArea(quadrants_window) + first, *_ = rca.img_cols_rows + snapshot = first.copy() + + for renderer in quadrants_window.GetRenderers(): + renderer.SetBackground(0, 0, 0) + second, *_ = rca.img_cols_rows + + assert not np.array_equal(second, snapshot) + assert np.array_equal(first, snapshot) + + +@pytest.mark.parametrize("encoder", list(RcaImageEncoder)) +def test_encoded_frame_is_top_down_rgb(quadrants_window, encoder): + rca = VtkRemoteControlledArea(quadrants_window) + image = decode(encoder.encode(*rca.img_cols_rows, 90)[0]) + # No alpha channel: a frame must never come out (partly) transparent + assert image.mode == "RGB" + decoded = np.asarray(image) + assert decoded.shape == (HEIGHT, WIDTH, 3) + for name, (row, col) in CENTERS.items(): + # JPEG is lossy, but flat quadrants stay within a few levels + assert np.allclose(decoded[row, col], QUADRANTS[name][1], atol=8), name