Tap stream callback called multiple times for a single event

I’ve been trying to build a tool that helps you manipulate heatpmaps / pictures. I’ve simplified my code to just the minimum that reproduces the issues.

This tool offsets the pictures to put the clicked point on the top left corner. (finer relative controls are also provided through sliders)

The issues is that the callback ends up called twice on every click for unknown reasons. A larger issue is that if I try to “memorize” the requested offsets to allow more than the first click to work (see commented lines), then the callback is called 60+ times (85 usually) and the app crashes.
I’d like to understand why…

A tangential question, if anyone knows; when set to the “line” mode, I’d like a line to be dynamically drawn to the hovering cursor until the second click. polygon doesn’t seem to work in this setup, any ideas?

import numpy as np
import param
import panel as pn
import holoviews as hv
pn.extension()
hv.extension('bokeh')

class ImageClicker(pn.viewable.Viewer):
    x_offset = param.Integer(default=0, bounds=(-400, 400), step=1)
    y_offset = param.Integer(default=0, bounds=(-400, 400), step=1)
    tool_mode = param.Selector(default="Offset", objects=["None", "Offset", "Line"], doc="")

    offset = param.Integer(default=0)
    
    def __init__(self, results=None,  **params):
        super().__init__(**params)

        # Initialize image array
        self.raster_row = 400
        self.raster_col = 400
        array = np.zeros([self.raster_row, self.raster_col])
        array[150:250, 150:250] = 1
        array.flatten()
        # Randomly roll the array
        self.array = np.roll(array, 58742)

        # initialize internal values
        self.delta_offset = int(0)
        self.tool_offset = int(0)
        
        # Panel for display
        self._main_display = pn.pane.HoloViews(
                sizing_mode="stretch_both",
                min_width=500,
                #max_width= 3000,
                #min_height=self._min_height,
            )

        # Controls for relative offsets
        offset_ctrl = pn.Column(
            pn.Param(self.param.x_offset, widgets={
                "x_offset": {"type": pn.widgets.EditableIntSlider, "tooltips": True,
                                       "width": 200}}),
            pn.Param(self.param.y_offset, widgets={
                "y_offset": {"type": pn.widgets.EditableIntSlider, "tooltips": True,
                                       "width": 200}}), 
            pn.Param(self.param.tool_mode, widgets={
                "tool_mode": {"type": pn.widgets.Select, "width": 200}}), 
            sizing_mode="stretch_both")

        self.display = pn.pane.Markdown("")

        self.global_tab = pn.Column(offset_ctrl,self.display, sizing_mode="stretch_width", height=500) 
        

        # Set Tap dynamics
        self.tap_image = hv.streams.Tap()
        self._main_display_tools = hv.DynamicMap(self._react_to_click, streams=[self.tap_image])
        
        self.image_display = hv.DynamicMap(
            pn.bind(self._display_image, 
                    self.param.offset)
        )
        # Setup figure
        self._main_display.object = (
            (self.image_display * self._main_display_tools).opts(width=500, height = 500)
        )

        # App layout
        self.layout=pn.Row(self.global_tab, self._main_display, sizing_mode='stretch_both')

        # some counters to help out
        self.count = 0
        self.current_point = None
    
    def _react_to_click(self, x, y):
        if hasattr(self, 'tool_offset'):
            if x is None:
                x = 0
            if y is None:
                y=0
    
            if self.tool_mode == "None":
                # Do nothing
                return hv.Points([])
            elif self.tool_mode =="Offset":
                dy = self.raster_row - int(y)

                # This results in 60+ calls somehow
                # Uncomment me!!
                #self.tool_offset += self.convert_offset(-int(x), -dy)

                # This results in 2 calls
                # Comment me!!
                self.tool_offset = self.convert_offset(-int(x), -dy)
                
                self.offset = int(self.delta_offset+self.tool_offset)
                self.count += 1
                self.display.object=str(self.count )
                return hv.Points([])
            
            elif self.tool_mode == "Line":
                if self.current_point is None:
                    self.current_point = [x, y]
                    return hv.Points((x, y))
                else:
                    # Calculate distance between self.current_point and [x,y] 
                    # ...
                    self.current_point = None
                    return hv.Points([])

        else:
            return hv.Points([])

    @param.depends('x_offset', 'y_offset', watch=True)
    def update_offset(self):
        self.delta_offset = self.convert_offset(self.x_offset, -self.y_offset)
        self.offset = int(self.delta_offset+self.tool_offset)

    def convert_offset(self, x_offset, y_offset):
        n_pixel_per_row = self.raster_col
        return int(x_offset+n_pixel_per_row*y_offset)
    
    def _display_image(self, offset):
            array = np.roll(self.array, offset)
            array.reshape(self.raster_row, self.raster_col)
            
            return hv.Image((np.arange(self.raster_col), np.arange(self.raster_row)[::-1], array)).opts(cmap='cwr')
    
    
    def __panel__(self):
        return self.layout

