Skip to content

Commit

Permalink
Simplify ThumbnailSource by using Python ref counting and merging wit…
Browse files Browse the repository at this point in the history
…h ThumbnailProcessor.
  • Loading branch information
cmeyer committed Nov 26, 2024
1 parent 2d73324 commit cadb6e7
Show file tree
Hide file tree
Showing 8 changed files with 145 additions and 192 deletions.
10 changes: 3 additions & 7 deletions nion/swift/DataItemThumbnailWidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,12 +439,8 @@ def close(self) -> None:
super().close()

def __detach_listeners(self) -> None:
if self.__thumbnail_updated_event_listener:
self.__thumbnail_updated_event_listener.close()
self.__thumbnail_updated_event_listener = None
if self.__thumbnail_source:
self.__thumbnail_source.remove_ref()
self.__thumbnail_source = None
self.__thumbnail_updated_event_listener = None
self.__thumbnail_source = None

def __update_thumbnail(self) -> None:
if self.__display_item:
Expand All @@ -461,7 +457,7 @@ def set_display_item(self, display_item: typing.Optional[DisplayItem.DisplayItem
self.__detach_listeners()
self.__display_item = display_item
if display_item:
self.__thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.ui, display_item).add_ref()
self.__thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.ui, display_item)
self.__thumbnail_updated_event_listener = self.__thumbnail_source.thumbnail_updated_event.listen(self.__update_thumbnail)
self.__update_thumbnail()
if self.__display_item_binding:
Expand Down
21 changes: 8 additions & 13 deletions nion/swift/DataPanel.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,8 @@ def display_item_removed() -> None:

def close(self) -> None:
# remove the listener.
if self.__thumbnail_updated_event_listener:
self.__thumbnail_updated_event_listener.close()
self.__thumbnail_updated_event_listener = None
if self.__thumbnail_source:
self.__thumbnail_source.remove_ref()
self.__thumbnail_source = None
self.__thumbnail_updated_event_listener = None
self.__thumbnail_source = None
if self.__display_changed_event_listener:
self.__display_changed_event_listener.close()
self.__display_changed_event_listener = None
Expand Down Expand Up @@ -184,7 +180,7 @@ def drag_started(self, ui: UserInterface.UserInterface, x: int, y: int, modifier
def calculate_thumbnail_data(self) -> typing.Optional[_NDArray]:
# grab the display specifier and if there is a display, handle thumbnail updating.
if self.__display_item and not self.__thumbnail_source:
self.__thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.ui, self.__display_item).add_ref()
self.__thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.ui, self.__display_item)

def thumbnail_updated() -> None:
self.__list_item_drawing_context = None
Expand Down Expand Up @@ -611,7 +607,7 @@ def thumbnail_updated() -> None:
self.__thumbnail_canvas_item.bitmap = Bitmap.Bitmap(rgba_bitmap_data=bitmap_data)

self.__thumbnail_canvas_item = thumbnail_canvas_item
self.__thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.__ui, self.__display_item).add_ref()
self.__thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.__ui, self.__display_item)
self.__thumbnail_updated_event_listener = self.__thumbnail_source.thumbnail_updated_event.listen(thumbnail_updated)

thumbnail_updated()
Expand All @@ -623,7 +619,8 @@ def thumbnail_updated() -> None:
self.__item_changed_listener = display_item.item_changed_event.listen(ReferenceCounting.weak_partial(DataPanelListItem.__item_changed, self))

def close(self) -> None:
self.__thumbnail_source.remove_ref()
self.__thumbnail_updated_event_listener = typing.cast(typing.Any, None)
self.__thumbnail_source = typing.cast(typing.Any, None)
super().close()

