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

Refactor TwoPhotonSeries #241

Draft
wants to merge 24 commits into
base: master
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion nwbwidgets/controllers/__init__.py
@@ -1,3 +1,5 @@
from .time_window_controllers import StartAndDurationController, RangeController
from .group_and_sort_controllers import GroupAndSortController
from .misc import ProgressBar, make_trial_event_controller
from .image_controllers import RotationController, ImShowController
from .misc import ProgressBar, ViewTypeController, make_trial_event_controller
from .multicontroller import MultiController
70 changes: 70 additions & 0 deletions nwbwidgets/controllers/image_controllers.py
@@ -0,0 +1,70 @@
import ipywidgets as widgets


class RotationController(widgets.HBox):
controller_fields = ("rotation", "rotate_left", "rotate_right")

def __init__(self):
super().__init__()

self.rotation = 0 # A hidden non-widget value counter to keep relative track of progression
self.rotate_left = widgets.Button(icon="rotate-left", layout=widgets.Layout(width="35px"))
self.rotate_right = widgets.Button(icon="rotate-right", layout=widgets.Layout(width="35px"))

self.set_observers()

self.children = (self.rotate_left, self.rotate_right)

def set_observers(self):
def _rotate_right(change):
self.rotation += 1

def _rotate_left(change):
self.rotation -= 1

self.rotate_right.on_click(_rotate_right)
self.rotate_left.on_click(_rotate_left)


class ImShowController(widgets.VBox):
"""Controller specifically for handling various options for the plot.express.imshow function."""

controller_fields = ("contrast_type_toggle", "auto_contrast_method", "manual_contrast_slider")

def __init__(self):
super().__init__()

self.contrast_type_toggle = widgets.ToggleButtons(
description="Constrast: ",
options=[
("Automatic", "Automatic"),
("Manual", "Manual"),
], # Values set to strings for external readability
)
self.auto_contrast_method = widgets.Dropdown(description="Method: ", options=["minmax", "infer"])
self.manual_contrast_slider = widgets.IntRangeSlider(
value=(0, 1), # Actual value will depend on data selection
min=0, # Actual value will depend on data selection
max=1, # Actual value will depend on data selection
orientation="horizontal",
description="Range: ",
continuous_update=False,
)

# Setup initial controller-specific layout
self.children = (self.contrast_type_toggle, self.auto_contrast_method)

# Setup controller-specific observer events
self.setup_observers()

def setup_observers(self):
self.contrast_type_toggle.observe(
lambda change: self.switch_contrast_modes(enable_manual_contrast=change.new), names="value"
)

def switch_contrast_modes(self, enable_manual_contrast: bool):
"""When the manual contrast toggle is altered, adjust the manual vs. automatic visibility of the components."""
if self.contrast_type_toggle.value == "Manual":
self.children = (self.contrast_type_toggle, self.manual_contrast_slider)
elif self.contrast_type_toggle.value == "Automatic":
self.children = (self.contrast_type_toggle, self.auto_contrast_method)
32 changes: 19 additions & 13 deletions nwbwidgets/controllers/misc.py
Expand Up @@ -11,33 +11,39 @@ def __init__(self, *arg, **kwargs):
# self.container.children[0].layout = Layout(width="80%")


class ViewTypeController(widgets.VBox):
controller_fields = ("view_type_toggle",)

def __init__(self):
super().__init__()

self.view_type_toggle = widgets.ToggleButtons(
options=[
("Simplified", "Simplified"),
("Detailed", "Detailed"),
], # Values set to strings for external readability
)
self.children = (self.view_type_toggle,)


def make_trial_event_controller(trials, layout=None, multiple=False):
"""Controller for which reference to use (e.g. start_time) when making time-aligned averages"""
"""Controller for which reference to use (e.g. start_time) when making time-aligned averages."""
trial_events = ["start_time"]
if not np.all(np.isnan(trials["stop_time"].data)):
trial_events.append("stop_time")
trial_events += [
x.name
for x in trials.columns
if (("_time" in x.name) and (x.name not in ("start_time", "stop_time")))
x.name for x in trials.columns if (("_time" in x.name) and (x.name not in ("start_time", "stop_time")))
]
kwargs = {}
if layout is not None:
kwargs.update(layout=layout)

