Consejos para agilizar el trabajo con gganimate

30 de junio de 2020

Herramientas: ggplot2, gganimate

Secciones

Últimamente he trasteado bastante con gganimate, el paquete para crear gráficos animados a partir de gráficos de ggplot2; y quien dice trastear, dice perder mucho tiempo esperando a que las animaciones terminen de renderizar.

Hay algunos factores que no puedo controlar: por ejemplo, mi ordenador de sobremesa, más viejito, tarda bastante más que el portátil en renderizar la misma animación.

Sin embargo, hay un par de trucos que nos pueden ayudar a reducir el tiempo de trabajo notablemente.

Para los ejemplos, voy a usar el gráfico con la línea de tiempo que empleé para el #tidytuesday 2020-06-23.

0.1 Procesamiento previo

library(tidyverse)
library(lubridate)
library(gganimate)
library(ggtext)

locations <- readr::read_csv('https://raw.githubusercontent.com/rfordatascience/tidytuesday/master/data/2020/2020-06-23/locations.csv')

estudios <- c(unique(locations$study_site))
colores <-
  c(
    "#bf616a",
    "#88c0d0",
    "#d08770",
    "#d8dee9",
    "#ebcb8b",
    "#b48ead",
    "#a3be8c",
    "#4c566a"
  )
paleta <- bind_cols(study_site = estudios, color = colores)
datos_validos <- locations %>%
  mutate(anyo = as_date(floor_date(timestamp, "year"))) %>%
  left_join(paleta)

0.2 Gráfico de partida

tl_study <- datos_validos %>%
  group_by(study_site) %>%
  summarise(
    inicio = as_date(min(timestamp)),
    final = as_date(max(timestamp)),
    inicio4label = inicio %m+% months(6),
    color = unique(color)
  ) %>%
  arrange(inicio) %>%
  mutate(order = row_number()) %>%
  pivot_longer(inicio:final, names_to = "momento", values_to = "time") %>%
  ungroup()

tl_mark <- datos_validos %>%
  group_by(anyo) %>%
  summarise() %>%
  rename(time = anyo) %>%
  mutate(time4anim = time)

no_data_years <- 1993:2000
y <- rep(Inf, times = 8)

missing_years <- tibble(x = as_date(parse_date_time(no_data_years, "y")),
                        y = y)

tl <- ggplot() +
  geom_line(
    data = tl_study,
    aes(
      time,
      fct_reorder(study_site, order),
      group = study_site,
      color = color
    ),
    size = 7.5
  ) +
  geom_text(
    data = tl_study %>% filter(momento == "inicio"),
    aes(x = inicio4label, y = study_site, label = study_site),
    color = "#2e3440",
    hjust = -0.018,
    vjust = 0.5,
    fontface = "bold",
    size = 4
  ) +
  geom_text(
    data = tl_study %>% filter(momento == "inicio"),
    aes(x = inicio4label, y = study_site, label = study_site),
    color = "white",
    hjust = 0,
    vjust = 0.4,
    fontface = "bold",
    size = 4
  ) +
  geom_vline(data = tl_mark,
             aes(xintercept = time),
             color = "#eceff4") +
  geom_point(
    data = tl_mark,
    aes(x = time, y = -Inf),
    shape = 18,
    size = 5,
    color = "#eceff4"
  ) +
  geom_rect(
    data = missing_years,
    aes(
      xmin = min(x),
      xmax = max(x),
      ymin = -Inf,
      ymax = Inf
    ),
    fill = "#2e3440",
    color = "#434c5e"
  ) +
  annotate(
    "text",
    x = min(tl_study$time) %m+% months(4),
    y = "Scott",
    label = "Colonia o manada",
    angle = 90,
    color = "#eceff4",
    hjust = 1
  ) +
  annotate(
    "text",
    label = "NO DATA",
    color = "#434c5e",
    x = ymd("2000-01-01"),
    y = "Quintette",
    hjust = 1.01,
    size = 10
  ) +
  labs(
    title = "Áreas de movimiento de caribús por animal y año",
    caption = paste0("<span style='color: #eceff4'>2015-01-01</span>")
  ) +
  scale_color_identity() +
  scale_x_date(breaks = "2 year", date_labels = "%Y") +
  theme_void() +
  theme(
    plot.margin = margin(
      t = .15,
      r = 0.15,
      b = .05,
      l = .15,
      unit = "in"
    ),
    plot.title = element_text(
      margin = margin(b = 0.1, unit = "in"),
      color = "#eceff4",
      size = 20,
      face = "bold",
      hjust = 0.5
    ),
    plot.caption = element_markdown(
      hjust = 0.5,
      color = "#eceff4",
      size = 14,
      face = "bold"
    ),
    plot.background = element_rect(fill = "#2e3440"),
    panel.background = element_blank(),
    axis.title.x = element_blank(),
    axis.title.y = element_blank(),
    axis.text.y = element_blank(),
    axis.ticks.y = element_blank()
  )

