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 calculates intensity of live images via QThreads #2457

Open
wants to merge 43 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
c6e6e22
Live Viewer Spectrum added
MikeSullivan7 Aug 7, 2024
9ab3421
Images stored in LRUCache and new means append correctly (with debug …
MikeSullivan7 Nov 26, 2024
b109761
Mean buffer loading and prints removed
MikeSullivan7 Nov 27, 2024
e919962
mean calculated with incoming data and displayed, base ImageCache cla…
MikeSullivan7 Nov 29, 2024
fde6860
LV Model add and stores mean in dict, replots when ROI is moved
MikeSullivan7 Dec 2, 2024
a9484b0
ruff, mypy, and unit test fixes
MikeSullivan7 Dec 2, 2024
83966fd
New mean array plotted when ROI is starting to move
MikeSullivan7 Dec 2, 2024
aa8dab2
check if mean for given image path has been added to prevent duplication
MikeSullivan7 Dec 3, 2024
59be83d
mean of cached imaged calculated with ROI is moved around
MikeSullivan7 Dec 3, 2024
0e2c08f
When ROI is moved, mean of cache is shown immediately, then images ar…
MikeSullivan7 Dec 3, 2024
530ca11
Store Image_Data obj in cache and added TODOs
MikeSullivan7 Dec 4, 2024
1906269
Model no longer directly accesses image_cache, mean added in presente…
MikeSullivan7 Dec 4, 2024
e7524e8
unit test fixes and test_WHEN_image_added_to_cache_THEN_image_is_in_c…
MikeSullivan7 Dec 4, 2024
636c805
remove buffer_size from ImageCache
MikeSullivan7 Dec 4, 2024
b3fc3ac
Made ImageCacheTest more randomised and added test_WHEN_image_removed…
MikeSullivan7 Dec 5, 2024
405ac82
removed unneeded get methods in ImageCache
MikeSullivan7 Dec 5, 2024
33a835e
added unit tests test_WHEN_image_not_in_cache_when_loaded_THEN_image_…
MikeSullivan7 Dec 5, 2024
9155428
yapf ruff fixes
MikeSullivan7 Dec 5, 2024
6dfbf43
pyright fixes
MikeSullivan7 Dec 6, 2024
0d94b18
spectrum is updated asyncronously via a Thread (with warnings)
MikeSullivan7 Dec 6, 2024
6696624
Mean is calculated in separate thread, spectrum updated in Main thread
MikeSullivan7 Dec 10, 2024
00e96d9
cleanup and suggested changes
MikeSullivan7 Dec 11, 2024
98abb50
eyes test fix and load_image error handling
MikeSullivan7 Dec 12, 2024
104db56
Live Viewer get_roi() can now return None
MikeSullivan7 Dec 12, 2024
8a1fff6
release note
MikeSullivan7 Dec 12, 2024
3993b86
further changes and cleanup
MikeSullivan7 Dec 12, 2024
8e2961b
increase size of cache dict to 100
MikeSullivan7 Dec 13, 2024
4c5755d
roi is disabled while spectrum is calculating to prevent Qthread bein…
MikeSullivan7 Jan 8, 2025
046b801
using QTimer to notify LV to recalcualte and plot spectrum asynchrono…
MikeSullivan7 Jan 9, 2025
844a58b
set_roi_enabled refactor
MikeSullivan7 Jan 9, 2025
f6be919
mypy type annotations
MikeSullivan7 Jan 9, 2025
7210ec5
unit tests added: test_WHEN_handle_notify_roi_moved_THEN_timer_starte…
MikeSullivan7 Jan 10, 2025
94f07de
Added Live Viewer System Tests, setUp and test_open_intensity_profile
MikeSullivan7 Jan 10, 2025
42de40c
add test_roi_resized and inital spectrum calc bug fix
MikeSullivan7 Jan 13, 2025
4e2acf7
test fixes and cleanup
MikeSullivan7 Jan 13, 2025
9248469
renaming spectrum to intensity profile
MikeSullivan7 Jan 14, 2025
d026d69
reduce Gui Tests SHOW_DELAY
MikeSullivan7 Jan 14, 2025
e1e50c2
MikeSullivan7 Jan 14, 2025
405a543
LV system test test_roi_resized fix
MikeSullivan7 Jan 14, 2025
c53660e
test_roi_resized fix
MikeSullivan7 Jan 14, 2025
0c25797
test_open_close_intensity_profile
MikeSullivan7 Jan 14, 2025
51d58a9
yapf fix
MikeSullivan7 Jan 14, 2025
5e45cb2
release note
MikeSullivan7 Jan 15, 2025
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.
1 change: 1 addition & 0 deletions docs/release_notes/next/feature-2448-LV-async-intensity
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#2448: Live Viewer Intensity is calculated asynchronously via QThreads
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
7 changes: 6 additions & 1 deletion mantidimaging/gui/test/gui_system_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,18 @@
from mantidimaging.test_helpers import start_qapplication, mock_versions

LOAD_SAMPLE = str(Path.home()) + "/mantidimaging-data/ISIS/IMAT/IMAT00010675/Tomo/IMAT_Flower_Tomo_000000.tif"
LOAD_SAMPLE_FOLDER = str(Path.home()) + "/mantidimaging-data/ISIS/IMAT/IMAT00010675/Tomo/"
LOAD_SAMPLE_MISSING_MESSAGE = """Data not present, please clone to your home directory e.g.
git clone https://github.com/mantidproject/mantidimaging-data.git"""

SHOW_DELAY = 10 # Can be increased to watch tests
SHOW_DELAY = 100 # Can be increased to watch tests
SHORT_DELAY = 100


@mock_versions
@pytest.mark.system
@unittest.skipUnless(os.path.exists(LOAD_SAMPLE), LOAD_SAMPLE_MISSING_MESSAGE)
@unittest.skipUnless(os.path.exists(LOAD_SAMPLE_FOLDER), LOAD_SAMPLE_MISSING_MESSAGE)
@start_qapplication
class GuiSystemBase(unittest.TestCase):
app: QApplication
Expand Down Expand Up @@ -134,6 +136,9 @@ def _open_reconstruction(self):
def _open_spectrum_viewer(self):
self.main_window.actionSpectrumViewer.trigger()

def _open_live_viewer(self):
self.main_window.show_live_viewer(Path(LOAD_SAMPLE_FOLDER))

def _close_image_stacks(self):
while self.main_window.dataset_tree_widget.topLevelItemCount():
self.main_window.dataset_tree_widget.topLevelItem(0).setSelected(True)
Expand Down
64 changes: 64 additions & 0 deletions mantidimaging/gui/test/gui_system_liveviewer_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Copyright (C) 2021 ISIS Rutherford Appleton Laboratory UKRI
# SPDX - License - Identifier: GPL-3.0-or-later
from __future__ import annotations

from unittest import mock

import numpy as np
from PyQt5.QtTest import QTest
from numpy.testing import assert_raises

from mantidimaging.gui.test.gui_system_base import GuiSystemBase, SHORT_DELAY, SHOW_DELAY
from mantidimaging.test_helpers.qt_test_helpers import wait_until


class TestGuiLiveViewer(GuiSystemBase):

def setUp(self) -> None:
patcher_show_error_dialog = mock.patch(
"mantidimaging.gui.windows.spectrum_viewer.view.SpectrumViewerWindowView.show_error_dialog")
self.mock_show_error_dialog = patcher_show_error_dialog.start()
self.addCleanup(patcher_show_error_dialog.stop)
super().setUp()
self._close_welcome()

self._open_live_viewer()
assert self.main_window.live_viewer_list[-1] is not None
self.live_viewer_window = self.main_window.live_viewer_list[-1]
print(f"{self.live_viewer_window=}")

self.assertTrue(self.live_viewer_window.isVisible())
QTest.qWait(SHORT_DELAY)

def tearDown(self) -> None:
self._close_image_stacks()
super().tearDown()
self.assertFalse(self.main_window.isVisible())

def test_open_close_intensity_profile(self):
self.assertEqual(self.live_viewer_window.splitter.sizes()[1], 0)
self.live_viewer_window.intensity_action.trigger()
QTest.qWait(SHORT_DELAY)
QTest.qWait(SHOW_DELAY)
wait_until(lambda: not np.isnan(self.live_viewer_window.presenter.model.mean).any(), max_retry=600)
self.assertFalse(np.isnan(self.live_viewer_window.presenter.model.mean).any())
self.assertNotEqual(self.live_viewer_window.splitter.sizes()[1], 0)
self.assertTrue(self.live_viewer_window.intensity_profile.isVisible())
self.live_viewer_window.intensity_action.trigger()
self.assertEqual(self.live_viewer_window.splitter.sizes()[1], 0)
QTest.qWait(SHOW_DELAY)

def test_roi_resized(self):
self.live_viewer_window.intensity_action.trigger()
QTest.qWait(SHORT_DELAY)
wait_until(lambda: not np.isnan(self.live_viewer_window.presenter.model.mean).any(), max_retry=600)
old_mean = self.live_viewer_window.presenter.model.mean
roi = self.live_viewer_window.live_viewer.roi_object.roi
handle_index = 0
new_position = (10, 20)
roi.movePoint(handle_index, new_position)
self.live_viewer_window.presenter.model.clear_mean_partial()
wait_until(lambda: not np.isnan(self.live_viewer_window.presenter.model.mean).any(), max_retry=600)
QTest.qWait(SHORT_DELAY)
assert_raises(AssertionError, np.testing.assert_array_equal, old_mean,
self.live_viewer_window.presenter.model.mean)
45 changes: 43 additions & 2 deletions 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_changing = pyqtSignal()
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 @@ -46,5 +56,36 @@ def handle_deleted(self) -> None:
"""
self.image.clear()

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

def add_roi(self) -> None:
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.sigRegionChanged.connect(self.roi_changing.emit)
self.image.vb.addItem(self.roi_object.roi)

def set_image_shape(self, shape: tuple) -> None:
self.image_shape = shape

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)
121 changes: 114 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:
"""
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 | None:
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,10 @@ 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)

@property
def path(self) -> Path | None:
Expand All @@ -125,9 +188,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 +202,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) -> 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) -> None:
while np.isnan(self.mean).any():
self.calc_mean_chunk(chunk_size)


class ImageWatcher(QObject):
"""
Expand All @@ -161,6 +268,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 +374,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