Repository: cta-observatory/ctapipe
Pull request #1863 (2022-03-30 10:19:35)

Use write_table for image statistics
This was the last table in dl1 output that was still using the astropy mechanism. As of now, we don't have any `table_column_meta` tables in the output


Patch:
diff --git a/.pylintrc b/.pylintrc
index 05f6e08e..5dbceccf 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -425,8 +425,7 @@ good-names=i,
            x,
            y,
            n,
-           f,
-           r,
+		   f,
            ex,
            _
 
diff --git a/ctapipe/calib/camera/flatfield.py b/ctapipe/calib/camera/flatfield.py
index 3724e960..f9504c02 100644
--- a/ctapipe/calib/camera/flatfield.py
+++ b/ctapipe/calib/camera/flatfield.py
@@ -120,7 +120,7 @@ class FlasherFlatFieldCalculator(FlatFieldCalculator):
        waveform time
 
 
-     Parameters
+     Parameters:
      ----------
      charge_cut_outliers : List[2]
          Interval of accepted charge values (fraction with respect to camera median value)
diff --git a/ctapipe/io/toymodel.py b/ctapipe/io/toymodel.py
index 935f6560..24993784 100644
--- a/ctapipe/io/toymodel.py
+++ b/ctapipe/io/toymodel.py
@@ -129,10 +129,7 @@ class ToyEventSource(EventSource, TelescopeComponent):
             width = self.calc_width(eccentricity, length)
 
             psi = self.rng.uniform(0, 360)
-            shower_area_ratio = (
-                2 * np.pi * width * length / cam.pix_area.mean().to_value(u.m ** 2)
-            )
-            intensity = self.rng.poisson(50) * shower_area_ratio
+            intensity = self.rng.poisson(int(1e5 * width * length))
             skewness = self.rng.uniform(
                 self.min_skewness.tel[tel_id], self.max_skewness.tel[tel_id]
             )
diff --git a/ctapipe/plotting/__init__.py b/ctapipe/plotting/__init__.py
new file mode 100644
index 00000000..bd0aa1b6
--- /dev/null
+++ b/ctapipe/plotting/__init__.py
@@ -0,0 +1,2 @@
+"""Module to handle common plots.
+"""
diff --git a/ctapipe/plotting/bokeh_event_viewer.py b/ctapipe/plotting/bokeh_event_viewer.py
new file mode 100644
index 00000000..c17e30f1
--- /dev/null
+++ b/ctapipe/plotting/bokeh_event_viewer.py
@@ -0,0 +1,419 @@
+from bokeh.layouts import layout, column
+from bokeh.models import Select
+from ctapipe.core import Component
+from ctapipe.visualization.bokeh import CameraDisplay, WaveformDisplay
+
+
+class BokehEventViewerCamera(CameraDisplay):
+    def __init__(self, event_viewer, fig=None):
+        """
+        A `ctapipe.visualization.bokeh.CameraDisplay` modified to utilise a
+        `ctapipe.core.container.ArrayEventContainer` directly.
+
+        Parameters
+        ----------
+        event_viewer : BokehEventViewer
+            The BokehEventViewer this object belongs to
+        fig : bokeh.plotting.figure
+            Figure to store the bokeh plot onto (optional)
+        """
+        self._event = None
+        self._view = "r0"
+        self._telid = None
+        self._channel = 0
+        self._time = 0
+        super().__init__(fig=fig)
+
+        self._view_options = {
+            "r0": lambda e, t, c, time: e.r0.tel[t].waveform[c, :, time],
+            "r1": lambda e, t, c, time: e.r1.tel[t].waveform[:, time],
+            "dl0": lambda e, t, c, time: e.dl0.tel[t].waveform[:, time],
+            "dl1": lambda e, t, c, time: e.dl1.tel[t].image[:],
+            "peak_time": lambda e, t, c, time: e.dl1.tel[t].peak_time[:],
+        }
+
+        self.w_view = None
+        self._geom_tel = None
+
+        self.event_viewer = event_viewer
+
+    def _reset(self):
+        self.reset_pixels()
+        self.event_viewer.change_time(0)
+
+    def _set_image(self):
+        e = self.event
+        v = self.view
+        t = self.telid
+        c = self.channel
+        time = self.time
+        if not e:
+            self.event_viewer.log.warning("No event has been provided")
+            return
+
+        tels = list(e.r0.tel.keys())
+        if t is None:
+            t = tels[0]
+        if t not in tels:
+            raise KeyError(f"Telescope {t} has no data")
+
+        try:
+            self.image = self._view_options[v](e, t, c, time)
+            self.fig.title.text = f"{v} (T = {time})"
+        except TypeError:
+            self.image = None
+
+    def _update_geometry(self):
+        e = self.event
+        t = self.telid
+        if e:
+            # Check if geom actually needs to be changed
+            if not t == self._geom_tel:
+                self.geom = self.event_viewer.subarray.tel[t].camera.geometry
+                self._geom_tel = t
+        else:
+            self.event_viewer.log.warning("No event has been provided")
+
+    def refresh(self):
+        self._set_image()
+
+    @property
+    def event(self):
+        return self._event
+
+    @event.setter
+    def event(self, val):
+        self._event = val
+        self._update_geometry()
+        self._set_image()
+
+    def change_event(self, event, telid):
+        if self.event:  # Only reset when an event exists
+            self._reset()
+        self._telid = telid
+        self.event = event
+
+    @property
+    def view(self):
+        return self._view
+
+    @view.setter
+    def view(self, val):
+        if val not in list(self._view_options.keys()):
+            raise ValueError(f"View is not valid: {val}")
+        self._view = val
+        self._set_image()
+
+    @property
+    def telid(self):
+        return self._telid
+
+    @telid.setter
+    def telid(self, val):
+        if self.event:  # Only reset when an event exists
+            self._reset()
+        self._telid = val
+        self._update_geometry()
+        self._set_image()
+
+    @property
+    def channel(self):
+        return self._channel
+
+    @channel.setter
+    def channel(self, val):
+        self._channel = val
+        self._set_image()
+
+    @property
+    def time(self):
+        return self._time
+
+    @time.setter
+    def time(self, val):
+        self._time = int(val)
+        self._set_image()
+
+    def _on_pixel_click(self, pix_id):
+        super()._on_pixel_click(pix_id)
+        ai = self.active_index
+        self.event_viewer.waveforms[ai].pixel = pix_id
+
+    def create_view_widget(self):
+        self.w_view = Select(title="View:", value="", options=[], width=5)
+        self.w_view.on_change("value", self.on_view_widget_change)
+        self.layout = column([self.w_view, self.layout])
+
+    def update_view_widget(self):
+        self.w_view.options = list(self._view_options.keys())
+        self.w_view.value = self.view
+
+    def on_view_widget_change(self, _, __, ___):
+        if self.view != self.w_view.value:
+            self.view = self.w_view.value
+
+
+class BokehEventViewerWaveform(WaveformDisplay):
+    def __init__(self, event_viewer, fig=None):
+        """
+        A `ctapipe.visualization.bokeh.WaveformDisplay` modified to utilise a
+        `ctapipe.core.container.ArrayEventContainer` directly.
+
+        Parameters
+        ----------
+        event_viewer : BokehEventViewer
+            The BokehEventViewer this object belongs to
+        fig : bokeh.plotting.figure
+            Figure to store the bokeh plot onto (optional)
+        """
+        self._event = None
+        self._view = "r0"
+        self._telid = None
+        self._channel = 0
+        self._pixel = 0
+        super().__init__(fig=fig)
+
+        self._view_options = {
+            "r0": lambda e, t, c, p: e.r0.tel[t].waveform[c, p],
+            "r1": lambda e, t, c, p: e.r1.tel[t].waveform[p],
+            "dl0": lambda e, t, c, p: e.dl0.tel[t].waveform[p],
+        }
+
+        self.w_view = None
+
+        self.event_viewer = event_viewer
+
+    def _reset(self):
+        for wav in self.event_viewer.waveforms:
+            wav.pixel = 0
+
+    def _set_waveform(self):
+        e = self.event
+        v = self.view
+        t = self.telid
+        c = self.channel
+        p = self.pixel
+        if not e:
+            self.event_viewer.log.warning("No event has been provided")
+            return
+
+        tels = list(e.r0.tel.keys())
+        if t is None:
+            t = tels[0]
+        if t not in tels:
+            raise KeyError(f"Telescope {t} has no data")
+
+        try:
+            self.waveform = self._view_options[v](e, t, c, p)
+            self.fig.title.text = f"{v} (Pixel = {p})"
+        except TypeError:
+            self.waveform = None
+
+    def refresh(self):
+        self._set_waveform()
+
+    @property
+    def event(self):
+        return self._event
+
+    @event.setter
+    def event(self, val):
+        self._event = val
+        self._set_waveform()
+
+    def change_event(self, event, telid):
+        if self.event:  # Only reset when an event exists
+            self._reset()
+        self._telid = telid
+        self.event = event
+
+    @property
+    def view(self):
+        return self._view
+
+    @view.setter
+    def view(self, val):
+        if val not in list(self._view_options.keys()):
+            raise ValueError(f"View is not valid: {val}")
+        self._view = val
+        self._set_waveform()
+
+    @property
+    def telid(self):
+        return self._telid
+
+    @telid.setter
+    def telid(self, val):
+        if self.event:  # Only reset when an event exists
+            self._reset()
+        self._telid = val
+        self._set_waveform()
+
+    @property
+    def channel(self):
+        return self._channel
+
+    @channel.setter
+    def channel(self, val):
+        self._channel = val
+        self._set_waveform()
+
+    @property
+    def pixel(self):
+        return self._pixel
+
+    @pixel.setter
+    def pixel(self, val):
+        self._pixel = val
+        self._set_waveform()
+
+    def _on_waveform_click(self, time):
+        super()._on_waveform_click(time)
+        self.event_viewer.change_time(time)
+
+    def create_view_widget(self):
+        self.w_view = Select(title="View:", value="", options=[], width=5)
+        self.w_view.on_change("value", self.on_view_widget_change)
+        self.layout = column([self.w_view, self.layout])
+
+    def update_view_widget(self):
+        self.w_view.options = list(self._view_options.keys())
+        self.w_view.value = self.view
+
+    def on_view_widget_change(self, _, __, ___):
+        if self.view != self.w_view.value:
+            self.view = self.w_view.value
+
+
+class BokehEventViewer(Component):
+    def __init__(
+        self,
+        subarray,
+        config=None,
+        parent=None,
+        num_cameras=1,
+        num_waveforms=2,
+        **kwargs,
+    ):
+        """
+        A class to organise the interface between
+        `ctapipe.visualization.bokeh.CameraDisplay`,
+        `ctapipe.visualization.bokeh.WaveformDisplay` and
+        `ctapipe.core.container.ArrayEventContainer`.
+
+        Parameters
+        ----------
+        config : traitlets.loader.Config
+            Configuration specified by config file or cmdline arguments.
+            Used to set traitlet values.
+            Set to None if no configuration to pass.
+        tool : ctapipe.core.Tool
+            Tool executable that is calling this component.
+            Passes the correct logger to the component.
+            Set to None if no Tool to pass.
+        num_cameras : int
+            Number of camera figures to handle
+        num_waveforms : int
+            Number of waveform figures to handle
+        kwargs
+        """
+        super().__init__(config=config, parent=parent, **kwargs)
+
+        self._event = None
+        self._view = "r0"
+        self._telid = None
+        self._channel = 0
+
+        self.num_cameras = num_cameras
+        self.num_waveforms = num_waveforms
+
+        self.cameras = []
+        self.camera_layouts = []
+        self.waveforms = []
+        self.waveform_layouts = []
+        self.subarray = subarray
+
+        self.layout = None
+
+    def create(self):
+        for _ in range(self.num_cameras):
+            cam = BokehEventViewerCamera(self)
+            cam.enable_pixel_picker(self.num_waveforms)
+            cam.create_view_widget()
+            cam.update_view_widget()
+            cam.add_colorbar()
+
+            self.cameras.append(cam)
+            self.camera_layouts.append(cam.layout)
+
+        for iwav in range(self.num_waveforms):
+            wav = BokehEventViewerWaveform(self)
+            active_color = self.cameras[0].active_colors[iwav]
+            wav.fig.select(name="line")[0].glyph.line_color = active_color
+            wav.enable_time_picker()
+            wav.create_view_widget()
+            wav.update_view_widget()
+
+            self.waveforms.append(wav)
+            self.waveform_layouts.append(wav.layout)
+
+        self.layout = layout(
+            [[column(self.camera_layouts), column(self.waveform_layouts)]]
+        )
+
+    def enable_automatic_index_increment(self):
+        for cam in self.cameras:
+            cam.automatic_index_increment = True
+
+    def change_time(self, time):
+        for wav in self.waveforms:
+            if wav.active_time != time:
+                wav.active_time = time
+        for camera in self.cameras:
+            camera.time = self.waveforms[0].active_time
+
+    def sub_event_viewer_generator(self):
+        for camera in self.cameras:
+            yield camera
+        for waveform in self.waveforms:
+            yield waveform
+
+    def refresh(self):
+        for sub in self.sub_event_viewer_generator():
+            sub.refresh()
+
+    @property
+    def event(self):
+        return self._event
+
+    @event.setter
+    def event(self, val):
+        if self._event != val:
+            self._event = val
+            tels = list(val.r0.tel.keys())
+            if self.telid not in tels:
+                self._telid = tels[0]
+            for sub in self.sub_event_viewer_generator():
+                sub.change_event(val, self.telid)
+
+    @property
+    def telid(self):
+        return self._telid
+
+    @telid.setter
+    def telid(self, val):
+        if self._telid != val:
+            self._telid = val
+            for sub in self.sub_event_viewer_generator():
+                sub.telid = val
+
+    @property
+    def channel(self):
+        return self._channel
+
+    @channel.setter
+    def channel(self, val):
+        if self._channel != val:
+            self._channel = val
+            for sub in self.sub_event_viewer_generator():
+                sub.channel = val
diff --git a/ctapipe/reco/hillas_reconstructor.py b/ctapipe/reco/hillas_reconstructor.py
index 4d16df50..358530c0 100644
--- a/ctapipe/reco/hillas_reconstructor.py
+++ b/ctapipe/reco/hillas_reconstructor.py
@@ -230,7 +230,7 @@ class HillasReconstructor(Reconstructor):
         class are set to np.nan
 
         Parameters