Any help is appreciated!

Skimming it now…

I think these can be simplified to

pn.widgets.EditableIntSlider.from_param(self.param.x_offset)

That might be an issue, because pn.Param is supposedly to be used on all params simultaneously (e.g. pn.Param(self) not individual params)

I think there’s three triggers:

  1. Tap → x_offset/y_offset → offset

Maybe you can reduce it down to

  1. Tap → offset

I can’t identify the issue from looking at it right now, but maybe you can batch call some of these and see if Developer Experience — Panel v1.6.1 helps.

Also, import traceback, traceback.print_stack() inside the callbacks to see what’s causing what.

Updated the widgets call, but it didn’t make any difference. I’ve taken the habit of calling them this way I believe to have greater control on some display details when I first started using Panel… I should revisit this.

I want to have more than one way of offsetting the image. While it may seem trivial here, the coarse/precise offset are useful in my actual app. I see 2 paths:
x_offset/y_offset → offset
tap → offset

I believe tap does not impact x_offset or y_offset. If it does, I’d like to understand why.

I’ll check traceback; I didn’t know how to access this

Thanks!

Can you share your latest code, and maybe I can take a look.

Skimming your code, everything looks right to me and follows most of the best practices I know about

Hi,

here’s the updated code. I’ve also removed an inconsequential part of the code and added a part to be able to call panel serve and traceback. I’ve tried to understand the log without much success so far.

Thanks!

import numpy as np
import param
import panel as pn
import holoviews as hv
import traceback

pn.extension()
hv.extension('bokeh')

class ImageClicker(pn.viewable.Viewer):
    x_offset = param.Integer(default=0, bounds=(-400, 400), step=1)
    y_offset = param.Integer(default=0, bounds=(-400, 400), step=1)
    tool_mode = param.Selector(default="Offset", objects=["None", "Offset", "Line"], doc="")

    offset = param.Integer(default=0)
    
    def __init__(self, results=None,  **params):
        super().__init__(**params)

        # Initialize image array
        self.raster_row = 400
        self.raster_col = 400
        array = np.zeros([self.raster_row, self.raster_col])
        array[150:250, 150:250] = 1
        array.flatten()
        # Randomly roll the array
        self.array = np.roll(array, 58742)

        # initialize internal values
        self.delta_offset = int(0)
        self.tool_offset = int(0)
        
        # Panel for display
        self._main_display = pn.pane.HoloViews(
                sizing_mode="stretch_both",
                min_width=500,
                #max_width= 3000,
                #min_height=self._min_height,
            )

        # Controls for relative offsets
        offset_ctrl = pn.Column(
            pn.widgets.EditableIntSlider.from_param(self.param.x_offset, tooltips=True,
                width=200),
            pn.widgets.EditableIntSlider.from_param(self.param.y_offset),
            pn.widgets.Select.from_param(self.param.tool_mode))

        self.display = pn.pane.Markdown("")

        self.global_tab = pn.Column(offset_ctrl,self.display, sizing_mode="stretch_width", height=500) 
        

        # Set Tap dynamics
        self.tap_image = hv.streams.Tap()
        self._main_display_tools = hv.DynamicMap(self._react_to_click, streams=[self.tap_image])
        
        self.image_display = hv.DynamicMap(
            pn.bind(self._display_image, 
                    self.param.offset)
        )
        # Setup figure
        self._main_display.object = (
            (self.image_display * self._main_display_tools).opts(width=500, height = 500)
        )

        # App layout
        self.layout=pn.Row(self.global_tab, self._main_display, sizing_mode='stretch_both')

        # some counters to help out
        self.count = 0
        self.current_point = None
    
    def _react_to_click(self, x, y):
        if hasattr(self, 'tool_offset'):
            if x is None:
                x = 0
            if y is None:
                y=0

            dy = self.raster_row - int(y)

            # This results in 60+ calls somehow
            # Uncomment me!!
            self.tool_offset += self.convert_offset(-int(x), -dy)

            # This results in 2 calls
            # Comment me!!
            #self.tool_offset = self.convert_offset(-int(x), -dy)
            
            self.offset = int(self.delta_offset+self.tool_offset)
            self.count += 1
            self.display.object=str(self.count )

            traceback.print_stack()
            
            return hv.Points([])
            
        else:
            return hv.Points([])

    @param.depends('x_offset', 'y_offset', watch=True)
    def update_offset(self):
        self.delta_offset = self.convert_offset(self.x_offset, -self.y_offset)
        self.offset = int(self.delta_offset+self.tool_offset)

    def convert_offset(self, x_offset, y_offset):
        n_pixel_per_row = self.raster_col
        return int(x_offset+n_pixel_per_row*y_offset)
    
    def _display_image(self, offset):
            array = np.roll(self.array, offset)
            array.reshape(self.raster_row, self.raster_col)
            
            return hv.Image((np.arange(self.raster_col), np.arange(self.raster_row)[::-1], array)).opts(cmap='cwr')
    
    
    def __panel__(self):
        return self.layout
        
        

