Stop motion editing with stopmotion: the laser dinosaur

Credit

The dinosaur animation used throughout this vignette was created by @looksrawr and is released under the CC0 1.0 Universal Public Domain Dedication.


Overview

stopmotion is a pipeline-friendly toolkit for assembling and editing stop motion animations from sequences of still images. Its functions fall into three families:

Family Functions
Load read()
Restructure duplicate(), splice()
Transform rotate(), wiggle(), flip(), flop(), blur(), scale(), crop(), trim(), border(), background(), centre()
Display montage(), preview()

All functions accept an optional frames argument so that any operation can target a precise subset of frames — the feature that sets stopmotion apart from plain magick pipelines.

After each operation stopmotion prints a message listing the updated frame sequence. These messages are shown in interactive sessions and suppressed automatically during document rendering. To control verbosity explicitly, use stopmotion_verbosity() or set the option directly:

Or edit your .Rprofile to save the option with usethis::edit_r_profile()

This vignette walks through a complete editing session using the bundled extdata/ frames: a ten-frame cartoon dinosaur whose eyes shoot a laser ray.


1 Loading a GIF

The bundled extdata/ directory contains the ten frames of the dinosaur animation as individual PNG files — exactly the kind of image sequence that stopmotion is designed to work with. read() loads them in lexicographic order and returns a magick-image object accepted by every stopmotion function.

dino_dir <- system.file("extdata", package = "stopmotion")
dino <- read(dir = dino_dir)
dino |> preview(fps = 2)

cat("Number of frames:", length(dino), "\n")
#> Number of frames: 10
image_info(dino)[, c("width", "height", "filesize")]
#>    width height filesize
#> 1    480    480     5430
#> 2    480    480     5392
#> 3    480    480     5496
#> 4    480    480     9207
#> 5    480    480    11016
#> 6    480    480    11595
#> 7    480    480    10458
#> 8    480    480    10736
#> 9    480    480     6515
#> 10   480    480     6450

Ten frames, 480 × 480 pixels.


2 Inspecting frames

montage is a quick way to display all frames side-by-side.

montage(dino, tile = "10x1", geometry = "64x64+2+2")

Scanning left to right you can read the story:


3 Hand-held shake with wiggle()

wiggle() inserts two slightly rotated copies after each selected frame — one tilted +degrees, one −degrees — creating the organic wobble typical of real stop motion. We apply it to the quieter frames at the start so the calm contrast with the explosive laser section.

dino_w <- wiggle(dino, degrees = 2, frames = 1:3)
cat("Total frames after wiggle():", length(dino_w), "\n")
#> Total frames after wiggle(): 16

4 Building anticipation with duplicate()

The charging phase (frames 5–6) flashes by too quickly. duplicate() with style = "looped" inserts a copy of those frames immediately after the originals, making the build-up feel more deliberate.

dino2 <- duplicate(dino, frames = 5:6, style = "looped")
cat("Frames after duplicate():", length(dino2), "\n")
#> Frames after duplicate(): 12

The looped style repeats the selected range in order, so the sequence becomes …5, 6, 5, 6… — a natural charging pulse.


5 Adding drama with border()

A vivid red border on the laser frames (now frames 7–11 after the insertion) signals “danger” to the viewer.

dino3 <- border(dino2, color = "red", geometry = "8x8", frames = 7:11)

6 Motion blur on the laser beam with blur()

The three peak laser frames (frames 8–10) benefit from a subtle blur that conveys raw energy.

dino4 <- blur(dino3, radius = 3, sigma = 1.5, frames = 8:10)

7 Putting it together: the full pipeline

The edits above can be chained into a single pipe for clarity.

read(dir = system.file("extdata", package = "stopmotion")) |>
  wiggle(degrees = 2, frames = 1:3) |>             # hand-held shake
  duplicate(frames = 5:6, style = "looped") |>     # hold the charge
  border(color = "red", geometry = "8x8",
          frames = 7:11) |>                        # danger border
  blur(radius = 3, sigma = 1.5, frames = 8:10) |>  # energy blur
  preview(fps = 2)


8 Exporting the result

magick::image_write_gif() writes the edited sequence back to a GIF file. The delay argument controls playback speed (seconds per frame).

out <- tempfile(fileext = ".gif")
image_write_gif(dino_final, path = out, delay = 1 / 8)
message("Saved to: ", out)

9 The celebratory somersault: flip(), flop(), and rotate()

Once the laser fades, the dinosaur celebrates with a somersault. This example chains three pure geometric transforms — each applied to a precise subset of frames — to choreograph a full forward flip.

Step Function Visual effect
Mirror left–right flop() Dino faces left for the run-up
Lean forward 90° rotate() Start of the forward flip
Upside-down apex flip() Top-to-bottom mirror at the peak
Lean back 270° rotate() Completing the circle
Loop it twice duplicate() Two full somersaults
# Frames 1–2: mirror horizontally so the dino faces left (run-up)
dino_s <- flop(dino, frames = 1:2)
# Frame 3: rotate 90° — leaning forward into the jump
dino_s <- rotate(dino_s, degrees = 90, frames = 3L)
# Frame 4: flip vertically — upside-down at the apex of the somersault
dino_s <- flip(dino_s, frames = 4L)
# Frame 5: rotate 270° — coming back around to land upright
dino_s <- rotate(dino_s, degrees = 270, frames = 5L)
# Duplicate the spin frames so the dino does two full somersaults
dino_s <- duplicate(dino_s, frames = 1:5, style = "looped")
cat("Frames after duplication:", length(dino_s), "\n")
#> Frames after duplication: 15

