manim-handdraw
Turn a line-art bitmap into a progressive hand-drawn animation in Manim.
Manim can grow a line with Create(). What it can't do is look at a PNG and know
where the lines are, in what order a human would draw them, and how to dress the
process up as a drawing session — compass, construction lines, ink fills, colour washes.
This package wraps that whole pipeline behind a few plain statements.
from manim import *
import manim_handdraw as hd
class Demo(hd.HandDrawScene):
def construct(self):
self.hand_draw("lineart.png", color="color.png")
That single call extracts the strokes, draws them one at a time with a moving pen cursor, then washes the colour in strip by strip.
Demo: the bundled peashooter example, rendered end-to-end by this library.
Install
pip install manim-handdraw # rendering only (manim + numpy)
pip install "manim-handdraw[extract]" # + the image → strokes pipeline
The split is deliberate: extracting strokes needs scikit-image / scipy / Pillow,
but those are imported lazily, inside functions. Manim imports every registered
plugin at startup, so keeping the top level clean means installing this package never
slows down unrelated manim runs.
Requirements
- Python ≥ 3.9, Manim ≥ 0.18
- The extract layer additionally needs
scikit-image,scipy,Pillow
Quick start
manim-handdraw demo lineart.png -o my_scene --color color.png
manim -ql my_scene/demo_scene.py Demo
Or write it yourself:
from manim import *
import manim_handdraw as hd
config.frame_width, config.frame_height = 16, 9
config.background_color = hd.PAPER
class Demo(hd.HandDrawScene):
def construct(self):
self.hand_draw(
"lineart.png",
color="color.png", # or ["green.png", "brown.png", "red.png"]
size=7.0, # fits height, same as ImageMobject.scale_to_fit_height
center=(0.0, 0.0),
pen_speed=0.9, # Manim units per second — lower is slower/longer
)
hand_draw(..., draw=False) returns the StrokeSet instead of playing it, so you can
interleave your own close-ups and geometric constructions. That's exactly what
examples/peashooter does.
What's inside
Three layers, usable independently. Everything is plain Manim — no custom renderer, no monkey-patching, so it composes with animations you already know.
| Layer | What it gives you | Needs [extract]? |
|---|---|---|
| Extract | bitmap → ordered stroke paths (from_image, extract_strokes) |
yes |
| Draw | StrokeSet, Stylus, per-stroke timing, InkBlot fills |
no |
| Draft | Compass, trace_ellipse, construction lines, colour sweep |
no |
Draw — strokes and the pen
strokes = hd.from_image("lineart.png", size=7.0)
print(strokes.describe())
# <StrokeSet 99 strokes, total length 52.4 units ...>
stylus = hd.Stylus()
self.play(*stylus.draw_all(strokes)) # one call, timing auto-distributed
Stylus.draw() returns (animations, run_time, mobject) when you want to control
pacing yourself:
stylus.move_to(strokes[0][0])
self.play(FadeIn(stylus), run_time=0.4)
for p in strokes:
group, rt, mob = stylus.draw(p)
self.play(*group, run_time=rt)
Draft — the geometry session
Draw a circle with a compass, sweep out an ellipse from its parametric equation, then trace it in ink:
# compass: pivot + jointed arms; rotation stays in sync with the circle being drawn
compass = hd.Compass(pivot, radius=0.67)
self.play(FadeIn(compass), run_time=0.5)
anims, guide = compass.draw_circle(dashed=True)
self.play(*anims, run_time=2.6) # dashed guide reads as "construction"
self.play(FadeOut(compass), run_time=0.5)
# ellipse: auxiliary circle + major/minor axes + a rotating radius arm
info = hd.trace_ellipse(center, a=0.29, b=0.16, tilt=-83.5, show_label="eye")
self.play(*info["anims"], run_time=info["run_time"])
self.play(FadeOut(info["scaffold"]), FadeOut(info["arm"]))
# then: trace it in ink, and let ink bleed out from the centre
self.play(*stylus.retrace(info["points"]))
blot = hd.InkBlot(center, a * 0.97, b * 0.97, tilt=-83.5)
self.play(UpdateFromAlphaFunc(blot, hd.blot_grow))
HandDrawScene bundles these as self.construct_ellipse(...) / self.construct_circle(...).
Colour — the wash
Splitting a finished image into vertical strips and revealing them in sequence reads as watercolour soaking across the page, instead of the whole layer popping in:
anim, group = self.sweep_color("color.png", n=7, run_time=3.2)
self.play(anim)
The strips are laid out from exact pixel boundaries. Rounding each strip's width leaves ~1px of misalignment between neighbours, which shows up as a thin seam straight down your character — a bug that is easy to ship and annoying to diagnose.
The extraction pipeline
from_image() runs this, then caches the result to <image>.strokes.npz so re-renders
cost nothing:
| Step | What happens | Why |
|---|---|---|
| Ink mask | alpha channel (or luminance) → boolean | works with transparent PNGs and white-background scans |
| Skeletonize | thin the ink to a 1-pixel centreline | turns areas into curves while preserving topology |
| Distance transform | record local ink half-width along the centreline | lets you approximate the original line weight later |
| Cut at junctions | 8-neighbour count ≥ 3 → junction; remove, then label the rest | yields "atomic" segments containing no forks |
| Walk | from an endpoint, follow neighbours into an ordered polyline | a stroke must have a start and an end to be drawn |
| Merge | join segments whose endpoints are close and whose directions are collinear | also closes the gaps left at junctions — one pass, two problems solved |
| Clean | drop short segments that have no neighbour | removes skeletonisation specks; short segments with neighbours are kept, because they are usually real detail that the junction cut fragmented |
| Sort | band by row, greedy nearest-neighbour inside each band | draws top-to-bottom, like a person, without jumping left and right |
Segments are resampled by arc length, which simultaneously smooths pixel stair-stepping and caps the point count.
Two knobs matter most:
hd.extract_strokes(
"lineart.png",
merge_gap=9.0, # px — larger ⇒ fewer, longer strokes
merge_angle=0.30, # 0..1 — direction agreement required to merge
drop_inside=[(cx, cy, a, b, tilt)], # ellipses to exclude
)
CLI — keep heavy deps out of your Manim environment
manim-handdraw prep lineart.png --strips color.png
manim-handdraw info lineart.strokes.npz
骨架 7666 像素 -> 原始分段 204
合并清理 -> 99 段(剔除碎点 0)
已写出 lineart.strokes.npz
StrokeSet(99 笔,总长 52.4 单位,单笔 0.06~3.21,范围 x[-2.05,2.05] y[-2.80,3.00])
Then render anywhere with just manim + numpy:
strokes = hd.StrokeSet.load("lineart.strokes.npz")
Recipes / gotchas
The parts that cost real debugging time. All of them are handled inside the library, but they bite anyone writing this by hand:
VGroupcannot holdImageMobject. UseGroup.VGrouponly acceptsVMobject, and the error message doesn't hint at it.- Fade the ink layer out once the colour takes over. Otherwise a later recoil/translation
of the character slides the colour image but not the line art underneath, and a ghost
outline leaks out from behind. See
hand_draw()'s tail. - Strip seams from rounding. Compute strip spans as
round(i*W/n)and place each strip by its exact[x0, x1]; never derive position from a per-stripwidth * n. - Zooming the camera magnifies your captions too. Scale the caption by
frame.width / config.frame_widthand place it atcamera_center + home * scale.tag_text()andHandDrawScene.zoom_to()do this. stroke_widthis not pixels directly. At 1080p the rendered width is about1.2 × stroke_width—3.0 → 3.6px,5.0 → 6.0px.UpdateFromAlphaFuncappliesrate_funcfor you. Don't apply it twice.move_toneeds 3D points. Stroke arrays are naturally(n, 2); pad them.- Solid areas skeletonise into artifacts. A filled eye becomes one thin line through
its middle. Detect blobs with
find_solid_blobs()and hand them to the geometry helpers instead. - Never name an attribute
pointson aVMobjectsubclass.VMobjectalready owns that name for its raw point array, and it is a property whose setter coerces throughnp.asarray. Storing an integer there yields a 0-d array, and the failure surfaces much later asTypeError: only integer scalar arrays can be converted to a scalar indexfrom insidenp.linspace— with no hint about where the bad value came from.
Examples
| File | Shows |
|---|---|
examples/01_minimal.py |
the one-liner |
examples/02_construct_face.py |
replacing solid areas with compass/parametric construction |
examples/peashooter/ |
the full 2.5-minute video: drawing, close-up construction of eyes and muzzle, colour wash, recoil physics |
cd examples/peashooter
manim -ql peashooter.py CowboyPea
Limitations
Being upfront, because this determines whether it fits your artwork:
- It does not know what it's looking at. It sees ink and background. Which strokes form
"the eyes", where the muzzle centre is, which circle the compass should trace — those are
yours to supply. The library gives you the measuring tools (
moment_ellipse,robust_circle_fit,find_solid_blobs) to derive them; the peashooter example shows the whole calibration. - Best on clean, single-colour line art with a transparent or white background. Photos, textures and anti-aliased grey edges will produce noisy skeletons.
- Colour wash ≠ layer separation. You supply the per-layer images; the library slices and reveals them.
- Line weight variation is measured and stored in
StrokeSet.widths, but the default renderer draws uniform strokes. Draw variable-width ribbons yourself fromwidthsif you want a brush feel.
License
MIT — see LICENSE.
Bundled example artwork (examples/peashooter/assets/) is released under the same licence.
Release files for manim-handdraw 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| manim_handdraw-0.1.0.tar.gz | 1.6 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| manim_handdraw-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 1.6 MB
Release files / manim_handdraw-0.1.0.tar.gz
| Download URL | manim_handdraw-0.1.0.tar.gz |
|---|---|
| Size | 1.6 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
916f9a67ec8040201dbf433803cdab1ef2d72ac8a094aaef05cffe3020329089
|
|
BLAKE2b-256 checksum How to use checksums |
4fed484a62d1b52a6210b1123a964653aa6585f57f9b96fc8eec7e4cf8514ec3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency logRelease files / manim_handdraw-0.1.0-py3-none-any.whl
| Download URL | manim_handdraw-0.1.0-py3-none-any.whl |
|---|---|
| Size | 35.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7d4c527f8c80c01bcf41d6b76dd1124a011ef6a79d7d0823ae0b4152462f4ad6
|
|
BLAKE2b-256 checksum How to use checksums |
ff622455dc8d4ef3d8208bc09d8d2179876d2813731eb93a9dede3a32f0570be
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency log