Skip to content
Open
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
10 changes: 3 additions & 7 deletions examples/turntable_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from f3d_extras import (
download_file_if_url,
image_sequence_to_video,
turntable_state_interpolator,
turntable_interpolator,
)


Expand Down Expand Up @@ -46,16 +46,12 @@ def main():
engine.window.camera.position = initial_camera_position
engine.window.camera.reset_to_bounds(zoom_factor=camera_zoom_factor)

camera_state_interpolator = turntable_state_interpolator(
engine.window.camera.state,
engine.options["scene.up_direction"], # type: ignore
turns=turns,
)
camera_state_interpolator = turntable_interpolator(engine, turns=turns)

def render_images():
t = 0.0
while t < duration:
engine.window.camera.state = camera_state_interpolator(float(t) / duration)
camera_state_interpolator(t / duration)
yield engine.window.render_to_image()
t += 1.0 / fps

Expand Down
2 changes: 1 addition & 1 deletion f3d_extras/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from .files import download_file, download_file_if_url
from .turntable import turntable_state_interpolator
from .turntable import turntable_interpolator, turntable_state_interpolator
from .video import (
ffmpeg_output_args_mp4,
ffmpeg_output_args_webm,
Expand Down
19 changes: 19 additions & 0 deletions f3d_extras/turntable.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@
from numpy.typing import NDArray


def turntable_interpolator(
engine: f3d.Engine, *, turns: float = 1
) -> Callable[[float], None]:
"""Return a `t: float -> None` function that interpolates from `0 <= t <= 1`
to spin the camera `turns` times around the engine's current focal point
about its current `up_direction`."""

state_interpolator = turntable_state_interpolator(
initial_state=engine.window.camera.state,
axis=engine.options["scene.up_direction"], # type: ignore
turns=turns,
)

def f(t: float):
engine.window.camera.state = state_interpolator(t)

return f


def turntable_state_interpolator(
initial_state: f3d.CameraState,
axis: tuple[float, float, float],
Expand Down
81 changes: 60 additions & 21 deletions tests/test_turntable.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,23 @@

import numpy as np
import pytest
from f3d import CameraState
from f3d import CameraState, Engine
from numpy import linspace
from numpy.typing import NDArray
from pytest import approx # type: ignore

from f3d_extras.turntable import turntable_state_interpolator
from f3d_extras.turntable import turntable_interpolator, turntable_state_interpolator

TEST_PARAMS = [
# foc, pos, turns, axis, sample_count, expected_angles
((0, 0, 0), (1, 0, 0), 1, (0, 0, 1), 10, linspace(0, 360, 10)),
((1, 2, 3), (4, 3, 2), 2, (0, 1, 0), 12, linspace(0, 720, 12)),
((1, 2, 3), (10, 11, 12), -3, (3, 4, 5), 15, linspace(1080, 0, 15)),
]


@pytest.mark.parametrize(
"foc, pos, turns, axis, sample_count, expected_angles",
[
((0, 0, 0), (1, 0, 0), 1, (0, 0, 1), 10, linspace(0, 360, 10)),
((1, 2, 3), (4, 3, 2), 2, (0, 1, 0), 12, linspace(0, 720, 12)),
((1, 2, 3), (10, 11, 12), -3, (3, 4, 5), 15, linspace(1080, 0, 15)),
],
"foc, pos, turns, axis, sample_count, expected_angles", TEST_PARAMS
)
def test_turntable_camera_state_func(
foc: tuple[float, float, float],
Expand All @@ -32,29 +34,66 @@ def test_turntable_camera_state_func(
initial_state.position = pos
f = turntable_state_interpolator(initial_state, axis, turns=turns)

def proj(xyz: tuple[float, float, float]):
p = np.array(xyz, dtype=np.float64)
a = np.array(axis, dtype=np.float64)
a /= np.linalg.norm(a)
return p - a * np.dot(p, a)

def check_angle(state: CameraState):
p0 = proj(initial_state.focal_point)
p1 = proj(initial_state.position)
p2 = proj(state.position)
return angle_between_vectors(p1 - p0, p2 - p0, ref=np.array(axis))

states = list(map(f, linspace(0, 1, sample_count)))

assert all(
approx(state.focal_point) == initial_state.focal_point for state in states
)
assert all(
abs(check_angle(state) % 360 - angle % 360) % 360 < 0.01
abs(turntable_angle(initial_state, state, axis) % 360 - angle % 360) % 360
< 0.01
for state, angle in zip(states, expected_angles)
)


@pytest.mark.parametrize(
"foc, pos, turns, axis, sample_count, expected_angles", TEST_PARAMS
)
def test_turntable_interpolator(
foc: tuple[float, float, float],
pos: tuple[float, float, float],
turns: float,
axis: tuple[float, float, float],
sample_count: int,
expected_angles: Sequence[float],
):
engine = Engine.create(offscreen=True)
engine.window.camera.focal_point = foc
engine.window.camera.position = pos
engine.options["scene.up_direction"] = list(axis)
initial_state = engine.window.camera.state
f = turntable_interpolator(engine, turns=turns)

def collect_states():
for t in linspace(0, 1, sample_count):
f(t)
yield engine.window.camera.state

states = list(collect_states())

assert all(approx(state.focal_point) == foc for state in states)
assert all(
abs(turntable_angle(initial_state, state, axis) % 360 - angle % 360) % 360
< 0.01
for state, angle in zip(states, expected_angles)
)


def turntable_angle(
state1: CameraState, state2: CameraState, axis: tuple[float, float, float]
):
def proj(xyz: tuple[float, float, float]):
p = np.array(xyz, dtype=np.float64)
a = np.array(axis, dtype=np.float64)
a /= np.linalg.norm(a)
return p - a * np.dot(p, a)

p0 = proj(state1.focal_point)
p1 = proj(state1.position)
p2 = proj(state2.position)
return angle_between_vectors(p1 - p0, p2 - p0, ref=np.array(axis))


def angle_between_vectors(
v1: NDArray[np.floating], v2: NDArray[np.floating], ref: NDArray[np.floating]
):
Expand Down