The five steps collapse into a single pipe:

dino_somersault <- dino |>
  flop(frames = 1:2)               |>   # run-up: face left
  rotate(degrees = 90,  frames = 3L) |> # lean into the jump
  flip(frames = 4L)                |>   # upside-down apex
  rotate(degrees = 270, frames = 5L) |> # complete the circle
  duplicate(frames = 1:5, style = "looped") # loop it twice

montage(dino_somersault[1:10], tile = "10x1", geometry = "64x64+2+2")

dino_somersault |> preview(fps = 2)


10 Other useful operations

10.1 Dropping unwanted frames with splice()

splice() inserts new frames after a given position. Combined with standard magick subsetting you can also remove frames:

# Insert a custom "RAWR!" title card after frame 4
title_card <- image_blank(480, 480, color = "black") |>
  image_annotate("RAWR!", size = 80, color = "red", gravity = "Center")

dino_with_title <- splice(dino, insert = title_card, after = 4L)

10.2 Scaling down for the web

scale() accepts any magick geometry string.

dino_small <- scale(dino, geometry = "50%")

10.3 Cropping to the face

# Keep a 200×200 window centred on the head (adjust offsets to taste)
dino_face <- crop(dino, geometry = "200x200+140+60")

10.4 Aligning frames with centre()

When frames drift slightly between photos — a common artefact of hand-held stop motion — centre() performs a full affine warp (translation, rotation, and scaling simultaneously) so the subject stays locked in place across the whole animation. It needs exactly two landmarks per frame: consistent anatomical anchors such as the left and right eye. The reference frame defines the target position; every other frame is warped to match it.

Collecting landmarks interactively

The most practical way to record landmarks is locator() from base R. Click the two anchors on each frame in turn (always in the same order), and build up the data frame row by row. locator() returns y measured from the bottom edge of the plot, which is the convention centre() expects:

# Run once per editing session — requires an interactive graphics device.
# Display each frame, click the two landmarks, store the coordinates.
pts_list <- lapply(seq_along(dino), function(i) {
  plot(as.raster(dino[i])) # display frame i
  message("Frame ", i, ": click LEFT eye then RIGHT eye")
  p <- locator(2L) # two clicks; y is from the bottom edge
  data.frame(frame = i, x = p$x, y = p$y)
})
pts <- do.call(rbind, pts_list)

A worked example with artificial drift

The bundled dino is a clean digital sprite with no accidental drift, so the example below introduces known drift first via a pure-translation affine warp, then corrects it — making the fix unambiguous.

Frame 2 is shifted +5 px right / +3 px down; frame 3 is shifted −4 px left / +2 px down (all in ImageMagick’s top-edge coordinate system used by image_distort). The landmark table is in the bottom-edge convention that centre() expects.

# Introduce known translational drift.  Two widely-spaced control-point pairs
# both encoding the same displacement define a pure translation.
# Coordinates are in ImageMagick top-edge convention for image_distort.
dino_d <- c(
  dino[1],
  magick::image_distort(dino[2], "Affine",        # +5 right, +3 down
    c(100, 100, 105, 103,  380, 380, 385, 383)),
  magick::image_distort(dino[3], "Affine",        # −4 left, +2 down
    c(100, 100,  96, 102,  380, 380, 376, 382)),
  dino[4:10]
)

# Eye positions in the drifted sequence — y from the bottom edge (locator convention).
# Frame 1 reference (unchanged):       left (212, 271), right (272, 270).
# Frame 2 shifted (+5 right, +3 down): left (217, 268), right (277, 267).
# Frame 3 shifted (−4 left,  +2 down): left (208, 269), right (268, 268).
pts <- data.frame(
  frame = c(1L, 1L, 2L, 2L, 3L, 3L),
  x     = c(212, 272, 217, 277, 208, 268),
  y     = c(271, 270, 268, 267, 269, 268)
)

# Correct only the drifted frames; leave 4–10 untouched.
dino_stabilised <- centre(dino_d, points = pts, reference = 1L, frames = 2:3)

Compare the original drifted sequence with the stabilised one:

montage(dino_d[1:3],          tile = "3x1", geometry = "128x128+2+2")

montage(dino_stabilised[1:3], tile = "3x1", geometry = "128x128+2+2")


Summary

Step Function Key argument
Hold charging frames duplicate() style = "looped"
Red danger border border() color, geometry
Energy motion blur blur() radius, sigma
Hand-held shake wiggle() degrees
Insert title card splice() insert, after
Resize for web scale() geometry
Crop to face crop() geometry
Stabilise frames centre() points, reference
Mirror left–right flop() frames
Mirror top–bottom flip() frames
Rotate by angle rotate() degrees

All of the above accept a frames argument to restrict the operation to any subset of frames, giving you frame-precise control over your animation.