if multiple:
trial_event_controller = widgets.SelectMultiple(
options=trial_events,
value=["start_time"],
description='align to:',
disabled=False,
**kwargs
options=trial_events, value=["start_time"], description="align to:", disabled=False, **kwargs
)
else:
trial_event_controller = widgets.Dropdown(
options=trial_events,
value="start_time",
description="align to: ",
**kwargs
options=trial_events, value="start_time", description="align to: ", **kwargs
)
return trial_event_controller
32 changes: 32 additions & 0 deletions nwbwidgets/controllers/multicontroller.py
@@ -0,0 +1,32 @@
from typing import Tuple, Dict

import ipywidgets as widgets


class MultiController(widgets.VBox):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you make this inherit from widgets.Box so components can be either horizontal or vertical? See

https://ipywidgets.readthedocs.io/en/7.x/examples/Widget%20Styling.html#The-VBox-and-HBox-helpers

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I started running into this on another PR as well

controller_fields: Tuple[str] = tuple()
components: Dict[str, widgets.VBox] = dict()

def __init__(self, components: list):
super().__init__()

children = list()
controller_fields = list()
self.components = {component.__class__.__name__: component for component in components}
for component in self.components.values():
# Set attributes at outermost level
for field in component.controller_fields:
controller_fields.append(field)
setattr(self, field, getattr(component, field))

# Default layout of children
if isinstance(component, widgets.Widget) and not isinstance(component, MultiController):
children.append(component)

self.children = tuple(children)
self.controller_fields = tuple(controller_fields)

self.setup_observers()

def setup_observers(self):
pass
Comment on lines +6 to +32
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like the distinction between Controllers and MultiControllers. I think a widget that contains multiple controllers should just be another controller.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's fine to have a MultiController class that automates combination of Controllers. But this class should also be a Controller.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I just need to utilize a base Controller class, which a MultiController itself is as well in every respect

4 changes: 4 additions & 0 deletions nwbwidgets/ophys/__init__.py
@@ -0,0 +1,4 @@
from .plane_slice import PlaneSliceVisualization
from .two_photon_series import TwoPhotonSeriesVisualization
from .volume import VolumeVisualization
from .segmentation import PlaneSegmentation2DWidget, RoiResponseSeries, show_image_segmentation, route_plane_segmentation, show_df_over_f, RoiResponseSeriesWidget
97 changes: 97 additions & 0 deletions nwbwidgets/ophys/ophys_controllers.py
@@ -0,0 +1,97 @@
from typing import Optional

import ipywidgets as widgets

from ..controllers import RotationController, ImShowController, ViewTypeController, MultiController


class FrameController(widgets.VBox):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should be a Controller base class.

controller_fields = ("frame_slider",)

def __init__(self):
super().__init__()

self.frame_slider = widgets.IntSlider(
value=0, # Actual value will depend on data selection
min=0, # Actual value will depend on data selection
max=1, # Actual value will depend on data selection
orientation="horizontal",
description="Frame: ",
continuous_update=False,
)

self.children = (self.frame_slider,)


class PlaneController(widgets.VBox):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think defining controllers that contain a single widget is a bit overkill. In my mind, the point of controllers is collections of widgets (or collections of collections of widgets) in a system that is reusable.

controller_fields = ("plane_slider",)

def __init__(self):
super().__init__()

self.plane_slider = widgets.IntSlider(
value=0, # Actual value will depend on data selection
min=0, # Actual value will depend on data selection
max=1, # Actual value will depend on data selection
orientation="horizontal",
description="Plane: ",
continuous_update=False,
)

self.children = (self.plane_slider,)


class SinglePlaneDataController(MultiController):
def __init__(self, components: Optional[list] = None):
default_components = [RotationController(), FrameController()]
if components is not None:
default_components.extend(components)
super().__init__(components=default_components)

# Align rotation buttons to center of sliders
self.layout.align_items = "center"


class VolumetricDataController(SinglePlaneDataController):
def __init__(self):
super().__init__(components=[PlaneController()])


class BasePlaneSliceController(MultiController):
def __init__(self, components: Optional[list] = None):
default_components = [ViewTypeController(), ImShowController()]
if components is not None:
default_components.extend(components)
super().__init__(components=default_components)

self.setup_visibility()
self.setup_observers()

def set_detailed_visibility(self, visibile: bool):
widget_visibility_type = "visible" if visibile else "hidden"