La animación va a consistir en una serie de elementos estáticos (barras horizontales de colores, textos…) y dos elementos animados (geom_vline y geom_point), que marcarán el paso de los años.

Hay otro elemento animado, el texto que nos ofrece información sobre el estado de la animación, que en este ejemplo recogemos en caption; esta información depende de las variables calculadas de gganimate, por lo que para una de las técnicas (trabajar con imágenes estáticas) introduciremos dicha información de forma manual, y cuando trabajemos directamente con gganimate usaremos las etiquetas de variable que genera el paquete.

0.3 Trabajar con imágenes estáticas

Renderizar una animación puede llevar bastante tiempo, especialmente si se trata de un gráfico complejo o nuestro ordenador ya pasó sus días de gloria.

Una forma de no malgastar el tiempo consiste en trabajar toda la parte del diseño del gráfico con imágenes estáticas generadas directamente con ggplot2, y dejar la animación para el último momento.

0.3.1 No debemos fiarnos de la pestaña “Plots” de RStudio

Si vamos a trabajar sobre imágenes estáticas, lo primero que hemos de saber es que no debemos fiarnos del visualizador de RStudio. La razón es que si cambiamos las dimensiones de ese panel el gráfico se readaptará, y las proporciones entre las marcas gráficas variarán.

Comportamiento de la pestaña Plots de RStudio

Por eso, para trabajar en este modo debemos guardar los gráficos en un archivo de imagen con la función ggsave(). Y para no tener problemas, la configuración de la imagen (altura, anchura y dpi -puntos por pulgada, pero también píxeles por pulgada-) ha de ser la misma que usaremos para la animación, como veremos en los ejemplos posteriores.

Si pensamos en la manera de trabajar de ggplot, una animación no sería más que un gráfico facetado en el que todas las facetas están solapadas en las escalas x e y y espaciadas en el tiempo.

Siguiendo esta lógica, podemos seguir dos vías de trabajo.

0.3.2 Trabajar con todos los datos necesarios para generar la animación.

Al asignar los datos a ggplot2, podemos pasarle los datos reales con los que va a trabajar posteriormente la animación. En este caso, obtendremos un gráfico sin filtrar, por lo que tendremos más marcas gráficas de las que realmente se verán en cada fotograma de la animación.

  • width, height: no podemos dar las medidas en píxeles, solo en pulgadas (valor por defecto), centímetros o milímetros. Sin embargo, la función anim_save() de gganimate sí que permite indicar las dimensiones en píxeles. Por tanto, tendremos que calcular las dimensiones correctas de ggsave para que coincidan con las de anim_save
  • dpi: puntos por pulgada.

La forma más simple de hacer coincidir las dimensiones de las imágenes de ggplot y las animaciones de gganimate consiste en dividir el número de píxeles de anchura/altura de la animación final por el dpi que seleccionemos.

Por ejemplo, si queremos obtener una anchura de 1200 píxeles con una resolución de 96 dpi, tendremos que indicar a ggsave la anchura como 1200/96 (12,5) pulgadas (usamos pulgadas por ser la unidad por defecto, y la más simple de calcular).

Hasta hace poco las configuraciones de dpis más habituales para pantallas eran 72 y 96, pero actualmente existen pantallas con resoluciones mayores (especialmente para móviles).

Debemos decidir cuanto antes con qué dpi vamos a trabajar, ya que afecta al tamaño de la tipografía.

ggsave(
  "todos_los_datos_1200x450_96dpi.png",
  plot = tl,
  path = here("static", "images"),
  dpi = 96,
  width = 1200 / 96,
  height = 450 / 96
)

Imagen a 96 dpi

ggsave(
  "todos_los_datos_1200x450_150dpi.png",
  plot = tl,
  path = here("static", "images"),
  dpi = 150,
  width = 1200 / 150,
  height = 450 / 150
)

Imagen a 150 dpi

0.3.3 Trabajar con imágenes filtradas

Generar el gráfico con todas las marcas necesarias para la animación puede crear una vista demasiado abigarrada, por lo que puede ser preferible filtrar los datos para obtener una imagen de un fotograma concreto de la animación. En este ejemplo, tendríamos que filtrar los datos de geom_vline() y geom_point para mostrar un único año.

NOTA: también cambiamos los datos para caption aunque en la animación usaríamos las etiquetas generadas por gganimate para rellenar ese texto.

fecha_muestra <- ymd("2010-01-01")

tl_filtrado <- ggplot() +
  geom_line(
    data = tl_study,
    aes(
      time,
      fct_reorder(study_site, order),
      group = study_site,
      color = color
    ),
    size = 7.5
  ) +
  geom_text(
    data = tl_study %>% filter(momento == "inicio"),
    aes(x = inicio4label, y = study_site, label = study_site),
    color = "#2e3440",
    hjust = -0.018,
    vjust = 0.5,
    fontface = "bold",
    size = 4
  ) +
  geom_text(
    data = tl_study %>% filter(momento == "inicio"),
    aes(x = inicio4label, y = study_site, label = study_site),
    color = "white",
    hjust = 0,
    vjust = 0.4,
    fontface = "bold",
    size = 4
  ) +
  geom_vline(data = tl_mark,
             aes(xintercept = fecha_muestra),
             color = "#eceff4") +
  geom_point(
    data = tl_mark,
    aes(x = fecha_muestra, y = -Inf),
    shape = 18,
    size = 5,
    color = "#eceff4"
  ) +
  geom_rect(
    data = missing_years,
    aes(
      xmin = min(x),
      xmax = max(x),
      ymin = -Inf,
      ymax = Inf
    ),
    fill = "#2e3440",
    color = "#434c5e"
  ) +
  annotate(
    "text",
    x = min(tl_study$time) %m+% months(4),
    y = "Scott",
    label = "Colonia o manada",
    angle = 90,
    color = "#eceff4",
    hjust = 1
  ) +
  annotate(
    "text",
    label = "NO DATA",
    color = "#434c5e",
    x = ymd("2000-01-01"),
    y = "Quintette",
    hjust = 1.01,
    size = 10
  ) +
  labs(
    title = "Áreas de movimiento de caribús por animal y año",
    caption = paste0("<span style='color: #eceff4'>", fecha_muestra, "</span>")
  ) +
  scale_color_identity() +
  scale_x_date(breaks = "2 year", date_labels = "%Y") +
  theme_void() +
  theme(
    plot.margin = margin(
      t = .15,
      r = 0.15,
      b = .05,
      l = .15,
      unit = "in"
    ),
    plot.title = element_text(
      margin = margin(b = 0.1, unit = "in"),
      color = "#eceff4",
      size = 20,
      face = "bold",
      hjust = 0.5
    ),
    plot.caption = element_markdown(
      hjust = 0.5,
      color = "#eceff4",
      size = 14,
      face = "bold"
    ),
    plot.background = element_rect(fill = "#2e3440"),
    panel.background = element_blank(),
    axis.title.x = element_blank(),
    axis.title.y = element_blank(),
    axis.text.y = element_blank(),
    axis.ticks.y = element_blank()
  )
ggsave("datos_filtrados_1200x450_96dpi.png", plot = tl_filtrado, path = here("static","images"),dpi = 96, width = 12.5, height = 450/96)

Imagen a 96 dpi

¡CUIDADO!

Si usamos este método, antes de generar la animación tenemos que acordarnos de volver a usar todos los datos, ya que de otra forma obtendremos una imagen estática.

0.4 Bajar el número de frames

Por defecto, las animaciones de gganimate constan de 100 fotogramas.

