Skip to content

Commit ff325c0

Browse files
committed
Add renderer.animation
1 parent 081e712 commit ff325c0

File tree

8 files changed

+196
-24
lines changed

8 files changed

+196
-24
lines changed

docs/carna.rst

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,41 @@ Example
1010
These can be used to, for example, quickly assemble a scene of multiple objects, and then render it into a NumPy array:
1111

1212
.. literalinclude:: ../test/test_integration.py
13-
:start-after: # .. OpaqueRenderingStage: example-start
14-
:end-before: # .. OpaqueRenderingStage: example-end
13+
:start-after: # .. OpaqueRenderingStage: example-setup-start
14+
:end-before: # .. OpaqueRenderingStage: example-setup-end
1515
:dedent: 8
1616

1717
Note on ``GEOMETRY_TYPE_OPAQUE``: A *geometry type* is an arbitrary integer constant, that establishes a relation
1818
between the geometry nodes of a scene graph, and the corresponding rendering stages (see below for details).
1919

20+
It is very easy to just render the scene into a NumPy array:
21+
22+
.. literalinclude:: ../test/test_integration.py
23+
:start-after: # .. OpaqueRenderingStage: example-single-frame-start
24+
:end-before: # .. OpaqueRenderingStage: example-single-frame-end
25+
:dedent: 8
26+
2027
This is the image ``array`` rendered in the example:
2128

2229
.. image:: ../test/results/expected/test_integration.OpaqueRenderingStage.test.png
2330
:width: 400
2431

32+
However, it is much easier to visually grasp the information in a 3D scene by looking at it from different angles. For
33+
this reason, there is a set of convenience functions that fascilitates creating animations, by rendering multiple
34+
frames at once:
35+
36+
.. literalinclude:: ../test/test_integration.py
37+
:start-after: # .. OpaqueRenderingStage: example-animation-start
38+
:end-before: # .. OpaqueRenderingStage: example-animation-end
39+
:dedent: 8
40+
41+
In this example, the camera is rotated around the *center of the scene* (more precisely: around it's parent node, that
42+
happens to be the ``root`` node of the scene). The scene is rendered from 50 different angles. For each angle, the
43+
result is a NumPy array, that is stored in the list of ``frames``. This is our example animation:
44+
45+
.. image:: ../test/results/expected/test_integration.OpaqueRenderingStage.test__animated.png
46+
:width: 400
47+
2548
Geometry Types
2649
--------------
2750

environment.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,8 @@ dependencies:
1717
- pyyaml
1818
- setuptools
1919
- matplotlib-base # for tests
20-
- scipy # for tests
20+
- scipy # for tests
21+
- pip
22+
- pip:
23+
- numpngw ==0.1.4 # for tests and docs (writes APNG)
24+
- apng ==0.3.4 # for tests and docs (reads APNG)

misc/py.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import re
2-
from typing import Iterable
2+
from typing import (
3+
Callable,
4+
Iterable,
5+
Literal,
6+
Sequence,
7+
)
38

49
import numpy as np
510

@@ -9,6 +14,27 @@
914
import carna.helpers
1015

1116

17+
AxisLiteral = Literal['x', 'y', 'z']
18+
AxisHint = AxisLiteral | tuple[float, float, float] | list[float, float, float]
19+
20+
21+
def _resolve_axis_hint(axis: AxisHint) -> tuple[float, float, float]:
22+
if isinstance(axis, str):
23+
match axis:
24+
case 'x':
25+
return (1, 0, 0)
26+
case 'y':
27+
return (0, 1, 0)
28+
case 'z':
29+
return (0, 0, 1)
30+
case _:
31+
raise ValueError(f'Invalid axis hint: {axis}')
32+
elif len(axis) == 3:
33+
return tuple(axis)
34+
else:
35+
raise ValueError(f'Invalid axis hint: {axis}')
36+
37+
1238
def _camel_to_snake(name):
1339
s = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
1440
return re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', s).lower()
@@ -189,6 +215,38 @@ def frustum(self, fov: float, z_near: float, z_far: float) -> np.ndarray:
189215
...
190216

191217

