Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use image preview list to show tomograms #26

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ install_requires =
ndjson
qtpy
superqt
scikit-image

python_requires = >=3.8
include_package_data = True
Expand Down
51 changes: 16 additions & 35 deletions src/napari_cryoet_data_portal/_filter.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from typing import TYPE_CHECKING, Dict, Generator, List, Protocol, Tuple, Type, Union
from typing import TYPE_CHECKING, Dict, Generator, List, Protocol, Set, Tuple, Type, TypeVar, Union

from cryoet_data_portal import Client, Dataset, Run, Tomogram, TomogramVoxelSpacing

Expand All @@ -11,7 +11,7 @@


class Filter(Protocol):
def load(self, client: Client) -> Generator[Tuple[Dataset, List[Tomogram]], None, None]:
def load(self, client: Client) -> Generator[Dataset, None, None]:
"""Load the datasets and tomograms that match this filter."""
...

Expand All @@ -20,63 +20,44 @@ def load(self, client: Client) -> Generator[Tuple[Dataset, List[Tomogram]], None
class DatasetFilter:
ids: Tuple[int, ...] = ()

def load(self, client: Client) -> Generator[Tuple[Dataset, List[Tomogram]], None, None]:
def load(self, client: Client) -> Generator[Dataset, None, None]:
gql_filters = _ids_to_gql(Dataset.id, self.ids)
for dataset in Dataset.find(client, gql_filters):
tomograms: List[Tomogram] = []
for run in dataset.runs:
for spacing in run.tomogram_voxel_spacings:
tomograms.extend(spacing.tomograms)
yield dataset, tomograms
yield from Dataset.find(client, gql_filters)


@dataclass(frozen=True)
class RunFilter:
ids: Tuple[int, ...] = ()

def load(self, client: Client) -> Generator[Tuple[Dataset, List[Tomogram]], None, None]:
datasets: Dict[int, Dataset] = {}
tomograms: Dict[int, List[Tomogram]] = defaultdict(list)
def load(self, client: Client) -> Generator[Dataset, None, None]:
datasets: Set[Dataset] = set()
gql_filters = _ids_to_gql(Run.id, self.ids)
for run in Run.find(client, gql_filters):
dataset = run.dataset
datasets[dataset.id] = dataset
for spacing in run.tomogram_voxel_spacings:
tomograms[dataset.id].extend(spacing.tomograms)
for i in datasets:
yield datasets[i], tomograms[i]
datasets += run.dataset
yield from datasets


@dataclass(frozen=True)
class SpacingFilter:
ids: Tuple[int, ...] = ()

def load(self, client: Client) -> Generator[Tuple[Dataset, List[Tomogram]], None, None]:
datasets: Dict[int, Dataset] = {}
tomograms: Dict[int, List[Tomogram]] = defaultdict(list)
def load(self, client: Client) -> Generator[Dataset, None, None]:
datasets: Set[Dataset] = set()
gql_filters = _ids_to_gql(TomogramVoxelSpacing.id, self.ids)
for spacing in TomogramVoxelSpacing.find(client, gql_filters):
dataset = spacing.run.dataset
datasets[dataset.id] = dataset
tomograms[dataset.id].extend(spacing.tomograms)
for i in datasets:
yield datasets[i], tomograms[i]

datasets += spacing.run.dataset
yield from datasets

@dataclass(frozen=True)
class TomogramFilter:
ids: Tuple[int, ...] = ()

def load(self, client: Client) -> Generator[Tuple[Dataset, List[Tomogram]], None, None]:
datasets: Dict[int, Dataset] = {}
tomograms: Dict[int, List[Tomogram]] = defaultdict(list)
def load(self, client: Client) -> Generator[Dataset, None, None]:
datasets: Set[Dataset] = set()
gql_filters = _ids_to_gql(Tomogram.id, self.ids)
for tomogram in Tomogram.find(client, gql_filters):
dataset = tomogram.tomogram_voxel_spacing.run.dataset
datasets[dataset.id] = dataset
tomograms[dataset.id].append(tomogram)
for i in datasets:
yield datasets[i], tomograms[i]
datasets += tomogram.tomogram_voxel_spacing.run.dataset
yield from datasets


def make_filter(type: Union[Type[Dataset], Type[Run], Type[TomogramVoxelSpacing], Type[Tomogram]], ids: Tuple[int, ...]) -> Filter:
Expand Down
13 changes: 4 additions & 9 deletions src/napari_cryoet_data_portal/_listing_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class ListingWidget(QGroupBox):
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)