tl_anim <- tl +
  transition_states(states = time4anim, transition_length = 1, state_length = 2) +
  labs(title="Áreas de movimiento de caribús\npor animal y estación del año",
       caption="<span style='color: #eceff4'>{closest_state}</span>")

anim_tl <- animate(tl_anim, width = 1200, height = 450, res = 96)

anim_tl

Para perder menos tiempo en el renderizado, podemos usar menos fotogramas, y una vez que todo esté a nuestro gusto podremos generar la animación con el número de fotogramas que realmente nos interese.

Sin embargo, hemos de tener en cuenta que está animación parecerá más rápida. Podemos usar esta técnica para testar que todos los elementos de la animación están donde deben estar, pero sin olvidar que el ritmo no es el definitivo (ver el punto “Algunas consideraciones sobre el ritmo de la animación”).

anim_tl_50fr <- animate(tl_anim, nframes = 50, width = 1200, height = 450, res = 96)

anim_tl_50fr

A la hora de rebajar el número de fotogramas, hemos de tener en cuenta que debemos dejar “espacio” para todos los elementos de la animación.

En este ejemplo, la animación consta de 21 años. Pero además debemos tomar en consideración los parámetros transition_length y state_length del tipo de transición que estamos usando (a saber, transition_states()) (por defecto 1 y 1), que indican a ggplot la relación de la duración entre la “foto” estática (la de un año concreto) y la animación de un año a otro, y que nos llevan a necesitar un mínimo de 42 fotogramas.

Por otra parte, si usamos los parámetros start_pause y/o end_pause de la función animate(), estaremos ocupando parte de los frames. En el siguiente ejemplo, estamos usando 45 de los 50 fotogramas para las pausas de inicio y final, por lo que no nos quedan suficientes fotogramas para mostrar toda la animación (que ya hemos visto que necesita un mínimo de 42). En este caso, gganimate genera una animación sin dar ningún error, pero no es la animación que queremos obtener, ya que faltan la mayoría de los años.

NOTA: si el número de fotogramas es muy bajo (por ejemplo, menor que el número de estados) la función animate() mostrará un mensaje de error y no se ejecutará.

anim_tl_50fr_s15_e30 <- animate(tl_anim, nframes = 50, width = 1200, height = 450, res = 96, start_pause = 15, end_pause = 30)

anim_tl_50fr_s15_e30

0.5 Algunas consideraciones sobre el ritmo de la animación

  • Para calcular el número mínimo de fotogramas debemos tener en cuenta de cuántos pasos o estados constará la animación tenemos, y si necesitamos fotogramas adicionales para usar con start_pause o end_pause.
  • Una animación con el mismo número de fotogramas y un número de fotogramas por segundo superior será más rápida. Si queremos variar el ritmo sin modificar el tamaño del archivo final podemos bajar el número de fps.
anim_tl_50fr_10_fps <- animate(tl_anim, nframes = 50, width = 1200, height = 450, res = 96)

anim_tl_50fr_10_fps

Animación a 10 fps(configuración por defecto)

anim_tl_50fr_5fps <- animate(tl_anim, nframes = 50, fps = 5, width = 1200, height = 450, res = 96)

anim_tl_50fr_5fps

Animación a 5 fps (la animación tarda el doble de tiempo, pero el archivo pesa lo mismo que el anterior).

Finalmente, dependiendo del tipo de transición que hayamos seleccionado, dispondremos de otras opciones para marcar el ritmo de la animación:

  • Dependiendo de la función de transición con la que estemos trabajando, podemos jugar con el ritmo con los parámetros transition_length() y [state|filter|layer]_length(). Si el segundo parámetro es mayor que el primero, la animación irá “a saltos” (se detendrá unos fotogramas en cada estado/filtro/capa), mientras que si es al revés irá “fluida”.
  • Las transiciones basadas en una variable temporal (transition_[time|reveal|events]) no disponen de este tipo de parámetros, por lo que el ritmo de la animación es uniforme.
  • Las transiciones transition_[events|components] permiten indicar tiempos de entrada y salida distintos para cada estado de la animación ([enter|exit]_length), lo que repercute en el ritmo.

Más posts