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
30 changes: 28 additions & 2 deletions .github/workflows/test_and_release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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_<version>_<arch>.deb # amd64 or arm64

Once your system is ready, you can try our code example:

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ dev = [
"pre-commit",
"pytest",
"pytest-asyncio",
"pytest-benchmark",
"pytest-playwright",
"pytest-xprocess",
"ruff",
Expand Down
4 changes: 2 additions & 2 deletions src/trame_rca/rca/vtk_rca.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
105 changes: 105 additions & 0 deletions tests/test_benchmark.py
Original file line number Diff line number Diff line change
@@ -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)
28 changes: 28 additions & 0 deletions tests/test_image_encoders.py
Original file line number Diff line number Diff line change
@@ -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
87 changes: 87 additions & 0 deletions tests/test_rca_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading