Colorbar with contrast limit handles#

An ImGUI colorbar widget rendered with wgpu, with draggable vmin/vmax handles, a gamma slider, and a right-click colormap picker.

colorbar
Unable to find extension: VK_EXT_physical_device_drm
No windowing system present. Using surfaceless platform
No config found!
No config found!

import numpy as np
import wgpu
from wgpu.utils.imgui import ImguiRenderer
from cmap import Colormap
from imgui_bundle import imgui
from rendercanvas.auto import RenderCanvas, loop


class ImguiColorbar:
    LUT_HEIGHT = 256
    TEX_WIDTH = 2
    HANDLE_HEIGHT = 8
    HANDLE_OVERHANG = 3  # how far a handle extends past the bar on each side
    BAR_BORDER = 1.0  # width of the outline drawn around the bar image

    def __init__(
        self,
        device,
        imgui_renderer,
        cmap_name: str,
        vmin: float,
        vmax: float,
        cmaps: list[str],
        data_range: tuple[float, float] | None = None,
        width: int = 15,
        height: int = 200,
    ):
        self._device = device
        self._renderer = imgui_renderer
        self._width = int(width)
        self._height = int(height)
        self._vmin = float(vmin)
        self._vmax = float(vmax)
        if data_range is None:
            data_range = (vmin, vmax)
        self._data_min, self._data_max = self._validate_range(data_range)
        self.gamma = 1.0

        # When True, dragging the bar between the handles moves both handles
        # together (shifts the vmin/vmax window without changing its width).
        self._region_drag = True

        # Offset (in data units) between the grabbed value and the value under
        # the cursor, captured when a drag starts so the handle tracks the
        # cursor without jumping.
        self._grab_offset = 0.0

        self._picker_tex_ids = {
            name: self._make_picker_texture(name) for name in cmaps
        }

        # The active colormap must be one of the colormaps in the picker.
        if cmap_name not in self._picker_tex_ids:
            raise ValueError(
                f"cmap_name {cmap_name!r} is not one of the available "
                f"colormaps: {list(self._picker_tex_ids)}"
            )
        self._cmap_name = cmap_name

        self._bar_texture = device.create_texture(
            size=(self.TEX_WIDTH, self.LUT_HEIGHT, 1),
            usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING,
            dimension=wgpu.TextureDimension.d2,
            format=wgpu.TextureFormat.rgba8unorm,
            mip_level_count=1,
            sample_count=1,
        )
        self._bar_tex_id = imgui_renderer.backend.register_texture(
            self._bar_texture.create_view()
        )
        self._update_bar_texture()

    @property
    def cmap_name(self):
        return self._cmap_name

    @cmap_name.setter
    def cmap_name(self, name):
        if name not in self._picker_tex_ids:
            raise ValueError(
                f"cmap_name {name!r} is not one of the available colormaps: "
                f"{list(self._picker_tex_ids)}"
            )
        if name != self._cmap_name:
            self._cmap_name = name
            self._update_bar_texture()

    @property
    def vmin(self):
        return self._vmin

    @vmin.setter
    def vmin(self, value):
        value = float(value)
        if value != self._vmin:
            self._vmin = value
            self._update_bar_texture()

    @property
    def vmax(self):
        return self._vmax

    @vmax.setter
    def vmax(self, value):
        value = float(value)
        if value != self._vmax:
            self._vmax = value
            self._update_bar_texture()

    @property
    def width(self):
        return self._width

    @width.setter
    def width(self, value):
        self._width = int(value)

    @property
    def height(self):
        return self._height

    @height.setter
    def height(self, value):
        self._height = int(value)

    @property
    def region_drag(self):
        return self._region_drag

    @region_drag.setter
    def region_drag(self, value):
        self._region_drag = bool(value)

    @property
    def data_range(self):
        return (self._data_min, self._data_max)

    @data_range.setter
    def data_range(self, value):
        self._data_min, self._data_max = self._validate_range(value)
        self._update_bar_texture()

    @staticmethod
    def _validate_range(data_range):
        data_min, data_max = float(data_range[0]), float(data_range[1])
        if data_max <= data_min:
            raise ValueError(
                f"data_range max ({data_max}) must be greater than "
                f"min ({data_min})"
            )
        return data_min, data_max

    def _make_picker_texture(self, name):
        lut = (Colormap(name)(np.linspace(0, 1, 256)) * 255).astype(np.uint8)
        data = np.ascontiguousarray(np.tile(lut[None, :, :], (2, 1, 1)))
        h, w = data.shape[:2]
        texture = self._device.create_texture(
            size=(w, h, 1),
            usage=wgpu.TextureUsage.COPY_DST | wgpu.TextureUsage.TEXTURE_BINDING,
            dimension=wgpu.TextureDimension.d2,
            format=wgpu.TextureFormat.rgba8unorm,
            mip_level_count=1,
            sample_count=1,
        )
        self._device.queue.write_texture(
            {"texture": texture, "mip_level": 0, "origin": (0, 0, 0)},
            data,
            {"offset": 0, "bytes_per_row": w * 4},
            (w, h, 1),
        )
        return self._renderer.backend.register_texture(texture.create_view())

    def _update_bar_texture(self):
        span = self._data_max - self._data_min
        lo = (self.vmin - self._data_min) / span
        hi = (self.vmax - self._data_min) / span
        t = np.linspace(1.0, 0.0, self.LUT_HEIGHT)
        norm = np.clip((t - lo) / (hi - lo), 0.0, 1.0)
        colors = (Colormap(self.cmap_name)(norm) * 255).astype(np.uint8)
        data = np.ascontiguousarray(
            np.tile(colors[:, None, :], (1, self.TEX_WIDTH, 1))
        )
        self._device.queue.write_texture(
            {"texture": self._bar_texture, "mip_level": 0, "origin": (0, 0, 0)},
            data,
            {"offset": 0, "bytes_per_row": self.TEX_WIDTH * 4},
            (self.TEX_WIDTH, self.LUT_HEIGHT, 1),
        )

    def draw(self):
        imgui.text(f"{self.vmax:.4g}")

        imgui.push_style_color(imgui.Col_.border, (1.0, 1.0, 1.0, 1.0))
        imgui.push_style_var(imgui.StyleVar_.image_border_size, self.BAR_BORDER)
        imgui.image(self._bar_tex_id, image_size=(self._width, self._height))
        imgui.pop_style_var()
        imgui.pop_style_color()

        # The border insets the colored bar from the item rect, so offset by the
        # border to get the bar's content origin. This keeps the handles centered
        # on the bar rather than on the (larger) bordered image.
        item_min = imgui.get_item_rect_min()
        bar_x = item_min.x + self.BAR_BORDER
        bar_y = item_min.y + self.BAR_BORDER

        if imgui.begin_popup_context_item("##colorbar_popup"):
            self._draw_popup()
            imgui.end_popup()

        imgui.text(f"{self.vmin:.4g}")
        self._draw_handles(bar_x, bar_y, self._width, self._height)

    def _value_to_y(self, v, y0, bar_h):
        span = self._data_max - self._data_min
        return y0 + (1.0 - (v - self._data_min) / span) * bar_h

    def _y_to_value(self, y, y0, bar_h):
        span = self._data_max - self._data_min
        return self._data_min + (1.0 - (y - y0) / bar_h) * span

    def _draw_handles(self, bar_x, bar_y, bar_w, bar_h):
        draw_list = imgui.get_window_draw_list()
        fill = imgui.color_convert_float4_to_u32((1.0, 1.0, 1.0, 1.0))
        outline = imgui.color_convert_float4_to_u32((0.0, 0.0, 0.0, 1.0))
        h = self.HANDLE_HEIGHT
        # Extend the handle HANDLE_OVERHANG pixels past the bar's outer (bordered)
        # edge on each side.
        x0 = bar_x - self.BAR_BORDER - self.HANDLE_OVERHANG
        x1 = bar_x + bar_w + self.BAR_BORDER + self.HANDLE_OVERHANG
        span = self._data_max - self._data_min
        min_sep = (h / bar_h) * span

        def cursor_value():
            # The data value under the cursor. Handles track this absolute
            # position (plus the grab offset) rather than accumulating per-frame
            # deltas, so a fast drag past the top/bottom edge pins the handle to
            # the extreme instead of stalling partway.
            return self._y_to_value(imgui.get_io().mouse_pos.y, bar_y, bar_h)

        # Drag the region between the handles to move both handles together.
        if self.region_drag:
            top = self._value_to_y(self.vmax, bar_y, bar_h) + h / 2
            bottom = self._value_to_y(self.vmin, bar_y, bar_h) - h / 2
            if bottom > top:
                imgui.set_cursor_screen_pos((x0, top))
                imgui.invisible_button("##colorbar_region", (x1 - x0, bottom - top))
                if imgui.is_item_activated():
                    self._grab_offset = 0.5 * (self.vmin + self.vmax) - cursor_value()
                if imgui.is_item_active():
                    half = 0.5 * (self.vmax - self.vmin)
                    center = cursor_value() + self._grab_offset
                    center = max(self._data_min + half,
                                 min(self._data_max - half, center))
                    vmin, vmax = center - half, center + half
                    if (vmin, vmax) != (self._vmin, self._vmax):
                        self._vmin, self._vmax = vmin, vmax
                        self._update_bar_texture()

        for v in (self.vmax, self.vmin):
            y = self._value_to_y(v, bar_y, bar_h)
            draw_list.add_rect_filled((x0, y - h / 2), (x1, y + h / 2), fill)
            draw_list.add_rect(
                (x0, y - h / 2), (x1, y + h / 2), outline, thickness=1.0
            )

        for label, attr, lo_fn, hi_fn in (
            ("##vmax_handle", "vmax",
             lambda: self.vmin + min_sep, lambda: self._data_max),
            ("##vmin_handle", "vmin",
             lambda: self._data_min, lambda: self.vmax - min_sep),
        ):
            cur = getattr(self, attr)
            y = self._value_to_y(cur, bar_y, bar_h)
            imgui.set_cursor_screen_pos((x0, y - h / 2))
            imgui.invisible_button(label, (x1 - x0, h))
            if imgui.is_item_activated():
                self._grab_offset = cur - cursor_value()
            if imgui.is_item_active():
                setattr(
                    self, attr,
                    max(lo_fn(), min(hi_fn(), cursor_value() + self._grab_offset)),
                )

    def _draw_popup(self):
        _, self.gamma = imgui.slider_float("gamma", self.gamma, 0.1, 5.0)
        imgui.separator()
        texture_height = imgui.get_font_size() - 2
        for name, tex_id in self._picker_tex_ids.items():
            _clicked, selected = imgui.menu_item(
                name, "", p_selected=(self.cmap_name == name)
            )
            imgui.same_line()
            imgui.push_style_color(imgui.Col_.border, (1.0, 1.0, 1.0, 1.0))
            imgui.push_style_var(imgui.StyleVar_.image_border_size, 1.0)
            imgui.image(tex_id, image_size=(100, texture_height))
            imgui.pop_style_var()
            imgui.pop_style_color()
            if selected:
                self.cmap_name = name


canvas = RenderCanvas(size=(256, 400), max_fps=60, update_mode="continuous")
adapter = wgpu.gpu.request_adapter_sync(power_preference="high-performance")
device = adapter.request_device_sync()
imgui_renderer = ImguiRenderer(device, canvas)

colorbar = ImguiColorbar(
    device=device,
    imgui_renderer=imgui_renderer,
    cmap_name="green",
    vmin=0.0,
    vmax=1.0,
    cmaps=["green", "red", "blue", "gray"],
)


def update_gui():
    imgui.set_next_window_pos((20, 20), imgui.Cond_.appearing)
    # Give the window an explicit initial size so it renders on the first frame.
    # An auto-fit window has no size until its second frame, which would leave
    # the gallery screenshot (a single rendered frame) blank.
    imgui.set_next_window_size((120, 340), imgui.Cond_.appearing)
    imgui.begin("colorbar", None)
    colorbar.draw()
    imgui.end()


imgui_renderer.set_gui(update_gui)
canvas.request_draw(imgui_renderer.render)


if __name__ == "__main__":
    loop.run()

Total running time of the script: (0 minutes 1.875 seconds)

Gallery generated by Sphinx-Gallery