cardkit-anim 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Carlo Alberto Barbano
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,332 @@
1
+ Metadata-Version: 2.4
2
+ Name: cardkit-anim
3
+ Version: 0.1.0
4
+ Summary: Write an animated scene once in Python and export it as GIF, WebP, MP4 and a looping, script-free animated SVG that look identical.
5
+ Author: Carlo Alberto Barbano
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/carloalbertobarbano/cardkit
8
+ Project-URL: Source, https://github.com/carloalbertobarbano/cardkit
9
+ Project-URL: Issues, https://github.com/carloalbertobarbano/cardkit/issues
10
+ Keywords: animation,svg,smil,matplotlib,gif,graphical abstract,scientific figures
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Multimedia :: Graphics
13
+ Classifier: Topic :: Scientific/Engineering :: Visualization
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: numpy
18
+ Requires-Dist: matplotlib>=3.5
19
+ Requires-Dist: pillow>=9.1
20
+ Provides-Extra: video
21
+ Requires-Dist: imageio-ffmpeg; extra == "video"
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest; extra == "test"
24
+ Dynamic: license-file
25
+
26
+ # cardkit
27
+
28
+ **Write an animation once in Python. Get a GIF, a WebP, an MP4 and an animated SVG, and they all look the same.**
29
+
30
+ cardkit is a small scene graph built on matplotlib. You describe a card as a list of shapes, lines, text and images.
31
+ Any property of any element can be a function of time. cardkit then renders the scene in two ways:
32
+
33
+ * **raster**: matplotlib draws each frame, which becomes a GIF, an animated WebP, an MP4 or a PNG poster;
34
+ * **vector**: a native SVG whose animation uses SMIL `<animate>` tags, built by sampling the same functions.
35
+ It loops forever, needs no JavaScript and no fonts, and plays inside a plain `<img>` tag (GitHub READMEs, websites, slides).
36
+
37
+ All text is converted to outlines, mathtext included (`"$\hat{y} = \sum_i w_i \varphi_i(x)$"`), so the SVG matches the
38
+ frames pixel for pixel on any machine.
39
+
40
+ <p align="center">
41
+ <img src="https://raw.githubusercontent.com/carloalbertobarbano/cardkit/main/docs/tour_animated.svg" width="48%" alt="cardkit tour (animated SVG)">
42
+ <img src="https://raw.githubusercontent.com/carloalbertobarbano/cardkit/main/docs/tour.gif" width="48%" alt="cardkit tour (GIF)">
43
+ </p>
44
+ <p align="center"><sub>The same scene (<a href="https://github.com/carloalbertobarbano/cardkit/blob/main/examples/tour.py">examples/tour.py</a>): animated SVG on the left (101 kB), GIF on the right.</sub></p>
45
+
46
+ It was built for scientific graphical abstracts, website cards and social-media videos, where you want real plots,
47
+ real numbers and typeset maths, plus an SVG that stays sharp.
48
+
49
+ ---
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ pip install cardkit-anim # numpy, matplotlib and pillow are the only dependencies
55
+ pip install "cardkit-anim[video]" # + imageio-ffmpeg, for MP4 export if ffmpeg is not installed
56
+ ```
57
+
58
+ The package on PyPI is `cardkit-anim`; the module you import is `cardkit` (`import cardkit as ck`).
59
+
60
+ ## Quick start
61
+
62
+ ```python
63
+ import numpy as np
64
+ import cardkit as ck
65
+ from cardkit import Txt, Path, Poly, Circ, ramp
66
+
67
+ INK, ACCENT, MUTED = "#1d2433", "#0f9d8a", "#8a93a3"
68
+
69
+ scene = ck.Scene(duration=5, size=(9, 9), background="#ffffff")
70
+
71
+ # a title that fades in between t = 0.2 s and t = 0.8 s
72
+ scene.add(Txt("Hello, cardkit", (0.6, 7.9), size=26, color=INK, weight="bold", op=ramp(0.2, 0.8)))
73
+
74
+ # a sine wave that draws itself from left to right
75
+ x = np.linspace(0.8, 8.2, 120)
76
+ scene.add(Path(np.c_[x, 5.2 + 0.7 * np.sin(2.2 * x)], color=ACCENT, lw=3, draw=ramp(1.0, 2.6)))
77
+
78
+ # bars that grow one after the other: each shape morphs from a flat to a full rectangle
79
+ for k, h in enumerate([1.2, 2.0, 1.6, 2.6, 3.1]):
80
+ x0, t0 = 1.0 + 1.5 * k, 2.4 + 0.2 * k
81
+ flat, full = ck.rect(x0, 0.9, x0 + 1.0, 0.91), ck.rect(x0, 0.9, x0 + 1.0, 0.9 + h)
82
+ scene.add(Poly(ck.tween(flat, full, t0, t0 + 0.8), fill=ACCENT, op=0.35 + 0.13 * k))
83
+
84
+ # a dot that pulses forever
85
+ scene.add(Circ((8.2, 7.75), r=ck.pulse(1.2, 0.10, 0.18), fill=ACCENT))
86
+
87
+ scene.save_all("out/quickstart", "gif", "svg", poster=4.5)
88
+ # -> out/quickstart.gif, out/quickstart_animated.svg, out/quickstart_poster.png
89
+ ```
90
+
91
+ <p align="center"><img src="https://raw.githubusercontent.com/carloalbertobarbano/cardkit/main/docs/quickstart_animated.svg" width="300" alt="quick start result"></p>
92
+
93
+ To preview while you work, run `scene.frame(2.0)`, which returns a PIL image (it shows inline in Jupyter), or
94
+ `scene.storyboard("board.png")` for a contact sheet of the whole animation:
95
+
96
+ <p align="center"><img src="https://raw.githubusercontent.com/carloalbertobarbano/cardkit/main/docs/tour_storyboard.png" width="640" alt="storyboard"></p>
97
+
98
+ ## Gallery
99
+
100
+ <p align="center">
101
+ <img src="https://raw.githubusercontent.com/carloalbertobarbano/cardkit/main/docs/network_animated.svg" width="32%" alt="neural network example">
102
+ <img src="https://raw.githubusercontent.com/carloalbertobarbano/cardkit/main/docs/three_d_animated.svg" width="32%" alt="3-D example">
103
+ <img src="https://raw.githubusercontent.com/carloalbertobarbano/cardkit/main/docs/brain_animated.svg" width="32%" alt="nilearn example">
104
+ </p>
105
+
106
+ | example | shows |
107
+ |---|---|
108
+ | [`network.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/network.py) | an MLP: forward-pass activation waves, then training, with about 80 edges whose width and colour follow their weights; softmax bars, a loss curve, counters |
109
+ | [`three_d.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/three_d.py) | `cardkit.three`: a connectivity matrix as a lit 3-D slab sending shaded tubes to recolouring label cubes, then a growing 3-D bar chart |
110
+ | [`brain.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/brain.py) | nilearn glass brains whose activation follows a BOLD curve as it draws, then a connectome with a node-strength bar chart |
111
+
112
+ Every image above is the animated SVG itself, with no GIF involved. Run `python docs/make_docs.py` to rebuild all the demos.
113
+
114
+ ---
115
+
116
+ ## The model in five ideas
117
+
118
+ ### 1. Coordinates are slide units
119
+
120
+ A scene is `size=(width, height)` units, with **y pointing up** and `(0, 0)` at the bottom-left corner. The default card
121
+ is 9 × 9. Font sizes and line widths are in **points**, and 1 unit = 43.2 pt, so a 9-unit card is 388.8 pt
122
+ (5.4 in) wide. This keeps type the same size relative to the card at any output resolution. `width_px` (default
123
+ 120 px per unit, so 1080 px for 9 units) only sets the resolution of the raster outputs.
124
+
125
+ For other aspect ratios, change `size` (and optionally `origin`, the slide coordinates of the bottom-left corner):
126
+
127
+ ```python
128
+ ck.Scene(25, size=(9, 11.25), origin=(0, -0.85)) # 4:5 portrait: the 9x9 card stays at 0..9, extra room below and above
129
+ ```
130
+
131
+ ### 2. Anything can be a function of time
132
+
133
+ Every attribute takes either a constant or a function `t -> value`: positions, point lists, colours, radii, opacity,
134
+ line widths, the fraction of a line that is drawn, even the text string.
135
+
136
+ ```python
137
+ Circ(c=lambda t: (1 + t, 4.5), r=0.2, fill="#e17d38") # moves right at 1 unit / s
138
+ Txt(lambda t: f"{int(100 * t)} %", (4.5, 4.5), 30, ha="center") # a counter
139
+ ```
140
+
141
+ The helpers in [Timing helpers](#timing-helpers) cover the common cases without lambdas.
142
+
143
+ ### 3. Five primitives
144
+
145
+ | primitive | what | main arguments |
146
+ |---|---|---|
147
+ | `Poly(pts, fill=None, stroke=None, lw=0)` | closed polygon | `pts`: (N, 2) vertices |
148
+ | `Path(pts, color, lw=1.5, draw=1.0, dash=None, cap="round")` | open polyline | `draw`: fraction drawn, 0..1; `dash`: (on, off) in units |
149
+ | `Circ(c, r, fill=None, stroke=None, lw=0)` | circle | centre and radius in units |
150
+ | `Txt(s, xy, size=12, color="#000", ha="left", weight=, style=, family=)` | text as outlines | `xy` on the baseline; `ha`: left / center / right |
151
+ | `Img(arr, extent, interp="bilinear")` | raster image | `arr`: (H, W, 3 or 4) array; `extent`: (x0, x1, y0, y1) |
152
+
153
+ They all also take `op` (opacity, 0..1), `z` (drawing order: higher is on top) and `group` (see below). If you prefer
154
+ longer names, `ck.Polygon`, `ck.Line`, `ck.Circle`, `ck.Text` and `ck.Image` are the same classes.
155
+
156
+ ### 4. Groups fade as a whole
157
+
158
+ Put the elements of one step of your story in a **group** with an opacity **envelope**. The group fades as a single
159
+ layer, so overlapping translucent shapes never show seams, and the SVG only samples a group while it is visible, which
160
+ keeps files small.
161
+
162
+ ```python
163
+ with scene.group("step1", ck.fade(0.0, 6.4)): # visible from 0 to 6.4 s, fading in and out
164
+ scene.add(Txt("Step one", (0.5, 8), 22))
165
+ scene.add(Path(curve, "#0f9d8a", 3, draw=ramp(0.5, 2.0)))
166
+
167
+ with scene.group("step2", (6.4, 12.0)): # a (t0, t1) tuple is shorthand for fade(t0, t1)
168
+ ...
169
+ ```
170
+
171
+ Elements added outside any group are in the base group, which is always visible.
172
+
173
+ ### 5. Author in scene time, then add an intro and pauses
174
+
175
+ You time every element in **scene time**. The scene can be played back differently from how you wrote it:
176
+
177
+ ```python
178
+ scene = ck.Scene(
179
+ duration=25, # the story runs from scene time 0 to 25
180
+ intro=3.0, # ...plus an intro authored at negative times, -3 .. 0
181
+ holds={6.05: 0.9, 12.0: 0.9}, # freeze at these scene times for 0.9 s (reading pauses)
182
+ )
183
+ scene.duration # 29.8: the actual loop length (display time)
184
+ scene.to_display(6.4) # scene time -> display time
185
+ ```
186
+
187
+ Adding an intro or a pause never forces you to re-time the rest. The display-time functions (`frame`, `storyboard`,
188
+ `check_text`, `save(..., at=)`) take display time, and `frame_at(tau)` and `poster=` take scene time.
189
+
190
+ ---
191
+
192
+ ## Timing helpers
193
+
194
+ They all return `Fn` objects: functions of time that support arithmetic, such as `ramp(1, 2) * 0.5` or
195
+ `1 - ramp(3, 3.5)`, or `fade(0, 6) * pulse(2, 0.6, 1)`.
196
+
197
+ | helper | gives |
198
+ |---|---|
199
+ | `ramp(t0, t1)` | 0 → 1 between t0 and t1, smoothstep-eased (`eased=False` for linear) |
200
+ | `fade(t0, t1=None, d=0.35)` | fades in after t0 and out before t1, over `d` s (or `(d_in, d_out)`) |
201
+ | `tween(a, b, t0, t1)` | interpolates numbers, arrays (point lists, so shapes morph) or colours |
202
+ | `keyframes({t: value, ...})` | piecewise interpolation through several keys (numbers, arrays, colours) |
203
+ | `along(pts, t0, t1)` | a position travelling along a polyline: `Circ(along(path, 1, 3), 0.1)` |
204
+ | `pulse(period, lo, hi)` | smooth periodic oscillation |
205
+ | `every(t0, period, n=None)` | integer step counter (0, 1, 2, ...), handy to cycle through examples |
206
+ | `fn(callable)` | wraps your own `t -> value` function to get the arithmetic |
207
+ | `seg(t, a, b)`, `ease(x)`, `lin(t, a, b)` | the raw scalar functions |
208
+
209
+ > **The lambda-in-a-loop trap.** `for k in range(5): Circ(..., op=lambda t: seg(t, k, k + 1))` gives every circle
210
+ > the *last* `k`. Use the helpers (`op=ramp(k, k + 1)`), which capture values correctly, or bind with `lambda t, k=k: ...`.
211
+
212
+ ## Shapes and colours
213
+
214
+ `rect(x0, y0, x1, y1)`, `rounded_rect(x0, y0, x1, y1, r)`, `bezier(p0, c1, c2, p1, n)` (2-D or 3-D),
215
+ `arc(c, r, a0, a1)`, `ngon(c, r, k)` and `arrow_head(tip, direction)` return point arrays.
216
+ `mix(c1, c2, k)`, `shade(c, k)` and `lighten(c, k)` blend colours, and `colorize(c, fn)` applies one of them to a
217
+ colour that is itself a function of time. `text_width(s, size)` measures a label, for example to size a chip around it.
218
+
219
+ ## Exporting
220
+
221
+ ```python
222
+ scene.save("card.svg") # animated SVG (SMIL)
223
+ scene.save("card_still.svg", at=12.0) # static SVG snapshot at display time 12 s
224
+ scene.save("poster.png", at=23.6) # one frame
225
+ scene.save("card.gif") # renders every frame, then encodes
226
+ scene.save_all("out/card", "gif", "webp", "mp4", "svg", poster=23.6) # renders the frames once for all formats
227
+ ```
228
+
229
+ | format | notes |
230
+ |---|---|
231
+ | **SVG** | `save_svg(path, at=None, dt=0.1, simplify=True)`. Attributes are sampled every `dt` s; runs of identical samples are dropped (about half the size, same animation). Plays in `<img>`. |
232
+ | **GIF** | Pillow only, no ffmpeg. One shared 256-colour palette without dithering (crisp flat colours), `gif_fps=15`, `gif_width=720`. |
233
+ | **WebP** | Full resolution, `webp_quality=90`. |
234
+ | **MP4** | H.264 / yuv420p / faststart, `crf=16`: plays on LinkedIn, X, Slack and Keynote. Needs ffmpeg (`$FFMPEG`, `PATH`, or `pip install imageio-ffmpeg`). |
235
+ | **PNG** | `frame(t)` / `frame_at(tau)` return PIL images. |
236
+
237
+ Frames are rendered in parallel with all cores but one (`workers=1` renders serially). Use `fps=` (default 30) and
238
+ `width_px=` to change the frame rate and resolution.
239
+
240
+ ## Quality checks
241
+
242
+ ```python
243
+ scene.check_text(scene.to_display(6.0))
244
+ # [check t=7.80] 14 texts, 1 issues
245
+ # OVERLAP 'FC matrix' <-> 'subjects per dataset'
246
+ ```
247
+
248
+ `check_text(t, margin=0.25)` lists visible texts that overlap each other or come closer than `margin` units to the edge.
249
+ It is cheap: run it at a few key instants after every layout change.
250
+
251
+ ## 3-D
252
+
253
+ `cardkit.three` builds 3-D-looking objects from plain `Poly` faces, seen through a fixed oblique camera with Lambert
254
+ lighting. Because the camera never moves, hidden faces are removed and faces are ordered once, when the scene is built.
255
+ The objects can still grow, move and change colour over time, and the SVG stays fully vector.
256
+
257
+ ```python
258
+ from cardkit.three import Camera, box, slab, tube
259
+
260
+ cam = Camera(origin=(1.0, 2.0)) # world axes: X right, D into the screen, Z up
261
+ scene.add(box(cam, (0, 0, 0), (0.6, 0.6, ramp(1, 2) * 2.5), "#0f9d8a")) # a bar that grows to 2.5 units
262
+ scene.add(slab(cam, (0, 0, 0), matrix, 0.3, colors=lambda v: ck.mix("#fff", "#0f9d8a", v))) # a matrix lying flat
263
+ scene.add(tube(cam, ck.bezier(p0, c1, c2, p1), 0.04, "#6c5ce7", t0=1.0, dur=1.0)) # a shaded arrow that grows
264
+ ```
265
+
266
+ `Camera.project(P)` maps (N, 3) world points to the slide, `Camera.lit(color, normal)` shades a colour, and
267
+ `Camera.depth(P)` gives a value to use as `z` when separate objects overlap (closer objects get higher `z`).
268
+
269
+ ## Using real figures
270
+
271
+ `Img` takes any array, so you can render a matplotlib or nilearn figure to an RGBA array once and place it in the card:
272
+
273
+ ```python
274
+ fig = plt.figure(figsize=(6, 2.4), dpi=200)
275
+ plotting.plot_glass_brain(stat_img, display_mode="lzr", figure=fig, annotate=False)
276
+ fig.canvas.draw(); arr = np.asarray(fig.canvas.buffer_rgba()) / 255; plt.close(fig)
277
+ scene.add(Img(arr, (0.5, 8.5, 3.6, 6.8), op=ramp(0.3, 0.9)))
278
+ ```
279
+
280
+ To animate *inside* a rendered figure, render it in two states and fade between them.
281
+ [`brain.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/brain.py) renders an empty glass brain and the same brain with the activation, then turns the
282
+ difference into an exact overlay whose opacity follows a signal. The outline never moves; only the activation fades.
283
+ Render both states from the same figure layout (for example with `display.add_overlay`), so that they line up.
284
+
285
+ Inside the SVG, images are embedded as palette PNGs (128 colours) to keep files small, so smooth gradients can show
286
+ some banding there. Mathtext follows matplotlib's `rcParams` (for example `plt.rcParams["mathtext.fontset"] = "cm"`),
287
+ and so does the default font family.
288
+
289
+ ## Limitations (SVG only; the raster backend has none of these)
290
+
291
+ * `Txt.size` and `Img.arr` cannot be animated. Position, colour, opacity and the string itself can.
292
+ * A `Path` with `dash` cannot also animate `draw`: the SVG shows the dashes and cardkit warns you.
293
+ * Shapes whose number of vertices changes over time switch between shapes discretely instead of morphing.
294
+ * SVG time is sampled every `dt` (0.1 s by default): very fast motion may need a smaller `dt`.
295
+
296
+ ---
297
+
298
+ ## Reference
299
+
300
+ ```
301
+ Scene(duration, size=(9, 9), background="#ffffff", *, origin=(0, 0), width_px=None, fps=30,
302
+ intro=0.0, holds=None, hold=0.9, title="")
303
+ .add(*prims) add primitives or lists of primitives; returns them
304
+ .group(name, envelope=None) register a group; also a context manager
305
+ .duration loop length in display seconds
306
+ .to_display(tau) scene time -> display time
307
+ .frame(t, width_px=None, isolate=True) PIL image at display time t
308
+ .frame_at(tau) PIL image at scene time tau
309
+ .storyboard(path=None, times=None, n=8, cols=4, width=360)
310
+ .check_text(t, margin=0.25, min_alpha=0.2)
311
+ .save(path, at=None, ...) by extension: .svg .png .jpg .gif .webp .mp4
312
+ .save_svg(path, at=None, dt=0.1, simplify=True, width=None)
313
+ .save_all(stem, *formats, poster=None, fps=None, width_px=None, workers=None,
314
+ gif_fps=15, gif_width=720, webp_quality=90, crf=16)
315
+ .render_frames(folder, fps=None, width_px=None, workers=None)
316
+ Timeline(duration, intro=0.0, holds=None, hold=0.9) .warp(T) .to_display(tau) .display_duration
317
+ ```
318
+
319
+ ## Examples and tests
320
+
321
+ * [`examples/quickstart.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/quickstart.py): the quick start above.
322
+ * [`examples/tour.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/tour.py): intro, groups, holds, motion along a path, keyframed colour, a counter,
323
+ mathtext, an image, a progress indicator, the storyboard and the text check.
324
+ * [`examples/network.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/network.py), [`examples/three_d.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/three_d.py) and
325
+ [`examples/brain.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/brain.py): the gallery (the last one needs `pip install nilearn`).
326
+ * `python docs/make_docs.py` rebuilds every demo image in `docs/`.
327
+ * `python -m unittest discover tests`. The tests include a SMIL evaluator that checks the compressed SVG animates exactly
328
+ like the uncompressed one.
329
+
330
+ ## License
331
+
332
+ MIT, see [LICENSE](https://github.com/carloalbertobarbano/cardkit/blob/main/LICENSE).
@@ -0,0 +1,307 @@
1
+ # cardkit
2
+
3
+ **Write an animation once in Python. Get a GIF, a WebP, an MP4 and an animated SVG, and they all look the same.**
4
+
5
+ cardkit is a small scene graph built on matplotlib. You describe a card as a list of shapes, lines, text and images.
6
+ Any property of any element can be a function of time. cardkit then renders the scene in two ways:
7
+
8
+ * **raster**: matplotlib draws each frame, which becomes a GIF, an animated WebP, an MP4 or a PNG poster;
9
+ * **vector**: a native SVG whose animation uses SMIL `<animate>` tags, built by sampling the same functions.
10
+ It loops forever, needs no JavaScript and no fonts, and plays inside a plain `<img>` tag (GitHub READMEs, websites, slides).
11
+
12
+ All text is converted to outlines, mathtext included (`"$\hat{y} = \sum_i w_i \varphi_i(x)$"`), so the SVG matches the
13
+ frames pixel for pixel on any machine.
14
+
15
+ <p align="center">
16
+ <img src="https://raw.githubusercontent.com/carloalbertobarbano/cardkit/main/docs/tour_animated.svg" width="48%" alt="cardkit tour (animated SVG)">
17
+ <img src="https://raw.githubusercontent.com/carloalbertobarbano/cardkit/main/docs/tour.gif" width="48%" alt="cardkit tour (GIF)">
18
+ </p>
19
+ <p align="center"><sub>The same scene (<a href="https://github.com/carloalbertobarbano/cardkit/blob/main/examples/tour.py">examples/tour.py</a>): animated SVG on the left (101 kB), GIF on the right.</sub></p>
20
+
21
+ It was built for scientific graphical abstracts, website cards and social-media videos, where you want real plots,
22
+ real numbers and typeset maths, plus an SVG that stays sharp.
23
+
24
+ ---
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install cardkit-anim # numpy, matplotlib and pillow are the only dependencies
30
+ pip install "cardkit-anim[video]" # + imageio-ffmpeg, for MP4 export if ffmpeg is not installed
31
+ ```
32
+
33
+ The package on PyPI is `cardkit-anim`; the module you import is `cardkit` (`import cardkit as ck`).
34
+
35
+ ## Quick start
36
+
37
+ ```python
38
+ import numpy as np
39
+ import cardkit as ck
40
+ from cardkit import Txt, Path, Poly, Circ, ramp
41
+
42
+ INK, ACCENT, MUTED = "#1d2433", "#0f9d8a", "#8a93a3"
43
+
44
+ scene = ck.Scene(duration=5, size=(9, 9), background="#ffffff")
45
+
46
+ # a title that fades in between t = 0.2 s and t = 0.8 s
47
+ scene.add(Txt("Hello, cardkit", (0.6, 7.9), size=26, color=INK, weight="bold", op=ramp(0.2, 0.8)))
48
+
49
+ # a sine wave that draws itself from left to right
50
+ x = np.linspace(0.8, 8.2, 120)
51
+ scene.add(Path(np.c_[x, 5.2 + 0.7 * np.sin(2.2 * x)], color=ACCENT, lw=3, draw=ramp(1.0, 2.6)))
52
+
53
+ # bars that grow one after the other: each shape morphs from a flat to a full rectangle
54
+ for k, h in enumerate([1.2, 2.0, 1.6, 2.6, 3.1]):
55
+ x0, t0 = 1.0 + 1.5 * k, 2.4 + 0.2 * k
56
+ flat, full = ck.rect(x0, 0.9, x0 + 1.0, 0.91), ck.rect(x0, 0.9, x0 + 1.0, 0.9 + h)
57
+ scene.add(Poly(ck.tween(flat, full, t0, t0 + 0.8), fill=ACCENT, op=0.35 + 0.13 * k))
58
+
59
+ # a dot that pulses forever
60
+ scene.add(Circ((8.2, 7.75), r=ck.pulse(1.2, 0.10, 0.18), fill=ACCENT))
61
+
62
+ scene.save_all("out/quickstart", "gif", "svg", poster=4.5)
63
+ # -> out/quickstart.gif, out/quickstart_animated.svg, out/quickstart_poster.png
64
+ ```
65
+
66
+ <p align="center"><img src="https://raw.githubusercontent.com/carloalbertobarbano/cardkit/main/docs/quickstart_animated.svg" width="300" alt="quick start result"></p>
67
+
68
+ To preview while you work, run `scene.frame(2.0)`, which returns a PIL image (it shows inline in Jupyter), or
69
+ `scene.storyboard("board.png")` for a contact sheet of the whole animation:
70
+
71
+ <p align="center"><img src="https://raw.githubusercontent.com/carloalbertobarbano/cardkit/main/docs/tour_storyboard.png" width="640" alt="storyboard"></p>
72
+
73
+ ## Gallery
74
+
75
+ <p align="center">
76
+ <img src="https://raw.githubusercontent.com/carloalbertobarbano/cardkit/main/docs/network_animated.svg" width="32%" alt="neural network example">
77
+ <img src="https://raw.githubusercontent.com/carloalbertobarbano/cardkit/main/docs/three_d_animated.svg" width="32%" alt="3-D example">
78
+ <img src="https://raw.githubusercontent.com/carloalbertobarbano/cardkit/main/docs/brain_animated.svg" width="32%" alt="nilearn example">
79
+ </p>
80
+
81
+ | example | shows |
82
+ |---|---|
83
+ | [`network.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/network.py) | an MLP: forward-pass activation waves, then training, with about 80 edges whose width and colour follow their weights; softmax bars, a loss curve, counters |
84
+ | [`three_d.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/three_d.py) | `cardkit.three`: a connectivity matrix as a lit 3-D slab sending shaded tubes to recolouring label cubes, then a growing 3-D bar chart |
85
+ | [`brain.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/brain.py) | nilearn glass brains whose activation follows a BOLD curve as it draws, then a connectome with a node-strength bar chart |
86
+
87
+ Every image above is the animated SVG itself, with no GIF involved. Run `python docs/make_docs.py` to rebuild all the demos.
88
+
89
+ ---
90
+
91
+ ## The model in five ideas
92
+
93
+ ### 1. Coordinates are slide units
94
+
95
+ A scene is `size=(width, height)` units, with **y pointing up** and `(0, 0)` at the bottom-left corner. The default card
96
+ is 9 × 9. Font sizes and line widths are in **points**, and 1 unit = 43.2 pt, so a 9-unit card is 388.8 pt
97
+ (5.4 in) wide. This keeps type the same size relative to the card at any output resolution. `width_px` (default
98
+ 120 px per unit, so 1080 px for 9 units) only sets the resolution of the raster outputs.
99
+
100
+ For other aspect ratios, change `size` (and optionally `origin`, the slide coordinates of the bottom-left corner):
101
+
102
+ ```python
103
+ ck.Scene(25, size=(9, 11.25), origin=(0, -0.85)) # 4:5 portrait: the 9x9 card stays at 0..9, extra room below and above
104
+ ```
105
+
106
+ ### 2. Anything can be a function of time
107
+
108
+ Every attribute takes either a constant or a function `t -> value`: positions, point lists, colours, radii, opacity,
109
+ line widths, the fraction of a line that is drawn, even the text string.
110
+
111
+ ```python
112
+ Circ(c=lambda t: (1 + t, 4.5), r=0.2, fill="#e17d38") # moves right at 1 unit / s
113
+ Txt(lambda t: f"{int(100 * t)} %", (4.5, 4.5), 30, ha="center") # a counter
114
+ ```
115
+
116
+ The helpers in [Timing helpers](#timing-helpers) cover the common cases without lambdas.
117
+
118
+ ### 3. Five primitives
119
+
120
+ | primitive | what | main arguments |
121
+ |---|---|---|
122
+ | `Poly(pts, fill=None, stroke=None, lw=0)` | closed polygon | `pts`: (N, 2) vertices |
123
+ | `Path(pts, color, lw=1.5, draw=1.0, dash=None, cap="round")` | open polyline | `draw`: fraction drawn, 0..1; `dash`: (on, off) in units |
124
+ | `Circ(c, r, fill=None, stroke=None, lw=0)` | circle | centre and radius in units |
125
+ | `Txt(s, xy, size=12, color="#000", ha="left", weight=, style=, family=)` | text as outlines | `xy` on the baseline; `ha`: left / center / right |
126
+ | `Img(arr, extent, interp="bilinear")` | raster image | `arr`: (H, W, 3 or 4) array; `extent`: (x0, x1, y0, y1) |
127
+
128
+ They all also take `op` (opacity, 0..1), `z` (drawing order: higher is on top) and `group` (see below). If you prefer
129
+ longer names, `ck.Polygon`, `ck.Line`, `ck.Circle`, `ck.Text` and `ck.Image` are the same classes.
130
+
131
+ ### 4. Groups fade as a whole
132
+
133
+ Put the elements of one step of your story in a **group** with an opacity **envelope**. The group fades as a single
134
+ layer, so overlapping translucent shapes never show seams, and the SVG only samples a group while it is visible, which
135
+ keeps files small.
136
+
137
+ ```python
138
+ with scene.group("step1", ck.fade(0.0, 6.4)): # visible from 0 to 6.4 s, fading in and out
139
+ scene.add(Txt("Step one", (0.5, 8), 22))
140
+ scene.add(Path(curve, "#0f9d8a", 3, draw=ramp(0.5, 2.0)))
141
+
142
+ with scene.group("step2", (6.4, 12.0)): # a (t0, t1) tuple is shorthand for fade(t0, t1)
143
+ ...
144
+ ```
145
+
146
+ Elements added outside any group are in the base group, which is always visible.
147
+
148
+ ### 5. Author in scene time, then add an intro and pauses
149
+
150
+ You time every element in **scene time**. The scene can be played back differently from how you wrote it:
151
+
152
+ ```python
153
+ scene = ck.Scene(
154
+ duration=25, # the story runs from scene time 0 to 25
155
+ intro=3.0, # ...plus an intro authored at negative times, -3 .. 0
156
+ holds={6.05: 0.9, 12.0: 0.9}, # freeze at these scene times for 0.9 s (reading pauses)
157
+ )
158
+ scene.duration # 29.8: the actual loop length (display time)
159
+ scene.to_display(6.4) # scene time -> display time
160
+ ```
161
+
162
+ Adding an intro or a pause never forces you to re-time the rest. The display-time functions (`frame`, `storyboard`,
163
+ `check_text`, `save(..., at=)`) take display time, and `frame_at(tau)` and `poster=` take scene time.
164
+
165
+ ---
166
+
167
+ ## Timing helpers
168
+
169
+ They all return `Fn` objects: functions of time that support arithmetic, such as `ramp(1, 2) * 0.5` or
170
+ `1 - ramp(3, 3.5)`, or `fade(0, 6) * pulse(2, 0.6, 1)`.
171
+
172
+ | helper | gives |
173
+ |---|---|
174
+ | `ramp(t0, t1)` | 0 → 1 between t0 and t1, smoothstep-eased (`eased=False` for linear) |
175
+ | `fade(t0, t1=None, d=0.35)` | fades in after t0 and out before t1, over `d` s (or `(d_in, d_out)`) |
176
+ | `tween(a, b, t0, t1)` | interpolates numbers, arrays (point lists, so shapes morph) or colours |
177
+ | `keyframes({t: value, ...})` | piecewise interpolation through several keys (numbers, arrays, colours) |
178
+ | `along(pts, t0, t1)` | a position travelling along a polyline: `Circ(along(path, 1, 3), 0.1)` |
179
+ | `pulse(period, lo, hi)` | smooth periodic oscillation |
180
+ | `every(t0, period, n=None)` | integer step counter (0, 1, 2, ...), handy to cycle through examples |
181
+ | `fn(callable)` | wraps your own `t -> value` function to get the arithmetic |
182
+ | `seg(t, a, b)`, `ease(x)`, `lin(t, a, b)` | the raw scalar functions |
183
+
184
+ > **The lambda-in-a-loop trap.** `for k in range(5): Circ(..., op=lambda t: seg(t, k, k + 1))` gives every circle
185
+ > the *last* `k`. Use the helpers (`op=ramp(k, k + 1)`), which capture values correctly, or bind with `lambda t, k=k: ...`.
186
+
187
+ ## Shapes and colours
188
+
189
+ `rect(x0, y0, x1, y1)`, `rounded_rect(x0, y0, x1, y1, r)`, `bezier(p0, c1, c2, p1, n)` (2-D or 3-D),
190
+ `arc(c, r, a0, a1)`, `ngon(c, r, k)` and `arrow_head(tip, direction)` return point arrays.
191
+ `mix(c1, c2, k)`, `shade(c, k)` and `lighten(c, k)` blend colours, and `colorize(c, fn)` applies one of them to a
192
+ colour that is itself a function of time. `text_width(s, size)` measures a label, for example to size a chip around it.
193
+
194
+ ## Exporting
195
+
196
+ ```python
197
+ scene.save("card.svg") # animated SVG (SMIL)
198
+ scene.save("card_still.svg", at=12.0) # static SVG snapshot at display time 12 s
199
+ scene.save("poster.png", at=23.6) # one frame
200
+ scene.save("card.gif") # renders every frame, then encodes
201
+ scene.save_all("out/card", "gif", "webp", "mp4", "svg", poster=23.6) # renders the frames once for all formats
202
+ ```
203
+
204
+ | format | notes |
205
+ |---|---|
206
+ | **SVG** | `save_svg(path, at=None, dt=0.1, simplify=True)`. Attributes are sampled every `dt` s; runs of identical samples are dropped (about half the size, same animation). Plays in `<img>`. |
207
+ | **GIF** | Pillow only, no ffmpeg. One shared 256-colour palette without dithering (crisp flat colours), `gif_fps=15`, `gif_width=720`. |
208
+ | **WebP** | Full resolution, `webp_quality=90`. |
209
+ | **MP4** | H.264 / yuv420p / faststart, `crf=16`: plays on LinkedIn, X, Slack and Keynote. Needs ffmpeg (`$FFMPEG`, `PATH`, or `pip install imageio-ffmpeg`). |
210
+ | **PNG** | `frame(t)` / `frame_at(tau)` return PIL images. |
211
+
212
+ Frames are rendered in parallel with all cores but one (`workers=1` renders serially). Use `fps=` (default 30) and
213
+ `width_px=` to change the frame rate and resolution.
214
+
215
+ ## Quality checks
216
+
217
+ ```python
218
+ scene.check_text(scene.to_display(6.0))
219
+ # [check t=7.80] 14 texts, 1 issues
220
+ # OVERLAP 'FC matrix' <-> 'subjects per dataset'
221
+ ```
222
+
223
+ `check_text(t, margin=0.25)` lists visible texts that overlap each other or come closer than `margin` units to the edge.
224
+ It is cheap: run it at a few key instants after every layout change.
225
+
226
+ ## 3-D
227
+
228
+ `cardkit.three` builds 3-D-looking objects from plain `Poly` faces, seen through a fixed oblique camera with Lambert
229
+ lighting. Because the camera never moves, hidden faces are removed and faces are ordered once, when the scene is built.
230
+ The objects can still grow, move and change colour over time, and the SVG stays fully vector.
231
+
232
+ ```python
233
+ from cardkit.three import Camera, box, slab, tube
234
+
235
+ cam = Camera(origin=(1.0, 2.0)) # world axes: X right, D into the screen, Z up
236
+ scene.add(box(cam, (0, 0, 0), (0.6, 0.6, ramp(1, 2) * 2.5), "#0f9d8a")) # a bar that grows to 2.5 units
237
+ scene.add(slab(cam, (0, 0, 0), matrix, 0.3, colors=lambda v: ck.mix("#fff", "#0f9d8a", v))) # a matrix lying flat
238
+ scene.add(tube(cam, ck.bezier(p0, c1, c2, p1), 0.04, "#6c5ce7", t0=1.0, dur=1.0)) # a shaded arrow that grows
239
+ ```
240
+
241
+ `Camera.project(P)` maps (N, 3) world points to the slide, `Camera.lit(color, normal)` shades a colour, and
242
+ `Camera.depth(P)` gives a value to use as `z` when separate objects overlap (closer objects get higher `z`).
243
+
244
+ ## Using real figures
245
+
246
+ `Img` takes any array, so you can render a matplotlib or nilearn figure to an RGBA array once and place it in the card:
247
+
248
+ ```python
249
+ fig = plt.figure(figsize=(6, 2.4), dpi=200)
250
+ plotting.plot_glass_brain(stat_img, display_mode="lzr", figure=fig, annotate=False)
251
+ fig.canvas.draw(); arr = np.asarray(fig.canvas.buffer_rgba()) / 255; plt.close(fig)
252
+ scene.add(Img(arr, (0.5, 8.5, 3.6, 6.8), op=ramp(0.3, 0.9)))
253
+ ```
254
+
255
+ To animate *inside* a rendered figure, render it in two states and fade between them.
256
+ [`brain.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/brain.py) renders an empty glass brain and the same brain with the activation, then turns the
257
+ difference into an exact overlay whose opacity follows a signal. The outline never moves; only the activation fades.
258
+ Render both states from the same figure layout (for example with `display.add_overlay`), so that they line up.
259
+
260
+ Inside the SVG, images are embedded as palette PNGs (128 colours) to keep files small, so smooth gradients can show
261
+ some banding there. Mathtext follows matplotlib's `rcParams` (for example `plt.rcParams["mathtext.fontset"] = "cm"`),
262
+ and so does the default font family.
263
+
264
+ ## Limitations (SVG only; the raster backend has none of these)
265
+
266
+ * `Txt.size` and `Img.arr` cannot be animated. Position, colour, opacity and the string itself can.
267
+ * A `Path` with `dash` cannot also animate `draw`: the SVG shows the dashes and cardkit warns you.
268
+ * Shapes whose number of vertices changes over time switch between shapes discretely instead of morphing.
269
+ * SVG time is sampled every `dt` (0.1 s by default): very fast motion may need a smaller `dt`.
270
+
271
+ ---
272
+
273
+ ## Reference
274
+
275
+ ```
276
+ Scene(duration, size=(9, 9), background="#ffffff", *, origin=(0, 0), width_px=None, fps=30,
277
+ intro=0.0, holds=None, hold=0.9, title="")
278
+ .add(*prims) add primitives or lists of primitives; returns them
279
+ .group(name, envelope=None) register a group; also a context manager
280
+ .duration loop length in display seconds
281
+ .to_display(tau) scene time -> display time
282
+ .frame(t, width_px=None, isolate=True) PIL image at display time t
283
+ .frame_at(tau) PIL image at scene time tau
284
+ .storyboard(path=None, times=None, n=8, cols=4, width=360)
285
+ .check_text(t, margin=0.25, min_alpha=0.2)
286
+ .save(path, at=None, ...) by extension: .svg .png .jpg .gif .webp .mp4
287
+ .save_svg(path, at=None, dt=0.1, simplify=True, width=None)
288
+ .save_all(stem, *formats, poster=None, fps=None, width_px=None, workers=None,
289
+ gif_fps=15, gif_width=720, webp_quality=90, crf=16)
290
+ .render_frames(folder, fps=None, width_px=None, workers=None)
291
+ Timeline(duration, intro=0.0, holds=None, hold=0.9) .warp(T) .to_display(tau) .display_duration
292
+ ```
293
+
294
+ ## Examples and tests
295
+
296
+ * [`examples/quickstart.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/quickstart.py): the quick start above.
297
+ * [`examples/tour.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/tour.py): intro, groups, holds, motion along a path, keyframed colour, a counter,
298
+ mathtext, an image, a progress indicator, the storyboard and the text check.
299
+ * [`examples/network.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/network.py), [`examples/three_d.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/three_d.py) and
300
+ [`examples/brain.py`](https://github.com/carloalbertobarbano/cardkit/blob/main/examples/brain.py): the gallery (the last one needs `pip install nilearn`).
301
+ * `python docs/make_docs.py` rebuilds every demo image in `docs/`.
302
+ * `python -m unittest discover tests`. The tests include a SMIL evaluator that checks the compressed SVG animates exactly
303
+ like the uncompressed one.
304
+
305
+ ## License
306
+
307
+ MIT, see [LICENSE](https://github.com/carloalbertobarbano/cardkit/blob/main/LICENSE).