-        ----------
+        -----------
         hillas_dict: dict
             dictionary with telescope IDs as key and
             HillasParametersContainer instances as values
@@ -467,14 +467,14 @@ class HillasReconstructor(Reconstructor):
         Estimate the core position by intersection the major ellipse lines of each telescope.
 
         Parameters
-        ----------
+        -----------
         hillas_dict: dict[HillasContainer]
             dictionary of hillas moments
         array_pointing: SkyCoord[HorizonFrame]
             Pointing direction of the array
 
         Returns
-        -------
+        -----------
         core_x: u.Quantity
             estimated x position of impact
         core_y: u.Quantity
@@ -535,7 +535,7 @@ class HillasReconstructor(Reconstructor):
         Estimate the max height by intersecting the lines of the cog directions of each telescope.
 
         Returns
-        -------
+        -----------
         astropy.unit.Quantity
             the estimated max height
         """
diff --git a/ctapipe/tools/bokeh/__init__.py b/ctapipe/tools/bokeh/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/ctapipe/tools/bokeh/file_viewer.py b/ctapipe/tools/bokeh/file_viewer.py
new file mode 100644
index 00000000..f0c4d411
--- /dev/null
+++ b/ctapipe/tools/bokeh/file_viewer.py
@@ -0,0 +1,375 @@
+import os
+
+from bokeh.document.document import jinja2
+from bokeh.layouts import layout, widgetbox
+from bokeh.models import Button, PreText, Select, TextInput
+from bokeh.server.server import Server
+from bokeh.themes import Theme
+from traitlets import Bool, Dict, Int
+
+from ctapipe.calib import CameraCalibrator
+from ctapipe.core import Tool, traits
+from ctapipe.image.extractor import ImageExtractor
+from ctapipe.io import EventSource
+from ctapipe.io.eventseeker import EventSeeker
+from ctapipe.plotting.bokeh_event_viewer import BokehEventViewer
+from ctapipe.utils import get_dataset_path
+
+
+class BokehFileViewer(Tool):
+    name = "BokehFileViewer"
+    description = (
+        "Interactively explore an event file using the bokeh " "visualisation package"
+    )
+
+    port = Int(5006, help="Port to open bokeh server onto").tag(config=True)
+    disable_server = Bool(
+        False, help="Do not start the bokeh server " "(useful for testing)"
+    ).tag(config=True)
+
+    default_url = get_dataset_path("gamma_test_large.simtel.gz")
+    EventSource.input_url.default_value = default_url
+
+    extractor_product = traits.create_class_enum_trait(
+        ImageExtractor, default_value="NeighborPeakWindowSum"
+    )
+
+    aliases = Dict(
+        dict(
+            port="BokehFileViewer.port",
+            disable_server="BokehFileViewer.disable_server",
+            f="EventSource.input_url",
+            max_events="EventSource.max_events",
+            extractor="BokehFileViewer.extractor_product",
+        )
+    )
+
+    classes = [EventSource] + traits.classes_with_traits(ImageExtractor)
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+        self._event = None
+        self._event_index = None
+        self._event_id = None
+        self._telid = None
+        self._channel = None
+
+        self.w_next_event = None
+        self.w_previous_event = None
+        self.w_event_index = None
+        self.w_event_id = None
+        self.w_goto_event_index = None
+        self.w_goto_event_id = None
+        self.w_telid = None
+        self.w_channel = None
+        self.w_dl1_dict = None
+        self.wb_extractor = None
+        self.layout = None
+
+        self.reader = None
+        self.seeker = None
+        self.extractor = None
+        self.calibrator = None
+        self.viewer = None
+
+        self._updating_dl1 = False
+        # make sure, gzip files are seekable
+        self.config.SimTelEventSource.back_seekable = True
+
+    def setup(self):
+        self.log_format = "%(levelname)s: %(message)s [%(name)s.%(funcName)s]"
+
+        self.reader = EventSource(parent=self)
+        self.seeker = EventSeeker(self.reader, parent=self)
+
+        self.extractor = ImageExtractor.from_name(
+            self.extractor_product, parent=self, subarray=self.reader.subarray
+        )
+        self.calibrator = CameraCalibrator(
+            subarray=self.reader.subarray, parent=self, image_extractor=self.extractor
+        )
+
+        self.viewer = BokehEventViewer(parent=self, subarray=self.reader.subarray)
+
+        # Setup widgets
+        self.viewer.create()
+        self.viewer.enable_automatic_index_increment()
+        self.create_previous_event_widget()
+        self.create_next_event_widget()
+        self.create_event_index_widget()
+        self.create_goto_event_index_widget()
+        self.create_event_id_widget()
+        self.create_goto_event_id_widget()
+        self.create_telid_widget()
+        self.create_channel_widget()
+        self.create_dl1_widgets()
+        self.update_dl1_widget_values()
+
+        # Setup layout
+        self.layout = layout(
+            [
+                [self.viewer.layout],
+                [
+                    self.w_previous_event,
+                    self.w_next_event,
+                    self.w_goto_event_index,
+                    self.w_goto_event_id,
+                ],
+                [self.w_event_index, self.w_event_id],
+                [self.w_telid, self.w_channel],
+                [self.wb_extractor],
+            ]
+        )
+
+    def start(self):
+        self.event_index = 0
+
+    def finish(self):
+        if not self.disable_server:
+
+            def modify_doc(doc):
+                doc.add_root(self.layout)
+                doc.title = self.name
+
+                directory = os.path.abspath(os.path.dirname(__file__))
+                theme_path = os.path.join(directory, "theme.yaml")
+                template_path = os.path.join(directory, "templates")
+                doc.theme = Theme(filename=theme_path)
+                env = jinja2.Environment(loader=jinja2.FileSystemLoader(template_path))
+                doc.template = env.get_template("index.html")
+
+            self.log.info(
+                "Opening Bokeh application on " "http://localhost:{}/".format(self.port)
+            )
+            server = Server({"/": modify_doc}, num_procs=1, port=self.port)
+            server.start()
+            server.io_loop.add_callback(server.show, "/")
+            server.io_loop.start()
+
+    @property
+    def event_index(self):
+        return self._event_index
+
+    @event_index.setter
+    def event_index(self, val):
+        try:
+            self.event = self.seeker.get_event_index(val)
+        except IndexError:
+            self.log.warning(f"Event Index {val} does not exist")
+
+    @property
+    def event_id(self):
+        return self._event_id
+
+    @event_id.setter
+    def event_id(self, val):
+        try:
+            self.event = self.seeker.get_event_id(val)
+        except IndexError:
+            self.log.warning(f"Event ID {val} does not exist")
+
+    @property
+    def telid(self):
+        return self._telid
+
+    @telid.setter
+    def telid(self, val):
+        self.channel = 0
+        tels = list(self.event.r0.tel.keys())
+        if val not in tels:
+            val = tels[0]
+        self._telid = val
+        self.viewer.telid = val
+        self.update_telid_widget()
+
+    @property
+    def channel(self):
+        return self._channel
+
+    @channel.setter
+    def channel(self, val):
+        self._channel = val
+        self.viewer.channel = val
+        self.update_channel_widget()
+
+    @property
+    def event(self):
+        return self._event
+
+    @event.setter
+    def event(self, val):
+        self.calibrator(val)
+
+        self._event = val
+
+        self.viewer.event = val
+
+        self._event_index = val.count
+        self._event_id = val.index.event_id
+        self.update_event_index_widget()
+        self.update_event_id_widget()
+
+        self._telid = self.viewer.telid
+        self.update_telid_widget()
+
+        self._channel = self.viewer.channel
+        self.update_channel_widget()
+
+    def update_dl1_calibrator(self, extractor=None):
+        """
+        Recreate the dl1 calibrator with the specified extractor and cleaner
+
+        Parameters
+        ----------
+        extractor : ctapipe.image.extractor.ImageExtractor
+        """
+        if extractor is None:
+            extractor = self.calibrator.image_extractor
+
+        self.extractor = extractor
+
+        self.calibrator = CameraCalibrator(
+            subarray=self.reader.subarray, parent=self, image_extractor=self.extractor
+        )
+        self.viewer.refresh()
+
+    def create_next_event_widget(self):
+        self.w_next_event = Button(label=">", button_type="default", width=50)
+        self.w_next_event.on_click(self.on_next_event_widget_click)
+
+    def on_next_event_widget_click(self):
+        self.event_index += 1
+
+    def create_previous_event_widget(self):
+        self.w_previous_event = Button(label="<", button_type="default", width=50)
+        self.w_previous_event.on_click(self.on_previous_event_widget_click)
+
+    def on_previous_event_widget_click(self):
+        self.event_index -= 1
+
+    def create_event_index_widget(self):
+        self.w_event_index = TextInput(title="Event Index:", value="")
+
+    def update_event_index_widget(self):
+        if self.w_event_index:
+            self.w_event_index.value = str(self.event_index)
+
+    def create_event_id_widget(self):
+        self.w_event_id = TextInput(title="Event ID:", value="")
+
+    def update_event_id_widget(self):
+        if self.w_event_id:
+            self.w_event_id.value = str(self.event_id)
+
+    def create_goto_event_index_widget(self):
+        self.w_goto_event_index = Button(
+            label="GOTO Index", button_type="default", width=100
+        )
+        self.w_goto_event_index.on_click(self.on_goto_event_index_widget_click)
+
+    def on_goto_event_index_widget_click(self):
+        self.event_index = int(self.w_event_index.value)
+
+    def create_goto_event_id_widget(self):
+        self.w_goto_event_id = Button(label="GOTO ID", button_type="default", width=70)
+        self.w_goto_event_id.on_click(self.on_goto_event_id_widget_click)
+
+    def on_goto_event_id_widget_click(self):
+        self.event_id = int(self.w_event_id.value)
+
+    def create_telid_widget(self):
+        self.w_telid = Select(title="Telescope:", value="", options=[])
+        self.w_telid.on_change("value", self.on_telid_widget_change)
+
+    def update_telid_widget(self):
+        if self.w_telid:
+            tels = [str(t) for t in self.event.r0.tel.keys()]
+            self.w_telid.options = tels
+            self.w_telid.value = str(self.telid)
+
+    def on_telid_widget_change(self, _, __, ___):
+        if self.telid != int(self.w_telid.value):
+            self.telid = int(self.w_telid.value)
+
+    def create_channel_widget(self):
+        self.w_channel = Select(title="Channel:", value="", options=[])
+        self.w_channel.on_change("value", self.on_channel_widget_change)
+
+    def update_channel_widget(self):
+        if self.w_channel:
+            try:
+                n_chan = self.event.r0.tel[self.telid].waveform.shape[0]
+            except AttributeError:
+                n_chan = 1
+            channels = [str(c) for c in range(n_chan)]
+            self.w_channel.options = channels
+            self.w_channel.value = str(self.channel)
+
+    def on_channel_widget_change(self, _, __, ___):
+        if self.channel != int(self.w_channel.value):
+            self.channel = int(self.w_channel.value)
+
+    def create_dl1_widgets(self):
+        self.w_dl1_dict = dict(
+            extractor=Select(
+                title="Extractor:",
+                value="",
+                width=5,
+                options=BokehFileViewer.extractor_product.values,
+            ),
+            extractor_window_start=TextInput(title="Window Start:", value=""),
+            extractor_window_width=TextInput(title="Window Width:", value=""),
+            extractor_window_shift=TextInput(title="Window Shift:", value=""),
+            extractor_lwt=TextInput(title="Local Pixel Weight:", value=""),
+        )
+
+        for val in self.w_dl1_dict.values():
+            val.on_change("value", self.on_dl1_widget_change)
+
+        self.wb_extractor = widgetbox(
+            PreText(text="Charge Extractor Configuration"),
+            self.w_dl1_dict["extractor"],
+            self.w_dl1_dict["extractor_window_start"],
+            self.w_dl1_dict["extractor_window_width"],
+            self.w_dl1_dict["extractor_window_shift"],
+            self.w_dl1_dict["extractor_lwt"],
+        )
+
+    def update_dl1_widget_values(self):
+        if self.w_dl1_dict:
+            for key, val in self.w_dl1_dict.items():
+                if "extractor" in key:
+                    if key == "extractor":
+                        val.value = self.extractor.__class__.__name__
+                    else:
+                        key = key.replace("extractor_", "")
+                        try:
+                            val.value = str(getattr(self.extractor, key))
+                        except AttributeError:
+                            val.value = ""
+
+    def on_dl1_widget_change(self, _, __, ___):
+        if self.event:
+            if not self._updating_dl1:
+                self._updating_dl1 = True
+                cmdline = []
+                for key, val in self.w_dl1_dict.items():
+                    k = key.replace("extractor_", "ImageExtractor.")
+                    if val.value:
+                        cmdline.append(f"--{k}={val.value}")
+                self.parse_command_line(cmdline)
+                extractor = ImageExtractor.from_name(
+                    self.extractor_product, parent=self
+                )
+                self.update_dl1_calibrator(extractor)
+                self.update_dl1_widget_values()
+                self._updating_dl1 = False
+
+
+def main():
+    exe = BokehFileViewer()
+    exe.run()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/ctapipe/tools/bokeh/templates/index.html b/ctapipe/tools/bokeh/templates/index.html
new file mode 100644
index 00000000..8f495f50
--- /dev/null
+++ b/ctapipe/tools/bokeh/templates/index.html
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html lang="en">
+    <head>
+        <style>
+            @import url(https://fonts.googleapis.com/css?family=Noto+Sans);
+            body {
+              font-family: 'Noto Sans', sans-serif;
+              -webkit-font-smoothing: antialiased;
+              text-rendering: optimizeLegibility;
+              color: #ff0000;
+              background: #2F2F2F;
+             }
+        </style>
+        <meta charset="utf-8">
+        <title>Bokeh Crossfilter Example</title>
+        {{ bokeh_css }}
+        {{ bokeh_js }}
+    </head>
+    <body>
+        {{ plot_div|indent(8) }}
+        {{ plot_script|indent(8) }}
+    </body>
+</html>
+
diff --git a/ctapipe/tools/bokeh/theme.yaml b/ctapipe/tools/bokeh/theme.yaml
new file mode 100644
index 00000000..3540cca0
--- /dev/null
+++ b/ctapipe/tools/bokeh/theme.yaml
@@ -0,0 +1,17 @@
+attrs:
+    Figure:
+        background_fill_color: '#2F2F2F'
+        border_fill_color: '#2F2F2F'
+        outline_line_color: '#444444'
+    Axis:
+        axis_line_color: "white"
+        axis_label_text_color: "white"
+        major_label_text_color: "white"
+        major_tick_line_color: "white"
+        minor_tick_line_color: "white"
+        minor_tick_line_color: "white"
+    Grid:
+        grid_line_dash: [6, 4]
+        grid_line_alpha: .3
+    Title:
+        text_color: "white"
diff --git a/ctapipe/tools/dl1_merge.py b/ctapipe/tools/dl1_merge.py
index 5ab71b9e..4334e870 100644
--- a/ctapipe/tools/dl1_merge.py
+++ b/ctapipe/tools/dl1_merge.py
@@ -315,7 +315,8 @@ class MergeTool(Tool):
             )
 
             for node in service_nodes:
-                file.copy_node(node, newparent=target_group)
+                if node in file:
+                    file.copy_node(node, newparent=target_group)
 
     def merge_tables(self, file):
         # Loop over all nodes listed in usable_nodes. Appends table to output_file
diff --git a/ctapipe/tools/process.py b/ctapipe/tools/process.py
index 24846ac6..0a1eb5b4 100644
--- a/ctapipe/tools/process.py
+++ b/ctapipe/tools/process.py
@@ -10,7 +10,7 @@ from ..core import QualityQuery, Tool
 from ..core.traits import Bool, classes_with_traits, flag
 from ..image import ImageCleaner, ImageProcessor
 from ..image.extractor import ImageExtractor
-from ..io import DataLevel, DataWriter, EventSource, SimTelEventSource
+from ..io import DataLevel, DataWriter, EventSource, SimTelEventSource, write_table
 from ..io.datawriter import DATA_MODEL_VERSION
 from ..reco import ShowerProcessor
 from ..utils import EventTypeFilter
@@ -221,20 +221,20 @@ class ProcessorTool(Tool):
 
         if self.should_compute_dl1:
             image_stats = self.process_images.check_image.to_table(functions=True)
-            image_stats.write(
+            write_table(
+                image_stats,
                 self.write.output_path,
                 path="/dl1/service/image_statistics",
                 append=True,
-                serialize_meta=True,
             )
 
         if self.should_compute_dl2:
             shower_stats = self.process_shower.check_shower.to_table(functions=True)
-            shower_stats.write(
+            write_table(
+                shower_stats,
                 self.write.output_path,
-                path="/dl2/service/image_statistics",
+                "/dl2/service/image_statistics",
                 append=True,
-                serialize_meta=True,
             )
 
     def start(self):
diff --git a/ctapipe/visualization/bokeh.py b/ctapipe/visualization/bokeh.py
index c99b83ab..3f5a3b2a 100644
--- a/ctapipe/visualization/bokeh.py
+++ b/ctapipe/visualization/bokeh.py
@@ -1,696 +1,408 @@
-import sys
-import tempfile
-from abc import ABCMeta
-import matplotlib.pyplot as plt
-from matplotlib.colors import to_hex
-
+import warnings
 import numpy as np
-
-from bokeh.io import output_notebook, push_notebook, show, output_file
 from bokeh.plotting import figure
+from bokeh.events import Tap
+from bokeh import palettes
 from bokeh.models import (
     ColumnDataSource,
     TapTool,
+    Span,
     ColorBar,
     LinearColorMapper,
-    LogColorMapper,
-    ContinuousColorMapper,
-    CategoricalColorMapper,
-    HoverTool,
-    BoxZoomTool,
-    Ellipse,
-    Label,
 )
-from bokeh.palettes import Viridis256, Magma256, Inferno256, Greys256, d3
-import astropy.units as u
-
-from ..instrument import CameraGeometry, PixelShape
-
+from ctapipe.utils.rgbtohex import intensity_to_hex
 
 PLOTARGS = dict(tools="", toolbar_location=None, outline_line_color="#595959")
 
 
-# mapper to mpl names
-CMAPS = {
-    "viridis": Viridis256,
-    "magma": Magma256,
-    "inferno": Inferno256,
-    "grey": Greys256,
-    "gray": Greys256,
-}
-
-
-def palette_from_mpl_name(name):
-    """Create a bokeh palette from a matplotlib colormap name"""
-    if name in CMAPS:
-        return CMAPS[name]
-
-    rgba = plt.get_cmap(name)(np.linspace(0, 1, 256))
-    palette = [to_hex(color) for color in rgba]
-    return palette
-
-
-def is_notebook():
-    """
-    Returns True if currently running in a notebook session,
-    see https://stackoverflow.com/a/37661854/3838691
-    """
-    return "ipykernel" in sys.modules
-
-
-def generate_hex_vertices(geom):
-    """Generate vertices of pixels for a hexagonal grid camera geometry"""
-    phi = np.arange(0, 2 * np.pi, np.pi / 3)
-
-    # apply pixel rotation and conversion from flat top to pointy top
-    phi += geom.pix_rotation.rad + np.deg2rad(30)
-
-    # we need the circumcircle radius, pixel_width is incircle diameter
-    r = 2 / np.sqrt(3) * geom.pixel_width.value / 2
-
-    x = geom.pix_x.value
-    y = geom.pix_y.value
-
-    return (
-        x[:, np.newaxis] + r[:, np.newaxis] * np.cos(phi)[np.newaxis],
-        y[:, np.newaxis] + r[:, np.newaxis] * np.sin(phi)[np.newaxis],
-    )
-
-
-def generate_square_vertices(geom):
-    """Generate vertices of pixels for a square grid camera geometry"""
-    width = geom.pixel_width.value / 2
-    x = geom.pix_x.value
-    y = geom.pix_y.value
-
-    x_offset = width[:, np.newaxis] * np.array([-1, -1, 1, 1])
-    y_offset = width[:, np.newaxis] * np.array([1, -1, -1, 1])
-
-    x = x[:, np.newaxis] + x_offset
-    y = y[:, np.newaxis] + y_offset
-    return x, y
+class CameraDisplay:
+    def __init__(self, geometry=None, image=None, fig=None):
+        """
+        Camera display that utilises the bokeh visualisation library
 
+        Parameters
+        ----------
+        geometry : `~ctapipe.instrument.CameraGeometry`
+            Definition of the Camera/Image
+        image : ndarray
+            1D array containing the image values for each pixel
+        fig : bokeh.plotting.figure
+            Figure to store the bokeh plot onto (optional)
+        """
+        self._geom = None
+        self._image = None
+        self._colors = None
+        self._image_min = None
+        self._image_max = None
+        self._fig = None
+
+        self._n_pixels = None
+        self._pix_sizes = np.ones(1)
+        self._pix_areas = np.ones(1)
+        self._pix_x = np.zeros(1)
+        self._pix_y = np.zeros(1)
+
+        self.glyphs = None
+        self.cm = None
+        self.cb = None
+
+        cdsource_d = dict(
+            image=[],
+            x=[],
+            y=[],
+            width=[],
+            height=[],
+            outline_color=[],
+            outline_alpha=[],
+        )
+        self.cdsource = ColumnDataSource(data=cdsource_d)
 
-class BokehPlot(metaclass=ABCMeta):
-    """Base class for bokeh plots"""
+        self._active_pixels = []
+        self.active_index = 0
+        self.active_colors = []
+        self.automatic_index_increment = False
 
-    def __init__(
-        self,
-        use_notebook=None,
-        autoscale=True,
-        cmap="inferno",
-        norm="lin",
-        **figure_kwargs,
-    ):
-        # only use autoshow / use_notebook by default if we are in a notebook
-        self._use_notebook = use_notebook if use_notebook is not None else is_notebook()
-        self._patches = None
-        self._handle = None
-        self._color_bar = None
-        self._color_mapper = None
-        self._palette = None
-        self._annotations = []
-        self._labels = []
+        self.geom = geometry
+        self.image = image
+        self.fig = fig
 
-        self.cmap = cmap
-        self.norm = norm
+        self.layout = self.fig
 
-        self.autoscale = autoscale
+    @property
+    def fig(self):
+        return self._fig
 
-        self.datasource = ColumnDataSource(data={})
-        self.figure = figure(**figure_kwargs)
-        self.figure.add_tools(HoverTool(tooltips=[("id", "@id"), ("value", "@values")]))
+    @fig.setter
+    def fig(self, val):
+        if val is None:
+            val = figure(plot_width=550, plot_height=500, **PLOTARGS)
+        val.axis.visible = False
+        val.grid.grid_line_color = None
+        self._fig = val
 
-        if figure_kwargs.get("match_aspect"):
-            # Make sure the box zoom tool does not distort the camera display
-            for tool in self.figure.toolbar.tools:
-                if isinstance(tool, BoxZoomTool):
-                    tool.match_aspect = True
+        self._draw_camera()
 
-    def show(self):
-        """Display the figure"""
-        if self._use_notebook:
-            output_notebook()
+    @property
+    def geom(self):
+        return self._geom
+
+    @geom.setter
+    def geom(self, val):
+        self._geom = val
+
+        if val is not None:
+            self._pix_areas = val.pix_area.value
+            self._pix_sizes = np.sqrt(self._pix_areas)
+            self._pix_x = val.pix_x.value
+            self._pix_y = val.pix_y.value
+
+        self._n_pixels = self._pix_x.size
+        if self._n_pixels == len(self.cdsource.data["x"]):
+            self.cdsource.data["x"] = self._pix_x
+            self.cdsource.data["y"] = self._pix_y
+            self.cdsource.data["width"] = self._pix_sizes
+            self.cdsource.data["height"] = self._pix_sizes
         else:
-            # this only sets the default name, created only when show is called
-            output_file(tempfile.mktemp(prefix="ctapipe_bokeh_", suffix=".html"))
-
-        self._handle = show(self.figure, notebook_handle=self._use_notebook)
-
-    def update(self):
-        """Update the figure"""
-        if self._use_notebook and self._handle:
-            push_notebook(handle=self._handle)
-
-    def add_colorbar(self):
-        self._color_bar = ColorBar(
-            color_mapper=self._color_mapper,
-            label_standoff=12,
-            border_line_color=None,
-            location=(0, 0),
-        )
-        self.figure.add_layout(self._color_bar, "right")
-        self.update()
-
-    def set_limits_minmax(self, zmin, zmax):
-        """Set the limits of the color range to ``zmin`` / ``zmax``"""
-        self._color_mapper.update(low=zmin, high=zmax)
-        self.update()
-
-    def set_limits_percent(self, percent=95):
-        """Set the limits to min / fraction of max value"""
-        low = np.nanmin(self.datasource.data["values"])
-        high = np.nanmax(self.datasource.data["values"])
+            self._image = np.empty(self._pix_x.shape)
+            alpha = [0] * self._n_pixels
+            color = ["black"] * self._n_pixels
+            cdsource_d = dict(
+                image=self.image,
+                x=self._pix_x,
+                y=self._pix_y,
+                width=self._pix_sizes,
+                height=self._pix_sizes,
+                outline_color=color,
+                outline_alpha=alpha,
+            )
+            self.cdsource.data = cdsource_d
 
-        frac = percent / 100.0
-        self.set_limits_minmax(low, high - (1.0 - frac) * (high - low))
+        self.active_pixels = [0] * len(self.active_pixels)
 
     @property
-    def cmap(self):
-        """Get the current colormap"""
-        return self._palette
-
-    @cmap.setter
-    def cmap(self, cmap):
-        """Set colormap"""
-        if isinstance(cmap, str):
-            cmap = palette_from_mpl_name(cmap)
-
-        self._palette = cmap
-        # might be called in __init__ before color mapper is setup
-        if self._color_mapper is not None:
-            self._color_mapper.palette = cmap
-            self._trigger_cm_update()
-            self.update()
-
-    def _trigger_cm_update(self):
-        # it seems changing palette does not trigger a color change,
-        # so we reassign a property
-        if isinstance(self._color_mapper, CategoricalColorMapper):
-            self._color_mapper.update(factors=self._color_mapper.factors)
-        else:
-            self._color_mapper.update(low=self._color_mapper.low)
+    def image(self):
+        return self._image
 
-    def clear_overlays(self):
-        """Remove any added overlays from the figure"""
-        while self._annotations:
-            self.figure.renderers.remove(self._annotations.pop())
+    @image.setter
+    def image(self, val):
+        if val is None:
+            val = np.zeros(self._n_pixels)
+
+        image_min = np.nanmin(val)
+        image_max = np.nanmax(val)
+        if image_max == image_min:
+            image_min -= 1
+            image_max += 1
+        colors = intensity_to_hex(val, image_min, image_max)
+
+        self._image = val
+        self._colors = colors
+        self.image_min = image_min
+        self.image_max = image_max
+
+        if len(colors) == self._n_pixels:
+            with warnings.catch_warnings():
+                warnings.simplefilter(action="ignore", category=FutureWarning)
+                self.cdsource.data["image"] = colors
+        else:
+            raise ValueError(
+                "Image has a different size {} than the current "
+                "CameraGeometry n_pixels {}".format(colors.size, self._n_pixels)
+            )
 
-        while self._labels:
-            self.figure.center.remove(self._labels.pop())
+    @property
+    def image_min(self):
+        return self._image_min
 
-    def rescale(self):
-        """Scale pixel colors to min/max range"""
-        low = self.datasource.data["values"].min()
-        high = self.datasource.data["values"].max()
+    @image_min.setter
+    def image_min(self, val):
+        self._image_min = val
+        if self.cb:
+            self.cm.low = val.item()
 
-        # force color to be at lower end of the colormap if
-        # data is all equal
-        if low == high:
-            high += 1
+    @property
+    def image_max(self):
+        return self._image_max
 
-        self.set_limits_minmax(low, high)
+    @image_max.setter
+    def image_max(self, val):
+        self._image_max = val
+        if self.cb:
+            self.cm.high = val.item()
 
     @property
-    def norm(self):
-        """
-        The norm instance of the Display
+    def active_pixels(self):
+        return self._active_pixels
+
+    @active_pixels.setter
+    def active_pixels(self, listval):
+        self._active_pixels = listval
+
+        palette = palettes.Set1[9]
+        palette = tuple([palette[0]] + list(palette[3:]))
+        self.active_colors = [palette[i % (len(palette))] for i in range(len(listval))]
+        self.highlight_pixels()
+
+    def reset_pixels(self):
+        self.active_pixels = [0] * len(self.active_pixels)
+
+    def _draw_camera(self):
+        # TODO: Support other pixel shapes OR switch to ellipse
+        # after https://github.com/bokeh/bokeh/issues/6985
+        self.glyphs = self.fig.ellipse(
+            "x",
+            "y",
+            color="image",
+            width="width",
+            height="height",
+            line_color="outline_color",
+            line_alpha="outline_alpha",
+            line_width=2,
+            nonselection_fill_color="image",
+            nonselection_fill_alpha=1,
+            nonselection_line_color="outline_color",
+            nonselection_line_alpha="outline_alpha",
+            source=self.cdsource,
+        )
 