if pn.state.served:

    panel_app = ImageClicker()

    panel_app.servable()

I found a (inconvenient) work around.

If I go back to using something like below and add within the callback a forceful change of the selected tool mode (breaking the Tap update loop); it works. But you have to reselect the tool type you want each time.

From the looks of it, when I update offset, it triggers an update to the display and that update re-launch the tap trigger. This loops quite a few times if offset is used to modify offset (self.offset += x)

if self.tool_mode == "None":
                # Do nothing
                return hv.Points([])
            elif self.tool_mode =="Offset":
                self.tool_mode = "None" # <---------- New line
                dy = self.raster_row - int(y)

                # This results in 60+ calls somehow
                # Uncomment me!!
                #self.tool_offset += self.convert_offset(-int(x), -dy)

                # This results in 2 calls
                # Comment me!!
                self.tool_offset = self.convert_offset(-int(x), -dy)
                
                self.offset = int(self.delta_offset+self.tool_offset)
                self.count += 1
                self.display.object=str(self.count )
                return hv.Points([])
            
            elif self.tool_mode == "Line":

                if self.current_point is None:
                    self.current_point = [x, y]
                    return hv.Points((x, y))
                else:
                    self.tool_mode = "None" # <---------- New line
                    # Calculate distance between self.current_point and [x,y] 
                    # ...
                    self.current_point = None
                    return hv.Points([])

The issue is that _react_to_click is triggering _display_image and potentially affecting x and y

One way to fix is add a self._updating = True:

import numpy as np
import param
import panel as pn
import holoviews as hv
import traceback

pn.extension()
hv.extension("bokeh")


class ImageClicker(pn.viewable.Viewer):
    x_offset = param.Integer(default=0, bounds=(-400, 400), step=1)
    y_offset = param.Integer(default=0, bounds=(-400, 400), step=1)
    tool_mode = param.Selector(
        default="Offset", objects=["None", "Offset", "Line"], doc=""
    )

    offset = param.Integer(default=0)

    def __init__(self, results=None, **params):
        super().__init__(**params)

        # Initialize image array
        self.raster_row = 400
        self.raster_col = 400
        array = np.zeros([self.raster_row, self.raster_col])
        array[150:250, 150:250] = 1
        array.flatten()
        # Randomly roll the array
        self.array = np.roll(array, 58742)

        # initialize internal values
        self.delta_offset = int(0)
        self.tool_offset = int(0)
        # Flag to prevent recursion
        self._updating = False

        # Panel for display
        self._main_display = pn.pane.HoloViews(
            sizing_mode="stretch_both",
            min_width=500,
            # max_width= 3000,
            # min_height=self._min_height,
        )

        # Controls for relative offsets
        offset_ctrl = pn.Column(
            pn.widgets.EditableIntSlider.from_param(
                self.param.x_offset, tooltips=True, width=200
            ),
            pn.widgets.EditableIntSlider.from_param(self.param.y_offset),
            pn.widgets.Select.from_param(self.param.tool_mode),
        )

        self.display = pn.pane.Markdown("")

        self.global_tab = pn.Column(
            offset_ctrl, self.display, sizing_mode="stretch_width", height=500
        )

        # Set Tap dynamics
        self.tap_image = hv.streams.Tap()
        self._main_display_tools = hv.DynamicMap(
            self._react_to_click, streams=[self.tap_image]
        )

        self.image_display = hv.DynamicMap(
            pn.bind(self._display_image, self.param.offset)
        )
        # Setup figure
        self._main_display.object = (
            self.image_display * self._main_display_tools
        ).opts(width=500, height=500)

        # App layout
        self.layout = pn.Row(
            self.global_tab, self._main_display, sizing_mode="stretch_both"
        )

        # some counters to help out
        self.count = 0
        self.current_point = None

    def _react_to_click(self, x, y):
        # Prevent reacting to click if we're already updating
        if self._updating or self.tool_mode == "None":
            return hv.Points([])
            
        print(f"Click at: {x}, {y}")
        
        if x is None or y is None:
            return hv.Points([])

        # Set updating flag to prevent recursion
        self._updating = True
        
        try:
            dy = self.raster_row - int(y)
            
            # Use this for cumulative updates
            if self.tool_mode == "Offset":
                # Calculate the new offset based on click position
                click_offset = self.convert_offset(-int(x), -dy)
                
                # Update the tool offset directly
                self.tool_offset += click_offset
                
                # Update the total offset once
                self.offset = int(self.delta_offset + self.tool_offset)
                
                self.count += 1
                self.display.object = f"Click count: {self.count}, Offset: {self.offset}"
        finally:
            # Always reset the updating flag
            self._updating = False
            
        return hv.Points([])

    @param.depends("x_offset", "y_offset", watch=True)
    def update_offset(self):
        # Prevent recursion
        if self._updating:
            return
            
        self._updating = True
        try:
            self.delta_offset = self.convert_offset(self.x_offset, -self.y_offset)
            self.offset = int(self.delta_offset + self.tool_offset)
        finally:
            self._updating = False

    def convert_offset(self, x_offset, y_offset):
        n_pixel_per_row = self.raster_col
        return int(x_offset + n_pixel_per_row * y_offset)

    def _display_image(self, offset):
        # Simple display function that doesn't modify state
        array = np.roll(self.array, offset)
        return hv.Image(
            (np.arange(self.raster_col), np.arange(self.raster_row)[::-1], array)
        ).opts(cmap="cwr")

    def __panel__(self):
        return self.layout


