Here’s an example implementation of both. Hope it helps.
Note that it may take a few seconds to load because Bokeh has to compile the TypeScript sources.
from base64 import b64decode
import cv2
import numpy as np
from bokeh.core.property.dataspec import AngleSpec
from bokeh.core.property.primitive import Bool, Int
from bokeh.io import curdoc
from bokeh.layouts import column, row
from bokeh.models import ImageRGBA, Button, ColumnDataSource
from bokeh.plotting import figure
from bokeh.util.compiler import TypeScript
class AnimatedImage(ImageRGBA):
animating = Bool(default=False)
delay = Int(default=100, help="Animation delay in milliseconds.")
__implementation__ = TypeScript("""
import {ImageRGBAData, ImageRGBAView, ImageRGBA} from "models/glyphs/image_rgba"
import {Class} from "core/class"
import * as p from "core/properties"
import {Context2d} from "core/util/canvas"
export interface AnimatedImageData extends ImageRGBAData {}
export interface AnimatedImageView extends ImageRGBAData {}
export class AnimatedImageView extends ImageRGBAView {
model: AnimatedImage
visuals: AnimatedImage.Visuals
protected _intervalID: number | null = null
protected _angle: number = 0
protected _angle_delta: number = 0.1
initialize(): void {
super.initialize()
this.connect(this.model.properties.animating.change, () => this.renderer.request_render())
this.connect(this.model.properties.delay.change, () => {
if (this._intervalID != null) {
clearInterval(this._intervalID)
this._setup_interval()
}
})
}
protected _setup_interval() {
this._intervalID = setInterval(() => {
this._angle += this._angle_delta
this.renderer.request_render()
}, this.model.delay)
}
protected _render(ctx: Context2d, indices: number[], {image_data, sx, sy, sw, sh}: AnimatedImageData): void {
const old_smoothing = ctx.getImageSmoothingEnabled()
ctx.setImageSmoothingEnabled(false)
ctx.globalAlpha = this.model.global_alpha
for (const i of indices) {
if (isNaN(sx[i] + sy[i] + sw[i] + sh[i]))
continue
const sxi = sx[i] | 0
const syi = sy[i] | 0
const y_offset = syi
ctx.translate(0, y_offset)
ctx.scale(1, -1)
ctx.translate(0, -y_offset)
ctx.translate(sxi + sw[i] / 2, syi + sh[i] / 2)
// Note that here the sign is different from the `RotatedImage`'s implementation.
// That's because of the way Bokeh transforms all values in the `AngleSpec` that's
// used by `RotatedImage`.
ctx.rotate(-this._angle)
ctx.drawImage(image_data[i], -sw[i] / 2, -sh[i] / 2, sw[i], sh[i])
ctx.rotate(this._angle)
ctx.translate(-(sxi + sw[i] / 2), -(syi + sh[i] / 2))
ctx.translate(0, y_offset)
ctx.scale(1, -1)
ctx.translate(0, -y_offset)
}
ctx.setImageSmoothingEnabled(old_smoothing)
if (!this.model.animating) {
if (this._intervalID != null) {
clearInterval(this._intervalID)
this._intervalID = null
}
} else if (this._intervalID == null) {
this._setup_interval()
}
}
}
export namespace AnimatedImage {
export type Attrs = p.AttrsOf<Props>
export type Props = ImageRGBA.Props & {
animating: p.Property<boolean>
delay: p.Property<number>
}
export type Visuals = ImageRGBA.Visuals
}
export interface AnimatedImage extends AnimatedImage.Attrs {}
export class AnimatedImage extends ImageRGBA {
properties: AnimatedImage.Props
default_view: Class<AnimatedImageView>
constructor(attrs?: Partial<AnimatedImage.Attrs>) {
super(attrs)
}
static init_AnimatedImage(): void {
this.prototype.default_view = AnimatedImageView
this.define<AnimatedImage.Props>({
animating: [ p.Boolean, false ],
delay: [ p.Int, 100 ],
})
}
}
""")
class RotatedImage(ImageRGBA):
angle = AngleSpec(units_default='deg')
__implementation__ = TypeScript("""
import {ImageRGBAData, ImageRGBAView, ImageRGBA} from "models/glyphs/image_rgba"
import {Class} from "core/class"
import {Arrayable} from "core/types"
import * as p from "core/properties"
import {Context2d} from "core/util/canvas"
export interface RotatedImageData extends ImageRGBAData {
_angle: Arrayable<number>
}
export interface RotatedImageView extends ImageRGBAData {}
export class RotatedImageView extends ImageRGBAView {
model: RotatedImage
visuals: RotatedImage.Visuals
initialize(): void {
super.initialize()
this.connect(this.model.properties.angle.change, () => this.renderer.request_render())
}
protected _render(ctx: Context2d, indices: number[], {image_data, sx, sy, sw, sh, _angle}: RotatedImageData): void {
const old_smoothing = ctx.getImageSmoothingEnabled()
ctx.setImageSmoothingEnabled(false)
ctx.globalAlpha = this.model.global_alpha
for (const i of indices) {
if (isNaN(sx[i] + sy[i] + sw[i] + sh[i]))
continue
const sxi = sx[i] | 0
const syi = sy[i] | 0
const y_offset = syi
ctx.translate(0, y_offset)
ctx.scale(1, -1)
ctx.translate(0, -y_offset)
ctx.translate(sxi + sw[i] / 2, syi + sh[i] / 2)
ctx.rotate(_angle[i])
ctx.drawImage(image_data[i], -sw[i] / 2, -sh[i] / 2, sw[i], sh[i])
ctx.rotate(-_angle[i])
ctx.translate(-(sxi + sw[i] / 2), -(syi + sh[i] / 2))
ctx.translate(0, y_offset)
ctx.scale(1, -1)
ctx.translate(0, -y_offset)
}
ctx.setImageSmoothingEnabled(old_smoothing)
}
}
export namespace RotatedImage {
export type Attrs = p.AttrsOf<Props>
export type Props = ImageRGBA.Props & {
angle: p.AngleSpec
}
export type Visuals = ImageRGBA.Visuals
}
export interface RotatedImage extends RotatedImage.Attrs {}
export class RotatedImage extends ImageRGBA {
properties: RotatedImage.Props
default_view: Class<RotatedImageView>
constructor(attrs?: Partial<RotatedImage.Attrs>) {
super(attrs)
}
static init_RotatedImage(): void {
this.prototype.default_view = RotatedImageView
this.define<RotatedImage.Props>({
angle: [ p.AngleSpec, 0 ],
})
}
}
""")
img_jpeg = b64decode(
'iVBORw0KGgoAAAANSUhEUgAAADIAAAAOBAMAAACfqVJUAAAAHlBMVEUAAAAAAwCKLY3jF17xWyICq65Qlc8js1T2qhmnzTbxGUeMAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAACVSURBVBjTpY89DsIwDIWfRRF087sB6sCMyhHIAbp078TMgroy9gq5LXYSooqOfYqsF3+Jf0BU6c9IcjvI6bUlJiDO78EJIXYVJRXptHEeRyP22PPC7Cy2EUcnKQOmkPrwHIHnpNQNWT5OvLi1oa7/LMB1ouZqqES0CUCXm6+Iz9aER9/5bKyYZZ9w7y91n0KKDjf86QvVrhIPSsvLCwAAAABJRU5ErkJggg==')
arr = np.fromstring(img_jpeg, np.uint8)
frame = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED)
img = frame[::-1].copy().view(dtype=np.uint32)
pw = ph = 100
ap = figure(aspect_ratio=1, x_range=(0, pw), y_range=(0, ph))
rp = figure(aspect_ratio=1, x_range=(0, pw), y_range=(0, ph))
ih, iw, _ = img.shape
ds = ColumnDataSource(data=dict(image=[img], dw=[iw], dh=[ih], x=[(pw - iw) / 2], y=[(ph - ih) / 2], angle=[0]))
ai = AnimatedImage(image='image', x='x', y='x', dw='dw', dh='dh', delay=10)
ap.add_glyph(ds, ai)
rp.add_glyph(ds, RotatedImage(image='image', x='x', y='x', dw='dw', dh='dh', angle='angle'))
angle_delta = 15
ab = Button(label='Start animation')
rb = Button(label=f'Rotate {angle_delta} degrees')
def toggle_animation():
# It is trivial to move this whole callback to the JS side by using `Button.js_on_click`.
ai.animating = not ai.animating
if ai.animating:
ab.label = 'Stop animation'
else:
ab.label = 'Start animation'
ab.on_click(toggle_animation)
def increate_rotation_angle():
# Same here - it could be moved completely to the JS side.
angle = [a + angle_delta for a in ds.data['angle']]
ds.patch(dict(angle=[(slice(len(angle)), angle)]))
rb.on_click(increate_rotation_angle)
curdoc().add_root(row(column(ap, ab), column(rp, rb)))