self.setTitle("Data")
self.setTitle("Datasets")
self.tree = ListingTreeWidget()
self.filter = QLineEdit()
self.filter.setPlaceholderText("Filter datasets and tomograms")
Expand Down Expand Up @@ -56,20 +56,15 @@ def cancel(self) -> None:
logger.debug("ListingWidget.cancel")
self._progress.cancel()

def _loadDatasets(self, uri: str, filter: Filter) -> Generator[Tuple[Dataset, List[Tomogram]], None, None]:
def _loadDatasets(self, uri: str, filter: Filter) -> Generator[Dataset, None, None]:
logger.debug("ListingWidget._loadDatasets: %s", uri)
client = Client(uri)
yield from filter.load(client)

def _onDatasetLoaded(self, result: Tuple[Dataset, List[Tomogram]]) -> None:
dataset, tomograms = result
def _onDatasetLoaded(self, dataset: Dataset) -> None:
logger.debug("ListingWidget._onDatasetLoaded: %s", dataset.id)
text = f"{dataset.id} ({len(tomograms)})"
text = f"{dataset.id}"
item = QTreeWidgetItem((text,))
item.setData(0, Qt.ItemDataRole.UserRole, dataset)
for tomogram in tomograms:
tomogram_item = QTreeWidgetItem((tomogram.name,))
tomogram_item.setData(0, Qt.ItemDataRole.UserRole, tomogram)
item.addChild(tomogram_item)
_update_visible_items(item, self.tree.last_filter)
self.tree.addTopLevelItem(item)
46 changes: 46 additions & 0 deletions src/napari_cryoet_data_portal/_preview_list_widget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from typing import Optional

from qtpy.QtCore import QRegularExpression, QSize
from qtpy.QtWidgets import (
QListWidget,
QListWidgetItem,
QWidget,
)

from napari_cryoet_data_portal._logging import logger


class PreviewListWidget(QListWidget):
"""A filterable list of icons."""

def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)

self._last_filter: QRegularExpression = QRegularExpression("")

self.setViewMode(QListWidget.ViewMode.IconMode)
self.setSelectionMode(QListWidget.SelectionMode.SingleSelection)
self.setFlow(QListWidget.Flow.LeftToRight)
self.setDragEnabled(False)
self.setAcceptDrops(False)
self.setIconSize(QSize(64, 64))

def addItem(self, item: QListWidgetItem) -> None:
logger.debug("PreviewListWidget.addItem: %s", item)
_update_visible_item(item, self._last_filter)
super().addItem(item)

def updateVisibleItems(self, pattern: str) -> None:
logger.debug("PreviewListWidget.updateVisibleItems: %s", pattern)
self._last_filter = QRegularExpression(pattern)
for i in range(self.count()):
item = self.item(i)
_update_visible_item(item, self._last_filter)


def _update_visible_item(
item: QListWidgetItem, expression: QRegularExpression
) -> None:
text = item.text()
visible = expression.match(text).hasMatch()
item.setHidden(not visible)
91 changes: 91 additions & 0 deletions src/napari_cryoet_data_portal/_preview_widget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from typing import Generator, Optional, Tuple

import numpy as np
from qtpy.QtCore import Qt
from qtpy.QtGui import QBitmap, QIcon, QImage
from qtpy.QtWidgets import (
QGroupBox,
QListWidgetItem,
QVBoxLayout,
QWidget,
)
from skimage.io import imread
from cryoet_data_portal import Client, Dataset, Run, Tomogram

from napari_cryoet_data_portal._logging import logger
from napari_cryoet_data_portal._preview_list_widget import PreviewListWidget
from napari_cryoet_data_portal._progress_widget import ProgressWidget
from napari_cryoet_data_portal._reader import read_tomogram


class PreviewWidget(QGroupBox):
"""Previews tomograms in a dataset as a list of thumbnail images."""

def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)

self._uri: Optional[str] = None

self.setTitle("Tomograms")
self.list = PreviewListWidget()
self._progress = ProgressWidget(
work=self._loadTomograms,
yieldCallback=self._onTomogramLoaded,
)

layout = QVBoxLayout()
layout.addWidget(self.list, 1)
layout.addWidget(self._progress)
layout.addStretch(0)
self.setLayout(layout)

def setUri(self, uri: str) -> None:
"""Sets the URI of the portal that should be used to open preview data."""
self._uri = uri

def load(self, dataset: Dataset) -> None:
"""Previews the tomograms of the given dataset."""
logger.debug("PreviewWidget.load: %s", dataset.id)
self.list.clear()
self.show()
self._progress.submit(dataset)

def cancel(self) -> None:
"""Cancels the last dataset preview load."""
logger.debug("PreviewWidget.cancel")
self._progress.cancel()