panel_app = ImageClicker()
panel_app.servable()

Although since you just return an empty hv.Points, I think a cleaner solution would be to combine those into one

import numpy as np
import param
import panel as pn
import holoviews as hv

pn.extension()
hv.extension("bokeh")


class ImageClicker(pn.viewable.Viewer):
    x_offset = param.Integer(default=0, bounds=(-400, 400), step=1)
    y_offset = param.Integer(default=0, bounds=(-400, 400), step=1)
    tool_mode = param.Selector(
        default="Offset", objects=["None", "Offset", "Line"], doc=""
    )

    def __init__(self, results=None, **params):
        super().__init__(**params)

        # Initialize image array
        self.raster_row = 400
        self.raster_col = 400
        array = np.zeros([self.raster_row, self.raster_col])
        array[150:250, 150:250] = 1
        # Randomly roll the array
        self.array = np.roll(array, 58742)

        # initialize internal values
        self.delta_offset = int(0)
        self.tool_offset = int(0)
        self.offset = int(0)

        # Panel for display
        self._main_display = pn.pane.HoloViews(
            sizing_mode="stretch_both",
            min_width=500,
        )

        # Controls for relative offsets
        offset_ctrl = pn.Column(
            pn.widgets.EditableIntSlider.from_param(
                self.param.x_offset, tooltips=True, width=200
            ),
            pn.widgets.EditableIntSlider.from_param(self.param.y_offset),
            pn.widgets.Select.from_param(self.param.tool_mode),
        )

        self.display = pn.pane.Markdown("")

        self.global_tab = pn.Column(
            offset_ctrl, self.display, sizing_mode="stretch_width", height=500
        )

        # Set Tap dynamics with the combined handler
        self.tap_image = hv.streams.Tap()
        self.combined_map = hv.DynamicMap(
            self._combined_handler, streams=[self.tap_image]
        )

        # Setup figure
        self._main_display.object = self.combined_map.opts(width=500, height=500)

        # App layout
        self.layout = pn.Row(
            self.global_tab, self._main_display, sizing_mode="stretch_both"
        )

        # Click counter
        self.count = 0

    def _combined_handler(self, x=None, y=None):
        # Process click if it's valid and tool is enabled
        if x is not None and y is not None and self.tool_mode != "None":
            dy = self.raster_row - int(y)
            click_offset = self.convert_offset(-int(x), -dy)
            
            # Update the tool offset directly
            self.tool_offset += click_offset
            
            # Update the click counter
            self.count += 1
            self.display.object = f"Click count: {self.count}, Offset: {self.offset}"
        
        # Apply current offsets
        self.delta_offset = self.convert_offset(self.x_offset, -self.y_offset)
        self.offset = int(self.delta_offset + self.tool_offset)
        
        # Generate the image with current offset
        array = np.roll(self.array, self.offset)
        image = hv.Image(
            (np.arange(self.raster_col), np.arange(self.raster_row)[::-1], array)
        ).opts(cmap="cwr")
        
        # Add empty points layer for the click handler
        points = hv.Points([])
        
        # Return the combined visualization
        return image * points

    def convert_offset(self, x_offset, y_offset):
        n_pixel_per_row = self.raster_col
        return int(x_offset + n_pixel_per_row * y_offset)

    @param.depends("x_offset", "y_offset", "tool_mode", watch=True)
    def update_display(self):
        # Force a redraw when these parameters change
        self._main_display.object = self.combined_map

    def __panel__(self):
        return self.layout


panel_app = ImageClicker()
panel_app.servable()

That’s an interesting idea, thanks!

There’s a mode in which I actually need to return a point, so I can’t combine them; but the other solution seems to work!

Thanks again!