Bokeh image format standards and interoperability

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)))