218+
class animation:
219+
"""
220+
Create an animation that can be rendered.
221+
222+
Arguments:
223+
step: Function that is called for each frame of the animation. The function is called with a single
224+
argument `t`, which is a float in the range [0, 1]. The function should modify the scene in place.
225+
n_frames: Number of frames to be rendered.
226+
"""
227+
228+
def __init__(self, step_functions: list[Callable[[float], None]], n_frames: int = 25):
229+
self.step_functions = step_functions
230+
self.n_frames = n_frames
231+
232+
def render(self, r: renderer, *args, **kwargs) -> Iterable[np.ndarray]:
233+
for t in np.linspace(0, 1, num=self.n_frames):
234+
for step in self.step_functions:
235+
step(t)
236+
yield r.render(*args, **kwargs)
237+
238+
@staticmethod
239+
def rotate_local(spatial: carna.base.Spatial, axis: AxisHint = 'y') -> Callable[[float], None]:
240+
"""
241+
Create a step function for rotating an object's local coordinate system.
242+
"""
243+
base_transform = spatial.local_transform
244+
axis = _resolve_axis_hint(axis)
245+
def step(t: float):
246+
spatial.local_transform = carna.math.rotation(axis, radians=2 * np.pi * t) @ base_transform
247+
return step
248+
249+
192250
def volume(
193251
geometry_type: int,
194252
array: np.ndarray,

src/py/presets.cpp

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,13 @@ PYBIND11_MODULE( presets, m )
103103
.doc() = R"(Implements rendering stage that renders opaque meshes.
104104
105105
.. literalinclude:: ../test/test_integration.py
106-
:start-after: # .. OpaqueRenderingStage: example-start
107-
:end-before: # .. OpaqueRenderingStage: example-end
106+
:start-after: # .. OpaqueRenderingStage: example-setup-start
107+
:end-before: # .. OpaqueRenderingStage: example-setup-end
108108
:dedent: 8
109109
110-
This is the image ``array`` rendered in the example:
110+
Rendering the scene as an animation:
111111
112-
.. image:: ../test/results/expected/test_integration.OpaqueRenderingStage.test.png
112+
.. image:: ../test/results/expected/test_integration.OpaqueRenderingStage.test__animated.png
113113
:width: 400)";
114114

