diff --git a/examples/tiled_browser.py b/examples/tiled_browser.py new file mode 100644 index 0000000000..4f883789eb --- /dev/null +++ b/examples/tiled_browser.py @@ -0,0 +1,513 @@ +""" +This module is an example of a barebones QWidget plugin for napari + +It implements the Widget specification. +see: https://napari.org/plugins/guides.html?#widgets + +Replace code below according to your needs. +""" +import collections +from datetime import date, datetime +import functools +import json +import numpy as np +import dask.array as da + +from qtpy.QtCore import Qt, Signal +from qtpy.QtGui import QIcon, QPixmap +from qtpy.QtWidgets import ( + QAbstractItemView, + QComboBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSplitter, + QStyle, + QTableWidget, + QTableWidgetItem, + QTextEdit, + QVBoxLayout, + QWidget, +) +from tiled.client import from_uri +from tiled.client.array import DaskArrayClient +from tiled.client.container import Container +from tiled.structures.core import StructureFamily + +from silx.gui import qt +from silx.gui.plot import PlotWidget + +def json_decode(obj): + if isinstance(obj, (datetime, date)): + return obj.isoformat() + return str(obj) + + +class DummyClient: + "Placeholder for a structure family we cannot (yet) handle" + + def __init__(self, *args, item, **kwargs): + self.item = item + + +STRUCTURE_CLIENTS = collections.defaultdict(lambda: DummyClient) +STRUCTURE_CLIENTS.update({"array": DaskArrayClient, "container": Container}) + + +class TiledBrowser(qt.QMainWindow): + NODE_ID_MAXLEN = 8 + SUPPORTED_TYPES = (StructureFamily.array, StructureFamily.container) + + # your QWidget.__init__ can optionally request the napari viewer instance + # in one of two ways: + # 1. use a parameter called `napari_viewer`, as done here + # 2. use a type annotation of 'napari.viewer.Viewer' for any parameter + def __init__(self, parent=None): + super().__init__() + + self.set_root(None) + + # Create a PlotWidget + self._plot = PlotWidget(parent=self) + + # Connection elements + self.url_entry = QLineEdit() + self.url_entry.setPlaceholderText("Enter a url") + self.connect_button = QPushButton("Connect") + self.connection_label = QLabel("No url connected") + self.connection_widget = QWidget() + + # Connection layout + connection_layout = QVBoxLayout() + connection_layout.addWidget(self.url_entry) + connection_layout.addWidget(self.connect_button) + connection_layout.addWidget(self.connection_label) + connection_layout.addStretch() + self.connection_widget.setLayout(connection_layout) + + # Navigation elements + self.rows_per_page_label = QLabel("Rows per page: ") + self.rows_per_page_selector = QComboBox() + self.rows_per_page_selector.addItems(["5", "10", "25"]) + self.rows_per_page_selector.setCurrentIndex(0) + + self.current_location_label = QLabel() + self.first_page = ClickableQLabel("<<") + self.previous_page = ClickableQLabel("<") + self.next_page = ClickableQLabel(">") + self.last_page = ClickableQLabel(">>") + self.navigation_widget = QWidget() + + self._rows_per_page = int( + self.rows_per_page_selector.currentText() + ) + + # Navigation layout + navigation_layout = QHBoxLayout() + navigation_layout.addWidget(self.rows_per_page_label) + navigation_layout.addWidget(self.rows_per_page_selector) + navigation_layout.addWidget(self.current_location_label) + navigation_layout.addWidget(self.first_page) + navigation_layout.addWidget(self.previous_page) + navigation_layout.addWidget(self.next_page) + navigation_layout.addWidget(self.last_page) + self.navigation_widget.setLayout(navigation_layout) + + # Current path layout + self.current_path_layout = QHBoxLayout() + self.current_path_layout.setSpacing(10) + self.current_path_layout.setAlignment(Qt.AlignLeft) + self._rebuild_current_path_layout() + + # Catalog table elements + self.catalog_table = QTableWidget(0, 1) + self.catalog_table.horizontalHeader().setStretchLastSection(True) + self.catalog_table.setEditTriggers( + QTableWidget.EditTrigger.NoEditTriggers + ) # disable editing + self.catalog_table.horizontalHeader().hide() # remove header + self.catalog_table.setSelectionMode( + QAbstractItemView.SelectionMode.SingleSelection + ) # disable multi-select + # disabled due to bad colour palette: + # self.catalog_table.setAlternatingRowColors(True) + self.catalog_table.itemDoubleClicked.connect( + self._on_item_double_click + ) + self.catalog_table.itemSelectionChanged.connect(self._on_item_selected) + self.catalog_table_widget = QWidget() + self.catalog_breadcrumbs = None + + # Info layout + self.info_box = QTextEdit() + self.info_box.setReadOnly(True) + self.load_button = QPushButton("Open") + self.load_button.setEnabled(False) + self.load_button.clicked.connect(self._on_load) + catalog_info_layout = QHBoxLayout() + catalog_info_layout.addWidget(self.catalog_table) + load_layout = QVBoxLayout() + load_layout.addWidget(self.info_box) + load_layout.addWidget(self.load_button) + catalog_info_layout.addLayout(load_layout) + + # Catalog table layout + catalog_table_layout = QVBoxLayout() + catalog_table_layout.addLayout( self.current_path_layout ) + catalog_table_layout.addLayout(catalog_info_layout) + catalog_table_layout.addWidget(self.navigation_widget) + catalog_table_layout.addStretch(1) + self.catalog_table_widget.setLayout(catalog_table_layout) + self.catalog_table_widget.setVisible(False) + + self.splitter = QSplitter(self) + self.splitter.setOrientation(Qt.Orientation.Vertical) + + self.splitter.addWidget(self.connection_widget) + self.splitter.addWidget(self.catalog_table_widget) + + self.splitter.setStretchFactor(1, 2) + + browser_layout = QVBoxLayout() + browser_layout.addWidget(self.splitter) + + layout = QHBoxLayout() + layout.addWidget(self._plot) + layout.addLayout( browser_layout ) + + centralWidget = qt.QWidget(self) + centralWidget.setLayout(layout) + self.setCentralWidget(centralWidget) + + self.connect_button.clicked.connect(self._on_connect_clicked) + self.previous_page.clicked.connect(self._on_prev_page_clicked) + self.next_page.clicked.connect(self._on_next_page_clicked) + self.first_page.clicked.connect(self._on_first_page_clicked) + self.last_page.clicked.connect(self._on_last_page_clicked) + + self.rows_per_page_selector.currentTextChanged.connect( + self._on_rows_per_page_changed + ) + + def _on_connect_clicked(self): + url = self.url_entry.displayText().strip() + # url = "https://tiled-demo.blueskyproject.io/api" + if not url: + print("Please specify a url.") + return + try: + root = from_uri(url, STRUCTURE_CLIENTS) + if isinstance(root, DummyClient): + print("Unsupported tiled type detected") + except Exception: + print("Could not connect. Please check the url.") + else: + self.connection_label.setText(f"Connected to {url}") + self.set_root(root) + + def set_root(self, root): + self.root = root + self.node_path = () + self._current_page = 0 + if root is not None: + self.catalog_table_widget.setVisible(True) + self._rebuild() + + def get_current_node(self): + return self.get_node(self.node_path) + + @functools.lru_cache(maxsize=1) + def get_node(self, node_path): + if node_path: + return self.root[node_path] + return self.root + + def enter_node(self, node_id): + self.node_path += (node_id,) + self._current_page = 0 + self._rebuild() + + def exit_node(self): + self.node_path = self.node_path[:-1] + self._current_page = 0 + self._rebuild() + + def generate_plot(self, node): + plot = self.getPlotWidget() + plot.clear() + plot.getDefaultColormap().setName("viridis") + plot.addImage(node) + plot.resetZoom() + + def open_node(self, node_id): + node = self.get_current_node()[node_id] + family = node.item["attributes"]["structure_family"] + if isinstance(node, DummyClient): + print(f"Cannot open type: '{family}'") + return + if family == StructureFamily.array: + if node.ndim == 1: + node = node[:, np.newaxis] + self.generate_plot(node) + elif node.ndim > 3: + # Convert DaskArrayClient Object to Dask Array. + #node_arr = node.compute() + #node = da.from_array(node_arr) + + # Determine dimensions of array an which to slice. + num_dims = node.ndim + slicing_indices = tuple([0] * (num_dims - 3) + [slice(None)] * 3) + + # Convert array to three dimensions and plot data. + node_3d = node[slicing_indices] + try: + self.generate_plot(node_3d) + except ValueError: + print("RGB(A) image is expected to have 3" + "or 4 elements as last dimension. Got too many") + else: + self.generate_plot(node) + elif family == StructureFamily.container: + self.enter_node(node_id) + else: + print(f"Type not supported:'{family}") + + def _on_load(self): + selected = self.catalog_table.selectedItems() + if not selected: + return + item = selected[0] + if item is self.catalog_breadcrumbs: + return + self.open_node(item.text()) + + def _on_rows_per_page_changed(self, value): + self._rows_per_page = int(value) + self._current_page = 0 + self._rebuild_table() + self._set_current_location_label() + + def _on_item_double_click(self, item): + if item is self.catalog_breadcrumbs: + self.exit_node() + return + self.open_node(item.text()) + + def _on_item_selected(self): + selected = self.catalog_table.selectedItems() + if not selected or (item := selected[0]) is self.catalog_breadcrumbs: + self._clear_metadata() + return + + name = item.text() + node_path = self.node_path + (name,) + node = self.get_node(node_path) + + attrs = node.item["attributes"] + family = attrs["structure_family"] + metadata = json.dumps(attrs["metadata"], indent=2, default=json_decode) + + info = f"type: {family}
" + if family == StructureFamily.array: + shape = attrs["structure"]["shape"] + info += f"shape: {tuple(shape)}
" + info += f"metadata: {metadata}" + self.info_box.setText(info) + + if family in self.SUPPORTED_TYPES: + self.load_button.setEnabled(True) + else: + self.load_button.setEnabled(False) + + def _clear_metadata(self): + self.info_box.setText("") + self.load_button.setEnabled(False) + + def _on_breadcrumb_clicked(self, node): + # If root is selected. + if node == "root": + self.node_path = () + self._rebuild() + + # For any node other than root. + else: + try: + index = self.node_path.index(node) + self. node_path = self.node_path[:index + 1] + self._rebuild() + + # If node ID has been truncated. + except ValueError: + for i, node_id in enumerate(self.node_path): + if node == node_id[:self.NODE_ID_MAXLEN - 3] + "...": + index = i + break + + self.node_path = self.node_path[:index + 1] + self._rebuild() + + def _clear_current_path_layout(self): + for i in reversed(range(self.current_path_layout.count())): + widget = self.current_path_layout.itemAt(i).widget() + self.current_path_layout.removeWidget(widget) + widget.deleteLater() + + def _rebuild_current_path_layout(self): + # Add root to widget list. + root = ClickableQLabel("root") + root.clicked_with_text.connect(self._on_breadcrumb_clicked) + widgets = [root] + + # Appropriately shorten node_id. + for node_id in self.node_path: + if len(node_id) > self.NODE_ID_MAXLEN: + node_id = node_id[: self.NODE_ID_MAXLEN - 3] + "..." + + # Convert node_id into a ClickableQWidget and add to widget list. + clickable_label = ClickableQLabel(node_id) + clickable_label.clicked_with_text.connect(self._on_breadcrumb_clicked) + widgets.append(clickable_label) + + # Add nodes to node path. + if len(self.current_path_layout) < len(widgets): + for widget in widgets: + widget = widgets[-1] + self.current_path_layout.addWidget(widget) + + # Remove nodes from node path after they are left. + elif len(self.current_path_layout) > len(widgets): + self._clear_current_path_layout() + while len(self.current_path_layout) < len(widgets): + for widget in widgets: + self.current_path_layout.addWidget(widget) + + def _rebuild_table(self): + prev_block = self.catalog_table.blockSignals(True) + # Remove all rows first + while self.catalog_table.rowCount() > 0: + self.catalog_table.removeRow(0) + + if self.node_path: + # add breadcrumbs + self.catalog_breadcrumbs = QTableWidgetItem("..") + self.catalog_table.insertRow(0) + self.catalog_table.setItem(0, 0, self.catalog_breadcrumbs) + + # Then add new rows + for row in range(self._rows_per_page): + last_row_position = self.catalog_table.rowCount() + self.catalog_table.insertRow(last_row_position) + node_offset = self._rows_per_page * self._current_page + # Fetch a page of keys. + items = self.get_current_node().items()[ + node_offset : node_offset + self._rows_per_page + ] + # Loop over rows, filling in keys until we run out of keys. + start = 1 if self.node_path else 0 + for row_index, (key, value) in zip( + range(start, self.catalog_table.rowCount()), items + ): + family = value.item["attributes"]["structure_family"] + if family == StructureFamily.container: + icon = self.style().standardIcon(QStyle.SP_DirHomeIcon) + elif family == StructureFamily.array: + icon = self.style().standardIcon( + QStyle.SP_FileIcon + ) + else: + icon = self.style().standardIcon( + QStyle.SP_TitleBarContextHelpButton + ) + self.catalog_table.setItem( + row_index, 0, QTableWidgetItem(icon, key) + ) + + # remove extra rows + for row in range(self._rows_per_page - len(items)): + self.catalog_table.removeRow(self.catalog_table.rowCount() - 1) + + headers = [ + str(x + 1) + for x in range( + node_offset, node_offset + self.catalog_table.rowCount() + ) + ] + if self.node_path: + headers = [""] + headers + + self.catalog_table.setVerticalHeaderLabels(headers) + self._clear_metadata() + self.catalog_table.blockSignals(prev_block) + + def _rebuild(self): + self._rebuild_table() + self._rebuild_current_path_layout() + self._set_current_location_label() + + def _on_prev_page_clicked(self): + if self._current_page != 0: + self._current_page -= 1 + self._rebuild() + + def _on_next_page_clicked(self): + if ( + self._current_page * self._rows_per_page + ) + self._rows_per_page < len(self.get_current_node()): + self._current_page += 1 + self._rebuild() + + def _on_first_page_clicked(self): + if self._current_page != 0: + self._current_page = 0 + self._rebuild() + + def _on_last_page_clicked(self): + while True: + if ( + self._current_page * self._rows_per_page + ) + self._rows_per_page < len(self.get_current_node()): + self._current_page += 1 + else: + self._rebuild() + break + + def _set_current_location_label(self): + starting_index = self._current_page * self._rows_per_page + 1 + ending_index = min( + self._rows_per_page * (self._current_page + 1), + len(self.get_current_node()), + ) + current_location_text = f"{starting_index}-{ending_index} of {len(self.get_current_node())}" + self.current_location_label.setText(current_location_text) + + def getPlotWidget(self): + """Returns the PlotWidget contains in this window""" + return self._plot + + def showImage(self): + plot = self.getPlotWidget() + plot.clear() + plot.getDefaultColormap().setName("viridis") + + +class ClickableQLabel(QLabel): + clicked = Signal() + clicked_with_text = Signal(str) + + def mousePressEvent(self, event): + self.clicked.emit() + self.clicked_with_text.emit(self.text()) + + +def main(): + app = qt.QApplication([]) + w = TiledBrowser() + w.show() + app.exec() + +if __name__ == "__main__": + main() + +# TODO: handle changing the location label/current_page when on last page and +# increasing rows per page