self.contrast_type_toggle.layout.visibility = widget_visibility_type
self.manual_contrast_slider.layout.visibility = widget_visibility_type
self.auto_contrast_method.layout.visibility = widget_visibility_type

def update_visibility(self):
if self.view_type_toggle.value == "Simplified":
self.set_detailed_visibility(visibile=False)
elif self.view_type_toggle.value == "Detailed":
self.set_detailed_visibility(visibile=True)

def setup_visibility(self):
self.set_detailed_visibility(visibile=False)

def setup_observers(self):
self.view_type_toggle.observe(lambda change: self.update_visibility(), names="value")


class SinglePlaneSliceController(BasePlaneSliceController):
def __init__(self):
super().__init__(components=[SinglePlaneDataController()])


class VolumetricPlaneSliceController(BasePlaneSliceController):
def __init__(self):
super().__init__(components=[VolumetricDataController()])
91 changes: 91 additions & 0 deletions nwbwidgets/ophys/plane_slice.py
@@ -0,0 +1,91 @@
import math
from functools import lru_cache
from typing import Tuple, Optional

import numpy as np
import h5py

from .single_plane import SinglePlaneVisualization
from .ophys_controllers import VolumetricPlaneSliceController


class PlaneSliceVisualization(SinglePlaneVisualization):
"""Sub-widget specifically for plane-wise views of a 4D TwoPhotonSeries."""

def _dimension_check(self):
num_dimensions = len(self.two_photon_series.data.shape)
if num_dimensions != 4:
raise ValueError(
"The PlaneSliceVisualization is only appropriate for "
f"use on 4-dimensional TwoPhotonSeries! Detected dimension of {num_dimensions}."
)

@lru_cache # default size of 128 items ought to be enough to create a 1GB cache on large images
def _cache_data_read(self, dataset: h5py.Dataset, frame_index: int, plane_index: int) -> np.ndarray:
return dataset[frame_index, :, :, plane_index].T

def update_data(self, frame_index: Optional[int] = None, plane_index: Optional[int] = None):
frame_index = frame_index or self.Controller.frame_slider.value
plane_index = plane_index or self.Controller.plane_slider.value

self.data = self._cache_data_read(
dataset=self.two_photon_series.data, frame_index=frame_index, plane_index=plane_index
)

self.data = self.two_photon_series.data[frame_index, :, :, plane_index]

if self.Controller.contrast_type_toggle.value == "Manual":
self.update_contrast_range()

def setup_data(self, max_mb_treshold: float = 20.0):
"""
Start by loading only a single frame of a single plane.

If the image size relative to data type is too large, relative to max_mb_treshold (indicating the load
operation for initial setup would take a noticeable amount of time), then sample the image with a `by`.

Note this may not actually provide a speedup when streaming; need to think of way around that. Maybe set
a global flag for if streaming mode is enabled on the file, and if so make full use of data within contiguous
HDF5 chunks?
"""
itemsize = self.two_photon_series.data.dtype.itemsize
nbytes_per_image = math.prod(self.two_photon_series.data.shape) * itemsize
if nbytes_per_image <= max_mb_treshold:
self.update_data(frame_index=0, plane_index=0)
else:
# TOD: Figure out formula for calculating by in one-shot
by_width = 2
by_height = 2
self.data = self.two_photon_series.data[0, ::by_width, ::by_height, 0]

def pre_setup_controllers(self):
self.Controller = VolumetricPlaneSliceController()
self.data_controller_name = "VolumetricDataController"

def setup_controllers(self):
"""Controller updates are handled through the defined Controller class."""
super().setup_controllers()

self.Controller.plane_slider.max = self.two_photon_series.data.shape[-1] - 1

def update_figure(
self,
rotation_changed: Optional[bool] = None,
frame_index: Optional[int] = None,
plane_index: Optional[int] = None,
contrast_rescaling: Optional[str] = None,
contrast: Optional[Tuple[int]] = None,
):
if plane_index is not None:
self.update_data(plane_index=plane_index)
self.update_data_to_plot()

super().update_figure(rotation_changed=rotation_changed, frame_index=frame_index, contrast_rescaling=contrast_rescaling, contrast=contrast)

def set_canvas_title(self):
self.canvas_title = f"TwoPhotonSeries: {self.two_photon_series.name} - Planar slices of volume"

def setup_observers(self):
super().setup_observers()

self.Controller.plane_slider.observe(lambda change: self.update_canvas(plane_index=change.new), names="value")