Skip to content

Commit 1c0c194

Browse files
committed
Render the 3D README animation like the original
Go back to the original look: the LearnerND plot from the "Algorithms and examples" page (plotly markers, 5000 points, a quarter cut away) with the camera orbiting it, rendered with kaleido.
1 parent 7721797 commit 1c0c194

4 files changed

Lines changed: 46 additions & 63 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ uv run --group docs python docs/build_notebooks.py
209209
uv run --group docs zensical serve # preview on http://localhost:8000
210210
```
211211

212-
The animations in this README are rendered from real learner runs with `uv run --group docs python docs/make_animations.py` (requires `ffmpeg`).
212+
The animations in this README are rendered from real learner runs with `uv run --group docs python docs/make_animations.py` (requires `ffmpeg` and Chrome or Chromium).
213213

214214
## Citing
215215

‎docs/_static/readme/learnerND.gif‎

-139 KB
Loading

‎docs/make_animations.py‎

Lines changed: 44 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Render the README animations and the animated docs logo from real learner runs.
22
33
Writes docs/_static/readme/{learner1d,learner2d,learnerND}.gif and
4-
docs/_static/logo_docs.webm. Needs ffmpeg.
4+
docs/_static/logo_docs.webm. Needs ffmpeg, and Chrome or Chromium for kaleido
5+
(set BROWSER_PATH if it is not found).
56
67
Usage: python docs/make_animations.py [learner1d learner2d learnerND logo]
78
"""
@@ -21,8 +22,6 @@
2122
import matplotlib.pyplot as plt # noqa: E402
2223
import matplotlib.tri as mtri # noqa: E402
2324
import numpy as np # noqa: E402
24-
from matplotlib.colors import to_rgba # noqa: E402
25-
from mpl_toolkits.mplot3d.art3d import Poly3DCollection # noqa: E402
2625
from PIL import Image, ImageDraw # noqa: E402
2726

2827
import adaptive # noqa: E402
@@ -31,8 +30,6 @@
3130

3231
STATIC = Path(__file__).parent / "_static"
3332
DARK, ACCENT, NEW = "#2c3e6b", "#21918c", "#e4572e"
34-
BLUE, YELLOW = "#3b528b", "#fde725"
35-
NPOINTS_3D = 6000
3633
plt.rcParams.update(
3734
{
3835
"font.family": "DejaVu Sans",
@@ -172,65 +169,50 @@ def draw(fig, i):
172169

173170

174171
def learnerND(target: Path) -> None:
175-
learner = adaptive.LearnerND(shell, bounds=[(-1, 1)] * 3)
176-
adaptive.runner.simple(learner, npoints_goal=NPOINTS_3D)
177-
triangles = _isosurface(learner)
178-
centers = triangles.mean(axis=1)
179-
inner = np.linalg.norm(centers, axis=1) < 0.75
180-
# Cut an x > 0, y > 0 wedge out of the outer surface to show the inner one.
181-
keep = inner | ~((centers[:, 0] > 0) & (centers[:, 1] > 0))
182-
triangles, inner = triangles[keep], inner[keep]
183-
base = np.array([to_rgba(BLUE), to_rgba(YELLOW)])[inner.astype(int)]
184-
nframes = 90
185-
186-
def draw(fig, i):
187-
azim = 45 + 55 * np.sin(2 * np.pi * i / nframes) # keeps the cut in view
188-
colors = base * _lambert(triangles, azim - 30, 25 + 20)[:, None]
189-
colors[:, 3] = 1
190-
ax = fig.add_subplot(projection="3d")
191-
surface = Poly3DCollection(
192-
triangles, facecolors=colors, edgecolors=colors, linewidths=0.3
193-
)
194-
ax.add_collection3d(surface)
195-
ax.view_init(elev=25, azim=azim)
196-
for set_lim in (ax.set_xlim, ax.set_ylim, ax.set_zlim):
197-
set_lim(-1, 1)
198-
ax.set_box_aspect((1, 1, 1), zoom=1.25)
199-
ax.set_axis_off()
200-
fig.text(
201-
0.5, 0.04, f"LearnerND, {learner.npoints} points",
202-
ha="center", color="#555555", fontsize=12,
203-
) # fmt: skip
204-
fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
172+
"""Rotating version of the LearnerND plot on the "Algorithms and examples" page."""
173+
import plotly.graph_objects as go
174+
import plotly.io as pio
205175

206-
_render_gif(draw, nframes, (4.5, 4.5), target, hold=0, dpi=90)
207-
208-
209-
def _lambert(triangles: np.ndarray, azim: float, elev: float) -> np.ndarray:
210-
"""Brightness of each triangle lit from the direction (azim, elev) in degrees."""
211-
azim, elev = np.radians(azim), np.radians(elev)
212-
light = np.array(
213-
[np.cos(elev) * np.cos(azim), np.cos(elev) * np.sin(azim), np.sin(elev)]
214-
)
215-
normals = np.cross(
216-
triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0]
217-
)
218-
normals /= np.linalg.norm(normals, axis=1, keepdims=True)
219-
return 0.45 + 0.55 * np.clip(normals @ light, 0, 1)
220-
221-
222-
def _isosurface(learner: adaptive.LearnerND) -> np.ndarray:
223-
"""Triangles of the learner's f = 0.5 isosurface, with outward normals."""
224-
vertices, faces = learner._get_iso(level=0.5)
225-
triangles = np.array(vertices)[np.array(faces)]
226-
# Wind every triangle with its normal pointing away from the center,
227-
# so the shading is consistent.
228-
normals = np.cross(
229-
triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0]
176+
points, values = sampled(
177+
adaptive.LearnerND(shell, bounds=[(-1, 1)] * 3), npoints_goal=5000
230178
)
231-
inward = np.einsum("ij,ij->i", normals, triangles.mean(axis=1)) < 0
232-
triangles[inward] = triangles[inward][:, ::-1]
233-
return triangles
179+
# Cut away the x > 0, y > 0 quarter to show the shell in cross-section.
180+
keep = ~((points[:, 0] > 0) & (points[:, 1] > 0))
181+
marker = {
182+
"size": 5,
183+
"color": values[keep],
184+
"colorscale": "Viridis",
185+
"line": {"color": "rgb(50,50,50)", "width": 0.5},
186+
} # same style as LearnerND.plot_3D, with bigger markers
187+
x, y, z = points[keep].T
188+
fig = go.Figure(go.Scatter3d(x=x, y=y, z=z, mode="markers", marker=marker))
189+
hidden = {"visible": False}
190+
fig.update_layout(
191+
scene={
192+
"xaxis": hidden, "yaxis": hidden, "zaxis": hidden, "aspectmode": "cube",
193+
"camera": {"projection": {"type": "orthographic"}},
194+
},
195+
margin={"l": 0, "r": 0, "t": 0, "b": 0},
196+
showlegend=False,
197+
annotations=[
198+
{
199+
"text": f"LearnerND, {len(points)} points",
200+
"x": 0.5, "y": 0.02, "xref": "paper", "yref": "paper",
201+
"showarrow": False, "font": {"size": 15, "color": "#555555"},
202+
}
203+
],
204+
) # fmt: skip
205+
nframes = 90
206+
figures = []
207+
for i in range(nframes):
208+
angle = np.pi / 4 + 2 * np.pi * i / nframes # start looking into the cut
209+
eye = {"x": 2 * np.cos(angle), "y": 2 * np.sin(angle), "z": 0.25}
210+
figures.append(go.Figure(fig).update_layout(scene_camera_eye=eye))
211+
with tempfile.TemporaryDirectory() as folder:
212+
files = [Path(folder) / f"{i:04d}.png" for i in range(nframes)]
213+
pio.write_images(figures, files, width=400, height=400)
214+
to_gif(Path(folder), target, hold=0)
215+
print(f"{target.relative_to(STATIC.parent)}: {target.stat().st_size / 1e6:.2f} MB")
234216

235217

236218
def _render_gif(draw, nframes, figsize, target, hold=2.5, dpi=100, fps=15):

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ docs = [
8282
"mkdocstrings-python>=2,<3",
8383
"panel<1.10", # holoviews otherwise resolves a panel pre-release
8484
"jupytext",
85+
"kaleido", # docs/make_animations.py renders plotly frames
8586
"nbclient",
8687
"nbformat",
8788
"pillow",

0 commit comments

Comments
 (0)