-        Possible values:
+    def enable_pixel_picker(self, n_active):
+        """
+        Enables the selection of a pixel by clicking on it
 
-        - "lin": linear scale
-        - "log": log scale (cannot have negative values)
-        - "symlog": symmetric log scale (negative values are ok)
+        Parameters
+        ----------
+        n_active : int
+            Number of active pixels to keep record of
         """
-        return self._color_mapper
-
-    @norm.setter
-    def norm(self, norm):
-        """Set the norm"""
-        if not isinstance(norm, ContinuousColorMapper):
-            if norm == "lin":
-                norm = LinearColorMapper
-            elif norm == "log":
-                norm = LogColorMapper
-            else:
-                raise ValueError(f"Unsupported norm {norm}")
-
-        self._color_mapper = norm(self.cmap)
-        if self._patches is not None:
-            color = dict(transform=self._color_mapper)
-            self._patches.glyph.update(fill_color=color, line_color=color)
-
-        if self._color_bar is not None:
-            self._color_bar.update(color_mapper=self._color_mapper)
-
-        self.update()
-
-
-class CameraDisplay(BokehPlot):
-    """
-    CameraDisplay implementation in Bokeh
-
-    Parameters
-    ----------
-    geometry: CameraGeometry
-        CameraGeometry for the display
-    image: array
-        Values to display for each pixel
-    cmap: str or bokeh.palette.Palette
-        matplotlib colormap name or bokeh palette for color mapping values
-    norm: str or bokeh.
-        lin, log, symlog or a bokeh.models.ColorMapper instance
-    autoscale: bool
-        Whether to automatically adjust color range after updating image
-    use_notebook: bool or None
-        Whether to use bokehs notebook output. If None, tries to autodetect
-        running in a notebook.
-
-    **figure_kwargs are passed to bokeh.plots.figure
-    """
-
-    def __init__(
-        # same options as MPL display
-        self,
-        geometry: CameraGeometry = None,
-        image=None,
-        cmap="inferno",
-        norm="lin",
-        autoscale=True,
-        title=None,
-        # bokeh specific options
-        use_notebook=None,
-        **figure_kwargs,
-    ):
-        super().__init__(
-            use_notebook=use_notebook,
-            cmap=cmap,
-            norm=norm,
-            autoscale=autoscale,
-            title=title,
-            match_aspect=True,
-            aspect_scale=1,
-            **figure_kwargs,
-        )
+        self.active_pixels = [0] * n_active
+        self.fig.add_tools(TapTool())
 