@property
Expand Down Expand Up @@ -753,8 +750,7 @@ def _get_mime_data_and_thumbnail_data(self, drag_started_event: ListCanvasItem.L
mime_data = document_controller.ui.create_mime_data()
MimeTypes.mime_data_put_display_item(mime_data, display_item)
thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.__data_panel.ui, display_item)
with thumbnail_source.ref():
thumbnail_data = thumbnail_source.thumbnail_data
thumbnail_data = thumbnail_source.thumbnail_data
if thumbnail_data is not None:
# scaling is very slow
thumbnail_data = Image.get_rgba_data_from_rgba(
Expand All @@ -766,8 +762,7 @@ def _get_mime_data_and_thumbnail_data(self, drag_started_event: ListCanvasItem.L
anchor_index = self.__selection.anchor_index or 0
thumbnail_display_item = display_items[anchor_index]
thumbnail_source = Thumbnails.ThumbnailManager().thumbnail_source_for_display_item(self.__data_panel.ui, thumbnail_display_item)
with thumbnail_source.ref():
thumbnail_data = thumbnail_source.thumbnail_data
thumbnail_data = thumbnail_source.thumbnail_data
return mime_data, thumbnail_data

list_item_delegate = ListItemDelegate(self, self.__selection)
Expand Down
193 changes: 78 additions & 115 deletions nion/swift/Thumbnails.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from __future__ import annotations

# standard libraries
import concurrent.futures
import functools
import threading
import typing
Expand All @@ -13,6 +14,7 @@

# third-party libraries
import numpy
import numpy.typing

# local libraries
from nion.swift import DisplayPanel
Expand All @@ -23,83 +25,100 @@
from nion.utils import Event
from nion.utils import Geometry
from nion.utils import ReferenceCounting
from nion.utils import ThreadPool

_NDArray = numpy.typing.NDArray[typing.Any]
_ThumbnailSourceWeakRef = typing.Callable[[], typing.Optional["ThumbnailSource"]] # Python 3.9+


class ThumbnailProcessor:
"""Processes thumbnails for a display in a thread."""
class ThumbnailSource:
"""Produce a thumbnail for a display."""
_executor = concurrent.futures.ThreadPoolExecutor()

def __init__(self, ui: UserInterface.UserInterface, display_item: DisplayItem.DisplayItem) -> None:
super().__init__()
self._ui = ui
self._display_item = display_item

self.width = 256
self.height = 256

self.thumbnail_updated_event = Event.Event()

def __init__(self, display_item: DisplayItem.DisplayItem):
self.__display_item = display_item
self.__recompute_lock = threading.RLock()
self.__dispatcher = ThreadPool.SingleItemDispatcher(minimum_period=0.5)
self.__display_item_about_to_close_listener = self.__display_item.about_to_close_event.listen(self.__about_to_close_display_item)
self.__recompute_future: typing.Optional[concurrent.futures.Future[typing.Any]] = None
self.__cache = self.__display_item._display_cache
self.__cache_property_name = "thumbnail_data"
self.width = 256
self.height = 256
self.on_thumbnail_updated: typing.Optional[typing.Callable[[], None]] = None

def close(self) -> None:
self.on_thumbnail_updated = None
self.__dispatcher.close()
self.__dispatcher = typing.cast(typing.Any, None)
self.__display_item = typing.cast(typing.Any, None)
self.__display_item_about_to_close_listener.close()
self.__display_item_about_to_close_listener = typing.cast(typing.Any, None)
self.__display_changed_event_listener = display_item.display_changed_event.listen(ReferenceCounting.weak_partial(ThumbnailSource.__thumbnail_changed, self))
self.__graphics_changed_event_listener = display_item.graphics_changed_event.listen(ReferenceCounting.weak_partial(ThumbnailSource.__graphics_changed, self))

def __about_to_close_display_item(self) -> None:
self.close()
# initial recompute, if required
self.__recompute_on_thread()

# used internally and for testing
@property
def _is_cached_value_dirty(self) -> bool:
return self.__cache.is_cached_value_dirty(self.__display_item, self.__cache_property_name)
self.__display_will_close_listener = display_item.display_item_will_close_event.listen(ReferenceCounting.weak_partial(ThumbnailSource.__display_item_will_close, self))

# thread safe
def mark_data_dirty(self) -> None:
""" Called from item to indicate its data or metadata has changed."""
def __thumbnail_changed(self) -> None:
self.__cache.set_cached_value_dirty(self.__display_item, self.__cache_property_name)
self.__recompute_on_thread()

def __recompute_on_thread(self) -> None:
with self.__recompute_lock:
if not self.__recompute_future or self.__recompute_future.done():
self.__recompute_future = self._executor.submit(self.__recompute_data_if_needed)

def __get_cached_value(self) -> typing.Optional[_NDArray]:
return typing.cast(typing.Optional[_NDArray], self.__cache.get_cached_value(self.__display_item, self.__cache_property_name))
def __graphics_changed(self, graphic_selection: DisplayItem.GraphicSelection) -> None:
self.__thumbnail_changed()

def __display_item_will_close(self) -> None:
recompute_future: typing.Optional[concurrent.futures.Future[typing.Any]] = None
with self.__recompute_lock:
if self.__recompute_future and not self.__recompute_future.done():
self.__recompute_future.cancel()
recompute_future = self.__recompute_future
if recompute_future:
try:
concurrent.futures.wait([recompute_future], timeout=10.0)
except concurrent.futures.CancelledError:
pass
self.__display_item_about_to_close_listener = typing.cast(typing.Any, None)
self.__display_item = typing.cast(typing.Any, None)
self.__display_changed_event_listener = typing.cast(typing.Any, None)
self.__graphics_changed_event_listener = typing.cast(typing.Any, None)

def get_cached_data(self) -> typing.Optional[_NDArray]:
@property
def thumbnail_data(self) -> typing.Optional[_NDArray]:
"""Return the cached data for this processor.
This method is thread safe and always returns quickly, using the cached data.
"""
return self.__get_cached_value()

def __get_calculated_data(self, ui: UserInterface.UserInterface) -> typing.Optional[DrawingContext.RGBA32Type]:
display_item = self.__display_item
if display_item.display_data_shape and len(display_item.display_data_shape) == 2:
pixel_shape = Geometry.IntSize(height=512, width=512)
else:
pixel_shape = Geometry.IntSize(height=308, width=512)
drawing_context = DisplayPanel.preview(DisplayPanel.DisplayPanelUISettings(ui), display_item, pixel_shape)
thumbnail_drawing_context = DrawingContext.DrawingContext()
thumbnail_drawing_context.scale(self.width / 512, self.height / 512)
thumbnail_drawing_context.translate(0, (pixel_shape.width - pixel_shape.height) * 0.5)
thumbnail_drawing_context.add(drawing_context)
return ui.create_rgba_image(thumbnail_drawing_context, self.width, self.height)

def recompute(self, ui: UserInterface.UserInterface) -> None:
self.__dispatcher.dispatch(functools.partial(self.recompute_data, ui))

def recompute_data(self, ui: UserInterface.UserInterface) -> None:
return typing.cast(typing.Optional[_NDArray], self.__cache.get_cached_value(self.__display_item, self.__cache_property_name)) if self.__display_item else None

def __recompute_data_if_needed(self) -> None:
if self._is_thumbnail_dirty:
self.recompute_data()

def recompute_data(self) -> None:
"""Compute the data associated with this processor.
This method is thread safe and may take a long time to return. It should not be called from
the UI thread. Upon return, the results will be calculated with the latest data available
and the cache will not be marked dirty.
"""
ui = self._ui
with self.__recompute_lock:
try:
calculated_data = self.__get_calculated_data(ui)
display_item = self.__display_item
if display_item.display_data_shape and len(display_item.display_data_shape) == 2:
pixel_shape = Geometry.IntSize(height=512, width=512)
else:
pixel_shape = Geometry.IntSize(height=308, width=512)
drawing_context = DisplayPanel.preview(DisplayPanel.DisplayPanelUISettings(ui), display_item, pixel_shape)
thumbnail_drawing_context = DrawingContext.DrawingContext()
thumbnail_drawing_context.scale(self.width / 512, self.height / 512)
thumbnail_drawing_context.translate(0, (pixel_shape.width - pixel_shape.height) * 0.5)
thumbnail_drawing_context.add(drawing_context)
calculated_data = ui.create_rgba_image(thumbnail_drawing_context, self.width, self.height)
except Exception as e:
import traceback
traceback.print_exc()
Expand All @@ -108,73 +127,12 @@ def recompute_data(self, ui: UserInterface.UserInterface) -> None:
if calculated_data is None:
calculated_data = numpy.zeros((self.height, self.width), dtype=numpy.uint32)
self.__cache.set_cached_value(self.__display_item, self.__cache_property_name, calculated_data)
if callable(self.on_thumbnail_updated):
self.on_thumbnail_updated()
self.thumbnail_updated_event.fire()


class ThumbnailSource(ReferenceCounting.ReferenceCounted):
"""Produce a thumbnail for a display."""

def __init__(self, ui: UserInterface.UserInterface, display_item: DisplayItem.DisplayItem) -> None:
super().__init__()
self._ui = ui
self._display_item = display_item

self.thumbnail_updated_event = Event.Event()
self.__thumbnail_processor = ThumbnailProcessor(display_item)

def thumbnail_changed() -> None:
thumbnail_processor = self.__thumbnail_processor
if thumbnail_processor:
thumbnail_processor.mark_data_dirty()
thumbnail_processor.recompute(ui)

def graphics_changed(graphic_selection: DisplayItem.GraphicSelection) -> None:
thumbnail_changed()

self.__display_changed_event_listener = display_item.display_changed_event.listen(thumbnail_changed)
self.__graphics_changed_event_listener = display_item.graphics_changed_event.listen(graphics_changed)

def thumbnail_updated() -> None:
self.thumbnail_updated_event.fire()

self.__thumbnail_processor.on_thumbnail_updated = thumbnail_updated

# initial recompute, if required
if self.__thumbnail_processor._is_cached_value_dirty:
self.__thumbnail_processor.recompute(ui)

def display_item_will_close() -> None:
if self.__thumbnail_processor:
self.__thumbnail_processor.close()
self.__thumbnail_processor = typing.cast(typing.Any, None)

self.__display_will_close_listener = display_item.about_to_be_removed_event.listen(display_item_will_close)

def about_to_delete(self) -> None:
self.__display_will_close_listener = typing.cast(typing.Any, None)
if self.__thumbnail_processor:
self.__thumbnail_processor.close()
self.__thumbnail_processor = typing.cast(typing.Any, None)
self.__display_changed_event_listener = typing.cast(typing.Any, None)
self.__graphics_changed_event_listener = typing.cast(typing.Any, None)
super().about_to_delete()

def add_ref(self) -> ThumbnailSource:
super().add_ref()
return self

@property
def thumbnail_data(self) -> typing.Optional[_NDArray]:
return self.__thumbnail_processor.get_cached_data() if self.__thumbnail_processor else None

def recompute_data(self) -> None:
self.__thumbnail_processor.recompute_data(self._ui)

# used for testing
@property
def _is_thumbnail_dirty(self) -> bool:
return self.__thumbnail_processor._is_cached_value_dirty
assert self.__display_item
return self.__cache.is_cached_value_dirty(self.__display_item, self.__cache_property_name)


class ThumbnailManager(metaclass=Utility.Singleton):
Expand All @@ -184,8 +142,9 @@ def __init__(self) -> None:
self.__thumbnail_sources: typing.Dict[uuid.UUID, _ThumbnailSourceWeakRef] = dict()
self.__lock = threading.RLock()

def thumbnail_sources(self) -> typing.Dict[uuid.UUID, _ThumbnailSourceWeakRef]:
return self.__thumbnail_sources
def reset(self) -> None:
with self.__lock:
self.__thumbnail_sources.clear()

def thumbnail_source_for_display_item(self, ui: UserInterface.UserInterface, display_item: DisplayItem.DisplayItem) -> ThumbnailSource:
"""Returned ThumbnailSource must be closed."""
Expand All @@ -195,7 +154,11 @@ def thumbnail_source_for_display_item(self, ui: UserInterface.UserInterface, dis
if not thumbnail_source:
thumbnail_source = ThumbnailSource(ui, display_item)
self.__thumbnail_sources[display_item.uuid] = weakref.ref(thumbnail_source)
weakref.finalize(thumbnail_source, self.__thumbnail_sources.pop, display_item.uuid)

def pop_thumbnail_source(uuid: uuid.UUID) -> None:
self.__thumbnail_sources.pop(uuid, None)

weakref.finalize(thumbnail_source, pop_thumbnail_source, display_item.uuid)
else:
assert thumbnail_source._ui == ui
return thumbnail_source
Expand Down
2 changes: 2 additions & 0 deletions nion/swift/model/DisplayItem.py
Original file line number Diff line number Diff line change
Expand Up @@ -2313,6 +2313,7 @@ def __init__(self, item_uuid: typing.Optional[uuid.UUID] = None, *, data_item: t

self.display_property_changed_event = Event.Event()
self.display_changed_event = Event.Event()
self.display_item_will_close_event = Event.Event() # used to shut down thumbnail

self.__cache = Cache.ShadowCache()
self.__suspendable_storage_cache: typing.Optional[Cache.CacheLike] = None
Expand Down Expand Up @@ -2380,6 +2381,7 @@ def graphic_selection_changed() -> None:
self.append_display_data_channel_for_data_item(data_item)

def close(self) -> None:
self.display_item_will_close_event.fire() # let the thumbnail shut itself down
# wait for outstanding threads to finish
with self.__outstanding_condition:
while self.__outstanding_thread_count:
Expand Down
Loading

0 comments on commit cadb6e7

Please sign in to comment.