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

Live Viewer shows spectrum of images via caching recent images #2425

Open
wants to merge 29 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
974dff8
Live Viewer Spectrum added
MikeSullivan7 Aug 7, 2024
e3eefb3
Images stored in LRUCache and new means append correctly (with debug …
MikeSullivan7 Nov 26, 2024
a9e5c16
Mean buffer loading and prints removed
MikeSullivan7 Nov 27, 2024
ef20e6c
mean calculated with incoming data and displayed, base ImageCache cla…
MikeSullivan7 Nov 29, 2024
9d40078
LV Model add and stores mean in dict, replots when ROI is moved
MikeSullivan7 Dec 2, 2024
70d72ca
ruff, mypy, and unit test fixes
MikeSullivan7 Dec 2, 2024
036de89
New mean array plotted when ROI is starting to move
MikeSullivan7 Dec 2, 2024
5980d1e
check if mean for given image path has been added to prevent duplication
MikeSullivan7 Dec 3, 2024
7fdde54
mean of cached imaged calculated with ROI is moved around
MikeSullivan7 Dec 3, 2024
f852229
When ROI is moved, mean of cache is shown immediately, then images ar…
MikeSullivan7 Dec 3, 2024
49f4757
Store Image_Data obj in cache and added TODOs
MikeSullivan7 Dec 4, 2024
c47d8c7
Model no longer directly accesses image_cache, mean added in presente…
MikeSullivan7 Dec 4, 2024
60852b5
unit test fixes and test_WHEN_image_added_to_cache_THEN_image_is_in_c…
MikeSullivan7 Dec 4, 2024
2e16d20
remove buffer_size from ImageCache
MikeSullivan7 Dec 4, 2024
6e5ccc1
Made ImageCacheTest more randomised and added test_WHEN_image_removed…
MikeSullivan7 Dec 5, 2024
d1445b2
removed unneeded get methods in ImageCache
MikeSullivan7 Dec 5, 2024
261fa1e
added unit tests test_WHEN_image_not_in_cache_when_loaded_THEN_image_…
MikeSullivan7 Dec 5, 2024
7ee3b5d
yapf ruff fixes
MikeSullivan7 Dec 5, 2024
c19a336
pyright fixes
MikeSullivan7 Dec 6, 2024
0bc8dfc
spectrum is updated asyncronously via a Thread (with warnings)
MikeSullivan7 Dec 6, 2024
b99ab42
Mean is calculated in separate thread, spectrum updated in Main thread
MikeSullivan7 Dec 10, 2024
994054d
cleanup and suggested changes
MikeSullivan7 Dec 11, 2024
aed415e
eyes test fix and load_image error handling
MikeSullivan7 Dec 12, 2024
5b5ada4
Live Viewer get_roi() can now return None
MikeSullivan7 Dec 12, 2024
6460ce1
release note
MikeSullivan7 Dec 12, 2024
df14e06
further changes and cleanup
MikeSullivan7 Dec 12, 2024
95942e6
non-threaded version
MikeSullivan7 Dec 13, 2024
5bbc39e
type annotation fix and removed unneeded imports
MikeSullivan7 Dec 13, 2024
c0e5875
increase size of cache dict to 100
MikeSullivan7 Dec 13, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#2421: The Spectrum can be seen in the Live Viewer. An ImageCache is used to improve performance of loading images.
6 changes: 3 additions & 3 deletions mantidimaging/eyes_tests/live_viewer_window_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def test_live_view_opens_without_data(self, _mock_time, _mock_image_watcher):
self.imaging.show_live_viewer(self.live_directory)
self.check_target(widget=self.imaging.live_viewer_list[-1])

@mock.patch('mantidimaging.gui.windows.live_viewer.presenter.LiveViewerWindowPresenter.load_image')
@mock.patch('mantidimaging.gui.windows.live_viewer.model.load_image_from_path')
@mock.patch('mantidimaging.gui.windows.live_viewer.model.ImageWatcher')
@mock.patch("time.time", return_value=4000.0)
def test_live_view_opens_with_data(self, _mock_time, _mock_image_watcher, mock_load_image):
Expand All @@ -67,7 +67,7 @@ def test_live_view_opens_with_data(self, _mock_time, _mock_image_watcher, mock_l
self.imaging.live_viewer_list[-1].presenter.model._handle_image_changed_in_list(image_list)
self.check_target(widget=self.imaging.live_viewer_list[-1])

@mock.patch('mantidimaging.gui.windows.live_viewer.presenter.LiveViewerWindowPresenter.load_image')
@mock.patch('mantidimaging.gui.windows.live_viewer.model.load_image_from_path')
@mock.patch('mantidimaging.gui.windows.live_viewer.model.ImageWatcher')
@mock.patch("time.time", return_value=4000.0)
def test_live_view_opens_with_bad_data(self, _mock_time, _mock_image_watcher, mock_load_image):
Expand All @@ -78,7 +78,7 @@ def test_live_view_opens_with_bad_data(self, _mock_time, _mock_image_watcher, mo
self.imaging.live_viewer_list[-1].presenter.model._handle_image_changed_in_list(image_list)
self.check_target(widget=self.imaging.live_viewer_list[-1])

@mock.patch('mantidimaging.gui.windows.live_viewer.presenter.LiveViewerWindowPresenter.load_image')
@mock.patch('mantidimaging.gui.windows.live_viewer.model.load_image_from_path')
@mock.patch('mantidimaging.gui.windows.live_viewer.model.ImageWatcher')
@mock.patch("time.time", return_value=4000.0)
def test_rotate_operation_rotates_image(self, _mock_time, _mock_image_watcher, mock_load_image):
Expand Down
43 changes: 42 additions & 1 deletion mantidimaging/gui/windows/live_viewer/live_view_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@
# SPDX - License - Identifier: GPL-3.0-or-later
from __future__ import annotations
from typing import TYPE_CHECKING
from pyqtgraph import GraphicsLayoutWidget

from PyQt5.QtCore import pyqtSignal
from pyqtgraph import GraphicsLayoutWidget, mkPen

from mantidimaging.core.utility.close_enough_point import CloseEnoughPoint
from mantidimaging.core.utility.sensible_roi import SensibleROI
from mantidimaging.gui.widgets.mi_mini_image_view.view import MIMiniImageView
from mantidimaging.gui.widgets.zslider.zslider import ZSlider
from mantidimaging.gui.windows.spectrum_viewer.spectrum_widget import SpectrumROI

if TYPE_CHECKING:
import numpy as np
Expand All @@ -18,6 +23,10 @@ class LiveViewWidget(GraphicsLayoutWidget):
@param parent: The parent widget
"""
image: MIMiniImageView
image_shape: tuple = (-1, -1)
roi_changed = pyqtSignal()
roi_changed_start = pyqtSignal(int)
roi_object: SpectrumROI | None = None

def __init__(self) -> None:
super().__init__()
Expand All @@ -38,6 +47,7 @@ def show_image(self, image: np.ndarray) -> None:
Show the image in the image view.
@param image: The image to show
"""
self.image_shape = image.shape
self.image.setImage(image)

def handle_deleted(self) -> None:
Expand All @@ -48,3 +58,34 @@ def handle_deleted(self) -> None:

def show_error(self, message: str | None):
self.image.show_message(message)

def add_roi(self):
if self.image_shape == (-1, -1):
return
height, width = self.image_shape
roi = SensibleROI.from_list([0, 0, width, height])
self.roi_object = SpectrumROI('roi', roi, rotatable=False, scaleSnap=True, translateSnap=True)
self.roi_object.colour = (255, 194, 10, 255)
self.roi_object.hoverPen = mkPen(self.roi_object.colour, width=3)
self.roi_object.roi.sigRegionChangeFinished.connect(self.roi_changed.emit)
self.roi_object.roi.sigRegionChangeStarted.connect(self.roi_changed_start.emit)
self.image.vb.addItem(self.roi_object.roi)

def set_image_shape(self, shape: tuple) -> None:
self.image_shape = shape
MikeSullivan7 marked this conversation as resolved.
Show resolved Hide resolved

def get_roi(self) -> SensibleROI | None:
if not self.roi_object:
return None
roi = self.roi_object.roi
pos = CloseEnoughPoint(roi.pos())
size = CloseEnoughPoint(roi.size())
return SensibleROI.from_points(pos, size)

def set_roi_visibility_flags(self, visible: bool) -> None:
if not self.roi_object:
return
handles = self.roi_object.getHandles()
for handle in handles:
handle.setVisible(visible)
self.roi_object.setVisible(visible)
122 changes: 115 additions & 7 deletions mantidimaging/gui/windows/live_viewer/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,79 @@
from __future__ import annotations

import time
from operator import attrgetter
from typing import TYPE_CHECKING
from pathlib import Path
from logging import getLogger

import numpy as np
from PyQt5.QtCore import QFileSystemWatcher, QObject, pyqtSignal, QTimer

from tifffile import tifffile
from astropy.io import fits

from mantidimaging.core.utility.sensible_roi import SensibleROI

if TYPE_CHECKING:
from os import stat_result
from mantidimaging.gui.windows.live_viewer.view import LiveViewerWindowPresenter

LOG = getLogger(__name__)


def load_image_from_path(image_path: Path) -> np.ndarray:
"""
Load a .Tif, .Tiff or .Fits file only if it exists
and returns as an ndarray
"""
if image_path.suffix.lower() in [".tif", ".tiff"]:
with tifffile.TiffFile(image_path) as tif:
image_data = tif.asarray()
elif image_path.suffix.lower() == ".fits":
with fits.open(image_path.__str__()) as fit:
image_data = fit[0].data
return image_data


class ImageCache:
MikeSullivan7 marked this conversation as resolved.
Show resolved Hide resolved
"""
An ImageCache class to be used as a decorator on image read functions to store recent images in memory
"""
cache_dict: dict[Image_Data, np.ndarray]
max_cache_size: int | None = None

def __init__(self, max_cache_size=None):
self.max_cache_size = max_cache_size
self.cache_dict = {}

def _add_to_cache(self, image: Image_Data, image_array: np.ndarray) -> None:
if image not in self.cache_dict.keys():
if self.max_cache_size is not None:
if self.max_cache_size <= len(self.cache_dict):
self._remove_oldest_image()
self.cache_dict[image] = image_array

def _get_oldest_image(self) -> Image_Data:
time_ordered_cache = min(self.cache_dict.keys(), key=attrgetter('image_modified_time'))
return time_ordered_cache

def _remove_oldest_image(self) -> None:
del self.cache_dict[self._get_oldest_image()]

def load_image(self, image: Image_Data) -> np.ndarray:
if image in self.cache_dict.keys():
return self.cache_dict[image]
else:
try:
image_array = load_image_from_path(image.image_path)
except ValueError as error:
message = f"{type(error).__name__} reading image: {image.image_path}: {error}"
LOG.error(message)
raise ValueError from error
self._add_to_cache(image, image_array)
return image_array


class Image_Data:
"""
Image Data Class to store represent image data.
Expand All @@ -32,6 +93,8 @@ class Image_Data:
image_modified_time : float
last modified time of image file
"""
image_path: Path
image_name: str

def __init__(self, image_path: Path):
"""
Expand All @@ -45,16 +108,12 @@ def __init__(self, image_path: Path):
self.image_path = image_path
self.image_name = image_path.name
self._stat = image_path.stat()
self.image_modified_time = self._stat.st_mtime

@property
def stat(self) -> stat_result:
return self._stat

@property
def image_modified_time(self) -> float:
"""Return the image modified time"""
return self._stat.st_mtime

@property
def image_modified_time_stamp(self) -> str:
"""Return the image modified time as a string"""
Expand Down Expand Up @@ -103,6 +162,11 @@ def __init__(self, presenter: LiveViewerWindowPresenter):
self._dataset_path: Path | None = None
self.image_watcher: ImageWatcher | None = None
self.images: list[Image_Data] = []
self.mean: np.ndarray = np.empty(0)
self.mean_paths: set[Path] = set()
self.roi: SensibleROI | None = None
self.image_cache = ImageCache(max_cache_size=100)
self.calc_mean_all_chunks_thread = None

@property
def path(self) -> Path | None:
Expand All @@ -125,9 +189,11 @@ def _handle_image_changed_in_list(self, image_files: list[Image_Data]) -> None:
:param image_files: list of image files
"""
self.images = image_files
# if dask_image_stack.image_list:
# self.image_stack = dask_image_stack
self.presenter.update_image_list(image_files)

def handle_image_modified(self, image_path: Path):
def handle_image_modified(self, image_path: Path) -> None:
self.presenter.update_image_modified(image_path)

def close(self) -> None:
Expand All @@ -137,6 +203,48 @@ def close(self) -> None:
self.image_watcher = None
self.presenter = None # type: ignore # Model instance to be destroyed -type can be inconsistent

def add_mean(self, image_data_obj: Image_Data, image_array: np.ndarray | None) -> None:
if image_array is None:
mean_to_add = np.nan
elif self.roi is not None:
left, top, right, bottom = self.roi
mean_to_add = np.mean(image_array[top:bottom, left:right])
else:
mean_to_add = np.mean(image_array)
self.mean_paths.add(image_data_obj.image_path)
self.mean = np.append(self.mean, mean_to_add)

def clear_mean_partial(self) -> None:
self.mean_paths.clear()
self.mean = np.full(len(self.images), np.nan)

def clear_mean(self) -> None:
self.mean_paths.clear()
self.mean = np.delete(self.mean, np.arange(self.mean.size))

def calc_mean_fully(self) -> None:
if self.images is not None:
for image in self.images:
self.add_mean(image, self.image_cache.load_image(image))

def calc_mean_chunk(self, chunk_size: int = 10000) -> None:
if self.images is not None:
nanInds = np.argwhere(np.isnan(self.mean))
if self.roi:
left, top, right, bottom = self.roi
else:
left, top, right, bottom = (0, 0, -1, -1)
if nanInds.size > 0:
for ind in nanInds[-1:-chunk_size:-1]:
buffer_data = self.image_cache.load_image(self.images[ind[0]])
if buffer_data is not None:
buffer_mean = np.mean(buffer_data[top:bottom, left:right])
np.put(self.mean, ind, buffer_mean)

def calc_mean_all_chunks(self, chunk_size: int = 10000) -> None:
while np.isnan(self.mean).any():
self.calc_mean_chunk(chunk_size)


class ImageWatcher(QObject):
"""
Expand All @@ -161,6 +269,7 @@ class ImageWatcher(QObject):
Sort the images by modified time.
"""
image_changed = pyqtSignal(list) # Signal emitted when an image is added or removed
update_spectrum = pyqtSignal(np.ndarray) # Signal emitted to update the Live Viewer Spectrum
recent_image_changed = pyqtSignal(Path)

def __init__(self, directory: Path):
Expand Down Expand Up @@ -266,7 +375,6 @@ def _handle_directory_change(self) -> None:

if len(images) > 0:
break

images = self.sort_images_by_modified_time(images)
self.update_recent_watcher(images[-1:])
self.image_changed.emit(images)
Expand Down
Loading
Loading