-        self._geometry = geometry
-        self._tap_tool = None
-
-        if geometry is not None:
-            self._init_datasource(image)
-
-            if title is None:
-                frame = (
-                    geometry.frame.__class__.__name__
-                    if geometry.frame
-                    else "CameraFrame"
-                )
-                title = f"{geometry} ({frame})"
-            self.figure.title = title
-
-        # order is important because steps depend on each other
-        self.cmap = cmap
-        self.norm = norm
-        if geometry is not None:
-            self.rescale()
-            self._setup_camera()
-
-    def _init_datasource(self, image=None):
-        if image is None:
-            image = np.zeros(self._geometry.n_pixels)
-
-        data = dict(
-            id=self._geometry.pix_id,
-            values=image,
-            line_width=np.zeros(self._geometry.n_pixels),
-            line_color=["green"] * self._geometry.n_pixels,
-            line_alpha=np.zeros(self._geometry.n_pixels),
-        )
+        def source_change_response(_, __, val):
+            if val:
+                pix = val[0]
+                ai = self.active_index
+                self.active_pixels[ai] = pix
 
-        if self._geometry.pix_type == PixelShape.HEXAGON:
-            x, y = generate_hex_vertices(self._geometry)
+                self.highlight_pixels()
+                self._on_pixel_click(pix)
 
-        elif self._geometry.pix_type == PixelShape.SQUARE:
-            x, y = generate_square_vertices(self._geometry)
+                if self.automatic_index_increment:
+                    self.active_index = (ai + 1) % len(self.active_pixels)
 
-        elif self._geometry.pix_type == PixelShape.CIRCLE:
-            x, y = self._geometry.pix_x.value, self._geometry.pix_y.value
-            data["radius"] = self._geometry.pixel_width / 2
-        else:
-            raise NotImplementedError(
-                f"Unsupported pixel shape {self._geometry.pix_type}"
-            )
+        self.cdsource.selected.on_change("indices", source_change_response)
 
-        data["xs"], data["ys"] = x.tolist(), y.tolist()
+    def _on_pixel_click(self, pix_id):
+        print(f"Clicked pixel_id: {pix_id}")
+        print(f"Active Pixels: {self.active_pixels}")
 
-        self.datasource.update(data=data)
+    def highlight_pixels(self):
+        alpha = [0] * self._n_pixels
+        color = ["black"] * self._n_pixels
+        for i, pix in enumerate(self.active_pixels):
+            alpha[pix] = 1
+            color[pix] = self.active_colors[i]
+        self.cdsource.data["outline_alpha"] = alpha
+        self.cdsource.data["outline_color"] = color
 
