Weird behaviour on modified figures when using the Pan-/EditBox-Tools

[Bokeh 2.3.1]

Hi there,

I’m using multidimensional (image)data and want to plot the different dimensions against each other. For that I use image inside a figure. To display the images I set bounds to disallow panning away from the image. When I change the image/dimensions I adjust the figure's attributes aspect_ratio and x_range.start, x_range.end, x_range.bounds, same for y_range. I also adjust the images datasource: dw, dh and image.

Now here’s the problem: If I change the image (all of the above) and then use the boxedit tool, the bounds reset to the inital image’s bounds.
Normally I would think that I screwed up somewhere/am misusing Bokeh, but:
This does not happen if you pan around on the first image, change the image and then use the boxedit…

Either way, I could use some help (a workaround possibly).

Example code
import numpy as np
from bokeh.io import curdoc
from bokeh.layouts import column, row
from bokeh.models import Button, DataRange1d, BoxEditTool, ColumnDataSource
from bokeh.plotting import figure

xs = 10
ys = 10
data = np.random.rand(xs, ys)

source = ColumnDataSource(data=dict(image=[data], dw=[xs], dh=[ys]))

x_range = DataRange1d(start=0, end=xs, min_interval=1, range_padding=0, bounds=(0, xs))
y_range = DataRange1d(start=0, end=ys, min_interval=1, range_padding=0, bounds=(0, ys))

plot = figure(tools=["pan"], toolbar_location="above", x_range=x_range, y_range=y_range, aspect_ratio=xs/ys)

img = plot.image(source=source, x=0, y=0, dw="dw", dh="dh")

roi = plot.rect('x', 'y', 'width', 'height',
                 source=ColumnDataSource(data=dict(x=[], y=[], width=[], height=[])))

be = BoxEditTool(num_objects=1, renderers=[roi])
plot.add_tools(be)

def set_image():
    new_img = np.random.rand(13, 5)
    sx, sy = new_img.shape

    source.data['dw'] = [sx]
    source.data['dh'] = [sy]
    source.data['image'] = [new_img]
    plot.aspect_ratio = sx/sy
    plot.x_range.start = 0
    plot.x_range.end = sx
    plot.x_range.bounds = (0, sx)
    plot.y_range.start = 0
    plot.y_range.end = sy
    plot.y_range.bounds = (0, sy)


btn = Button(name="New Image")

btn.on_click(set_image)
curdoc().add_root(row(plot, btn))

Edit: Clip that shows my problem Gyazo
Clip that shows a way to avoid the problem with panning first: Gyazo

@kaprisonne I’ll have to look more in detail later but I should first note that the aspect ratio setting does nothing in your case. Aspect control is only active when ranges are auto-scaling. If you set explicit range start/end values, then Bokeh assumes you know what you want, and will not override those values for any reason.

Similarly range_padding is what is added to automatically computed ranges. If you are manually setting range start and, there is nothing to pad, and it has no effect.

I’ll also note that it is always considered best practice to update the .data dict “all at once”, e.g source.data = new_data, rather that mutating individual columns.

Edit: Please, also, always include version information in any request for help.

Here is a version with those changes that does not seem to suffer those changes.

FWIW The benefit of a highly compositional system like Bokeh is the power to assemble many different combinations of features. The drawback is always that some combinations don’t make sense at all, and it is not always simple to provide meaningful feedback in those situations. That seems to be the case here.

import numpy as np
from bokeh.io import curdoc
from bokeh.layouts import row
from bokeh.models import Button, BoxEditTool, ColumnDataSource
from bokeh.plotting import figure

xs = 10
ys = 10
data = np.random.rand(xs, ys)

source = ColumnDataSource(data=dict(image=[data], dw=[xs], dh=[ys]))

plot = figure(tools=["pan"], toolbar_location="above", x_range=(0, xs), y_range=(0, ys))
plot.x_range.bounds = (0, xs)
plot.y_range.bounds = (0, ys)