def _loadTomograms(
self, dataset: Dataset
) -> Generator[Tuple[Tomogram, np.ndarray], None, None]:
logger.debug("PreviewWidget._loadTomograms: %s", dataset.id)
client = Client(self._uri)
for run in Run.find(client, [Run.dataset_id == dataset.id]):
for spacing in run.tomogram_voxel_spacings:
for tomogram in spacing.tomograms:
data = imread(tomogram.key_photo_thumbnail_url)
yield tomogram, data

def _onTomogramLoaded(self, result: Tuple[Tomogram, np.ndarray]) -> None:
tomogram, data = result
logger.debug("PreviewWidget._onTomogramLoaded: %s", tomogram.name)
icon = _make_tomogram_preview(data)
item = QListWidgetItem(icon, tomogram.name)
item.setData(Qt.ItemDataRole.UserRole, tomogram)
self.list.addItem(item)


def _make_tomogram_preview(data: np.ndarray) -> QIcon:
if data.ndim == 2:
height, width = data.shape
image = QImage(data.data, width, height, width, QImage.Format_Mono)
elif data.ndim == 3:
height, width, depth = data.shape
assert depth == 3
row_bytes = depth * width
image = QImage(data.data, width, height, row_bytes, QImage.Format_RGB888)
else:
# TODO: at least log error.
return QIcon()
bitmap = QBitmap.fromImage(image)
return QIcon(bitmap)
40 changes: 26 additions & 14 deletions src/napari_cryoet_data_portal/_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@

from qtpy.QtCore import Qt
from qtpy.QtWidgets import (
QListWidgetItem,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from cryoet_data_portal import Tomogram

from napari_cryoet_data_portal._listing_widget import ListingWidget
from napari_cryoet_data_portal._logging import logger
from napari_cryoet_data_portal._metadata_widget import MetadataWidget
from napari_cryoet_data_portal._open_widget import OpenWidget
from napari_cryoet_data_portal._preview_widget import PreviewWidget
from napari_cryoet_data_portal._uri_widget import UriWidget

if TYPE_CHECKING:
Expand All @@ -23,7 +23,7 @@ class DataPortalWidget(QWidget):

This consists of a few privately defined sub-widgets, each of which
has its own task with respect to the data portal like the initial
connection or reading metadata from a dataset or tomogram.
connection or showing previews of a dataset or tomogram.
Each task is run asynchronously and can be cancelled.

Examples
Expand All @@ -44,8 +44,8 @@ def __init__(
self._listing = ListingWidget()
self._listing.hide()

self._metadata = MetadataWidget()
self._metadata.hide()
self._preview = PreviewWidget()
self._preview.hide()

self._open = OpenWidget(napari_viewer)
self._open.hide()
Expand All @@ -55,40 +55,52 @@ def __init__(
self._listing.tree.currentItemChanged.connect(
self._onListingItemChanged
)
self._preview.list.currentItemChanged.connect(
self._onPreviewItemChanged
)

layout = QVBoxLayout()
layout.addWidget(self._uri)
layout.addWidget(self._listing, 1)
layout.addWidget(self._metadata, 1)
layout.addWidget(self._preview, 1)
layout.addWidget(self._open)
layout.addStretch(0)

self.setLayout(layout)

def _onUriConnected(self, uri: str, filter: object) -> None:
logger.debug("DataPortalWidget._onUriConnected")
self._preview.setUri(uri)
self._open.setUri(uri)
self._listing.load(uri, filter=filter)

def _onUriDisconnected(self) -> None:
logger.debug("DataPortalWidget._onUriDisconnected")
for widget in (self._listing, self._metadata, self._open):
for widget in (self._listing, self._preview, self._open):
widget.cancel()
widget.hide()

def _onListingItemChanged(
self, item: QTreeWidgetItem, old_item: QTreeWidgetItem
) -> None:
logger.debug("DataPortalWidget._onListingItemClicked: %s", item)
self._open.hide()
# The new current item can be none when reconnecting since that
# clears the listing tree.
if item is None:
self._metadata.hide()
self._open.hide()
return
data = item.data(0, Qt.ItemDataRole.UserRole)
self._metadata.load(data)
if isinstance(data, Tomogram):
self._open.setTomogram(data)
self._preview.hide()
else:
data = item.data(0, Qt.ItemDataRole.UserRole)
self._preview.load(data)

def _onPreviewItemChanged(
self, item: QListWidgetItem, old_item: QListWidgetItem
) -> None:
logger.debug("DataPortalWidget._onPreviewItemChanged: %s", item)
# The new current item can be none when reconnecting since that
# clears the preview list.
if item is None:
self._open.hide()
else:
data = item.data(Qt.ItemDataRole.UserRole)
self._open.setTomogram(data)
Loading