-    def _setup_camera(self):
-        kwargs = dict(
-            fill_color=dict(field="values", transform=self.norm),
-            line_width="line_width",
-            line_color="line_color",
-            line_alpha="line_alpha",
-            source=self.datasource,
+    def add_colorbar(self):
+        self.cm = LinearColorMapper(
+            palette="Viridis256", low=0, high=100, low_color="white", high_color="red"
         )
-        if self._geometry.pix_type in (PixelShape.SQUARE, PixelShape.HEXAGON):
-            self._patches = self.figure.patches(xs="xs", ys="ys", **kwargs)
-        elif self._geometry.pix_type == PixelShape.CIRCLE:
-            self._patches = self.figure.circle(
-                x="xs", y="ys", radius="radius", **kwargs
-            )
+        self.cb = ColorBar(
+            color_mapper=self.cm,
+            border_line_color=None,
+            background_fill_alpha=0,
+            major_label_text_color="green",
+            location=(0, 0),
+        )
+        self.fig.add_layout(self.cb, "right")
+        self.cm.low = self.image_min.item()
+        self.cm.high = self.image_max.item()
 
-    def enable_pixel_picker(self, callback):
-        """Call `callback`` when a pixel is clicked"""
-        if self._tap_tool is None:
-            self.figure.add_tools(TapTool())
-        self.datasource.selected.on_change("indices", callback)
 
-    def highlight_pixels(self, pixels, color="g", linewidth=1, alpha=0.75):
+class FastCameraDisplay:
+    def __init__(self, x_pix, y_pix, pix_size):
         """
-        Highlight the given pixels with a colored line around them
+        A fast and simple version of the bokeh camera plotter that does not
+        allow for geometry changes
 
         Parameters
         ----------
-        pixels : index-like
-            The pixels to highlight.
-            Can either be a list or array of integers or a
-            boolean mask of length number of pixels
-        color: a matplotlib conform color
-            the color for the pixel highlighting
-        linewidth: float
-            linewidth of the highlighting in points
-        alpha: 0 <= alpha <= 1
-            The transparency
+        x_pix : ndarray
+            Pixel x positions
+        y_pix : ndarray
+            Pixel y positions
+        pix_size : ndarray
+            Pixel sizes
         """
-        n_pixels = self._geometry.n_pixels
-        pixels = np.asanyarray(pixels)
-
-        if pixels.dtype != np.bool:
-            selected = np.zeros(n_pixels, dtype=bool)
-            selected[pixels] = True
-            pixels = selected
-
-        new_data = {"line_alpha": [(slice(None), pixels.astype(float) * alpha)]}
-        if linewidth != self.datasource.data["line_width"][0]:
-            new_data["line_width"] = [(slice(None), np.full(n_pixels, linewidth))]
-
-        if color != self.datasource.data["line_color"][0]:
-            new_data["line_color"] = [(slice(None), [color] * n_pixels)]
-
-        self.datasource.patch(new_data)
-        self.update()
+        self._image = None
+        n_pix = x_pix.size
+
+        cdsource_d = dict(image=np.empty(n_pix, dtype="<U8"), x=x_pix, y=y_pix)
+        self.cdsource = ColumnDataSource(cdsource_d)
+        self.fig = figure(plot_width=400, plot_height=400, **PLOTARGS)
+        self.fig.grid.grid_line_color = None
+        self.fig.rect(
+            "x",
+            "y",
+            color="image",
+            source=self.cdsource,
+            width=pix_size[0],
+            height=pix_size[0],
+        )
 
-    @property
-    def geometry(self):
-        """Get the current geometry"""
-        return self._geometry
-
-    @geometry.setter
-    def geometry(self, new_geometry):
-        """Set the geometry"""
-        self._geometry = new_geometry
-        if self._patches in self.figure.renderers:
-            self.figure.renderers.remove(self._patches)
-        self._init_datasource()
-        self._setup_camera()
-        self.rescale()
-        self.update()
+        self.layout = self.fig
 
     @property
     def image(self):
-        """Get the current image"""
-        return self.datasource.data["values"]
+        return self._image
 
     @image.setter
-    def image(self, new_image):
-        """Set the image"""
-        self.datasource.patch({"values": [(slice(None), new_image)]})
-        if self.autoscale:
-            self.rescale()
-
-    def add_ellipse(self, centroid, length, width, angle, asymmetry=0.0, **kwargs):
+    def image(self, val):
         """
-        plot an ellipse on top of the camera
-
         Parameters
         ----------
-        centroid: (float, float)
-            position of centroid
-        length: float
-            major axis
-        width: float
-            minor axis
-        angle: float
-            rotation angle wrt x-axis about the centroid, anticlockwise, in radians
-        asymmetry: float
-            3rd-order moment for directionality if known
-        kwargs:
-            any MatPlotLib style arguments to pass to the Ellipse patch
-
+        val : ndarray
+            Array containing the image values, already converted into
+            hexidecimal strings
         """
-        ellipse = Ellipse(
-            x=centroid[0],
-            y=centroid[1],
-            width=length,
-            height=width,
-            angle=angle,
-            fill_color=None,
-            **kwargs,
-        )
-        glyph = self.figure.add_glyph(ellipse)
-        self._annotations.append(glyph)
-        self.update()
-        return ellipse
+        self.cdsource.data["image"] = val
+
 
-    def overlay_moments(
-        self, hillas_parameters, with_label=True, keep_old=False, **kwargs
-    ):
-        """helper to overlay ellipse from a `HillasParametersContainer` structure
+class WaveformDisplay:
+    def __init__(self, waveform=np.zeros(1), fig=None):
+        """
+        Waveform display that utilises the bokeh visualisation library
 
         Parameters
         ----------
-        hillas_parameters: `HillasParametersContainer`
-            structuring containing Hillas-style parameterization
-        with_label: bool
-            If True, show coordinates of centroid and width and length
-        keep_old: bool
-            If True, to not remove old overlays
-        kwargs: key=value
-            any style keywords to pass to matplotlib (e.g. color='red'
-            or linewidth=6)
+        waveform : ndarray
+            1D array containing the waveform samples
+        fig : bokeh.plotting.figure
+            Figure to store the bokeh plot onto (optional)
         """
-        if not keep_old:
-            self.clear_overlays()
-
-        # strip off any units
-        cen_x = u.Quantity(hillas_parameters.x).value
-        cen_y = u.Quantity(hillas_parameters.y).value
-        length = u.Quantity(hillas_parameters.length).value
-        width = u.Quantity(hillas_parameters.width).value
-
-        el = self.add_ellipse(
-            centroid=(cen_x, cen_y),
-            length=length * 2,
-            width=width * 2,
-            angle=hillas_parameters.psi.to_value(u.rad),
-            **kwargs,
-        )
+        self._waveform = None
+        self._fig = None
+        self._active_time = 0
 
-        if with_label:
-            label = Label(
-                x=cen_x,
-                y=cen_y,
-                text="({:.02f},{:.02f})\n[w={:.02f},l={:.02f}]".format(
-                    hillas_parameters.x,
-                    hillas_parameters.y,
-                    hillas_parameters.width,
-                    hillas_parameters.length,
-                ),
-                text_color=el.line_color,
-            )
-            self.figure.add_layout(label, "center")
-            self._labels.append(label)
-
-
-class ArrayDisplay(BokehPlot):
-    """
-    Display a top-town view of a telescope array.
-
-    This can be used in two ways: by default, you get a display of all
-    telescopes in the subarray, colored by telescope type, however you can
-    also color the telescopes by a value (like trigger pattern, or some other
-    scalar per-telescope parameter). To set the color value, simply set the
-    `value` attribute, and the fill color will be updated with the value. You
-    might want to set the border color to zero to avoid confusion between the
-    telescope type color and the value color (
-    `array_disp.telescope.set_linewidth(0)`)
-
-    To display a vector field over the telescope positions, e.g. for
-    reconstruction, call `set_uv()` to set cartesian vectors, or `set_r_phi()`
-    to set polar coordinate vectors.  These both take an array of length
-    N_tels, or a single value.
-
-
-    Parameters
-    ----------
-    subarray: ctapipe.instrument.SubarrayDescription
-        the array layout to display
-    values: array
-        Value to display for each telescope. If None, the telescope type will
-        be used.
-    scale: float
-        scaling between telescope mirror radius in m to displayed size
-    title: str
-        title of array plot
-    alpha: float
-        Alpha value for telescopes
-    cmap: str or bokeh.palette.Palette
-        matplotlib colormap name or bokeh palette for color mapping values
-    norm: str or bokeh.
-        lin, log, symlog or a bokeh.models.ColorMapper instance
-    radius: Union[float, list, None]
-        set telescope radius to value, list/array of values. If None, radius
-        is taken from the telescope's mirror size.
-    use_notebook: bool or None
-        Whether to use bokehs notebook output. If None, tries to autodetect
-        running in a notebook.
-    frame: GroundFrame or TiltedGroundFrame
-        If given, transform telescope positions into this frame
-
-    **figure_kwargs are passed to bokeh.plots.figure
-    """
-
-    def __init__(
-        self,
-        subarray,
-        values=None,
-        scale=5.0,
-        alpha=1.0,
-        title=None,
-        cmap=None,
-        norm="lin",
-        radius=None,
-        use_notebook=None,
-        frame=None,
-        **figure_kwargs,
-    ):
-        if title is None:
-            frame_name = (frame or subarray.tel_coords.frame).__class__.__name__
-            title = f"{subarray.name} ({frame_name})"
-
-        super().__init__(
-            use_notebook=use_notebook,
-            title=title,
-            match_aspect=True,
-            aspect_scale=1,
-            cmap=cmap,
-            norm=norm,
-            **figure_kwargs,
-        )
+        self.span = None
 
-        # color by type if no value given
-        if values is None:
-            types = list({str(t) for t in subarray.telescope_types})
-            cmap = cmap or d3["Category10"][10][: len(types)]
-            self._color_mapper = CategoricalColorMapper(palette=cmap, factors=types)
-            field = "type"
-        else:
-            self.cmap = "inferno"
-            field = "values"
-
-        self.frame = frame
-        self.subarray = subarray
-
-        self._init_datasource(
-            subarray,
-            values=values,
-            radius=radius,
-            frame=frame,
-            scale=scale,
-            alpha=alpha,
-        )
+        cdsource_d = dict(t=[], samples=[])
+        self.cdsource = ColumnDataSource(data=cdsource_d)
 
-        color = dict(field=field, transform=self._color_mapper)
-        self._patches = self.figure.circle(
-            x="x",
-            y="y",
-            radius="radius",
-            alpha="alpha",
-            line_alpha="alpha",
-            fill_color=color,
-            line_color=color,
-            source=self.datasource,
-            legend_field="type",
-        )
-        self.figure.add_tools(
-            HoverTool(tooltips=[("id", "@id"), ("type", "@type"), ("z", "@z")])
-        )
-        self.figure.legend.orientation = "horizontal"
-        self.figure.legend.location = "top_left"
+        self.waveform = waveform
+        self.fig = fig
 
-    def _init_datasource(self, subarray, values, *, radius, frame, scale, alpha):
-        telescope_ids = subarray.tel_ids
-        tel_coords = subarray.tel_coords
+        self.layout = self.fig
 
-        # get the telescope positions. If a new frame is set, this will
-        # transform to the new frame.
-        if frame is not None:
-            tel_coords = tel_coords.transform_to(frame)
+    @property
+    def fig(self):
+        return self._fig
 
-        tel_types = []
-        mirror_radii = np.zeros(len(telescope_ids))
+    @fig.setter
+    def fig(self, val):
+        if val is None:
+            val = figure(plot_width=700, plot_height=180, **PLOTARGS)
+        self._fig = val
 
-        for i, telescope_id in enumerate(telescope_ids):
-            telescope = subarray.tel[telescope_id]
-            tel_types.append(str(telescope))
-            mirror_area = telescope.optics.mirror_area.to_value(u.m ** 2)
-            mirror_radii[i] = np.sqrt(mirror_area) / np.pi
+        self._draw_waveform()
 
-        if values is None:
-            values = np.zeros(len(subarray))
+    @property
+    def waveform(self):
+        return self._waveform
+
+    @waveform.setter
+    def waveform(self, val):
+        if val is None:
+            val = np.full(1, np.nan)
+
+        self._waveform = val
 
-        if np.isscalar(alpha):
-            alpha = np.full(len(telescope_ids), alpha)
+        if len(val) == len(self.cdsource.data["t"]):
+            self.cdsource.data["samples"] = val
         else:
-            alpha = np.array(alpha)
-
-        data = {
-            "id": telescope_ids,
-            "x": tel_coords.x.to_value(u.m).tolist(),
-            "y": tel_coords.y.to_value(u.m).tolist(),
-            "z": tel_coords.z.to_value(u.m).tolist(),
-            "alpha": alpha.tolist(),
-            "values": values,
-            "type": tel_types,
-            "mirror_radius": mirror_radii.tolist(),
-            "radius": (radius if radius is not None else mirror_radii * scale).tolist(),
-        }
-
-        self.datasource.update(data=data)
+            cdsource_d = dict(t=np.arange(val.size), samples=val)
+            self.cdsource.data = cdsource_d
 
     @property
-    def values(self):
-        """Get the current image"""
-        return self.datasource.data["values"]
-
-    @values.setter
-    def values(self, new_values):
-        """Set the image"""
-        # currently displaying telescope types
-        if self._patches.glyph.fill_color["field"] == "type":
-            self.norm = "lin"
-            self.cmap = "inferno"
-            color = dict(field="values", transform=self._color_mapper)
-            self._patches.glyph.update(fill_color=color, line_color=color)
-
-            # recreate color bar, updating does not work here
-            if self._color_bar is not None:
-                self.figure.right.remove(self._color_bar)
-                self.add_colorbar()
-
-            self.update()
-
-        self.datasource.patch({"values": [(slice(None), new_values)]})
-        if self.autoscale:
-            self.rescale()
+    def active_time(self):
+        return self._active_time
+
+    @active_time.setter
+    def active_time(self, val):
+        max_t = self.cdsource.data["t"][-1]
+        if val is None:
+            val = 0
+        if val < 0:
+            val = 0
+        if val > max_t:
+            val = max_t
+        self.span.location = val
+        self._active_time = val
+
+    def _draw_waveform(self):
+        self.fig.line(x="t", y="samples", source=self.cdsource, name="line")
+
+    def enable_time_picker(self):
+        """
+        Enables the selection of a time by clicking on the waveform
+        """
+        self.span = Span(
+            location=0, dimension="height", line_color="red", line_dash="dashed"
+        )
+        self.fig.add_layout(self.span)
+
+        taptool = TapTool()
+        self.fig.add_tools(taptool)
+
+        def wf_tap_response(event):
+            time = event.x
+            if time is not None:
+                self.active_time = time
+                self._on_waveform_click(time)
+
+        self.fig.on_event(Tap, wf_tap_response)
+
+    def _on_waveform_click(self, time):
+        print(f"Clicked time: {time}")
+        print(f"Active time: {self.active_time}")
diff --git a/ctapipe/visualization/mpl_camera.py b/ctapipe/visualization/mpl_camera.py
index ebd125f2..4f7c77f1 100644
--- a/ctapipe/visualization/mpl_camera.py
+++ b/ctapipe/visualization/mpl_camera.py
@@ -85,18 +85,16 @@ class CameraDisplay:
 
     def __init__(
         self,
-        # same options as bokeh display
         geometry,
         image=None,
-        cmap="inferno",
-        norm="lin",
-        autoscale=True,
+        ax=None,
         title=None,
-        # mpl specific options
+        norm="lin",
+        cmap=None,
         allow_pick=False,
         autoupdate=True,
+        autoscale=True,
         show_frame=True,
-        ax=None,
     ):
         self.axes = ax if ax is not None else plt.gca()
         self.pixels = None
@@ -268,6 +266,7 @@ class CameraDisplay:
     def set_limits_minmax(self, zmin, zmax):
         """ set the color scale limits from min to max """
         self.pixels.set_clim(zmin, zmax)
+        self.autoscale = False
         self._update()
 
     def set_limits_percent(self, percent=95):
@@ -280,6 +279,7 @@ class CameraDisplay:
 
         dz = zmax - zmin
         frac = percent / 100.0
+        self.autoscale = False
         self.set_limits_minmax(zmin, zmax - (1.0 - frac) * dz)
 
     @property
@@ -453,7 +453,7 @@ class CameraDisplay:
             centroid=(cen_x, cen_y),
             length=length * 2,
             width=width * 2,
-            angle=hillas_parameters.psi.to_value(u.rad),
+            angle=hillas_parameters.psi.to_value("rad"),
             **kwargs,
         )
 
diff --git a/docs/conf.py b/docs/conf.py
index 2b23e1f0..1894a3d8 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -248,13 +248,13 @@ texinfo_documents = [
 
 # Example configuration for intersphinx: refer to the Python standard library.
 intersphinx_mapping = {
-    "python": ("https://docs.python.org/3.8", None),
-    "numpy": ("https://numpy.org/doc/stable/", None),
-    "scipy": ("https://docs.scipy.org/doc/scipy/", None),
-    "astropy": ("https://docs.astropy.org/en/latest/", None),
+    "python": ("https://docs.python.org/3.6", None),
+    "numpy": ("https://docs.scipy.org/doc/numpy/", None),
+    "scipy": ("https://docs.scipy.org/doc/scipy/reference/", None),
+    "astropy": ("http://docs.astropy.org/en/latest/", None),
     "pytables": ("http://www.pytables.org/", None),
-    "pandas": ("https://pandas.pydata.org/pandas-docs/stable/", None),
-    "matplotlib": ("https://matplotlib.org/stable", None),
+    "pandas": ("http://pandas.pydata.org/pandas-docs/stable/", None),
+    "matplotlib": ("http://matplotlib.org/", None),
     "cython": ("http://docs.cython.org/en/latest/", None),
     "iminuit": ("https://iminuit.readthedocs.io/en/latest/", None),
     "traitlets": ("https://traitlets.readthedocs.io/en/stable/", None),
diff --git a/environment.yml b/environment.yml
index 243ad6dd..40458978 100644
--- a/environment.yml
+++ b/environment.yml
@@ -8,7 +8,7 @@ dependencies:
   - pip
   - astropy=5
   - black
-  - bokeh=2
+  - bokeh=1
   - nbsphinx
   - cython
   - graphviz
@@ -44,4 +44,5 @@ dependencies:
   - zlib
   - zstandard
   - eventio>=1.5.0
-  - jinja2=3.0 # for sphinx 3.5, remove when updating to 4.x
+  # for bokeh=1, remove when updating bokeh
+  - jinja2=3.0
diff --git a/setup.py b/setup.py
index 7bc179fe..d23fa043 100755
--- a/setup.py
+++ b/setup.py
@@ -13,6 +13,7 @@ entry_points["console_scripts"] = [
     "ctapipe-info = ctapipe.tools.info:main",
     "ctapipe-dump-triggers = ctapipe.tools.dump_triggers:main",
     "ctapipe-dump-instrument=ctapipe.tools.dump_instrument:main",
+    "ctapipe-event-viewer = ctapipe.tools.bokeh.file_viewer:main",
     "ctapipe-reconstruct-muons = ctapipe.tools.muon_reconstruction:main",
     "ctapipe-display-dl1 = ctapipe.tools.display_dl1:main",
     "ctapipe-process = ctapipe.tools.process:main",
@@ -38,7 +39,7 @@ setup(
     python_requires=">=3.8",
     install_requires=[
         "astropy~=5.0",
-        "bokeh~=2.0",
+        "bokeh~=1.0",
         "eventio>=1.5.0,<2.0.0a0",
         "h5py",
         "iminuit>=2",
@@ -56,7 +57,7 @@ setup(
         "requests",
         "setuptools_scm>=3.4",
         "importlib_resources;python_version<'3.9'",
-        "jinja2~=3.0.2",  # for sphinx 3.5, update when moving to 4.x
+        "jinja2~=3.0.2",  # for bokeh=1, remove when updaiting bokeh
     ],
     # here are optional dependencies (as "tag" : "dependency spec")
     extras_require={
@@ -80,5 +81,7 @@ setup(
     ],
     zip_safe=False,
     entry_points=entry_points,
-    package_data={"": ["resources/*"]},
+    package_data={
+        "": ["tools/bokeh/*.yaml", "tools/bokeh/templates/*.html", "resources/*"]
+    },
 )


Test patch:
diff --git a/ctapipe/plotting/tests/__init__.py b/ctapipe/plotting/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/ctapipe/plotting/tests/test_bokeh_event_viewer.py b/ctapipe/plotting/tests/test_bokeh_event_viewer.py
new file mode 100644
index 00000000..98f8f530
--- /dev/null
+++ b/ctapipe/plotting/tests/test_bokeh_event_viewer.py
@@ -0,0 +1,165 @@
+from ctapipe.plotting.bokeh_event_viewer import BokehEventViewer
+from ctapipe.calib.camera.calibrator import CameraCalibrator
+import pytest
+
+
+def test_bokeh_event_viewer_creation(example_subarray):
+    viewer = BokehEventViewer(example_subarray)
+    viewer.create()
+
+
+def test_event_setting(example_event, example_subarray):
+    viewer = BokehEventViewer(example_subarray)
+    viewer.create()
+    viewer.event = example_event
+    for cam in viewer.cameras:
+        assert cam.event == example_event
+    for wf in viewer.waveforms:
+        assert wf.event == example_event
+
+
+def test_enable_automatic_index_increment(example_subarray):
+    viewer = BokehEventViewer(example_subarray)
+    viewer.create()
+    viewer.enable_automatic_index_increment()
+    for cam in viewer.cameras:
+        assert cam.automatic_index_increment
+
+
+def test_change_time(example_event, example_subarray):
+    viewer = BokehEventViewer(example_subarray)
+    viewer.create()
+    viewer.event = example_event
+
+    t = 5
+    viewer.change_time(t)
+    for cam in viewer.cameras:
+        assert cam.time == t
+    for wf in viewer.waveforms:
+        assert wf.active_time == t
+
+    t = -11
+    viewer.change_time(t)
+    for cam in viewer.cameras:
+        assert cam.time == 0
+    for wf in viewer.waveforms:
+        assert wf.active_time == 0
+
+    tel = list(example_event.r0.tel.keys())[0]
+    n_samples = example_event.r0.tel[tel].waveform.shape[-1]
+    t = 10000
+    viewer.change_time(t)
+    for cam in viewer.cameras:
+        assert cam.time == n_samples - 1
+    for wf in viewer.waveforms:
+        assert wf.active_time == n_samples - 1
+
+
+def test_on_waveform_click(example_event, example_subarray):
+    viewer = BokehEventViewer(example_subarray)
+    viewer.create()
+    viewer.event = example_event
+
+    t = 5
+    viewer.waveforms[0]._on_waveform_click(t)
+    for cam in viewer.cameras:
+        assert cam.time == t
+    for wf in viewer.waveforms:
+        assert wf.active_time == t
+
+
+def test_telid(example_event, example_subarray):
+    viewer = BokehEventViewer(example_subarray)
+    viewer.create()
+    viewer.event = example_event
+
+    tels = list(example_event.r0.tel.keys())
+
+    assert viewer.telid == tels[0]
+    for cam in viewer.cameras:
+        assert cam.telid == tels[0]
+    for wf in viewer.waveforms:
+        assert wf.telid == tels[0]
+
+    viewer.telid = tels[1]
+    assert viewer.telid == tels[1]
+    for cam in viewer.cameras:
+        assert cam.telid == tels[1]
+    for wf in viewer.waveforms:
+        assert wf.telid == tels[1]
+
+
+def test_telid_incorrect(example_event, example_subarray):
+    viewer = BokehEventViewer(example_subarray)
+    viewer.create()
+    viewer.event = example_event
+
+    with pytest.raises(KeyError):
+        viewer.telid = 148937242
+
+
+def test_on_pixel_click(example_event, example_subarray):
+    viewer = BokehEventViewer(example_subarray)
+    viewer.create()
+    viewer.event = example_event
+
+    p1 = 5
+    viewer.cameras[0]._on_pixel_click(p1)
+    assert viewer.waveforms[viewer.cameras[0].active_index].pixel == p1
+
+
+def test_channel(example_event, example_subarray):
+    viewer = BokehEventViewer(example_subarray)
+    viewer.create()
+    viewer.event = example_event
+
+    assert viewer.channel == 0
+    for cam in viewer.cameras:
+        assert cam.channel == 0
+    for wf in viewer.waveforms:
+        assert wf.channel == 0
+
+
+def test_channel_incorrect(example_event, example_subarray):
+    viewer = BokehEventViewer(example_subarray)
+    viewer.create()
+    viewer.event = example_event
+
+    with pytest.raises(IndexError):
+        viewer.channel = 148937242
+
+
+def test_view_camera(example_event, example_subarray):
+    viewer = BokehEventViewer(example_subarray)
+    viewer.create()
+    viewer.event = example_event
+
+    calibrator = CameraCalibrator(subarray=example_subarray)
+    calibrator(example_event)
+
+    t = list(example_event.r0.tel.keys())[0]
+
+    cam = viewer.cameras[0]
+    cam.view = "r1"
+    assert (cam.image == example_event.r1.tel[t].waveform[:, 0]).all()
+
+    with pytest.raises(ValueError):
+        cam.view = "q"
+
+
+def test_view_wf(example_event, example_subarray):
+    viewer = BokehEventViewer(example_subarray)
+    viewer.create()
+    viewer.event = example_event
+
+    calibrator = CameraCalibrator(subarray=example_subarray)
+    calibrator(example_event)
+
+    t = list(example_event.r0.tel.keys())[0]
+
+    wf = viewer.waveforms[0]
+    wf.view = "r1"
+    assert (wf.waveform == example_event.r1.tel[t].waveform[0, :]).all()
+
+    with pytest.raises(ValueError):
+        wf.view = "q"
diff --git a/ctapipe/tools/tests/test_tools.py b/ctapipe/tools/tests/test_tools.py
index f906213b..2f7860ee 100644
--- a/ctapipe/tools/tests/test_tools.py
+++ b/ctapipe/tools/tests/test_tools.py
@@ -156,3 +156,13 @@ def test_dump_instrument(tmp_path):
     assert (tmp_path / "subarray.h5").exists()
 
     assert run_tool(tool, ["--help-all"], cwd=tmp_path) == 0
+
+
+def test_bokeh_file_viewer(tmp_path):
+    from ctapipe.tools.bokeh.file_viewer import BokehFileViewer
+
+    sys.argv = ["bokeh_file_viewer"]
+    tool = BokehFileViewer(disable_server=True)
+    assert run_tool(tool, cwd=tmp_path) == 0
+    assert tool.reader.input_url == get_dataset_path("gamma_test_large.simtel.gz")
+    assert run_tool(tool, ["--help-all"]) == 0
diff --git a/ctapipe/visualization/tests/test_bokeh.py b/ctapipe/visualization/tests/test_bokeh.py
index 2e1dc1c8..5df3d73a 100644
--- a/ctapipe/visualization/tests/test_bokeh.py
+++ b/ctapipe/visualization/tests/test_bokeh.py
@@ -1,67 +1,56 @@
-"""Tests for the bokeh visualization"""
 import numpy as np
-from bokeh.io import output_file, save
+import pytest
 
-from ctapipe.coordinates import TelescopeFrame
 
-
-def test_create_display_without_geometry(example_event, example_subarray):
-    """Test we can create a display without giving the geometry to init"""
+def test_camera_display_create():
     from ctapipe.visualization.bokeh import CameraDisplay
 
-    # test we can create it without geometry, and then set all the stuff
-    display = CameraDisplay()
-
-    tel_id = next(iter(example_event.r0.tel.keys()))
-    display.geometry = example_subarray.tel[tel_id].camera.geometry
-    display.image = example_event.dl1.tel[tel_id].image
+    CameraDisplay()
 
 
-def test_camera_display_creation(example_event, example_subarray):
-    """Test we can create a display and check the resulting pixel coordinates"""
+def test_camera_geom(example_event, example_subarray):
     from ctapipe.visualization.bokeh import CameraDisplay
 
     t = list(example_event.r0.tel.keys())[0]
     geom = example_subarray.tel[t].camera.geometry
-    display = CameraDisplay(geom)
-
-    assert np.allclose(np.mean(display.datasource.data["xs"], axis=1), geom.pix_x.value)
-    assert np.allclose(np.mean(display.datasource.data["ys"], axis=1), geom.pix_y.value)
-
+    c_display = CameraDisplay(geom)
 
-def test_camera_display_telescope_frame(example_event, example_subarray):
-    """Test we can create a display in telescope frame"""
-    from ctapipe.visualization.bokeh import CameraDisplay
+    assert (c_display.cdsource.data["x"] == geom.pix_x.value).all()
+    assert (c_display.cdsource.data["y"] == geom.pix_y.value).all()
 
-    t = list(example_event.r0.tel.keys())[0]
-    geom = example_subarray.tel[t].camera.geometry.transform_to(TelescopeFrame())
-    display = CameraDisplay(geom)
+    t = list(example_event.r0.tel.keys())[1]
+    geom = example_subarray.tel[t].camera.geometry
+    c_display.geom = geom
+    assert (c_display.cdsource.data["x"] == geom.pix_x.value).all()
+    assert (c_display.cdsource.data["y"] == geom.pix_y.value).all()
 
-    assert np.allclose(np.mean(display.datasource.data["xs"], axis=1), geom.pix_x.value)
-    assert np.allclose(np.mean(display.datasource.data["ys"], axis=1), geom.pix_y.value)
 
-
-def test_camera_image(example_event, example_subarray, tmp_path):
-    """Test we set an image"""
-    from ctapipe.visualization.bokeh import CameraDisplay
+def test_camera_image(example_event, example_subarray):
+    from ctapipe.visualization.bokeh import CameraDisplay, intensity_to_hex
 
     t = list(example_event.r0.tel.keys())[0]
     geom = example_subarray.tel[t].camera.geometry
-    image = np.ones(geom.n_pixels)
+    n_pixels = geom.pix_x.value.size
+    image = np.ones(n_pixels)
+    colors = intensity_to_hex(image)
 
-    display = CameraDisplay(geom, image)
-    assert np.all(display.image == image)
+    with pytest.raises(ValueError):
+        CameraDisplay(None, image)
 
-    display.image = np.random.normal(size=geom.n_pixels)
-    assert np.all(display.image == image)
+    c_display = CameraDisplay(geom, image)
+    assert (c_display.cdsource.data["image"] == colors).all()
+    assert c_display.image_min == 0
+    assert c_display.image_max == 2
 
-    output_path = tmp_path / "test.html"
-    output_file(output_path)
-    save(display.figure, filename=output_path)
+    image[5] = 5
+    colors = intensity_to_hex(image)
+    c_display.image = image
+    assert (c_display.cdsource.data["image"] == colors).all()
+    assert c_display.image_min == image.min()
+    assert c_display.image_max == image.max()
 
 
 def test_camera_enable_pixel_picker(example_event, example_subarray):
-    """Test we can call enable_pixel_picker"""
     from ctapipe.visualization.bokeh import CameraDisplay
 
     t = list(example_event.r0.tel.keys())[0]
@@ -70,53 +59,82 @@ def test_camera_enable_pixel_picker(example_event, example_subarray):
     image = np.ones(n_pixels)
     c_display = CameraDisplay(geom, image)
 
-    def callback(attr, new, old):
-        print(attr, new, old)
+    c_display.enable_pixel_picker(2)
+    assert len(c_display.active_pixels) == 2
 
-    c_display.enable_pixel_picker(callback)
+    c_display.enable_pixel_picker(3)
+    assert len(c_display.active_pixels) == 3
 
 
-def test_matplotlib_cmaps(example_subarray):
-    """Test using matplotlib colormap names works"""
-    from ctapipe.visualization.bokeh import CameraDisplay
+def test_fast_camera_display_create(example_event, example_subarray):
+    from ctapipe.visualization.bokeh import FastCameraDisplay
 
-    geom = example_subarray.tel[1].camera.geometry
-    image = np.ones(len(geom))
-    display = CameraDisplay(geom, image)
-    display.cmap = "viridis"
-    display.cmap = "RdBu"
+    t = list(example_event.r0.tel.keys())[0]
+    geom = example_subarray.tel[t].camera.geometry
 
+    x = geom.pix_x.value
+    y = geom.pix_y.value
+    area = geom.pix_area.value
+    size = np.sqrt(area)
+
+    FastCameraDisplay(x, y, size)
+
+
+def test_fast_camera_image(example_event, example_subarray):
+    from ctapipe.visualization.bokeh import FastCameraDisplay, intensity_to_hex
+
+    t = list(example_event.r0.tel.keys())[0]
+    geom = example_subarray.tel[t].camera.geometry
+
+    x = geom.pix_x.value
+    y = geom.pix_y.value
+    area = geom.pix_area.value
+    size = np.sqrt(area)
+
+    c_display = FastCameraDisplay(x, y, size)
+
+    image = np.ones(x.size)
+    colors = intensity_to_hex(image)
+    c_display.image = colors
+
+    assert (c_display.cdsource.data["image"] == colors).all()
+
+
+def test_waveform_display_create():
+    from ctapipe.visualization.bokeh import WaveformDisplay
+
+    WaveformDisplay()
 
-def test_cameras(camera_geometry, tmp_path):
-    """Test for all known camera geometries"""
-    from ctapipe.visualization.bokeh import CameraDisplay
 
-    image = np.random.normal(size=len(camera_geometry))
-    display = CameraDisplay(camera_geometry, image)
+def test_waveform_values():
+    from ctapipe.visualization.bokeh import WaveformDisplay
 
-    output_path = tmp_path / "test.html"
-    output_file(output_path)
-    save(display.figure, filename=output_path)
+    wf = np.ones(30)
+    w_display = WaveformDisplay(wf)
 
+    assert (w_display.cdsource.data["samples"] == wf).all()
+    assert (w_display.cdsource.data["t"] == np.arange(wf.size)).all()
 
-def test_array_display_no_values(example_subarray, tmp_path):
-    """Test plain array display"""
-    from ctapipe.visualization.bokeh import ArrayDisplay
+    wf[5] = 5
+    w_display.waveform = wf
 
-    display = ArrayDisplay(example_subarray)
+    assert (w_display.cdsource.data["samples"] == wf).all()
+    assert (w_display.cdsource.data["t"] == np.arange(wf.size)).all()
 
-    output_path = tmp_path / "test.html"
-    output_file(output_path)
-    save(display.figure, filename=output_path)
 
+def test_span():
+    from ctapipe.visualization.bokeh import WaveformDisplay
 
-def test_array_display(example_subarray, tmp_path):
-    """Test array display with values for each telescope"""
-    from ctapipe.visualization.bokeh import ArrayDisplay
+    wf = np.ones(30)
+    w_display = WaveformDisplay(wf)
+    w_display.enable_time_picker()
+    w_display.active_time = 4
+    assert w_display.span.location == 4
 
-    display = ArrayDisplay(example_subarray, values=np.arange(len(example_subarray)))
-    display.add_colorbar()
+    w_display.active_time = -3
+    assert w_display.active_time == 0
+    assert w_display.span.location == 0
 
-    output_path = tmp_path / "test.html"
-    output_file(output_path)
-    save(display.figure, filename=output_path)
+    w_display.active_time = wf.size + 10
+    assert w_display.active_time == wf.size - 1
+    assert w_display.span.location == wf.size - 1