img = plot.image(source=source, x=0, y=0, dw="dw", dh="dh")

roi = plot.rect('x', 'y', 'width', 'height',
                 source=ColumnDataSource(data=dict(x=[], y=[], width=[], height=[])))

be = BoxEditTool(num_objects=1, renderers=[roi])
plot.add_tools(be)

def set_image():
    new_img = np.random.rand(13, 5)
    sx, sy = new_img.shape

    source.data = dict(image=[new_img], dw=[sx], dh=[sy])
    plot.x_range.start = 0
    plot.x_range.end = sx
    plot.x_range.bounds = (0, sx)
    plot.y_range.start = 0
    plot.y_range.end = sy
    plot.y_range.bounds = (0, sy)

btn = Button(name="New Image")

btn.on_click(set_image)
curdoc().add_root(row(plot, btn))

@Bryan Sorry about that; I added the version to the original post (Bokeh 2.3.1).

Aspect control is only active when ranges are auto-scaling.

I was a bit confused here as the plots are different with and w/o the aspect ratio set.

Thank you, that looks great. How can I make sure the “pixels” are proper squares without aspect_ratio? Do I have to change the plot size explicitly to achieve this?

If you explicitly managing ranges, then this is the only option. Unfortunately, that can be a bit tedious. You only have direct control over the entire canvas width and height and that include the axes (which can also resize to accommodate long labels) and toolbar. So you will have to set things like plot.min_border_left to make sure the padding is large enough to accommodate the largest size thos things might be, and then adjust the overall canvas width and height to accommodate the padding such that the central frame area has the aspect/dimensions you want.

You have have better like using a DataRange1d without setting start and end, and with range padding zero, and then using "Auto" bounds:

By default, the bounds will be None, allowing your plot to pan/zoom as far as you want. If bounds are ‘auto’ they will be computed to be the same as the start and end of the DataRange1d.

That’s what I thought, but it’s not too bad.

Solution
import numpy as np
from bokeh.io import curdoc
from bokeh.layouts import row
from bokeh.models import Button, ColumnDataSource, DataRange1d
from bokeh.plotting import figure

source = ColumnDataSource(data=dict(image=[], dw=[], dh=[]))
x_range = DataRange1d(range_padding=0, bounds="auto")
y_range = DataRange1d(range_padding=0, bounds="auto")
plot = figure(tools=["pan"], toolbar_location="above", x_range=x_range, y_range=y_range)
img = plot.image(source=source, x=0, y=0, dw="dw", dh="dh")

PREF_HEIGHT = 800
MAX_WIDTH = 1200
flip = True
def set_image():
    global flip
    _shape = (2, 20)
    if flip:
        _shape = _shape[::-1]
    flip = not flip

    new_img = np.random.rand(*_shape)
    sy, sx = new_img.shape

    x_ratio = sx/sy
    height = PREF_HEIGHT
    width = height*x_ratio
    if width > MAX_WIDTH:
        height = round(height * MAX_WIDTH/width)
        width = 1200
    width = round(width)

    plot.min_border_left = 50
    plot.min_border_bottom = 50
    plot.min_border_top = 50
    plot.plot_width = width + plot.min_border_left
    plot.plot_height = height + plot.min_border_bottom + plot.min_border_top

    plot.x_range.start = 0
    plot.x_range.end = sx
    plot.x_range.bounds = (0, sx)
    plot.y_range.start = 0
    plot.y_range.end = sy
    plot.y_range.bounds = (0, sy)

    print(plot.inner_width)
    print(plot.inner_height)

    source.data = dict(image=[new_img], dw=[sx], dh=[sy])


btn = Button(name="New Image")
btn.on_click(set_image)
curdoc().add_root(row(plot, btn))

I’m not entirely sure how I should use DataRange1d here though, but that’d just be min-maxing LOC, anyway.
Thank you for clarifying my confusion & your time.

1 Like