115115
/* VolumeRenderingStage
@@ -156,13 +156,13 @@ PYBIND11_MODULE( presets, m )
156156
.doc() = R"(Renders 3D masks.
157157
158158
.. literalinclude:: ../test/test_integration.py
159-
:start-after: # .. MaskRenderingStage: example-start
160-
:end-before: # .. MaskRenderingStage: example-end
159+
:start-after: # .. MaskRenderingStage: example-setup-start
160+
:end-before: # .. MaskRenderingStage: example-setup-end
161161
:dedent: 8
162162
163-
This is the image ``array`` rendered in the example:
163+
Rendering the scene as an animation:
164164
165-
.. image:: ../test/results/expected/test_integration.MaskRenderingStage.test.png
165+
.. image:: ../test/results/expected/test_integration.MaskRenderingStage.test__animated.png
166166
:width: 400)";
167167

168168
/*
153 KB
Loading
90.5 KB
Loading

test/test_integration.py

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ def test__render(self):
7979

8080
class OpaqueRenderingStage(testsuite.CarnaRenderingTestCase):
8181

82-
def test(self):
83-
# .. OpaqueRenderingStage: example-start
82+
def setUp(self):
83+
# .. OpaqueRenderingStage: example-setup-start
8484
GEOMETRY_TYPE_OPAQUE = 1
8585

8686
# Create and configure frame renderer
@@ -119,19 +119,45 @@ def test(self):
119119
projection=r.frustum(fov=np.pi / 2, z_near=1, z_far=1e3),
120120
local_transform=carna.math.translation(0, 0, 250),
121121
)
122+
# .. OpaqueRenderingStage: example-setup-end
122123

123-
# Render scene
124+
self.r, self.camera = r, camera
125+
126+
def test(self):
127+
r, camera = self.r, self.camera
128+
129+
# .. OpaqueRenderingStage: example-single-frame-start
124130
array = r.render(camera)
125-
# .. OpaqueRenderingStage: example-end
131+
# .. OpaqueRenderingStage: example-single-frame-end
126132

127133
# Verify result
128134
self.assert_image_almost_expected(array)
129135

136+
def test__animated(self):
137+
r, camera = self.r, self.camera
138+
139+
# Render the scene once
140+
# .. OpaqueRenderingStage: example-animation-start
141+
# Define animation
142+
animation = carna.animation(
143+
[
144+
carna.animation.rotate_local(camera)
145+
],
146+
n_frames=50,
147+
)
148+
149+
# Render animation
150+
frames = list(animation.render(r, camera))
151+
# .. OpaqueRenderingStage: example-animation-end
152+
153+
# Verify result
154+
self.assert_image_almost_expected(np.array(frames))
155+
130156

131157
class MaskRenderingStage(testsuite.CarnaRenderingTestCase):
132158

133-
def test(self):
134-
# .. MaskRenderingStage: example-start
159+
def setUp(self):
160+
# .. MaskRenderingStage: example-setup-start
135161
GEOMETRY_TYPE_VOLUME = 2
136162

137163
# Create and configure frame renderer
@@ -158,10 +184,32 @@ def test(self):
158184
projection=r.frustum(fov=np.pi / 2, z_near=1, z_far=500),
159185
local_transform=carna.math.translation(0, 0, 100),
160186
)
187+
# .. MaskRenderingStage: example-setup-end
188+
189+
self.r, self.camera = r, camera
190+
191+
def test(self):
192+
r, camera = self.r, self.camera
161193

162194
# Render scene
163195
array = r.render(camera)
164-
# .. MaskRenderingStage: example-end
165196

166197
# Verify result
167-
self.assert_image_almost_expected(array)
198+
self.assert_image_almost_expected(array)
199+
200+
def test__animated(self):
201+
r, camera = self.r, self.camera
202+
203+
# Define animation
204+
animation = carna.animation(
205+
[
206+
carna.animation.rotate_local(camera)
207+
],
208+
n_frames=50,
209+
)
210+
211+
# Render animation
212+
frames = list(animation.render(r, camera))
213+
214+
# Verify result
215+
self.assert_image_almost_expected(np.array(frames))

test/testsuite.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import io
12
import pathlib
23
import unittest
34

@@ -6,6 +7,45 @@
67

78
import matplotlib.pyplot as plt
89
import numpy as np
10+
from apng import APNG
11+
from numpngw import write_apng
12+
from PIL import Image
13+
14+
15+
def _imread(path: str) -> np.ndarray:
16+
"""
17+
Reads a PNG as a `YXC` array, and APNG as a `TYXC` array.
18+
"""
19+
20+
def read_png_frame(buf) -> np.ndarray:
21+
with Image.open(io.BytesIO(buf)) as im:
22+
array = np.array(im)
23+
array = array[:, :, :3] # Ignore alpha channel if present
24+
return array
25+
26+
im = APNG.open(path)
27+
array = np.array(
28+
[
29+
read_png_frame(frame[0].to_bytes())
30+
for frame in im.frames
31+
]
32+
)
33+
34+
# Convert `TYXC` to `YXC` format (APNG -> PNG)
35+
if array.shape[0] == 1 and array.ndim == 4:
36+
array = array[0]
37+
38+
return array
39+
40+
41+
def _imsave(path: str, array: np.ndarray):
42+
if array.ndim == 4:
43+
44+
# Palette APNG cannot be read proplery by the apng library
45+
write_apng(path, array, delay=40, use_palette=False)
46+
47+
else:
48+
plt.imsave(path, array)
949

1050

1151
class CarnaTestCase(unittest.TestCase):
@@ -17,11 +57,10 @@ class CarnaRenderingTestCase(CarnaTestCase):
1757

1858
def assert_image_almost_equal(self, actual, expected, decimal=5):
1959
if isinstance(actual, str):
20-
actual = plt.imread(actual)
60+
actual = _imread(actual)
2161
if isinstance(expected, str):
2262
expected = pathlib.Path('test/results/expected') / expected
23-
expected = plt.imread(str(expected))
24-
expected = expected[:, :, :3] # Ignore alpha channel if present
63+
expected = _imread(str(expected))
2564
if np.issubdtype(expected.dtype, np.floating):
2665
expected = (expected * 255).astype(np.uint8)
2766
np.testing.assert_array_almost_equal(actual, expected, decimal=decimal)
@@ -33,6 +72,6 @@ def assert_image_almost_expected(self, actual, **kwargs):
3372
except:
3473
actual_path = pathlib.Path('test/results/actual') / f'{expected}'
3574
actual_path.parent.mkdir(parents=True, exist_ok=True)
36-
plt.imsave(actual_path, actual)
75+
_imsave(actual_path, actual)
3776
print(f'Test result was written to: {actual_path.resolve()}')
3877
raise

0 commit comments

Comments
 (0)