Source code for broh5.lib.interactions

"""
This module links user interactions to the responses of the Broh5 software.
"""

import os
import h5py
import hdf5plugin
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as patches
from matplotlib.lines import Line2D
from nicegui import ui, events
import broh5.lib.rendering as re
import broh5.lib.utilities as util
from broh5.lib.rendering import GuiRendering, FilePicker, FileSaver


[docs] class GuiInteraction(GuiRendering): """ A subclass of GuiRendering that provides specific functionalities for interacting with the GUI elements in Broh5. This class handles user actions such as file selection, branch/leaf selection in an HDF tree, image saving, or data display; resets and updates the GUI in response to these interactions. Attributes ---------- current_state : tuple or None Current state of the GUI: file path, HDF key, slider values, ... columns : list or None Columns for displaying data in a table. rows : list or None Rows for displaying data in a table. image : np.ndarray or None Current slice from a 3D dataset. current_slice : tuple or None Information about the current slice being displayed. data_1d_2d : np.ndarray or None Current 1D or 2D data being displayed. timer : UI object To update the GUI in regular intervals. Methods ------- mouse_handler(MouseEventArguments) Display the image ROI or intensity profile when clicking on the image. show_key(ValueChangeEventArguments, str) Display the key of the HDF dataset/group when a tree node is clicked. pick_file() Open a file picker dialog to select a file. display_hdf_tree(str) Display the HDF file structure as an interactive tree. disable_sliders() Disable and reset the sliders for 3D-data slicing. enable_ui_elements_3d_data() Enable UI elements specific to 3D-data display. enable_ui_elements_1d_2d_data() Enable UI elements specific to 1D/2D data display. reset(keep_display=False) Reset the UI elements to their initial states. reset_min_max() Reset the minimum and maximum sliders for image contrast. display_3d_data(data_obj) Display a slice of a 3D dataset as an image. display_1d_2d_data(data_obj, "plot") Display 1D/2D data as a plot or table. show_data() Display data from an HDF file based on the current GUI state. save_image() Save the currently displayed image to a file. save_data() Save the currently displayed 1D/2D data to a file. shutdown() Routine to close the app. """ def __init__(self): super().__init__() self.select_file_button.on("click", self.pick_file) self.save_image_button.on("click", self.save_image) self.save_data_button.on("click", self.save_data) self.reset_button.on("click", self.reset_min_max) self.enable_zoom.on("click", self.__update_zoom_check_box) self.enable_profile.on("click", self.__update_profile_check_box) self.main_plot.on("click", self.mouse_handler) self.tab_one.on("click", self.__select_tab_one) self.tab_two.on("click", self.__select_tab_two) self.current_state, self.image, self.image_norm = None, None, None self.columns, self.rows = None, None self.current_slice, self.data_1d_2d = None, None self.timer = ui.timer(re.UPDATE_RATE, lambda: self.show_data()) self.tab_one.on("click", self.__select_tab_one) self.tab_two.on("click", self.__select_tab_two) self.selected_tab = 1 self.last_folder = "" self.fig, self.ax = None, None self.ver_line, self.hor_line, self.draw_roi = None, None, None def __select_tab_one(self): self.selected_tab = 1 def __select_tab_two(self): self.selected_tab = 2 def __update_zoom_check_box(self): if (self.enable_zoom.value is True and self.enable_profile.value is True): self.enable_profile.set_value(False) def __update_profile_check_box(self): if (self.enable_profile.value is True and self.enable_zoom.value is True): self.enable_zoom.set_value(False) def __get_xy(self, x, y, ax): try: xn, yn = ax.transData.inverted().transform((x, y)) return xn, yn except Exception as e: return None, None
[docs] def mouse_handler(self, e: events.MouseEventArguments): """ Show the zoomed area around the mouse-clicked location or the intensity profile across the clicked location. """ if self.image is not None and ( self.enable_profile.value or self.enable_zoom.value): x, y = self.__get_xy(e.args['offsetX'], e.args['offsetY'], self.ax) if x is not None: (height, width) = self.image.shape x = int(x) y_max = self.ax.transData.inverted().transform( (0, self.ax.get_ylim()[-1]))[-1] y = height - 1 - int(y) + int((y_max - height) / 2) if width > x >= 0 and height > y >= 0: zp_fig = self.zoom_profile_plot.figure zp_fig.clf() zp_fig.set_dpi(self.dpi) zp_ax = zp_fig.gca() if self.draw_roi is not None: self.draw_roi.set_visible(False) self.draw_roi = None if self.ver_line is not None: self.ver_line.set_visible(False) self.ver_line = None if self.hor_line is not None: self.hor_line.set_visible(False) self.hor_line = None if self.enable_profile.value: if self.profile_list.value == "vertical": self.ver_line = Line2D([x, x], [-1, height + 1], color=re.BOX_LINE_COLOR, linewidth=\ re.BOX_LINE_WIDTH) if self.ax is not None: self.ax.add_line(self.ver_line) list_data = self.image[:, x] zp_ax.plot(list_data) zp_ax.set_title(f"Profile at column: {x}") else: self.hor_line = Line2D([-1, width + 1], [y, y], color=re.BOX_LINE_COLOR, linewidth=\ re.BOX_LINE_WIDTH) if self.ax is not None: self.ax.add_line(self.hor_line) list_data = self.image[y] zp_ax.plot(list_data) zp_ax.set_title(f"Profile at row: {y}") zp_ax.set_xlabel("Index") zp_ax.set_ylabel("Intensity") zp_fig.tight_layout() self.main_plot.update() else: if self.image_norm is not None: val = self.zoom_list.value zoom = int(val.replace("x", "")) roi_img, x0, y0, size = \ util.get_image_roi(x, y, self.image_norm, zoom=zoom) self.draw_roi = patches.Rectangle( (x0, y0), size, size, linewidth=re.BOX_LINE_WIDTH, edgecolor=re.BOX_LINE_COLOR, facecolor='none') self.ax.add_patch(self.draw_roi) self.main_plot.update() zp_ax.imshow(roi_img, cmap=self.cmap_list.value) zp_fig.tight_layout() self.zoom_profile_plot.update()
[docs] def show_key(self, event: events.ValueChangeEventArguments, file_path): """ Show key to a dataset/group of a hdf file when users click to a branch of the hdf tree. """ hdf_key = event.value if hdf_key is not None: self.hdf_key_display.set_text(hdf_key) else: self.hdf_value_display.set_text("") if file_path is not None: self.file_path_display.set_text(file_path)
[docs] async def pick_file(self) -> None: """To pick a file when click the button 'Select file' """ config_data = util.load_config() if config_data is None: self.last_folder = "" else: try: self.last_folder = config_data["last_folder"] except KeyError: self.last_folder = "" if (self.last_folder == "") or (not os.path.exists(self.last_folder)): file_path = await FilePicker("~", allowed_extensions=re.INPUT_EXT) else: file_path = await FilePicker(self.last_folder, allowed_extensions=re.INPUT_EXT) if file_path: self.last_folder = os.path.dirname(file_path) config_data = {'last_folder': self.last_folder} util.save_config(config_data) self.display_hdf_tree(file_path)
[docs] def display_hdf_tree(self, file_path): """Display interactive tree structure of a hdf file""" with self.tree_container: file_path = file_path.replace("\\", "/") self.file_path_display.set_text(file_path) self.hdf_key_display.set_text("") hdf_dic = util.hdf_tree_to_dict(file_path) if isinstance(hdf_dic, list): tree_display = ui.card() def close_file(): if isinstance(self.current_state, tuple): if file_path == self.current_state[0]: self.reset() else: self.reset() self.tree_container.remove(tree_display) with tree_display.style("background-color: " + re.TREE_BGR_COLOR): ui.tree(hdf_dic, label_key="id", node_key="label", on_select=lambda e: self.show_key(e, file_path)) ui.button("Close file", on_click=lambda: close_file()) else: if isinstance(hdf_dic, str): ui.notify(hdf_dic) else: ui.notify("Input must be hdf, hdf5, nxs, or h5 format!")
[docs] def disable_sliders(self): """Disable and reset values of sliders""" self.main_slider.set_value(0) self.main_slider.disable() self.min_slider.set_value(0) self.min_slider.disable() self.max_slider.set_value(255) self.max_slider.disable()
[docs] def enable_ui_elements_3d_data(self): """ Enable UI-elements for displaying a slice from 3d data as an image and disable non-related UI-elements. """ # Enable ui-components related to image show self.main_slider.enable() self.max_slider.enable() self.min_slider.enable() self.main_plot.set_visibility(True) self.axis_list.enable() self.cmap_list.enable() self.enable_zoom.enable() self.zoom_list.enable() self.enable_profile.enable() self.profile_list.enable() self.save_image_button.enable() self.histogram_plot.set_visibility(True) self.image_info_table.set_visibility(True) if self.enable_profile.value or self.enable_zoom.value: self.zoom_profile_plot.figure.clf() self.zoom_profile_plot.set_visibility(True) else: self.zoom_profile_plot.figure.clf() self.zoom_profile_plot.set_visibility(False) self.image_norm = None # Disable other ui-components self.main_table.set_visibility(False) self.display_type.disable() self.marker_list.disable() self.save_data_button.disable() self.data_1d_2d = None
[docs] def enable_ui_elements_1d_2d_data(self): """ Enable UI-elements for displaying 1d/2d data as a table or plot, and disable non-related UI-elements. """ self.image, self.image_norm = None, None # Disable ui-components related to image show self.disable_sliders() self.axis_list.value = re.AXIS_LIST[0] self.cmap_list.value = re.CMAP_LIST[0] self.axis_list.disable() self.cmap_list.disable() self.enable_zoom.disable() self.zoom_list.disable() self.enable_profile.disable() self.profile_list.disable() self.save_image_button.disable() self.histogram_plot.set_visibility(False) self.image_info_table.set_visibility(False) self.zoom_profile_plot.set_visibility(False) # Enable other ui-components self.display_type.enable() self.marker_list.enable() self.save_data_button.enable() self.panel_tabs.set_value(self.tab_one) self.selected_tab = 1
[docs] def reset(self, keep_display=False): """Reset status of UI-elements""" if not keep_display: self.hdf_key_display.set_text("") self.file_path_display.set_text("") self.hdf_value_display.set_text("") self.axis_list.value = re.AXIS_LIST[0] self.cmap_list.value = re.CMAP_LIST[0] self.display_type.value = re.DISPLAY_TYPE[0] self.marker_list.value = re.MARKER_LIST[0] self.axis_list.disable() self.cmap_list.disable() self.display_type.disable() self.marker_list.disable() self.enable_zoom.set_value(False) self.enable_zoom.disable() self.zoom_list.disable() self.enable_profile.set_value(False) self.enable_profile.disable() self.profile_list.disable() self.disable_sliders() self.rows, self.columns = None, None self.image, self.image_norm, self.data_1d_2d = None, None, None self.main_table.set_visibility(False) self.main_plot.set_visibility(True) self.zoom_profile_plot.set_visibility(False) self.save_image_button.disable() self.save_data_button.disable() self.histogram_plot.set_visibility(False) self.image_info_table.set_visibility(False) self.zoom_profile_plot.set_visibility(False) self.panel_tabs.set_value(self.tab_one) self.selected_tab = 1
[docs] def reset_min_max(self): """Reset minimum and maximum values of sliders""" self.min_slider.set_value(0) self.max_slider.set_value(255)
[docs] def display_3d_data(self, data_obj): """Display a slice of 3d array as an image""" self.enable_ui_elements_3d_data() (depth, height, width) = data_obj.shape current_max = self.main_slider._props["max"] if int(self.axis_list.value) == 2: max_val = width - 1 elif int(self.axis_list.value) == 1: max_val = height - 1 else: max_val = depth - 1 if current_max != max_val: self.main_slider._props["max"] = max_val self.main_slider.update() d_pos = int(self.main_slider.value) if d_pos > max_val: self.main_slider.set_value(max_val) d_pos = max_val new_slice = (self.main_slider.value, self.axis_list.value, self.file_path_display.text) if new_slice != self.current_slice or self.image is None: self.current_slice = new_slice if int(self.axis_list.value) == 2: if depth > 1000 and height > 1000: ui.notify("Slicing along axis 2 is very time-consuming!") self.axis_list.value = 0 self.main_slider.set_value(0) self.image = data_obj[0] else: self.image = data_obj[:, :, d_pos] elif int(self.axis_list.value) == 1: if depth > 1000 and width > 1000: ui.notify("Slicing along axis 1 can take time !") self.image = data_obj[:, d_pos, :] else: self.image = data_obj[d_pos] min_val = int(self.min_slider.value) max_val = int(self.max_slider.value) if min_val > 0 or max_val < 255: if min_val >= max_val: min_val = np.clip(max_val - 1, 0, 254) self.min_slider.set_value(min_val) nmin, nmax = np.min(self.image), np.max(self.image) if nmax != nmin: self.image_norm = np.uint8( 255.0 * (self.image - nmin) / (nmax - nmin)) self.image_norm = np.clip(self.image_norm, min_val, max_val) else: self.image_norm = np.zeros(self.image.shape) else: self.image_norm = np.copy(self.image) self.fig = self.main_plot.figure self.fig.clf() self.fig.set_dpi(self.dpi) self.ax = self.fig.gca() self.ax.imshow(self.image_norm, cmap=self.cmap_list.value) self.fig.tight_layout() self.main_plot.update() if self.selected_tab == 2: rows, columns = util.format_statistical_info(self.image) self.image_info_table.rows[:] = rows self.image_info_table.columns[:] = columns self.image_info_table.update() with self.histogram_plot: plt.clf() flat_data = self.image.ravel() num_bins = min(255, len(flat_data)) hist, bin_edges = np.histogram(flat_data, bins=num_bins) plt.hist(bin_edges[:-1], bins=bin_edges, weights=hist, color='skyblue', edgecolor='black', alpha=0.65, label=f"Num bins: {num_bins}") plt.title("Histogram") plt.xlabel("Grayscale") plt.ylabel("Frequency") plt.legend() self.histogram_plot.update()
[docs] def display_1d_2d_data(self, data_obj, disp_type="plot"): """Display 1d/2d array as a table or plot""" self.enable_ui_elements_1d_2d_data() self.data_1d_2d = data_obj[:] if disp_type == "table": self.main_plot.set_visibility(False) self.main_table.set_visibility(True) rows, columns = util.format_table_from_array(data_obj[:]) self.main_table.rows[:] = rows self.main_table.columns[:] = columns self.main_table.update() else: self.main_plot.set_visibility(True) self.main_table.set_visibility(False) x, y = None, None img = False if len(data_obj.shape) == 2: (height, width) = data_obj.shape if height == 2: x, y = np.asarray(data_obj[0]), np.asarray(data_obj[1]) elif width == 2: x = np.asarray(data_obj[:, 0]) y = np.asarray(data_obj[:, 1]) else: img = True else: size = len(data_obj) x, y = np.arange(size), np.asarray(data_obj[:]) if x is not None: title = self.hdf_key_display.text.split("/")[-1] fig = self.main_plot.figure fig.clf() fig.set_dpi(self.dpi) ax = fig.gca() ax.set_title(title.capitalize()) ax.plot(x, y, marker=self.marker_list.value, color=re.PLOT_COLOR) fig.tight_layout() self.main_plot.update() if img: fig = self.main_plot.figure fig.clf() fig.set_dpi(self.dpi) ax = fig.gca() ax.imshow(data_obj[:], cmap=self.cmap_list.value, aspect="auto") fig.tight_layout() self.main_plot.update()
def __clear_plot(self): self.main_plot.figure.clf() self.main_plot.update() self.zoom_profile_plot.figure.clf() self.zoom_profile_plot.update() with self.histogram_plot: plt.clf() self.histogram_plot.update()
[docs] def show_data(self): """Display data getting from a hdf file""" file_path1 = self.file_path_display.text hdf_key1 = self.hdf_key_display.text if (file_path1 != "") and (hdf_key1 != "") and (hdf_key1 is not None): new_state = (file_path1, hdf_key1, self.main_slider.value, self.hdf_value_display.text, self.axis_list.value, self.cmap_list.value, self.display_type.value, self.marker_list.value, self.min_slider.value, self.max_slider.value, self.selected_tab, self.enable_zoom.value, self.enable_profile.value) if new_state != self.current_state: self.current_state = new_state try: (data_type, value) = util.get_hdf_data(file_path1, hdf_key1) if (data_type == "string" or data_type == "number" or data_type == "boolean"): self.hdf_value_display.set_text(str(value)) self.__clear_plot() self.reset(keep_display=True) elif data_type == "array": self.hdf_value_display.set_text("Array shape: " "" + str(value)) hdf_obj = h5py.File(file_path1, "r") dim = len(value) if dim == 3: self.display_3d_data(hdf_obj[hdf_key1]) elif dim < 3: self.display_1d_2d_data( hdf_obj[hdf_key1], disp_type=self.display_type.value) else: ui.notify("Can't display {}-d array!".format(dim)) self.__clear_plot() self.reset(keep_display=True) hdf_obj.close() else: self.hdf_value_display.set_text(data_type) self.__clear_plot() self.reset(keep_display=True) except Exception as error: self.reset(keep_display=True) _, broken_link, msg = util.check_external_link(file_path1, hdf_key1) if broken_link: ui.notify(msg) else: _, ext_compressed, msg = util.check_compressed_dataset( file_path1, hdf_key1) if ext_compressed: ui.notify(msg) else: ui.notify("Error: {}".format(error)) ui.notify("Dataset may be an external link and the" " target file is not accessible (moved, " "deleted, or corrupted) !!!") else: self.hdf_value_display.set_text("") self.__clear_plot() self.reset(keep_display=True)
[docs] async def save_image(self) -> None: """To save a slice to file when click 'Save image' """ if (self.last_folder == "") or (not os.path.exists(self.last_folder)): file_path = await FileSaver("~", title="File name (ext: .tif, " ".jpg, .png, or .csv)") else: file_path = await FileSaver(self.last_folder, title="File name (ext: .tif, " ".jpg, .png, or .csv)") if file_path and self.image is not None: file_ext = os.path.splitext(file_path)[-1] if (file_ext != ".tif" and file_ext != ".jpg" and file_ext != ".png" and file_ext != ".csv"): ui.notify("Please use .tif, .jpg, .png, or .csv as " "file extension!") else: check = os.path.isfile(file_path) if file_ext == ".csv": error = util.save_table(file_path, self.image) else: error = util.save_image(file_path, self.image) if error is not None: ui.notify(error) else: if check: ui.notify( "File {} is overwritten".format(file_path)) else: ui.notify("File is saved at: {}".format(file_path))
[docs] async def save_data(self) -> None: """To save data to file when click the button 'Save data' """ if (self.last_folder == "") or (not os.path.exists(self.last_folder)): file_path = await FileSaver("~", title="File name (ext: .csv)") else: file_path = await FileSaver(self.last_folder, title="File name (ext: .csv)") if file_path and self.data_1d_2d is not None: file_ext = os.path.splitext(file_path)[-1] if file_ext == "": file_ext = ".csv" file_path = file_path + file_ext if file_ext != ".csv": ui.notify("Please use .csv as file extension!") else: check = os.path.isfile(file_path) error = util.save_table(file_path, self.data_1d_2d) if error is not None: ui.notify(error) else: if check: ui.notify( "File {} is overwritten".format(file_path)) else: ui.notify( "File is saved at: {}".format(file_path))
[docs] def shutdown(self): """Routine to close the app""" self.timer.cancel() ui.notify("The server has been stopped. You can close this tab!")