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/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_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 d9af038..9a135c6 100644 --- a/tests/test_rca_image.py +++ b/tests/test_rca_image.py @@ -2,13 +2,20 @@ 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 ( + vtkRenderer, + vtkRenderWindow, + vtkRenderWindowInteractor, +) from trame_rca.encoders import RcaImageEncoder from trame_rca.schedulers import RcaImageRenderScheduler from trame_rca.rca import VtkRemoteControlledArea @@ -159,3 +166,83 @@ 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)), +} +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 +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() + + +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