2025, Nov 06 06:02
Анимация SST в Bokeh: почему не работает и как исправить
Почему анимация SST в Bokeh замирает: вложенные списки не подходят для image. Даём рабочий CustomJS и ColumnDataSource для плавного обновления кадров.
Анимация месячных карт температуры поверхности моря (SST) в Bokeh кажется простой: посчитать кадр для каждого месяца, прикрутить обратный вызов CustomJS и листать изображения по таймеру. Но в браузере получается неподвижный график, хотя данные от месяца к месяцу меняются. Загвоздка тонкая — и кроется на стороне JavaScript.
Постановка задачи
Код ниже создаёт сетку, синтезирует 12 кадров SST и пытается анимировать их в браузере, передавая в JavaScript вложенные списки.
import numpy as np
from bokeh.io import output_file, show
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource, LinearColorMapper, ColorBar, Button, CustomJS, Title
from bokeh.layouts import column
# Сетка
x_deg = np.linspace(-180, 180, 18)
y_deg = np.linspace(-90, 90, 9)
x2d, y2d = np.meshgrid(x_deg, y_deg)
# Генерируем 12 месяцев SST с изменчивостью
tiles = []
for m in range(12):
field = (
15
+ 10 * np.cos(y2d * np.pi / 18)
+ 10 * np.sin((x2d + m * 30) * np.pi / 9)
+ 5 * np.cos(y2d * np.pi / 45 + m)
+ np.random.normal(scale=1.5, size=y2d.shape)
)
tiles.append(field)
# Масштабирование цветов
cmin = np.min(tiles)
cmax = np.max(tiles)
cmap = LinearColorMapper(palette="Turbo256", low=cmin, high=cmax)
# Начальный кадр
src = ColumnDataSource(data=dict(image=[tiles[0]]))
# График
fig = figure(x_range=(-180, 180), y_range=(-90, 90), width=800, height=400)
fig.image(image="image", x=-180, y=-90, dw=360, dh=180, color_mapper=cmap, source=src)
cb = ColorBar(color_mapper=cmap)
fig.add_layout(cb, 'right')
# Заголовок
hdr = Title(text="Monthly SST - Jan")
fig.add_layout(hdr, 'above')
# Кнопка
play_btn = Button(label="▶ Play", width=100)
# Преобразуем кадры во вложенные списки для JavaScript
nested_tiles = [t.tolist() for t in tiles]
# Обработчик на JavaScript
cb_js = CustomJS(args=dict(source=src, button=play_btn, title=hdr), code=f"""
if (!window._anim_sst2) {{
var frames = {nested_tiles};
var names = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
var idx = 0;
window._anim_sst2 = setInterval(function() {{
idx = (idx + 1) % frames.length;
source.data = Object.assign({{}}, source.data, {{image: [frames[idx]]}});
title.text = "Monthly SST - " + names[idx];
}}, 600);
button.label = "⏸ Pause";
}} else {{
clearInterval(window._anim_sst2);
window._anim_sst2 = null;
button.label = "▶ Play";
}}
""")
play_btn.js_on_click(cb_js)
output_file("sst_animation_working.html")
show(column(play_btn, fig))В чём настоящая проблема
Критический момент — там, где кадры превращаются во вложенные списки для JavaScript. Bokeh/BokehJS уже много лет не принимает вложенные списки как «псевдо‑2D» массивы. Глиф image ожидает массивы, которые Bokeh сериализует в типизированные массивы с информацией о форме; обычные вложенные списки теряют эту структуру на стороне JS. В итоге браузер получает данные, которые он не может применить к глифу изображения, и во время анимации визуально ничего не меняется.
Исправление: хранить массивы в ColumnDataSource и копировать по месяцам
Загрузите изображения всех месяцев в ColumnDataSource заранее — так Bokeh корректно их сериализует. Затем в колбэке CustomJS копируйте столбец выбранного месяца в столбец image, который управляет глифом.
import numpy as np
from bokeh.io import output_file, show
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource, LinearColorMapper, ColorBar, Button, CustomJS, Title
from bokeh.layouts import column
# Сетка
x_deg = np.linspace(-180, 180, 18)
y_deg = np.linspace(-90, 90, 9)
x2d, y2d = np.meshgrid(x_deg, y_deg)
# Генерируем 12 месяцев SST с изменчивостью
tiles = []
for m in range(12):
field = (
15
+ 10 * np.cos(y2d * np.pi / 18)
+ 10 * np.sin((x2d + m * 30) * np.pi / 9)
+ 5 * np.cos(y2d * np.pi / 45 + m)
+ np.random.normal(scale=1.5, size=y2d.shape)
)
tiles.append(field)
# Масштабирование цветов
cmin = np.min(tiles)
cmax = np.max(tiles)
cmap = LinearColorMapper(palette="Turbo256", low=cmin, high=cmax)
# Поместим все месяцы в источник данных, чтобы они сериализовались как типизированные массивы
labels = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
full_ds = {"image": [tiles[0]]}
for i, lab in enumerate(labels):
full_ds[lab] = [tiles[i]]
src = ColumnDataSource(data=full_ds)
# График
fig = figure(x_range=(-180, 180), y_range=(-90, 90), width=800, height=400)
fig.image(image="image", x=-180, y=-90, dw=360, dh=180, color_mapper=cmap, source=src)
cb = ColorBar(color_mapper=cmap)
fig.add_layout(cb, 'right')
# Заголовок
hdr = Title(text="Monthly SST - Jan")
fig.add_layout(hdr, 'above')
# Кнопка
play_btn = Button(label="▶ Play", width=100)
# JavaScript: копируем столбец месяца в `image`
cb_js = CustomJS(args=dict(source=src, button=play_btn, title=hdr), code="""
if (!window._anim_sst2) {
var names = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
var i = 0;
window._anim_sst2 = setInterval(function() {
i = (i + 1) % names.length;
var data_copy = Object.assign({}, source.data);
data_copy.image = source.data[names[i]]; // используем правильно сериализованные данные
source.data = data_copy; // при необходимости "подтолкнуть" перерисовку
title.text = "Monthly SST - " + names[i];
}, 600);
button.label = "⏸ Pause";
} else {
clearInterval(window._anim_sst2);
window._anim_sst2 = null;
button.label = "▶ Play";
}
""")
play_btn.js_on_click(cb_js)
output_file("sst_animation_fixed.html")
show(column(play_btn, fig))Почему это важно знать
Сбой происходит в BokehJS, то есть в браузере. Отладка через print на стороне Python его не покажет. Если что‑то меняется в Python, но не на холсте, переключайтесь на инструменты разработчика браузера и отлаживайте JavaScript, включая остановку на необработанных исключениях. Хранение данных в ColumnDataSource в виде массивов, которые Bokeh сериализует в типизированные, избавляет от целого класса клиентских ловушек и сводит обновление анимации к простому переназначению столбца.
Выводы
Не передавайте глифам image вложенные списки, ожидая поведения как у 2D‑массивов. Храните массивы кадров в ColumnDataSource, чтобы они сериализовались с информацией о форме, а затем в колбэке CustomJS копируйте выбранный месяц в активный столбец image. Если обновление будто игнорируется, спровоцируйте перерисовку, переустановив source.data. Для диагностики проблем исполнения Bokeh в первую очередь смотрите в браузер. Наличие под рукой минимального воспроизводимого примера помогает быстрее замечать и чинить такие сбои.
Статья основана на вопросе с StackOverflow от Eric Sánchez и ответе bigreddot.