Source code for mantidimaging.gui.windows.live_viewer.model
# Copyright (C) 2024 ISIS Rutherford Appleton Laboratory UKRI
# SPDX - License - Identifier: GPL-3.0-or-later
from __future__ import annotations
import time
from typing import TYPE_CHECKING
from pathlib import Path
from logging import getLogger
from PyQt5.QtCore import QFileSystemWatcher, QObject, pyqtSignal
if TYPE_CHECKING:
from os import stat_result
from mantidimaging.gui.windows.live_viewer.view import LiveViewerWindowPresenter
LOG = getLogger(__name__)
[docs]
class Image_Data:
"""
Image Data Class to store represent image data.
...
Attributes
----------
image_path : Path
path to image file
image_name : str
name of image file
image_size : int
size of image file
image_modified_time : float
last modified time of image file
"""
def __init__(self, image_path: Path):
"""
Constructor for Image_Data class.
Parameters
----------
image_path : str
path to image file
"""
self.image_path = image_path
self.image_name = image_path.name
self._stat = image_path.stat()
@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
[docs]
class SubDirectory:
def __init__(self, path: Path) -> None:
self.path = path
self._stat = path.stat()
self.mtime = self._stat.st_mtime
@property
def modification_time(self) -> float:
return self.mtime
[docs]
class LiveViewerWindowModel:
"""
The model for the spectrum viewer window.
...
Attributes
----------
presenter : LiveViewerWindowPresenter
presenter for the spectrum viewer window
path : Path
path to dataset
images : list
list of images in directory
"""
def __init__(self, presenter: 'LiveViewerWindowPresenter'):
"""
Constructor for LiveViewerWindowModel class.
Parameters
----------
presenter : LiveViewerWindowPresenter
presenter for the spectrum viewer window
"""
self.presenter = presenter
self._dataset_path: Path | None = None
self.image_watcher: ImageWatcher | None = None
self.images: list[Image_Data] = []
@property
def path(self) -> Path | None:
return self._dataset_path
@path.setter
def path(self, path: Path) -> None:
self._dataset_path = path
self.image_watcher = ImageWatcher(path)
self.image_watcher.image_changed.connect(self._handle_image_changed_in_list)
self.image_watcher.recent_image_changed.connect(self.handle_image_modified)
self.image_watcher._handle_directory_change(str(path))
def _handle_image_changed_in_list(self, image_files: list[Image_Data]) -> None:
"""
Handle an image changed event. Update the image in the view.
This method is called when the image_watcher detects a change
which could be a new image, edited image or deleted image.
:param image_files: list of image files
"""
self.images = image_files
self.presenter.update_image_list(image_files)
[docs]
def handle_image_modified(self, image_path: Path):
self.presenter.update_image_modified(image_path)
[docs]
def close(self) -> None:
"""Close the model."""
if self.image_watcher:
self.image_watcher.remove_path()
self.image_watcher = None
self.presenter = None # type: ignore # Model instance to be destroyed -type can be inconsistent
[docs]
class ImageWatcher(QObject):
"""
A class to watch a directory for new images.
...
Attributes
----------
directory : Path
path to directory to watch
watcher : QFileSystemWatcher
file system watcher to watch directory
image_changed : pyqtSignal
signal emitted when an image is added or removed
Methods
-------
find_images()
Find all the images in the directory
sort_images_by_modified_time(images)
Sort the images by modified time.
"""
image_changed = pyqtSignal(list) # Signal emitted when an image is added or removed
recent_image_changed = pyqtSignal(Path)
def __init__(self, directory: Path):
"""
Constructor for ImageWatcher class which inherits from QObject.
Parameters
----------
directory : Path
path to directory to watch
"""
super().__init__()
self.directory = directory
self.watcher = QFileSystemWatcher()
self.watcher.directoryChanged.connect(self._handle_directory_change)
self.recent_file_watcher = QFileSystemWatcher()
self.recent_file_watcher.fileChanged.connect(self.handle_image_modified)
self.sub_directories: dict[Path, SubDirectory] = {}
self.add_sub_directory(SubDirectory(self.directory))
[docs]
def find_images(self, directory: Path) -> list[Image_Data]:
"""
Find all the images in the directory.
"""
image_files = []
for file_path in directory.iterdir():
if self._is_image_file(file_path.name):
try:
image_obj = Image_Data(file_path)
image_files.append(image_obj)
except FileNotFoundError:
continue
return image_files
[docs]
def find_sub_directories(self, directory: Path) -> None:
# COMPAT python < 3.12 - Can replace with Path.walk()
try:
for filename in directory.glob("**/*"):
if filename.is_dir():
self.add_sub_directory(SubDirectory(filename))
except FileNotFoundError:
pass
[docs]
def sort_sub_directory_by_modified_time(self) -> None:
self.sub_directories = dict(
sorted(self.sub_directories.items(), key=lambda p: p[1].modification_time, reverse=True))
[docs]
@staticmethod
def sort_images_by_modified_time(images: list[Image_Data]) -> list[Image_Data]:
"""
Sort the images by modified time.
:param images: list of image objects to sort by modified time
:return: sorted list of images
"""
return sorted(images, key=lambda x: x.image_modified_time)
def _handle_directory_change(self, directory: str) -> None:
"""
Handle a directory change event. Update the list of images
to reflect directory changes and emit the image_changed signal
with the sorted image list.
:param directory: directory that has changed
"""
directory_path = Path(directory)
# Force the modification time of signal directory, because file changes may not update
# parent dir mtime
if directory_path.exists():
this_dir = SubDirectory(directory_path)
this_dir.mtime = time.time()
self.add_sub_directory(this_dir)
self.clear_deleted_sub_directories(directory_path)
self.find_sub_directories(directory_path)
self.sort_sub_directory_by_modified_time()
for newest_directory in self.sub_directories.values():
try:
images = self.find_images(newest_directory.path)
except FileNotFoundError:
images = []
if len(images) > 0:
break
images = self.sort_images_by_modified_time(images)
self.update_recent_watcher(images[-1:])
self.image_changed.emit(images)
@staticmethod
def _is_image_file(file_name: str) -> bool:
"""
Check if a file is an tiff or tif image file.
:param file_name: name of file
:return: True if file is an image file
"""
image_extensions = ('tif', 'tiff', 'fits')
return file_name.rpartition(".")[2].lower() in image_extensions
[docs]
def remove_path(self):
"""
Remove the currently set path
"""
self.watcher.removePaths([str(path) for path in self.sub_directories.keys()])
self.recent_file_watcher.removePaths(self.recent_file_watcher.files())
assert len(self.watcher.files()) == 0
assert len(self.watcher.directories()) == 0
assert len(self.recent_file_watcher.files()) == 0
assert len(self.recent_file_watcher.directories()) == 0
[docs]
def update_recent_watcher(self, images: list[Image_Data]) -> None:
self.recent_file_watcher.removePaths(self.recent_file_watcher.files())
self.recent_file_watcher.addPaths([str(image.image_path) for image in images])
[docs]
def handle_image_modified(self, file_path):
self.recent_image_changed.emit(Path(file_path))
[docs]
def add_sub_directory(self, sub_dir: SubDirectory):
if sub_dir.path not in self.sub_directories:
self.watcher.addPath(str(sub_dir.path))
self.sub_directories[sub_dir.path] = sub_dir
[docs]
def remove_sub_directory(self, sub_dir: Path):
if sub_dir in self.sub_directories:
self.watcher.removePath(str(sub_dir))
del self.sub_directories[sub_dir]
[docs]
def clear_deleted_sub_directories(self, directory: Path):
for sub_dir in list(self.sub_directories):
if sub_dir.is_relative_to(directory) and not sub_dir.exists():
self.remove_sub_directory(sub_dir)