From ba515b8971ee40c6a5f8e9104b0a1e94dfa4e82f Mon Sep 17 00:00:00 2001 From: uermel Date: Sun, 6 Jul 2025 12:42:11 -0700 Subject: [PATCH 1/3] debug thumbs --- src/copick_shared_ui/core/image_interface.py | 82 +++++++++++++++++-- src/copick_shared_ui/core/thumbnail_cache.py | 81 ++++++++++++++++-- .../widgets/gallery/gallery_widget.py | 38 ++++++++- 3 files changed, 184 insertions(+), 17 deletions(-) diff --git a/src/copick_shared_ui/core/image_interface.py b/src/copick_shared_ui/core/image_interface.py index 55b2a6b..5186339 100644 --- a/src/copick_shared_ui/core/image_interface.py +++ b/src/copick_shared_ui/core/image_interface.py @@ -22,14 +22,18 @@ def _setup_qt(self) -> None: self._QPixmap = QPixmap self._qt_available = True + print(f"๐Ÿ–ผ๏ธ Qt interface initialized with qtpy: {QPixmap}") except ImportError: + print(f"๐Ÿ–ผ๏ธ qtpy not available, trying Qt fallback") try: # Fall back to Qt (ChimeraX) from Qt.QtGui import QPixmap self._QPixmap = QPixmap self._qt_available = True + print(f"๐Ÿ–ผ๏ธ Qt interface initialized with Qt: {QPixmap}") except ImportError: + print(f"โŒ No Qt interface available") self._qt_available = False def save_image(self, image: Any, path: str, format: str = "PNG") -> bool: @@ -43,14 +47,40 @@ def save_image(self, image: Any, path: str, format: str = "PNG") -> bool: Returns: True if successful, False otherwise """ - if not self._qt_available or not image: + print(f"๐Ÿ–ผ๏ธ Qt save_image called: {path}") + print(f"๐Ÿ–ผ๏ธ Qt available: {self._qt_available}") + print(f"๐Ÿ–ผ๏ธ Image provided: {image is not None}") + + if not self._qt_available: + print(f"โŒ Qt not available for save operation") + return False + + if not image: + print(f"โŒ No image provided for save operation") return False try: + print(f"๐Ÿ–ผ๏ธ Image type: {type(image)}") + print(f"๐Ÿ–ผ๏ธ Image isNull: {image.isNull() if hasattr(image, 'isNull') else 'No isNull method'}") + print(f"๐Ÿ–ผ๏ธ Image size: {image.size() if hasattr(image, 'size') else 'No size method'}") + # QPixmap has a save method - return image.save(path, format) + result = image.save(path, format) + print(f"๐Ÿ–ผ๏ธ Qt save result: {result}") + + # Verify the file was actually written + from pathlib import Path + saved_path = Path(path) + if saved_path.exists(): + print(f"๐Ÿ–ผ๏ธ File successfully written: {saved_path.stat().st_size} bytes") + else: + print(f"โŒ File not found after save operation: {path}") + + return result except Exception as e: - print(f"Error saving image: {e}") + print(f"โŒ Error saving image: {e}") + import traceback + print(f"โŒ Stack trace: {traceback.format_exc()}") return False def load_image(self, path: str) -> Optional[Any]: @@ -62,14 +92,37 @@ def load_image(self, path: str) -> Optional[Any]: Returns: QPixmap object if successful, None otherwise """ + print(f"๐Ÿ–ผ๏ธ Qt load_image called: {path}") + print(f"๐Ÿ–ผ๏ธ Qt available: {self._qt_available}") + if not self._qt_available: + print(f"โŒ Qt not available for load operation") return None try: + from pathlib import Path + path_obj = Path(path) + print(f"๐Ÿ–ผ๏ธ File exists: {path_obj.exists()}") + if path_obj.exists(): + print(f"๐Ÿ–ผ๏ธ File size: {path_obj.stat().st_size} bytes") + pixmap = self._QPixmap(path) - return pixmap if not pixmap.isNull() else None + print(f"๐Ÿ–ผ๏ธ QPixmap created: {pixmap is not None}") + + if pixmap: + is_null = pixmap.isNull() + print(f"๐Ÿ–ผ๏ธ QPixmap isNull: {is_null}") + if not is_null: + print(f"๐Ÿ–ผ๏ธ QPixmap size: {pixmap.size()}") + print(f"๐Ÿ–ผ๏ธ QPixmap format: {pixmap.format() if hasattr(pixmap, 'format') else 'No format method'}") + return pixmap if not is_null else None + else: + print(f"๐Ÿ–ผ๏ธ QPixmap creation returned None") + return None except Exception as e: - print(f"Error loading image: {e}") + print(f"โŒ Error loading image: {e}") + import traceback + print(f"โŒ Stack trace: {traceback.format_exc()}") return None def is_valid_image(self, image: Any) -> bool: @@ -81,13 +134,26 @@ def is_valid_image(self, image: Any) -> bool: Returns: True if valid, False otherwise """ - if not self._qt_available or not image: + print(f"๐Ÿ–ผ๏ธ Qt is_valid_image called") + print(f"๐Ÿ–ผ๏ธ Qt available: {self._qt_available}") + print(f"๐Ÿ–ผ๏ธ Image provided: {image is not None}") + + if not self._qt_available: + print(f"โŒ Qt not available for validation") + return False + + if not image: + print(f"โŒ No image provided for validation") return False try: # QPixmap has isNull method - return not image.isNull() - except Exception: + is_null = image.isNull() + is_valid = not is_null + print(f"๐Ÿ–ผ๏ธ Image isNull: {is_null}, isValid: {is_valid}") + return is_valid + except Exception as e: + print(f"โŒ Error validating image: {e}") return False diff --git a/src/copick_shared_ui/core/thumbnail_cache.py b/src/copick_shared_ui/core/thumbnail_cache.py index ec57e2f..ce85cc9 100644 --- a/src/copick_shared_ui/core/thumbnail_cache.py +++ b/src/copick_shared_ui/core/thumbnail_cache.py @@ -84,6 +84,10 @@ def _setup_cache_directory(self) -> None: base_cache_dir = Path(os.environ.get("LOCALAPPDATA", "")) / self.app_name / "thumbnails" elif platform.system() == "Darwin": # macOS base_cache_dir = Path.home() / "Library" / "Caches" / self.app_name / "thumbnails" + print(f"๐ŸŽ macOS detected - base cache directory: {base_cache_dir}") + print(f"๐ŸŽ Home directory: {Path.home()}") + print(f"๐ŸŽ Library directory exists: {(Path.home() / 'Library').exists()}") + print(f"๐ŸŽ Library/Caches directory exists: {(Path.home() / 'Library' / 'Caches').exists()}") else: # Linux and other Unix-like systems cache_home = os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache")) base_cache_dir = Path(cache_home) / self.app_name / "thumbnails" @@ -92,12 +96,28 @@ def _setup_cache_directory(self) -> None: # Create hash of the config file path and content for cache namespacing self.config_hash = self._compute_config_hash(self.config_path) self.cache_dir = base_cache_dir / self.config_hash + print(f"๐Ÿ”‘ Using config-specific cache directory: {self.cache_dir}") + print(f"๐Ÿ”‘ Config path: {self.config_path}") + print(f"๐Ÿ”‘ Config hash: {self.config_hash}") else: # Fallback to generic cache directory self.cache_dir = base_cache_dir / "default" + print(f"๐Ÿ”„ Using default cache directory: {self.cache_dir}") + + print(f"๐Ÿ“ Cache directory path: {self.cache_dir}") + print(f"๐Ÿ“ Cache directory exists before creation: {self.cache_dir.exists()}") + print(f"๐Ÿ“ Parent directory exists: {self.cache_dir.parent.exists()}") + print(f"๐Ÿ“ Parent directory writable: {os.access(self.cache_dir.parent, os.W_OK) if self.cache_dir.parent.exists() else 'Parent does not exist'}") # Create the cache directory if it doesn't exist - self.cache_dir.mkdir(parents=True, exist_ok=True) + try: + self.cache_dir.mkdir(parents=True, exist_ok=True) + print(f"๐Ÿ“ Cache directory created successfully") + print(f"๐Ÿ“ Cache directory exists after creation: {self.cache_dir.exists()}") + print(f"๐Ÿ“ Cache directory writable: {os.access(self.cache_dir, os.W_OK) if self.cache_dir.exists() else 'Directory does not exist'}") + except Exception as e: + print(f"โŒ Error creating cache directory: {e}") + raise # Create metadata file if it doesn't exist self._ensure_metadata_file() @@ -161,7 +181,9 @@ def get_cache_key( key_string = "_".join(key_parts) # Hash the key to handle special characters and ensure consistent filename - return hashlib.md5(key_string.encode("utf-8")).hexdigest() + cache_key = hashlib.md5(key_string.encode("utf-8")).hexdigest() + print(f"๐Ÿ”‘ Cache key generation: '{key_string}' -> '{cache_key}'") + return cache_key def get_thumbnail_path(self, cache_key: str) -> Path: """Get the file path for a thumbnail cache file. @@ -185,6 +207,13 @@ def has_thumbnail(self, cache_key: str) -> bool: """ thumbnail_path = self.get_thumbnail_path(cache_key) exists = thumbnail_path.exists() + print(f"๐Ÿ“ Checking thumbnail existence: {thumbnail_path} -> {exists}") + if exists: + try: + stat = thumbnail_path.stat() + print(f"๐Ÿ“ Thumbnail file size: {stat.st_size} bytes, modified: {stat.st_mtime}") + except Exception as e: + print(f"๐Ÿ“ Error getting thumbnail stats: {e}") return exists def save_thumbnail(self, cache_key: str, image: Any) -> bool: @@ -198,15 +227,33 @@ def save_thumbnail(self, cache_key: str, image: Any) -> bool: True if successful, False otherwise """ if not self._image_interface: - print("Error: No image interface set for thumbnail cache") + print("โŒ Error: No image interface set for thumbnail cache") return False try: thumbnail_path = self.get_thumbnail_path(cache_key) + print(f"๐Ÿ’พ Saving thumbnail for key '{cache_key}' to: {thumbnail_path}") + print(f"๐Ÿ’พ Image object type: {type(image)}") + print(f"๐Ÿ’พ Image interface type: {type(self._image_interface)}") + print(f"๐Ÿ’พ Directory exists: {thumbnail_path.parent.exists()}") + print(f"๐Ÿ’พ Directory writable: {os.access(thumbnail_path.parent, os.W_OK) if thumbnail_path.parent.exists() else 'Parent does not exist'}") + success = self._image_interface.save_image(image, str(thumbnail_path), "PNG") + print(f"๐Ÿ’พ Save operation result: {success}") + + if success and thumbnail_path.exists(): + stat = thumbnail_path.stat() + print(f"๐Ÿ’พ Thumbnail saved successfully - size: {stat.st_size} bytes") + elif success: + print(f"๐Ÿ’พ Save reported success but file doesn't exist: {thumbnail_path}") + else: + print(f"๐Ÿ’พ Save operation failed") + return success except Exception as e: - print(f"Error saving thumbnail to cache: {e}") + print(f"โŒ Error saving thumbnail to cache: {e}") + import traceback + print(f"โŒ Stack trace: {traceback.format_exc()}") return False def load_thumbnail(self, cache_key: str) -> Optional[Any]: @@ -219,17 +266,35 @@ def load_thumbnail(self, cache_key: str) -> Optional[Any]: Image object if successful, None otherwise """ if not self._image_interface: - print("Error: No image interface set for thumbnail cache") + print("โŒ Error: No image interface set for thumbnail cache") return None try: thumbnail_path = self.get_thumbnail_path(cache_key) + print(f"๐Ÿ” Loading thumbnail for key '{cache_key}' from: {thumbnail_path}") + if thumbnail_path.exists(): + stat = thumbnail_path.stat() + print(f"๐Ÿ” Thumbnail file exists - size: {stat.st_size} bytes") + image = self._image_interface.load_image(str(thumbnail_path)) - return image if image and self._image_interface.is_valid_image(image) else None - return None + print(f"๐Ÿ” Image loaded: {image is not None}") + + if image: + is_valid = self._image_interface.is_valid_image(image) + print(f"๐Ÿ” Image valid: {is_valid}") + print(f"๐Ÿ” Image type: {type(image)}") + return image if is_valid else None + else: + print(f"๐Ÿ” Image load returned None") + return None + else: + print(f"๐Ÿ” Thumbnail file does not exist: {thumbnail_path}") + return None except Exception as e: - print(f"Error loading thumbnail from cache: {e}") + print(f"โŒ Error loading thumbnail from cache: {e}") + import traceback + print(f"โŒ Stack trace: {traceback.format_exc()}") return None def _cleanup_old_cache_entries(self, max_age_days: int = 14) -> None: diff --git a/src/copick_shared_ui/widgets/gallery/gallery_widget.py b/src/copick_shared_ui/widgets/gallery/gallery_widget.py index 2bac696..42075df 100644 --- a/src/copick_shared_ui/widgets/gallery/gallery_widget.py +++ b/src/copick_shared_ui/widgets/gallery/gallery_widget.py @@ -124,10 +124,13 @@ def delete(self) -> None: def set_copick_root(self, copick_root: Optional[Any]) -> None: """Set the copick root and load runs.""" + print(f"๐Ÿ“ธ Gallery: Setting copick root - new root: {copick_root is not None}") + # Clear workers to cancel any pending thumbnail loads from previous session self.worker_interface.clear_workers() # Clear caches when root changes + print(f"๐Ÿ“ธ Gallery: Clearing caches - cards: {len(self.all_run_cards)}, thumbnails: {len(self.thumbnail_cache)}") self.all_run_cards.clear() self.visible_run_cards.clear() self.thumbnail_cache.clear() @@ -137,10 +140,12 @@ def set_copick_root(self, copick_root: Optional[Any]) -> None: if copick_root: self.runs = list(copick_root.runs) self.filtered_runs = self.runs.copy() + print(f"๐Ÿ“ธ Gallery: Loaded {len(self.runs)} runs from copick root") self._update_grid() else: self.runs = [] self.filtered_runs = [] + print(f"๐Ÿ“ธ Gallery: No copick root provided, clearing grid") self._clear_grid() def apply_search_filter(self, filter_text: str) -> None: @@ -215,8 +220,10 @@ def _update_grid(self) -> None: if run.name in self.all_run_cards: # Reuse existing card card = self.all_run_cards[run.name] + print(f"๐Ÿ“ธ Gallery: Reusing existing card for '{run.name}'") else: # Create new card + print(f"๐Ÿ“ธ Gallery: Creating new card for '{run.name}'") card = RunCard(run, self.theme_interface, self.image_interface) card.clicked.connect(self._on_run_card_clicked) card.info_requested.connect(self._on_run_info_requested) @@ -224,9 +231,13 @@ def _update_grid(self) -> None: # Check if we have a cached thumbnail if run.name in self.thumbnail_cache: - card.set_thumbnail(self.thumbnail_cache[run.name]) + print(f"๐Ÿ“ธ Gallery: Found cached thumbnail for '{run.name}'") + cached_thumbnail = self.thumbnail_cache[run.name] + print(f"๐Ÿ“ธ Gallery: Cached thumbnail type: {type(cached_thumbnail)}") + card.set_thumbnail(cached_thumbnail) else: # Start thumbnail loading + print(f"๐Ÿ“ธ Gallery: No cached thumbnail for '{run.name}', starting load") self._load_run_thumbnail(run, run.name) # Add to visible cards and grid layout @@ -241,10 +252,23 @@ def _load_run_thumbnail(self, run: "CopickRun", thumbnail_id: str, force_regener if self._is_destroyed: return + print(f"๐Ÿ“ธ Gallery: Starting thumbnail load for '{thumbnail_id}' - force_regenerate: {force_regenerate}") + try: + tomogram_count = len(run.tomograms) if hasattr(run, 'tomograms') else 'unknown' + print(f"๐Ÿ“ธ Gallery: Run has {tomogram_count} tomograms") + except Exception as e: + print(f"๐Ÿ“ธ Gallery: Could not get tomogram count: {e}") + self.worker_interface.start_thumbnail_worker(run, thumbnail_id, self._on_thumbnail_loaded, force_regenerate) def _on_thumbnail_loaded(self, thumbnail_id: str, pixmap: Optional[Any], error: Optional[str]) -> None: """Handle thumbnail loading completion.""" + print(f"๐Ÿ“ธ Gallery: Thumbnail loaded callback for '{thumbnail_id}'") + print(f"๐Ÿ“ธ Gallery: Widget destroyed: {self._is_destroyed}") + print(f"๐Ÿ“ธ Gallery: Card exists: {thumbnail_id in self.all_run_cards}") + print(f"๐Ÿ“ธ Gallery: Pixmap provided: {pixmap is not None}") + print(f"๐Ÿ“ธ Gallery: Error: {error}") + if self._is_destroyed or thumbnail_id not in self.all_run_cards: print( f"โš ๏ธ Gallery: Thumbnail callback for '{thumbnail_id}' ignored (destroyed={self._is_destroyed}, card_exists={thumbnail_id in self.all_run_cards})", @@ -254,12 +278,20 @@ def _on_thumbnail_loaded(self, thumbnail_id: str, pixmap: Optional[Any], error: card = self.all_run_cards[thumbnail_id] if error: + print(f"๐Ÿ“ธ Gallery: Setting error on card for '{thumbnail_id}': {error}") card.set_error(error) else: + print(f"๐Ÿ“ธ Gallery: Setting thumbnail on card for '{thumbnail_id}'") + if pixmap: + print(f"๐Ÿ“ธ Gallery: Pixmap type: {type(pixmap)}") + print(f"๐Ÿ“ธ Gallery: Pixmap size: {pixmap.size() if hasattr(pixmap, 'size') else 'No size method'}") card.set_thumbnail(pixmap) # Cache the thumbnail for future use if pixmap: self.thumbnail_cache[thumbnail_id] = pixmap + print(f"๐Ÿ“ธ Gallery: Cached thumbnail for '{thumbnail_id}' - cache size: {len(self.thumbnail_cache)}") + else: + print(f"๐Ÿ“ธ Gallery: No pixmap to cache for '{thumbnail_id}'") @Slot(object) def _on_run_card_clicked(self, run: "CopickRun") -> None: @@ -340,14 +372,18 @@ def _on_theme_changed(self) -> None: @Slot() def _on_regenerate_thumbnails(self) -> None: """Handle regenerate thumbnails button click.""" + print(f"๐Ÿ“ธ Gallery: Regenerating thumbnails - clearing cache of {len(self.thumbnail_cache)} items") + # Clear memory cache self.thumbnail_cache.clear() # Reset all cards to loading state + print(f"๐Ÿ“ธ Gallery: Resetting {len(self.all_run_cards)} cards to loading state") for card in self.all_run_cards.values(): card.set_loading("Regenerating...") # Force regenerate all visible thumbnails + print(f"๐Ÿ“ธ Gallery: Force regenerating {len(self.filtered_runs)} thumbnails") for run in self.filtered_runs: if run.name in self.all_run_cards: self._load_run_thumbnail(run, run.name, force_regenerate=True) From a534a9e4bd9dda971df50b1f27bed5e59917b4c2 Mon Sep 17 00:00:00 2001 From: uermel Date: Sun, 6 Jul 2025 22:05:11 -0700 Subject: [PATCH 2/3] improve import statements, improve performance, consolidate on superqt thread_worker --- pyproject.toml | 1 + src/copick_shared_ui/__init__.py | 6 +- src/copick_shared_ui/core/__init__.py | 4 +- src/copick_shared_ui/core/image_interface.py | 64 +-- src/copick_shared_ui/core/models.py | 2 +- src/copick_shared_ui/core/thumbnail_cache.py | 216 +++++--- .../platform/chimerax_integration.py | 48 +- .../platform/napari_integration.py | 12 +- src/copick_shared_ui/theming/__init__.py | 6 +- src/copick_shared_ui/theming/styles.py | 2 +- .../widgets/gallery/__init__.py | 4 +- .../widgets/gallery/gallery_widget.py | 53 +- .../widgets/gallery/run_card.py | 4 +- src/copick_shared_ui/widgets/info/__init__.py | 2 +- .../widgets/info/info_widget.py | 6 +- src/copick_shared_ui/workers/__init__.py | 53 +- src/copick_shared_ui/workers/base.py | 86 ++- src/copick_shared_ui/workers/chimerax.py | 448 ++++++---------- src/copick_shared_ui/workers/napari.py | 492 +----------------- .../workers/unified_workers.py | 391 ++++++++++++++ 20 files changed, 897 insertions(+), 1003 deletions(-) create mode 100644 src/copick_shared_ui/workers/unified_workers.py diff --git a/pyproject.toml b/pyproject.toml index 89c87eb..7274172 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "copick[all]>=1.5.0", "zarr<3", "numpy>=1.21.0", + "superqt" ] authors = [ {name = "Utz H. Ermel", email = "utz@ermel.me"}, diff --git a/src/copick_shared_ui/__init__.py b/src/copick_shared_ui/__init__.py index 9e61c8c..a39c504 100644 --- a/src/copick_shared_ui/__init__.py +++ b/src/copick_shared_ui/__init__.py @@ -3,7 +3,7 @@ __version__ = "0.1.1" # Core components -from .core import ( +from copick_shared_ui.core import ( AbstractImageInterface, AbstractInfoSessionInterface, AbstractSessionInterface, @@ -15,10 +15,10 @@ ) # UI components -from .ui.edit_object_types_dialog import ColorButton, EditObjectTypesDialog +from copick_shared_ui.ui.edit_object_types_dialog import ColorButton, EditObjectTypesDialog # Utilities -from .util.validation import generate_smart_copy_name, get_invalid_characters, validate_copick_name +from copick_shared_ui.util.validation import generate_smart_copy_name, get_invalid_characters, validate_copick_name __all__ = [ # Core interfaces and caching diff --git a/src/copick_shared_ui/core/__init__.py b/src/copick_shared_ui/core/__init__.py index 3a6f7dd..0c2e4e5 100644 --- a/src/copick_shared_ui/core/__init__.py +++ b/src/copick_shared_ui/core/__init__.py @@ -1,13 +1,13 @@ """Core shared components for copick UI.""" -from .models import ( +from copick_shared_ui.core.models import ( AbstractImageInterface, AbstractInfoSessionInterface, AbstractSessionInterface, AbstractThemeInterface, AbstractWorkerInterface, ) -from .thumbnail_cache import ThumbnailCache, get_global_cache, set_global_cache_config +from copick_shared_ui.core.thumbnail_cache import ThumbnailCache, get_global_cache, set_global_cache_config __all__ = [ "AbstractImageInterface", diff --git a/src/copick_shared_ui/core/image_interface.py b/src/copick_shared_ui/core/image_interface.py index 5186339..9cf2a15 100644 --- a/src/copick_shared_ui/core/image_interface.py +++ b/src/copick_shared_ui/core/image_interface.py @@ -2,7 +2,7 @@ from typing import Any, Optional -from .thumbnail_cache import ImageInterface +from copick_shared_ui.core.thumbnail_cache import ImageInterface class QtImageInterface(ImageInterface): @@ -22,18 +22,15 @@ def _setup_qt(self) -> None: self._QPixmap = QPixmap self._qt_available = True - print(f"๐Ÿ–ผ๏ธ Qt interface initialized with qtpy: {QPixmap}") except ImportError: - print(f"๐Ÿ–ผ๏ธ qtpy not available, trying Qt fallback") try: # Fall back to Qt (ChimeraX) from Qt.QtGui import QPixmap self._QPixmap = QPixmap self._qt_available = True - print(f"๐Ÿ–ผ๏ธ Qt interface initialized with Qt: {QPixmap}") except ImportError: - print(f"โŒ No Qt interface available") + print("โŒ No Qt interface available") self._qt_available = False def save_image(self, image: Any, path: str, format: str = "PNG") -> bool: @@ -47,39 +44,23 @@ def save_image(self, image: Any, path: str, format: str = "PNG") -> bool: Returns: True if successful, False otherwise """ - print(f"๐Ÿ–ผ๏ธ Qt save_image called: {path}") - print(f"๐Ÿ–ผ๏ธ Qt available: {self._qt_available}") - print(f"๐Ÿ–ผ๏ธ Image provided: {image is not None}") - if not self._qt_available: - print(f"โŒ Qt not available for save operation") + print("โŒ Qt not available for save operation") return False - + if not image: - print(f"โŒ No image provided for save operation") + print("โŒ No image provided for save operation") return False try: - print(f"๐Ÿ–ผ๏ธ Image type: {type(image)}") - print(f"๐Ÿ–ผ๏ธ Image isNull: {image.isNull() if hasattr(image, 'isNull') else 'No isNull method'}") - print(f"๐Ÿ–ผ๏ธ Image size: {image.size() if hasattr(image, 'size') else 'No size method'}") - # QPixmap has a save method result = image.save(path, format) - print(f"๐Ÿ–ผ๏ธ Qt save result: {result}") - - # Verify the file was actually written - from pathlib import Path - saved_path = Path(path) - if saved_path.exists(): - print(f"๐Ÿ–ผ๏ธ File successfully written: {saved_path.stat().st_size} bytes") - else: - print(f"โŒ File not found after save operation: {path}") - + return result except Exception as e: print(f"โŒ Error saving image: {e}") import traceback + print(f"โŒ Stack trace: {traceback.format_exc()}") return False @@ -92,36 +73,24 @@ def load_image(self, path: str) -> Optional[Any]: Returns: QPixmap object if successful, None otherwise """ - print(f"๐Ÿ–ผ๏ธ Qt load_image called: {path}") - print(f"๐Ÿ–ผ๏ธ Qt available: {self._qt_available}") - if not self._qt_available: - print(f"โŒ Qt not available for load operation") + print("โŒ Qt not available for load operation") return None try: from pathlib import Path - path_obj = Path(path) - print(f"๐Ÿ–ผ๏ธ File exists: {path_obj.exists()}") - if path_obj.exists(): - print(f"๐Ÿ–ผ๏ธ File size: {path_obj.stat().st_size} bytes") - + pixmap = self._QPixmap(path) - print(f"๐Ÿ–ผ๏ธ QPixmap created: {pixmap is not None}") - + if pixmap: is_null = pixmap.isNull() - print(f"๐Ÿ–ผ๏ธ QPixmap isNull: {is_null}") - if not is_null: - print(f"๐Ÿ–ผ๏ธ QPixmap size: {pixmap.size()}") - print(f"๐Ÿ–ผ๏ธ QPixmap format: {pixmap.format() if hasattr(pixmap, 'format') else 'No format method'}") return pixmap if not is_null else None else: - print(f"๐Ÿ–ผ๏ธ QPixmap creation returned None") return None except Exception as e: print(f"โŒ Error loading image: {e}") import traceback + print(f"โŒ Stack trace: {traceback.format_exc()}") return None @@ -134,23 +103,18 @@ def is_valid_image(self, image: Any) -> bool: Returns: True if valid, False otherwise """ - print(f"๐Ÿ–ผ๏ธ Qt is_valid_image called") - print(f"๐Ÿ–ผ๏ธ Qt available: {self._qt_available}") - print(f"๐Ÿ–ผ๏ธ Image provided: {image is not None}") - if not self._qt_available: - print(f"โŒ Qt not available for validation") + print("โŒ Qt not available for validation") return False - + if not image: - print(f"โŒ No image provided for validation") + print("โŒ No image provided for validation") return False try: # QPixmap has isNull method is_null = image.isNull() is_valid = not is_null - print(f"๐Ÿ–ผ๏ธ Image isNull: {is_null}, isValid: {is_valid}") return is_valid except Exception as e: print(f"โŒ Error validating image: {e}") diff --git a/src/copick_shared_ui/core/models.py b/src/copick_shared_ui/core/models.py index 4a2ab1e..d240bdc 100644 --- a/src/copick_shared_ui/core/models.py +++ b/src/copick_shared_ui/core/models.py @@ -211,7 +211,7 @@ def create_pixmap_from_array(self, array: Any) -> Any: return None @abstractmethod - def scale_pixmap(self, pixmap: Any, size: tuple, smooth: bool = True) -> Any: + def scale_pixmap(self, pixmap: Any, size: tuple, smooth: bool = False) -> Any: """Scale a pixmap to the specified size.""" pass diff --git a/src/copick_shared_ui/core/thumbnail_cache.py b/src/copick_shared_ui/core/thumbnail_cache.py index ce85cc9..8789126 100644 --- a/src/copick_shared_ui/core/thumbnail_cache.py +++ b/src/copick_shared_ui/core/thumbnail_cache.py @@ -67,7 +67,8 @@ def __init__(self, config_path: Optional[str] = None, app_name: str = "copick"): self.cache_dir: Optional[Path] = None self._image_interface: Optional[ImageInterface] = None self._setup_cache_directory() - self._cleanup_old_cache_entries() + # Skip cache cleanup during initialization to avoid blocking main thread + # self._cleanup_old_cache_entries() def set_image_interface(self, image_interface: ImageInterface) -> None: """Set the image interface for handling image operations. @@ -84,10 +85,6 @@ def _setup_cache_directory(self) -> None: base_cache_dir = Path(os.environ.get("LOCALAPPDATA", "")) / self.app_name / "thumbnails" elif platform.system() == "Darwin": # macOS base_cache_dir = Path.home() / "Library" / "Caches" / self.app_name / "thumbnails" - print(f"๐ŸŽ macOS detected - base cache directory: {base_cache_dir}") - print(f"๐ŸŽ Home directory: {Path.home()}") - print(f"๐ŸŽ Library directory exists: {(Path.home() / 'Library').exists()}") - print(f"๐ŸŽ Library/Caches directory exists: {(Path.home() / 'Library' / 'Caches').exists()}") else: # Linux and other Unix-like systems cache_home = os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache")) base_cache_dir = Path(cache_home) / self.app_name / "thumbnails" @@ -96,32 +93,21 @@ def _setup_cache_directory(self) -> None: # Create hash of the config file path and content for cache namespacing self.config_hash = self._compute_config_hash(self.config_path) self.cache_dir = base_cache_dir / self.config_hash - print(f"๐Ÿ”‘ Using config-specific cache directory: {self.cache_dir}") - print(f"๐Ÿ”‘ Config path: {self.config_path}") - print(f"๐Ÿ”‘ Config hash: {self.config_hash}") else: # Fallback to generic cache directory self.cache_dir = base_cache_dir / "default" - print(f"๐Ÿ”„ Using default cache directory: {self.cache_dir}") - - print(f"๐Ÿ“ Cache directory path: {self.cache_dir}") - print(f"๐Ÿ“ Cache directory exists before creation: {self.cache_dir.exists()}") - print(f"๐Ÿ“ Parent directory exists: {self.cache_dir.parent.exists()}") - print(f"๐Ÿ“ Parent directory writable: {os.access(self.cache_dir.parent, os.W_OK) if self.cache_dir.parent.exists() else 'Parent does not exist'}") # Create the cache directory if it doesn't exist try: self.cache_dir.mkdir(parents=True, exist_ok=True) - print(f"๐Ÿ“ Cache directory created successfully") - print(f"๐Ÿ“ Cache directory exists after creation: {self.cache_dir.exists()}") - print(f"๐Ÿ“ Cache directory writable: {os.access(self.cache_dir, os.W_OK) if self.cache_dir.exists() else 'Directory does not exist'}") + + # Create metadata file FIRST to ensure proper cache initialization + self._ensure_metadata_file() + except Exception as e: print(f"โŒ Error creating cache directory: {e}") raise - # Create metadata file if it doesn't exist - self._ensure_metadata_file() - def _compute_config_hash(self, config_path: str) -> str: """Compute a hash for the config file based on path and content.""" hasher = hashlib.sha256() @@ -163,7 +149,7 @@ def get_cache_key( tomogram_type: Optional[str] = None, voxel_spacing: Optional[float] = None, ) -> str: - """Generate a cache key for a thumbnail. + """Generate a human-readable cache key for a thumbnail. Args: run_name: Name of the copick run @@ -171,18 +157,18 @@ def get_cache_key( voxel_spacing: Voxel spacing value Returns: - Cache key string + Human-readable cache key string """ key_parts = [run_name] if tomogram_type: key_parts.append(tomogram_type) if voxel_spacing: - key_parts.append(f"vs{voxel_spacing}") + key_parts.append(f"vs{voxel_spacing:.3f}") - key_string = "_".join(key_parts) - # Hash the key to handle special characters and ensure consistent filename - cache_key = hashlib.md5(key_string.encode("utf-8")).hexdigest() - print(f"๐Ÿ”‘ Cache key generation: '{key_string}' -> '{cache_key}'") + # Use human-readable filename instead of hash + cache_key = "_".join(key_parts) + # Replace any problematic characters for filename safety + cache_key = cache_key.replace("/", "_").replace("\\", "_").replace(":", "_") return cache_key def get_thumbnail_path(self, cache_key: str) -> Path: @@ -207,13 +193,6 @@ def has_thumbnail(self, cache_key: str) -> bool: """ thumbnail_path = self.get_thumbnail_path(cache_key) exists = thumbnail_path.exists() - print(f"๐Ÿ“ Checking thumbnail existence: {thumbnail_path} -> {exists}") - if exists: - try: - stat = thumbnail_path.stat() - print(f"๐Ÿ“ Thumbnail file size: {stat.st_size} bytes, modified: {stat.st_mtime}") - except Exception as e: - print(f"๐Ÿ“ Error getting thumbnail stats: {e}") return exists def save_thumbnail(self, cache_key: str, image: Any) -> bool: @@ -232,27 +211,12 @@ def save_thumbnail(self, cache_key: str, image: Any) -> bool: try: thumbnail_path = self.get_thumbnail_path(cache_key) - print(f"๐Ÿ’พ Saving thumbnail for key '{cache_key}' to: {thumbnail_path}") - print(f"๐Ÿ’พ Image object type: {type(image)}") - print(f"๐Ÿ’พ Image interface type: {type(self._image_interface)}") - print(f"๐Ÿ’พ Directory exists: {thumbnail_path.parent.exists()}") - print(f"๐Ÿ’พ Directory writable: {os.access(thumbnail_path.parent, os.W_OK) if thumbnail_path.parent.exists() else 'Parent does not exist'}") - success = self._image_interface.save_image(image, str(thumbnail_path), "PNG") - print(f"๐Ÿ’พ Save operation result: {success}") - - if success and thumbnail_path.exists(): - stat = thumbnail_path.stat() - print(f"๐Ÿ’พ Thumbnail saved successfully - size: {stat.st_size} bytes") - elif success: - print(f"๐Ÿ’พ Save reported success but file doesn't exist: {thumbnail_path}") - else: - print(f"๐Ÿ’พ Save operation failed") - return success except Exception as e: print(f"โŒ Error saving thumbnail to cache: {e}") import traceback + print(f"โŒ Stack trace: {traceback.format_exc()}") return False @@ -271,22 +235,15 @@ def load_thumbnail(self, cache_key: str) -> Optional[Any]: try: thumbnail_path = self.get_thumbnail_path(cache_key) - print(f"๐Ÿ” Loading thumbnail for key '{cache_key}' from: {thumbnail_path}") - + if thumbnail_path.exists(): - stat = thumbnail_path.stat() - print(f"๐Ÿ” Thumbnail file exists - size: {stat.st_size} bytes") - image = self._image_interface.load_image(str(thumbnail_path)) - print(f"๐Ÿ” Image loaded: {image is not None}") - + if image: is_valid = self._image_interface.is_valid_image(image) - print(f"๐Ÿ” Image valid: {is_valid}") - print(f"๐Ÿ” Image type: {type(image)}") return image if is_valid else None else: - print(f"๐Ÿ” Image load returned None") + print("๐Ÿ” Image load returned None") return None else: print(f"๐Ÿ” Thumbnail file does not exist: {thumbnail_path}") @@ -294,6 +251,7 @@ def load_thumbnail(self, cache_key: str) -> Optional[Any]: except Exception as e: print(f"โŒ Error loading thumbnail from cache: {e}") import traceback + print(f"โŒ Stack trace: {traceback.format_exc()}") return None @@ -309,37 +267,43 @@ def _cleanup_old_cache_entries(self, max_age_days: int = 14) -> None: try: import time - current_time = time.time() - max_age_seconds = max_age_days * 24 * 60 * 60 # Convert days to seconds - - removed_count = 0 - for thumbnail_file in self.cache_dir.glob("*.png"): - try: - # Get file modification time - file_mtime = thumbnail_file.stat().st_mtime - age_seconds = current_time - file_mtime - - if age_seconds > max_age_seconds: - thumbnail_file.unlink() - removed_count += 1 - - except Exception as e: - print(f"Warning: Could not process cache file {thumbnail_file}: {e}") + # current_time = time.time() + # max_age_seconds = max_age_days * 24 * 60 * 60 # Convert days to seconds + # + # removed_count = 0 + # for thumbnail_file in self.cache_dir.glob("*.png"): + # try: + # # Get file modification time + # file_mtime = thumbnail_file.stat().st_mtime + # age_seconds = current_time - file_mtime + # + # if age_seconds > max_age_seconds: + # thumbnail_file.unlink() + # removed_count += 1 + + # except Exception as e: + # print(f"Warning: Could not process cache file {thumbnail_file}: {e}") except Exception as e: print(f"Warning: Cache cleanup failed: {e}") def clear_cache(self) -> bool: - """Clear all thumbnails from the cache. + """Clear all thumbnails and best tomogram info from the cache. Returns: True if successful, False otherwise """ try: if self.cache_dir and self.cache_dir.exists(): - # Remove all PNG files (thumbnails) but keep metadata + # Remove all PNG files (thumbnails) for thumbnail_file in self.cache_dir.glob("*.png"): thumbnail_file.unlink() + + # Remove all best tomogram info files + for best_tomo_file in self.cache_dir.glob("*_best_tomogram.json"): + best_tomo_file.unlink() + + print("๐Ÿงน Cache cleared successfully") return True return False except Exception as e: @@ -361,16 +325,97 @@ def get_cache_info(self) -> Dict[str, Any]: "cache_size_mb": 0.0, } + # Skip expensive file scanning to avoid blocking - use fast check only if self.cache_dir and self.cache_dir.exists(): - # Count thumbnails and calculate size - thumbnail_files = list(self.cache_dir.glob("*.png")) - info["thumbnail_count"] = len(thumbnail_files) - - total_size = sum(f.stat().st_size for f in thumbnail_files) - info["cache_size_mb"] = total_size / (1024 * 1024) + try: + # Quick directory listing without stat() calls to avoid blocking + thumbnail_files = list(self.cache_dir.glob("*.png")) + info["thumbnail_count"] = len(thumbnail_files) + # Skip size calculation to avoid blocking on large caches + info["cache_size_mb"] = 0.0 # Size calculation disabled for performance + except Exception as e: + print(f"โš ๏ธ Error getting cache info: {e}") return info + def save_best_tomogram_info(self, run_name: str, tomogram_type: str, voxel_spacing: float) -> bool: + """Save information about the best tomogram selection for a run. + + Args: + run_name: Name of the copick run + tomogram_type: Type of the selected best tomogram + voxel_spacing: Voxel spacing of the selected best tomogram + + Returns: + True if successful, False otherwise + """ + try: + import time + + # Generate the cache key and thumbnail path for this best tomogram + cache_key = self.get_cache_key(run_name, tomogram_type, voxel_spacing) + thumbnail_path = self.get_thumbnail_path(cache_key) + + best_tomo_file = self.cache_dir / f"{run_name}_best_tomogram.json" + best_tomo_info = { + "run_name": run_name, + "tomogram_type": tomogram_type, + "voxel_spacing": voxel_spacing, + "cache_key": cache_key, + "thumbnail_path": str(thumbnail_path), + "cached_at": str(time.time()), + } + + with open(best_tomo_file, "w") as f: + json.dump(best_tomo_info, f, indent=2) + + return True + + except Exception as e: + print(f"โŒ Error saving best tomogram info for '{run_name}': {e}") + return False + + def load_best_tomogram_info(self, run_name: str) -> Optional[Dict[str, Any]]: + """Load information about the best tomogram selection for a run. + + Args: + run_name: Name of the copick run + + Returns: + Dictionary with tomogram info if found, None otherwise + """ + try: + best_tomo_file = self.cache_dir / f"{run_name}_best_tomogram.json" + + if not best_tomo_file.exists(): + return None + + with open(best_tomo_file, "r") as f: + best_tomo_info = json.load(f) + + return best_tomo_info + + except Exception as e: + print(f"โŒ Error loading best tomogram info for '{run_name}': {e}") + return None + + def has_best_tomogram_info(self, run_name: str) -> bool: + """Check if best tomogram information is cached for a run. + + Args: + run_name: Name of the copick run + + Returns: + True if best tomogram info is cached, False otherwise + """ + try: + best_tomo_file = self.cache_dir / f"{run_name}_best_tomogram.json" + exists = best_tomo_file.exists() + return exists + except Exception as e: + print(f"โŒ Error checking best tomogram info for '{run_name}': {e}") + return False + def update_config(self, config_path: str) -> None: """Update the cache for a new config file. @@ -380,7 +425,8 @@ def update_config(self, config_path: str) -> None: if config_path != self.config_path: self.config_path = config_path self._setup_cache_directory() - self._cleanup_old_cache_entries() + # Skip cache cleanup to avoid blocking main thread + # self._cleanup_old_cache_entries() class GlobalCacheManager: diff --git a/src/copick_shared_ui/platform/chimerax_integration.py b/src/copick_shared_ui/platform/chimerax_integration.py index 3ab3275..1585fe9 100644 --- a/src/copick_shared_ui/platform/chimerax_integration.py +++ b/src/copick_shared_ui/platform/chimerax_integration.py @@ -10,20 +10,20 @@ except ImportError: QT_AVAILABLE = False -from ..core.models import ( +from copick_shared_ui.core.models import ( AbstractImageInterface, AbstractSessionInterface, AbstractThemeInterface, AbstractWorkerInterface, ) -from ..theming.colors import get_color_scheme -from ..theming.styles import ( +from copick_shared_ui.theming.colors import get_color_scheme +from copick_shared_ui.theming.styles import ( generate_button_stylesheet, generate_input_stylesheet, generate_stylesheet, ) -from ..theming.theme_detection import detect_theme -from ..workers.chimerax import ChimeraXWorkerManager +from copick_shared_ui.theming.theme_detection import detect_theme +from copick_shared_ui.workers.chimerax import ChimeraXWorkerManager if TYPE_CHECKING: from chimerax.core.session import Session @@ -244,22 +244,35 @@ class ChimeraXThemeInterface(AbstractThemeInterface): def __init__(self): self._current_theme = detect_theme() + # Cache theme-dependent data to avoid repeated expensive calls + self._cached_colors = None + self._cached_stylesheet = None + self._cached_button_stylesheets = {} + self._cached_input_stylesheet = None def get_theme_colors(self) -> Dict[str, str]: - """Get color scheme for current theme.""" - return get_color_scheme(self._current_theme) + """Get color scheme for current theme (cached).""" + if self._cached_colors is None: + self._cached_colors = get_color_scheme(self._current_theme) + return self._cached_colors def get_theme_stylesheet(self) -> str: - """Get base stylesheet for current theme.""" - return generate_stylesheet(self._current_theme) + """Get base stylesheet for current theme (cached).""" + if self._cached_stylesheet is None: + self._cached_stylesheet = generate_stylesheet(self._current_theme) + return self._cached_stylesheet def get_button_stylesheet(self, button_type: str = "primary") -> str: - """Get button stylesheet for current theme.""" - return generate_button_stylesheet(button_type, self._current_theme) + """Get button stylesheet for current theme (cached).""" + if button_type not in self._cached_button_stylesheets: + self._cached_button_stylesheets[button_type] = generate_button_stylesheet(button_type, self._current_theme) + return self._cached_button_stylesheets[button_type] def get_input_stylesheet(self) -> str: - """Get input field stylesheet for current theme.""" - return generate_input_stylesheet(self._current_theme) + """Get input field stylesheet for current theme (cached).""" + if self._cached_input_stylesheet is None: + self._cached_input_stylesheet = generate_input_stylesheet(self._current_theme) + return self._cached_input_stylesheet def connect_theme_changed(self, callback: Callable[[], None]) -> None: """Connect to theme change events.""" @@ -272,7 +285,10 @@ class ChimeraXWorkerInterface(AbstractWorkerInterface): """ChimeraX-specific worker interface.""" def __init__(self): - self._manager = ChimeraXWorkerManager() + self._manager = ChimeraXWorkerManager() # Uses default of 4 workers + print( + "๐Ÿ”ง ChimeraX Worker Manager initialized with max_concurrent_workers=4 (reduced from 16 to prevent blocking)", + ) def start_thumbnail_worker( self, @@ -343,7 +359,7 @@ def create_pixmap_from_array(self, array: Any) -> Any: print(f"Error creating pixmap from array: {e}") return None - def scale_pixmap(self, pixmap: Any, size: tuple, smooth: bool = True) -> Any: + def scale_pixmap(self, pixmap: Any, size: tuple, smooth: bool = False) -> Any: """Scale a QPixmap to the specified size.""" if not QT_AVAILABLE or not pixmap: return pixmap @@ -404,7 +420,7 @@ def __init__(self, session: "Session"): def create_gallery_widget(self, parent=None): """Create a gallery widget with ChimeraX integration.""" - from ..widgets.gallery.gallery_widget import CopickGalleryWidget + from copick_shared_ui.widgets.gallery.gallery_widget import CopickGalleryWidget return CopickGalleryWidget( self.session_interface, diff --git a/src/copick_shared_ui/platform/napari_integration.py b/src/copick_shared_ui/platform/napari_integration.py index a0ee74e..bd0e27a 100644 --- a/src/copick_shared_ui/platform/napari_integration.py +++ b/src/copick_shared_ui/platform/napari_integration.py @@ -10,19 +10,19 @@ except ImportError: QT_AVAILABLE = False -from ..core.models import ( +from copick_shared_ui.core.models import ( AbstractImageInterface, AbstractSessionInterface, AbstractThemeInterface, AbstractWorkerInterface, ) -from ..theming.colors import get_color_scheme -from ..theming.styles import ( +from copick_shared_ui.theming.colors import get_color_scheme +from copick_shared_ui.theming.styles import ( generate_button_stylesheet, generate_input_stylesheet, generate_stylesheet, ) -from ..workers.napari import NapariWorkerManager +from copick_shared_ui.workers.napari import NapariWorkerManager if TYPE_CHECKING: import napari @@ -200,7 +200,7 @@ def create_pixmap_from_array(self, array: Any) -> Any: print(f"Error creating pixmap from array: {e}") return None - def scale_pixmap(self, pixmap: Any, size: tuple, smooth: bool = True) -> Any: + def scale_pixmap(self, pixmap: Any, size: tuple, smooth: bool = False) -> Any: """Scale a QPixmap to the specified size.""" if not QT_AVAILABLE or not pixmap: return pixmap @@ -261,7 +261,7 @@ def __init__(self, viewer: "napari.Viewer"): def create_gallery_widget(self, parent=None): """Create a gallery widget with napari integration.""" - from ..widgets.gallery.gallery_widget import CopickGalleryWidget + from copick_shared_ui.widgets.gallery.gallery_widget import CopickGalleryWidget return CopickGalleryWidget( self.session_interface, diff --git a/src/copick_shared_ui/theming/__init__.py b/src/copick_shared_ui/theming/__init__.py index 19f92a0..cc3bdfb 100644 --- a/src/copick_shared_ui/theming/__init__.py +++ b/src/copick_shared_ui/theming/__init__.py @@ -1,8 +1,8 @@ """Theming support for gallery widgets.""" -from .colors import get_color_schemes -from .styles import generate_button_stylesheet, generate_input_stylesheet, generate_stylesheet -from .theme_detection import detect_theme +from copick_shared_ui.theming.colors import get_color_schemes +from copick_shared_ui.theming.styles import generate_button_stylesheet, generate_input_stylesheet, generate_stylesheet +from copick_shared_ui.theming.theme_detection import detect_theme __all__ = [ "get_color_schemes", diff --git a/src/copick_shared_ui/theming/styles.py b/src/copick_shared_ui/theming/styles.py index dba3e20..bb7c19b 100644 --- a/src/copick_shared_ui/theming/styles.py +++ b/src/copick_shared_ui/theming/styles.py @@ -1,6 +1,6 @@ """Stylesheet generation for gallery theming.""" -from .colors import get_color_scheme +from copick_shared_ui.theming.colors import get_color_scheme def generate_stylesheet(theme: str) -> str: diff --git a/src/copick_shared_ui/widgets/gallery/__init__.py b/src/copick_shared_ui/widgets/gallery/__init__.py index 55c3919..0217137 100644 --- a/src/copick_shared_ui/widgets/gallery/__init__.py +++ b/src/copick_shared_ui/widgets/gallery/__init__.py @@ -1,7 +1,7 @@ """Gallery widget components.""" -from .gallery_widget import CopickGalleryWidget -from .run_card import RunCard +from copick_shared_ui.widgets.gallery.gallery_widget import CopickGalleryWidget +from copick_shared_ui.widgets.gallery.run_card import RunCard __all__ = [ "CopickGalleryWidget", diff --git a/src/copick_shared_ui/widgets/gallery/gallery_widget.py b/src/copick_shared_ui/widgets/gallery/gallery_widget.py index 42075df..5c1d6e8 100644 --- a/src/copick_shared_ui/widgets/gallery/gallery_widget.py +++ b/src/copick_shared_ui/widgets/gallery/gallery_widget.py @@ -1,17 +1,19 @@ """Platform-agnostic gallery widget for displaying copick runs.""" +import logging +from pathlib import Path from typing import TYPE_CHECKING, Any, Dict, List, Optional from qtpy.QtCore import Qt, Signal, Slot from qtpy.QtWidgets import QGridLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QVBoxLayout, QWidget -from ...core.models import ( +from copick_shared_ui.core.models import ( AbstractImageInterface, AbstractSessionInterface, AbstractThemeInterface, AbstractWorkerInterface, ) -from .run_card import RunCard +from copick_shared_ui.widgets.gallery.run_card import RunCard if TYPE_CHECKING: from copick.models import CopickRun @@ -124,13 +126,10 @@ def delete(self) -> None: def set_copick_root(self, copick_root: Optional[Any]) -> None: """Set the copick root and load runs.""" - print(f"๐Ÿ“ธ Gallery: Setting copick root - new root: {copick_root is not None}") - # Clear workers to cancel any pending thumbnail loads from previous session self.worker_interface.clear_workers() # Clear caches when root changes - print(f"๐Ÿ“ธ Gallery: Clearing caches - cards: {len(self.all_run_cards)}, thumbnails: {len(self.thumbnail_cache)}") self.all_run_cards.clear() self.visible_run_cards.clear() self.thumbnail_cache.clear() @@ -140,12 +139,10 @@ def set_copick_root(self, copick_root: Optional[Any]) -> None: if copick_root: self.runs = list(copick_root.runs) self.filtered_runs = self.runs.copy() - print(f"๐Ÿ“ธ Gallery: Loaded {len(self.runs)} runs from copick root") self._update_grid() else: self.runs = [] self.filtered_runs = [] - print(f"๐Ÿ“ธ Gallery: No copick root provided, clearing grid") self._clear_grid() def apply_search_filter(self, filter_text: str) -> None: @@ -220,24 +217,19 @@ def _update_grid(self) -> None: if run.name in self.all_run_cards: # Reuse existing card card = self.all_run_cards[run.name] - print(f"๐Ÿ“ธ Gallery: Reusing existing card for '{run.name}'") else: # Create new card - print(f"๐Ÿ“ธ Gallery: Creating new card for '{run.name}'") card = RunCard(run, self.theme_interface, self.image_interface) card.clicked.connect(self._on_run_card_clicked) card.info_requested.connect(self._on_run_info_requested) self.all_run_cards[run.name] = card - # Check if we have a cached thumbnail + # Check if we have a cached thumbnail in memory first if run.name in self.thumbnail_cache: - print(f"๐Ÿ“ธ Gallery: Found cached thumbnail for '{run.name}'") cached_thumbnail = self.thumbnail_cache[run.name] - print(f"๐Ÿ“ธ Gallery: Cached thumbnail type: {type(cached_thumbnail)}") card.set_thumbnail(cached_thumbnail) else: - # Start thumbnail loading - print(f"๐Ÿ“ธ Gallery: No cached thumbnail for '{run.name}', starting load") + # Start thumbnail loading - let the worker check disk cache self._load_run_thumbnail(run, run.name) # Add to visible cards and grid layout @@ -252,46 +244,24 @@ def _load_run_thumbnail(self, run: "CopickRun", thumbnail_id: str, force_regener if self._is_destroyed: return - print(f"๐Ÿ“ธ Gallery: Starting thumbnail load for '{thumbnail_id}' - force_regenerate: {force_regenerate}") - try: - tomogram_count = len(run.tomograms) if hasattr(run, 'tomograms') else 'unknown' - print(f"๐Ÿ“ธ Gallery: Run has {tomogram_count} tomograms") - except Exception as e: - print(f"๐Ÿ“ธ Gallery: Could not get tomogram count: {e}") - + # Skip expensive tomogram count logging to prevent UI blocking with large datasets self.worker_interface.start_thumbnail_worker(run, thumbnail_id, self._on_thumbnail_loaded, force_regenerate) def _on_thumbnail_loaded(self, thumbnail_id: str, pixmap: Optional[Any], error: Optional[str]) -> None: """Handle thumbnail loading completion.""" - print(f"๐Ÿ“ธ Gallery: Thumbnail loaded callback for '{thumbnail_id}'") - print(f"๐Ÿ“ธ Gallery: Widget destroyed: {self._is_destroyed}") - print(f"๐Ÿ“ธ Gallery: Card exists: {thumbnail_id in self.all_run_cards}") - print(f"๐Ÿ“ธ Gallery: Pixmap provided: {pixmap is not None}") - print(f"๐Ÿ“ธ Gallery: Error: {error}") - + if self._is_destroyed or thumbnail_id not in self.all_run_cards: - print( - f"โš ๏ธ Gallery: Thumbnail callback for '{thumbnail_id}' ignored (destroyed={self._is_destroyed}, card_exists={thumbnail_id in self.all_run_cards})", - ) return card = self.all_run_cards[thumbnail_id] if error: - print(f"๐Ÿ“ธ Gallery: Setting error on card for '{thumbnail_id}': {error}") card.set_error(error) else: - print(f"๐Ÿ“ธ Gallery: Setting thumbnail on card for '{thumbnail_id}'") - if pixmap: - print(f"๐Ÿ“ธ Gallery: Pixmap type: {type(pixmap)}") - print(f"๐Ÿ“ธ Gallery: Pixmap size: {pixmap.size() if hasattr(pixmap, 'size') else 'No size method'}") card.set_thumbnail(pixmap) # Cache the thumbnail for future use if pixmap: self.thumbnail_cache[thumbnail_id] = pixmap - print(f"๐Ÿ“ธ Gallery: Cached thumbnail for '{thumbnail_id}' - cache size: {len(self.thumbnail_cache)}") - else: - print(f"๐Ÿ“ธ Gallery: No pixmap to cache for '{thumbnail_id}'") @Slot(object) def _on_run_card_clicked(self, run: "CopickRun") -> None: @@ -303,8 +273,7 @@ def _on_run_card_clicked(self, run: "CopickRun") -> None: # Emit signal for handling tomogram loading self.run_selected.emit(run) - except Exception as e: - print(f"Gallery: Error handling run card click: {e}") + except Exception: # Still emit the signal as fallback self.run_selected.emit(run) @@ -372,18 +341,14 @@ def _on_theme_changed(self) -> None: @Slot() def _on_regenerate_thumbnails(self) -> None: """Handle regenerate thumbnails button click.""" - print(f"๐Ÿ“ธ Gallery: Regenerating thumbnails - clearing cache of {len(self.thumbnail_cache)} items") - # Clear memory cache self.thumbnail_cache.clear() # Reset all cards to loading state - print(f"๐Ÿ“ธ Gallery: Resetting {len(self.all_run_cards)} cards to loading state") for card in self.all_run_cards.values(): card.set_loading("Regenerating...") # Force regenerate all visible thumbnails - print(f"๐Ÿ“ธ Gallery: Force regenerating {len(self.filtered_runs)} thumbnails") for run in self.filtered_runs: if run.name in self.all_run_cards: self._load_run_thumbnail(run, run.name, force_regenerate=True) diff --git a/src/copick_shared_ui/widgets/gallery/run_card.py b/src/copick_shared_ui/widgets/gallery/run_card.py index 72e9371..eac8c99 100644 --- a/src/copick_shared_ui/widgets/gallery/run_card.py +++ b/src/copick_shared_ui/widgets/gallery/run_card.py @@ -5,7 +5,7 @@ from qtpy.QtCore import Qt, Signal from qtpy.QtWidgets import QFrame, QLabel, QPushButton, QVBoxLayout, QWidget -from ...core.models import AbstractImageInterface, AbstractThemeInterface +from copick_shared_ui.core.models import AbstractImageInterface, AbstractThemeInterface if TYPE_CHECKING: from copick.models import CopickRun @@ -137,7 +137,7 @@ def set_thumbnail(self, pixmap: Optional[Any]) -> None: """Set the thumbnail pixmap.""" if pixmap: # Scale pixmap to fit label while maintaining aspect ratio - scaled_pixmap = self.image_interface.scale_pixmap(pixmap, self.thumbnail_label.size(), smooth=True) + scaled_pixmap = self.image_interface.scale_pixmap(pixmap, self.thumbnail_label.size(), smooth=False) self.thumbnail_label.setPixmap(scaled_pixmap) self.thumbnail_pixmap = pixmap else: diff --git a/src/copick_shared_ui/widgets/info/__init__.py b/src/copick_shared_ui/widgets/info/__init__.py index aaa89b7..06e7b8b 100644 --- a/src/copick_shared_ui/widgets/info/__init__.py +++ b/src/copick_shared_ui/widgets/info/__init__.py @@ -1,6 +1,6 @@ """Info widget components.""" -from .info_widget import CopickInfoWidget +from copick_shared_ui.widgets.info.info_widget import CopickInfoWidget __all__ = [ "CopickInfoWidget", diff --git a/src/copick_shared_ui/widgets/info/info_widget.py b/src/copick_shared_ui/widgets/info/info_widget.py index 7e45cfd..6660844 100644 --- a/src/copick_shared_ui/widgets/info/info_widget.py +++ b/src/copick_shared_ui/widgets/info/info_widget.py @@ -16,7 +16,7 @@ QWidget, ) -from ...core.models import ( +from copick_shared_ui.core.models import ( AbstractImageInterface, AbstractInfoSessionInterface, AbstractThemeInterface, @@ -697,7 +697,7 @@ def _create_tomogram_card(self, tomogram: "CopickTomogram") -> QFrame: # Use cached thumbnail pixmap = self._thumbnails[thumbnail_id] max_size = min(card.minimumSize().width() - 40, card.minimumSize().height() - 80) - scaled_pixmap = self.image_interface.scale_pixmap(pixmap, (max_size, max_size), smooth=True) + scaled_pixmap = self.image_interface.scale_pixmap(pixmap, (max_size, max_size), smooth=False) thumbnail_label.setPixmap(scaled_pixmap) else: # Show loading placeholder and start async loading @@ -790,7 +790,7 @@ def _on_thumbnail_loaded(self, thumbnail_id: str, pixmap: Optional[Any], error: scaled_pixmap = self.image_interface.scale_pixmap( pixmap, (max_size, max_size), - smooth=True, + smooth=False, ) thumbnail_label.setPixmap(scaled_pixmap) else: diff --git a/src/copick_shared_ui/workers/__init__.py b/src/copick_shared_ui/workers/__init__.py index 0cfc537..efac486 100644 --- a/src/copick_shared_ui/workers/__init__.py +++ b/src/copick_shared_ui/workers/__init__.py @@ -1,31 +1,71 @@ """Worker implementations for background processing.""" -from .base import AbstractThumbnailWorker +from copick_shared_ui.workers.base import AbstractThumbnailWorker +from copick_shared_ui.workers.base_manager import AbstractWorkerManager +from copick_shared_ui.workers.data_worker import AbstractDataWorker +# Import unified workers +from copick_shared_ui.workers.unified_workers import ( + UnifiedDataWorker, + UnifiedThumbnailWorker, + UnifiedWorkerManager, + create_worker_manager, + get_platform_info, + is_threading_available, +) + +# Import platform-specific workers (which now use unified system) try: - from .chimerax import ChimeraXThumbnailWorker, ChimeraXWorkerManager, ChimeraXWorkerSignals + from copick_shared_ui.workers.chimerax import ( + ChimeraXDataWorker, + ChimeraXThumbnailWorker, + ChimeraXWorkerManager, + create_chimerax_worker_manager, + get_chimerax_platform_info, + is_chimerax_threading_available, + ) CHIMERAX_AVAILABLE = True except ImportError: CHIMERAX_AVAILABLE = False try: - from .napari import NapariThumbnailWorker, NapariWorkerManager, NapariWorkerSignals + from copick_shared_ui.workers.napari import ( + NapariDataWorker, + NapariThumbnailWorker, + NapariWorkerManager, + create_napari_worker_manager, + get_napari_platform_info, + is_napari_threading_available, + ) NAPARI_AVAILABLE = True except ImportError: NAPARI_AVAILABLE = False __all__ = [ + # Base classes "AbstractThumbnailWorker", + "AbstractWorkerManager", + "AbstractDataWorker", + # Unified workers + "UnifiedThumbnailWorker", + "UnifiedDataWorker", + "UnifiedWorkerManager", + "is_threading_available", + "get_platform_info", + "create_worker_manager", ] if CHIMERAX_AVAILABLE: __all__.extend( [ "ChimeraXThumbnailWorker", + "ChimeraXDataWorker", "ChimeraXWorkerManager", - "ChimeraXWorkerSignals", + "is_chimerax_threading_available", + "get_chimerax_platform_info", + "create_chimerax_worker_manager", ], ) @@ -33,7 +73,10 @@ __all__.extend( [ "NapariThumbnailWorker", + "NapariDataWorker", "NapariWorkerManager", - "NapariWorkerSignals", + "is_napari_threading_available", + "get_napari_platform_info", + "create_napari_worker_manager", ], ) diff --git a/src/copick_shared_ui/workers/base.py b/src/copick_shared_ui/workers/base.py index 1d4a05f..50364fa 100644 --- a/src/copick_shared_ui/workers/base.py +++ b/src/copick_shared_ui/workers/base.py @@ -31,13 +31,13 @@ def __init__( self._setup_cache() def _setup_cache(self) -> None: - """Set up thumbnail cache for this worker.""" + """Set up thumbnail cache interface only - defer all disk I/O until worker runs.""" try: - from ..core.thumbnail_cache import get_global_cache + from copick_shared_ui.core.thumbnail_cache import get_global_cache self._cache = get_global_cache() - # Create cache key based on item type + # For tomograms, we can safely create cache key without disk I/O if self._is_tomogram(): tomogram = self.item run_name = tomogram.voxel_spacing.run.name @@ -47,9 +47,9 @@ def _setup_cache(self) -> None: voxel_spacing=tomogram.voxel_spacing.voxel_size, ) else: - # For runs, we'll create cache key once we know the best tomogram - run = self.item - self._cache_key = self._cache.get_cache_key(run_name=run.name) + # For runs, defer cache key creation until worker runs + # This avoids any potential disk I/O during construction + self._cache_key = None # Will be set when worker runs except Exception as e: print(f"Warning: Could not set up thumbnail cache: {e}") @@ -130,13 +130,7 @@ def _select_best_tomogram(self, run: "CopickRun") -> Optional["CopickTomogram"]: def generate_thumbnail_pixmap(self) -> tuple[Optional[Any], Optional[str]]: """Generate thumbnail pixmap from the item (run or tomogram). Returns (pixmap, error).""" - # Check cache first (if not force regenerating) - if not self.force_regenerate and self._cache and self._cache_key and self._cache.has_thumbnail(self._cache_key): - cached_pixmap = self._cache.load_thumbnail(self._cache_key) - if cached_pixmap is not None: - return cached_pixmap, None - - # Determine the tomogram to use + # Determine the tomogram to use and generate proper cache key if self._is_tomogram(): tomogram = self.item # Update cache key with specific tomogram info @@ -148,11 +142,56 @@ def generate_thumbnail_pixmap(self) -> tuple[Optional[Any], Optional[str]]: voxel_spacing=tomogram.voxel_spacing.voxel_size, ) else: - # Item is a run, select best tomogram + # Item is a run - check if we have cached best tomogram info first run = self.item - tomogram = self._select_best_tomogram(run) - if not tomogram: - return None, "No suitable tomogram found in run" + tomogram = None + + if not self.force_regenerate and self._cache and self._cache.has_best_tomogram_info(run.name): + # Use cached best tomogram info to avoid expensive determination + best_info = self._cache.load_best_tomogram_info(run.name) + if best_info: + # Use the cache key from the stored info (human-readable) + self._cache_key = best_info.get("cache_key") + if not self._cache_key: + # Fallback: generate cache key from cached info (for older cache entries) + self._cache_key = self._cache.get_cache_key( + run_name=run.name, + tomogram_type=best_info["tomogram_type"], + voxel_spacing=best_info["voxel_spacing"], + ) + + # Check if thumbnail exists with this cache key + if self._cache.has_thumbnail(self._cache_key): + cached_pixmap = self._cache.load_thumbnail(self._cache_key) + if cached_pixmap is not None: + return cached_pixmap, None + + # Find the specific tomogram matching the cached info + for tomo in run.tomograms: + try: + if ( + tomo.tomo_type == best_info["tomogram_type"] + and abs(tomo.voxel_spacing.voxel_size - best_info["voxel_spacing"]) < 0.001 + ): + tomogram = tomo + break + except Exception as e: + print(f"โš ๏ธ Error checking tomogram: {e}") + continue + + # If no cached best tomogram info or force regenerating, select best tomogram + if tomogram is None: + tomogram = self._select_best_tomogram(run) + if not tomogram: + return None, "No suitable tomogram found in run" + + # Save the best tomogram selection to cache for future use + if self._cache: + self._cache.save_best_tomogram_info( + run_name=run.name, + tomogram_type=tomogram.tomo_type, + voxel_spacing=tomogram.voxel_spacing.voxel_size, + ) # Update cache key with selected tomogram info if self._cache: @@ -162,6 +201,12 @@ def generate_thumbnail_pixmap(self) -> tuple[Optional[Any], Optional[str]]: voxel_spacing=tomogram.voxel_spacing.voxel_size, ) + # Check cache one more time with the proper cache key (if not force regenerating) + if not self.force_regenerate and self._cache and self._cache_key and self._cache.has_thumbnail(self._cache_key): + cached_pixmap = self._cache.load_thumbnail(self._cache_key) + if cached_pixmap is not None: + return cached_pixmap, None + # Generate thumbnail array thumbnail_array = self._generate_thumbnail_array(tomogram) if thumbnail_array is None: @@ -175,9 +220,14 @@ def generate_thumbnail_pixmap(self) -> tuple[Optional[Any], Optional[str]]: # Cache the result if self._cache and self._cache_key: try: - _ = self._cache.save_thumbnail(self._cache_key, pixmap) + self._cache.save_thumbnail(self._cache_key, pixmap) except Exception as e: print(f"โš ๏ธ Error caching thumbnail: {e}") + import traceback + + traceback.print_exc() + else: + print(f"โŒ Worker: Cannot cache - cache: {self._cache is not None}, cache_key: {self._cache_key}") return pixmap, None diff --git a/src/copick_shared_ui/workers/chimerax.py b/src/copick_shared_ui/workers/chimerax.py index 37bc51b..d2b8576 100644 --- a/src/copick_shared_ui/workers/chimerax.py +++ b/src/copick_shared_ui/workers/chimerax.py @@ -1,311 +1,171 @@ -"""ChimeraX-specific worker implementations using QRunnable and QThreadPool.""" +"""ChimeraX-specific worker implementations with enhanced UI responsiveness optimizations.""" from typing import TYPE_CHECKING, Any, Callable, Optional, Union -try: - from Qt.QtCore import QObject, QRunnable, QThreadPool, Signal - from Qt.QtGui import QImage, QPixmap - - QT_AVAILABLE = True -except ImportError: - try: - from qtpy.QtCore import QObject, QRunnable, QThreadPool, Signal - from qtpy.QtGui import QImage, QPixmap - - QT_AVAILABLE = True - except ImportError: - QT_AVAILABLE = False - - # Fallback classes - class QRunnable: - def run(self): - pass - - class QThreadPool: - def start(self, runnable): - pass - - def clear(self): - pass - - def waitForDone(self, timeout): - pass - - class QObject: - pass - - class Signal: - def __init__(self, *args): - pass - - def emit(self, *args): - pass - - def connect(self, func): - pass - - -from .base import AbstractThumbnailWorker -from .data_worker import AbstractDataWorker - -# Removed AbstractWorkerManager import - using Qt's built-in queue instead +from copick_shared_ui.workers.unified_workers import ( + QT_AVAILABLE, + UnifiedDataWorker, + UnifiedWorkerManager, + create_worker_manager, + get_platform_info, + is_threading_available, + thread_worker, +) if TYPE_CHECKING: from copick.models import CopickRun, CopickTomogram +if is_threading_available(): + from copick_shared_ui.workers.base import AbstractThumbnailWorker + + class ChimeraXThumbnailWorker(AbstractThumbnailWorker): + """ChimeraX-specific thumbnail worker with enhanced UI responsiveness optimizations.""" + + def __init__( + self, + item: Union["CopickRun", "CopickTomogram"], + thumbnail_id: str, + callback: Callable[[str, Optional[Any], Optional[str]], None], + force_regenerate: bool = False, + ): + super().__init__(item, thumbnail_id, callback, force_regenerate) + self._worker_func = None + self._cancelled = False + self._finished = False + + def start(self) -> None: + """Start the thumbnail loading work using thread_worker.""" + + @thread_worker + def load_thumbnail(): + if self._cancelled: + return None, "Cancelled" + + try: + yield "Starting ChimeraX thumbnail generation..." + if self._cancelled: + return None, "Cancelled" + + pixmap, error = self.generate_thumbnail_pixmap() + + if self._cancelled: + return None, "Cancelled" + + return pixmap, error + + except Exception as e: + print(f"๐Ÿ’ฅ ChimeraX Worker: Exception for '{self.thumbnail_id}': {e}") + return None, str(e) + + worker = load_thumbnail() + worker.returned.connect(self._on_worker_finished) + worker.errored.connect(self._on_worker_error) + + self._worker_func = worker + worker.start() + + def cancel(self) -> None: + """Cancel the thumbnail loading work.""" + self._cancelled = True + + # Cancel any pending timer callbacks + if hasattr(self, "_callback_timers"): + for timer in self._callback_timers[:]: + try: + if timer and hasattr(timer, "stop"): + timer.stop() + if timer in self._callback_timers: + self._callback_timers.remove(timer) + except (AttributeError, ValueError): + pass + + if self._worker_func and hasattr(self._worker_func, "quit"): + self._worker_func.quit() + + def _on_worker_finished(self, result): + """Handle worker completion with ChimeraX-specific UI responsiveness optimizations.""" + self._finished = True + if self._cancelled: + return + + pixmap, error = result + + # ChimeraX-specific optimizations to prevent UI blocking + if QT_AVAILABLE: + try: + from qtpy.QtCore import QTimer + + timer = QTimer(None) + timer.setSingleShot(True) + + if not hasattr(self, "_callback_timers"): + self._callback_timers = [] + self._callback_timers.append(timer) + + def execute_callback(): + try: + if ( + hasattr(self, "callback") + and hasattr(self, "thumbnail_id") + and self.callback is not None + and not self._cancelled + ): + self.callback(self.thumbnail_id, pixmap, error) + except (AttributeError, RuntimeError): + pass + finally: + try: + if hasattr(self, "_callback_timers") and timer in self._callback_timers: + self._callback_timers.remove(timer) + except (AttributeError, ValueError): + pass + + timer.timeout.connect(execute_callback) + # Use 0ms delay to defer callback to next event loop iteration + timer.start(5) + except ImportError: + if not self._cancelled: + self.callback(self.thumbnail_id, pixmap, error) + else: + if not self._cancelled: + self.callback(self.thumbnail_id, pixmap, error) + + def _on_worker_error(self, error): + """Handle worker error.""" + if not self._cancelled: + self.callback(self.thumbnail_id, None, str(error)) + +else: + # Fallback when threading is not available + class ChimeraXThumbnailWorker: + def __init__(self, *args, **kwargs): + pass -class ChimeraXWorkerSignals(QObject): - """ChimeraX-specific worker signals.""" - - thumbnail_loaded = Signal(str, object, object) # thumbnail_id, pixmap, error - data_loaded = Signal(str, object, object) # data_type, data, error - - -# Create a compatible metaclass to resolve QRunnable + ABC metaclass conflict -class CompatibleMeta(type(AbstractThumbnailWorker), type(QRunnable)): - """Metaclass that resolves conflicts between ABC and Qt metaclasses.""" - - pass - - -class CompatibleDataMeta(type(AbstractDataWorker), type(QRunnable)): - """Metaclass that resolves conflicts between ABC and Qt metaclasses for data workers.""" - - pass - - -class ChimeraXThumbnailWorker(AbstractThumbnailWorker, QRunnable, metaclass=CompatibleMeta): - """ChimeraX-specific thumbnail worker using QRunnable with unified caching.""" - - def __init__( - self, - signals: ChimeraXWorkerSignals, - item: Union["CopickRun", "CopickTomogram"], - thumbnail_id: str, - force_regenerate: bool = False, - ): - # Initialize AbstractThumbnailWorker first with a callback that uses signals - def callback(tid: str, pixmap: Optional[Any], error: Optional[str]) -> None: - """Callback that emits signal.""" - self.signals.thumbnail_loaded.emit(tid, pixmap, error) - - AbstractThumbnailWorker.__init__(self, item, thumbnail_id, callback, force_regenerate) - QRunnable.__init__(self) - self.signals = signals - self._cancelled = False - self._finished = False - - def start(self) -> None: - """Start method for compatibility - actual start is via QThreadPool.""" - pass - - def cancel(self) -> None: - """Cancel the thumbnail loading work.""" - self._cancelled = True + def start(self): + pass - def run(self) -> None: - """Run method called by QThreadPool.""" - if self._cancelled: - self._finished = True - return + def cancel(self): + pass - try: - # Use unified thumbnail generation with caching - pixmap, error = self.generate_thumbnail_pixmap() - self.signals.thumbnail_loaded.emit(self.thumbnail_id, pixmap, error) - except Exception as e: - self.signals.thumbnail_loaded.emit(self.thumbnail_id, None, str(e)) - finally: - self._finished = True +# Re-export unified classes with ChimeraX naming for compatibility +ChimeraXDataWorker = UnifiedDataWorker +ChimeraXWorkerManager = UnifiedWorkerManager - def _setup_cache_image_interface(self) -> None: - """Set up ChimeraX-specific image interface for caching.""" - if self._cache: - try: - from ..core.image_interface import QtImageInterface - - self._cache.set_image_interface(QtImageInterface()) - except Exception as e: - print(f"Warning: Could not set up ChimeraX image interface: {e}") - - def _array_to_pixmap(self, array: Any) -> Optional[QPixmap]: - """Convert numpy array to QPixmap.""" - if not QT_AVAILABLE: - return None - - try: - import numpy as np - - # Ensure array is uint8 - if array.dtype != np.uint8: - # Normalize to 0-255 range - array_min, array_max = array.min(), array.max() - if array_max > array_min: - array = ((array - array_min) / (array_max - array_min) * 255).astype(np.uint8) - else: - array = np.zeros_like(array, dtype=np.uint8) - - if array.ndim == 2: - # Grayscale image - height, width = array.shape - bytes_per_line = width - - # Create QImage from array - qimage = QImage(array.data, width, height, bytes_per_line, QImage.Format_Grayscale8) - - # Convert to QPixmap - pixmap = QPixmap.fromImage(qimage) - return pixmap - - elif array.ndim == 3 and array.shape[2] == 3: - # RGB image - height, width, channels = array.shape - bytes_per_line = width * channels - - # Create QImage from array - qimage = QImage(array.data, width, height, bytes_per_line, QImage.Format_RGB888) - - # Convert to QPixmap - pixmap = QPixmap.fromImage(qimage) - return pixmap - else: - print(f"Unsupported array shape: {array.shape}") - return None - - except Exception as e: - print(f"Error converting array to pixmap: {e}") - return None - - -class ChimeraXDataWorker(AbstractDataWorker, QRunnable, metaclass=CompatibleDataMeta): - """ChimeraX-specific data worker using QRunnable.""" - - def __init__( - self, - signals: ChimeraXWorkerSignals, - run: "CopickRun", - data_type: str, - ): - # Initialize AbstractDataWorker first with a callback that uses signals - def callback(dtype: str, data: Optional[Any], error: Optional[str]) -> None: - """Callback that emits signal.""" - self.signals.data_loaded.emit(dtype, data, error) - - AbstractDataWorker.__init__(self, run, data_type, callback) - QRunnable.__init__(self) - self.signals = signals - self._finished = False - - def start(self) -> None: - """Start method for compatibility - actual start is via QThreadPool.""" - pass - - def cancel(self) -> None: - """Cancel the data loading work.""" - self._cancelled = True - - def run(self) -> None: - """Run method called by QThreadPool.""" - if self._cancelled: - self._finished = True - return +# Convenience functions +def is_chimerax_threading_available() -> bool: + """Check if ChimeraX threading is available.""" + return is_threading_available() - try: - # Use base class data loading logic - data, error = self.load_data() - self.signals.data_loaded.emit(self.data_type, data, error) - except Exception as e: - self.signals.data_loaded.emit(self.data_type, None, str(e)) - finally: - self._finished = True +def get_chimerax_platform_info() -> dict: + """Get ChimeraX threading platform information.""" + info = get_platform_info() + info["platform"] = "ChimeraX" + return info -class ChimeraXWorkerManager: - """Manages ChimeraX thumbnail and data workers using QThreadPool's built-in queue.""" - - def __init__(self, max_concurrent_workers: int = 8): - """Initialize ChimeraX worker manager. - - Args: - max_concurrent_workers: Maximum number of workers that can run simultaneously. - Default is 8 to balance performance and system stability with large projects. - """ - self._max_concurrent = max_concurrent_workers - - if QT_AVAILABLE: - self._thread_pool = QThreadPool() - # Use Qt's built-in queue management - self._thread_pool.setMaxThreadCount(max_concurrent_workers) - else: - self._thread_pool = None - - def start_thumbnail_worker( - self, - item: Union["CopickRun", "CopickTomogram"], - thumbnail_id: str, - callback: Callable[[str, Optional[Any], Optional[str]], None], - force_regenerate: bool = False, - ) -> None: - """Start a thumbnail loading worker.""" - if not QT_AVAILABLE or not self._thread_pool: - callback(thumbnail_id, None, "Qt not available") - return - - # Create unique signals for each worker to avoid callback conflicts - worker_signals = ChimeraXWorkerSignals() - worker_signals.thumbnail_loaded.connect(callback) - - worker = ChimeraXThumbnailWorker(worker_signals, item, thumbnail_id, force_regenerate) - - # QThreadPool handles queuing automatically - self._thread_pool.start(worker) - - def start_data_worker( - self, - run: "CopickRun", - data_type: str, - callback: Callable[[str, Optional[Any], Optional[str]], None], - ) -> None: - """Start a data loading worker.""" - if not QT_AVAILABLE or not self._thread_pool: - callback(data_type, None, "Qt not available") - return - - # Create unique signals for each worker to avoid callback conflicts - worker_signals = ChimeraXWorkerSignals() - worker_signals.data_loaded.connect(callback) - - worker = ChimeraXDataWorker(worker_signals, run, data_type) - - # QThreadPool handles queuing automatically - self._thread_pool.start(worker) - - def clear_workers(self) -> None: - """Clear all pending workers.""" - if self._thread_pool: - self._thread_pool.clear() - - def shutdown_workers(self, timeout_ms: int = 3000) -> None: - """Shutdown all workers with timeout.""" - if self._thread_pool: - self._thread_pool.clear() - _ = self._thread_pool.waitForDone(timeout_ms) - - def get_status(self) -> dict: - """Get current worker manager status for debugging.""" - if self._thread_pool: - return { - "active_threads": self._thread_pool.activeThreadCount(), - "max_threads": self._thread_pool.maxThreadCount(), - "class_name": self.__class__.__name__, - } - else: - return { - "active_threads": 0, - "max_threads": 0, - "class_name": self.__class__.__name__, - "error": "Qt not available", - } +def create_chimerax_worker_manager(max_concurrent_workers: int = 6) -> ChimeraXWorkerManager: + """Create a ChimeraX worker manager with platform-appropriate defaults.""" + return create_worker_manager(max_concurrent_workers) diff --git a/src/copick_shared_ui/workers/napari.py b/src/copick_shared_ui/workers/napari.py index 40a8d4a..d6198c9 100644 --- a/src/copick_shared_ui/workers/napari.py +++ b/src/copick_shared_ui/workers/napari.py @@ -1,475 +1,33 @@ -"""napari-specific worker implementations using @thread_worker decorator.""" +"""napari-specific worker implementations using unified thread_worker system.""" -from typing import TYPE_CHECKING, Any, Callable, Optional, Union +from copick_shared_ui.workers.unified_workers import ( + UnifiedDataWorker, + UnifiedThumbnailWorker, + UnifiedWorkerManager, + create_worker_manager, + get_platform_info, + is_threading_available, +) -try: - from napari.qt.threading import thread_worker - from qtpy.QtCore import QObject, Signal - from qtpy.QtGui import QImage, QPixmap +# Re-export unified classes with napari naming for compatibility +NapariThumbnailWorker = UnifiedThumbnailWorker +NapariDataWorker = UnifiedDataWorker +NapariWorkerManager = UnifiedWorkerManager - NAPARI_AVAILABLE = True -except ImportError: - NAPARI_AVAILABLE = False -if NAPARI_AVAILABLE: - from .base import AbstractThumbnailWorker - from .base_manager import AbstractWorkerManager - from .data_worker import AbstractDataWorker +# Convenience functions +def is_napari_threading_available() -> bool: + """Check if napari threading is available.""" + return is_threading_available() -if TYPE_CHECKING: - from copick.models import CopickRun, CopickTomogram +def get_napari_platform_info() -> dict: + """Get napari threading platform information.""" + info = get_platform_info() + info["platform"] = "napari" + return info -if NAPARI_AVAILABLE: - class NapariWorkerSignals(QObject): - """napari-specific worker signals.""" - - thumbnail_loaded = Signal(str, object, object) # thumbnail_id, pixmap, error - data_loaded = Signal(str, object, object) # data_type, data, error - - class NapariThumbnailWorker(AbstractThumbnailWorker): - """napari-specific thumbnail worker using @thread_worker decorator with unified caching.""" - - def __init__( - self, - item: Union["CopickRun", "CopickTomogram"], - thumbnail_id: str, - callback: Callable[[str, Optional[Any], Optional[str]], None], - force_regenerate: bool = False, - ): - super().__init__(item, thumbnail_id, callback, force_regenerate) - self._worker_func = None - self._cancelled = False - self._finished = False - - def start(self) -> None: - """Start the thumbnail loading work using napari's thread_worker.""" - - if not NAPARI_AVAILABLE: - self.callback(self.thumbnail_id, None, "napari not available") - return - - # Create the worker function as a generator for better control - @thread_worker - def load_thumbnail(): - - # Check cancellation before starting - if self._cancelled: - print(f"โš ๏ธ NapariWorker: Cancelled '{self.thumbnail_id}' before starting") - return None, "Cancelled" - - try: - # Yield to allow interruption - yield "Starting thumbnail generation..." - - # Check cancellation again - if self._cancelled: - print(f"โš ๏ธ NapariWorker: Cancelled '{self.thumbnail_id}' during execution") - return None, "Cancelled" - - # Break up the thumbnail generation to allow cancellation - # Step 1: Check cache - yield "Checking cache..." - if self._cancelled: - return None, "Cancelled" - - # Check if we can use cached result - if self._cache and self._cache_key and not self.force_regenerate: - cached_pixmap = self._cache.load_thumbnail(self._cache_key) - if cached_pixmap is not None: - return cached_pixmap, None - - # Step 2: Determine tomogram to use - yield "Selecting tomogram..." - if self._cancelled: - return None, "Cancelled" - - if self._is_tomogram(): - tomogram = self.item - else: - # Item is a run, select best tomogram - run = self.item - tomogram = self._select_best_tomogram(run) - if not tomogram: - return None, "No suitable tomogram found in run" - - # Step 3: Generate thumbnail with periodic yielding - yield "Loading tomogram data..." - if self._cancelled: - return None, "Cancelled" - - # Generate thumbnail array with cancellation checks - thumbnail_array = self._generate_thumbnail_array_with_cancellation(tomogram) - if thumbnail_array is None: - return None, "Failed to generate thumbnail array" - - if self._cancelled: - return None, "Cancelled" - - # Step 4: Convert to pixmap - yield "Converting to pixmap..." - if self._cancelled: - return None, "Cancelled" - - pixmap = self._array_to_pixmap(thumbnail_array) - if pixmap is None: - return None, "Failed to convert array to pixmap" - - # Step 5: Cache result - yield "Caching result..." - if self._cancelled: - return None, "Cancelled" - - if self._cache and self._cache_key: - try: - _ = self._cache.save_thumbnail(self._cache_key, pixmap) - except Exception as e: - print(f"โš ๏ธ Error caching thumbnail: {e}") - - return pixmap, None - - except Exception as e: - print(f"๐Ÿ’ฅ NapariWorker: Exception in worker for '{self.thumbnail_id}': {e}") - import traceback - - traceback.print_exc() - return None, str(e) - - # Connect worker signals - worker = load_thumbnail() - worker.returned.connect(self._on_worker_finished) - worker.errored.connect(self._on_worker_error) - - # Store reference to worker - self._worker_func = worker - - # Actually start the worker! - worker.start() - - def cancel(self) -> None: - """Cancel the thumbnail loading work.""" - self._cancelled = True - if self._worker_func: - # Use napari's quit method to abort the worker - if hasattr(self._worker_func, "quit"): - self._worker_func.quit() - else: - print(f"โš ๏ธ NapariWorker: Worker for '{self.thumbnail_id}' has no quit method") - - def _on_worker_finished(self, result): - """Handle worker completion.""" - self._finished = True - if self._cancelled: - return - - pixmap, error = result - self.callback(self.thumbnail_id, pixmap, error) - - def _on_worker_error(self, error): - """Handle worker error.""" - if self._cancelled: - return - - self.callback(self.thumbnail_id, None, str(error)) - - def _setup_cache_image_interface(self) -> None: - """Set up napari-specific image interface for caching.""" - if self._cache: - try: - from ..core.image_interface import QtImageInterface - - self._cache.set_image_interface(QtImageInterface()) - except Exception as e: - print(f"Warning: Could not set up napari image interface: {e}") - - def _generate_thumbnail_array_with_cancellation(self, tomogram: "CopickTomogram") -> Optional[Any]: - """Generate thumbnail array from tomogram data with cancellation checks.""" - try: - import numpy as np - import zarr - - print(f"๐Ÿ”ง Loading zarr data for tomogram: {tomogram.tomo_type}") - - # Check cancellation before heavy I/O - if self._cancelled: - return None - - # Load tomogram data - handle multi-scale zarr properly - zarr_group = zarr.open(tomogram.zarr(), mode="r") - - # Check cancellation after opening zarr - if self._cancelled: - return None - - # Get the data array - handle multi-scale structure - if hasattr(zarr_group, "keys") and callable(zarr_group.keys): - # Multi-scale zarr group - get the HIGHEST binning level for faster thumbnails - scale_levels = sorted([k for k in zarr_group.keys() if k.isdigit()], key=int) # noqa: SIM118 - if scale_levels: - # Use the highest scale level (most binned/smallest) for thumbnails - highest_scale = scale_levels[-1] # Last element is highest number = most binned - tomo_data = zarr_group[highest_scale] - else: - # Fallback to first key - first_key = list(zarr_group.keys())[0] - tomo_data = zarr_group[first_key] - else: - # Direct zarr array - tomo_data = zarr_group - - # Check cancellation after getting data reference - if self._cancelled: - return None - - # Calculate downsampling factor based on data size - target_size = 200 - z_size, y_size, x_size = tomo_data.shape - - # Use middle slice for 2D thumbnail - mid_z = z_size // 2 - - # Check cancellation before reading slice data - if self._cancelled: - return None - - # Read the middle slice data - mid_slice = tomo_data[mid_z] - - # Check cancellation after reading slice - if self._cancelled: - return None - - # Calculate downsampling for the slice - downsample_y = max(1, y_size // target_size) - downsample_x = max(1, x_size // target_size) - - # Downsample the slice - thumbnail = mid_slice[::downsample_y, ::downsample_x] - - # Check cancellation after downsampling - if self._cancelled: - return None - - # Normalize to 0-255 range - thumb_min, thumb_max = thumbnail.min(), thumbnail.max() - if thumb_max > thumb_min: - thumbnail = ((thumbnail - thumb_min) / (thumb_max - thumb_min) * 255).astype(np.uint8) - else: - thumbnail = np.zeros_like(thumbnail, dtype=np.uint8) - - return thumbnail - - except Exception as e: - if self._cancelled: - return None - print(f"Error generating thumbnail array: {e}") - return None - - def _array_to_pixmap(self, array: Any) -> Optional[QPixmap]: - """Convert numpy array to QPixmap.""" - if not NAPARI_AVAILABLE: - return None - - try: - import numpy as np - - # Ensure array is uint8 - if array.dtype != np.uint8: - # Normalize to 0-255 range - array_min, array_max = array.min(), array.max() - if array_max > array_min: - array = ((array - array_min) / (array_max - array_min) * 255).astype(np.uint8) - else: - array = np.zeros_like(array, dtype=np.uint8) - - if array.ndim == 2: - # Grayscale image - height, width = array.shape - bytes_per_line = width - - # Create QImage from array - qimage = QImage(array.data, width, height, bytes_per_line, QImage.Format_Grayscale8) - - # Convert to QPixmap - pixmap = QPixmap.fromImage(qimage) - return pixmap - - elif array.ndim == 3 and array.shape[2] == 3: - # RGB image - height, width, channels = array.shape - bytes_per_line = width * channels - - # Create QImage from array - qimage = QImage(array.data, width, height, bytes_per_line, QImage.Format_RGB888) - - # Convert to QPixmap - pixmap = QPixmap.fromImage(qimage) - return pixmap - - else: - print(f"Unsupported array shape: {array.shape}") - return None - - except Exception as e: - print(f"Error converting array to pixmap: {e}") - return None - - class NapariDataWorker(AbstractDataWorker): - """napari-specific data worker using @thread_worker decorator.""" - - def __init__( - self, - run: "CopickRun", - data_type: str, - callback: Callable[[str, Optional[Any], Optional[str]], None], - ): - super().__init__(run, data_type, callback) - self._worker_func = None - self._finished = False - - def start(self) -> None: - """Start the data loading work using napari's thread_worker.""" - - if not NAPARI_AVAILABLE: - print(f"โŒ NapariDataWorker: napari not available for '{self.data_type}'") - self.callback(self.data_type, None, "napari not available") - return - - # Capture variables for the worker function - worker_run = self.run - worker_data_type = self.data_type - worker_cancelled = lambda: self._cancelled # noqa: E731 - - # Create the worker function as a generator for better control - @thread_worker - def load_data(): - - if worker_cancelled(): - print(f"โš ๏ธ NapariDataWorker: Cancelled '{worker_data_type}' before starting") - return None, "Cancelled" - - try: - # Yield to allow interruption - yield f"Starting {worker_data_type} loading..." - - # Check cancellation again - if worker_cancelled(): - print(f"โš ๏ธ NapariDataWorker: Cancelled '{worker_data_type}' during execution") - return None, "Cancelled" - - # Use base class data loading logic directly - if worker_data_type == "voxel_spacings": - data = list(worker_run.voxel_spacings) - elif worker_data_type == "tomograms": - tomograms = [] - for vs in worker_run.voxel_spacings: - if worker_cancelled(): - return None, "Cancelled" - tomograms.extend(list(vs.tomograms)) - data = tomograms - elif worker_data_type == "picks": - data = list(worker_run.picks) - elif worker_data_type == "meshes": - data = list(worker_run.meshes) - elif worker_data_type == "segmentations": - data = list(worker_run.segmentations) - else: - return None, f"Unknown data type: {worker_data_type}" - - if worker_cancelled(): - return None, "Cancelled" - - return data, None - - except Exception as e: - import traceback - - traceback.print_exc() - return None, str(e) - - # Connect worker signals - worker = load_data() - worker.returned.connect(self._on_worker_finished) - worker.errored.connect(self._on_worker_error) - - # Store reference to worker - self._worker_func = worker - - # Actually start the worker! - worker.start() - - def cancel(self) -> None: - """Cancel the data loading work.""" - self._cancelled = True - if self._worker_func: - # Use napari's quit method to abort the worker - if hasattr(self._worker_func, "quit"): - self._worker_func.quit() - else: - print(f"โš ๏ธ NapariDataWorker: Worker for '{self.data_type}' has no quit method") - - def _on_worker_finished(self, result): - """Handle worker completion.""" - self._finished = True - if self._cancelled: - return - - data, error = result - self.callback(self.data_type, data, error) - - def _on_worker_error(self, error): - """Handle worker error.""" - self._finished = True - if self._cancelled: - return - - self.callback(self.data_type, None, str(error)) - - class NapariWorkerManager(AbstractWorkerManager): - """Manages napari thumbnail and data workers with thread limiting.""" - - def __init__(self, max_concurrent_workers: int = 8): - """Initialize napari worker manager. - - Args: - max_concurrent_workers: Maximum number of workers that can run simultaneously. - Default is 8 to balance performance and system stability with large projects. - """ - super().__init__(max_concurrent_workers) - - def _create_thumbnail_worker( - self, - item: Union["CopickRun", "CopickTomogram"], - thumbnail_id: str, - callback: Callable[[str, Optional[Any], Optional[str]], None], - force_regenerate: bool = False, - ) -> NapariThumbnailWorker: - """Create a napari thumbnail worker.""" - return NapariThumbnailWorker(item, thumbnail_id, callback, force_regenerate) - - def _create_data_worker( - self, - run: "CopickRun", - data_type: str, - callback: Callable[[str, Optional[Any], Optional[str]], None], - ) -> NapariDataWorker: - """Create a napari data worker.""" - return NapariDataWorker(run, data_type, callback) - - def _start_worker(self, worker: Union[NapariThumbnailWorker, NapariDataWorker]) -> None: - """Start a napari worker.""" - worker.start() - - def _is_worker_active(self, worker: Union[NapariThumbnailWorker, NapariDataWorker]) -> bool: - """Check if a napari worker is still active.""" - return ( - hasattr(worker, "_worker_func") - and worker._worker_func is not None - and hasattr(worker, "_finished") - and not worker._finished - ) - - def _cancel_worker(self, worker: Union[NapariThumbnailWorker, NapariDataWorker]) -> None: - """Cancel a napari worker.""" - worker.cancel() +def create_napari_worker_manager(max_concurrent_workers: int = 8) -> NapariWorkerManager: + """Create a napari worker manager with platform-appropriate defaults.""" + return create_worker_manager(max_concurrent_workers) diff --git a/src/copick_shared_ui/workers/unified_workers.py b/src/copick_shared_ui/workers/unified_workers.py new file mode 100644 index 0000000..1732361 --- /dev/null +++ b/src/copick_shared_ui/workers/unified_workers.py @@ -0,0 +1,391 @@ +"""Unified worker implementations using thread_worker decorator for both napari and ChimeraX.""" + +from typing import TYPE_CHECKING, Any, Callable, Optional, Union + +# Try napari first, then superqt +try: + from napari.qt.threading import thread_worker + + THREAD_WORKER_SOURCE = "napari" +except ImportError: + try: + from superqt.utils._qthreading import thread_worker + + THREAD_WORKER_SOURCE = "superqt" + except ImportError: + thread_worker = None + THREAD_WORKER_SOURCE = None + +# Import Qt components +try: + from qtpy.QtCore import QObject, Signal + from qtpy.QtGui import QImage, QPixmap + + QT_AVAILABLE = True +except ImportError: + QT_AVAILABLE = False + +if QT_AVAILABLE and thread_worker: + from copick_shared_ui.workers.base import AbstractThumbnailWorker + from copick_shared_ui.workers.base_manager import AbstractWorkerManager + from copick_shared_ui.workers.data_worker import AbstractDataWorker + +if TYPE_CHECKING: + from copick.models import CopickRun, CopickTomogram + + +def is_threading_available() -> bool: + """Check if threading is available.""" + return QT_AVAILABLE and thread_worker is not None + + +def get_threading_source() -> Optional[str]: + """Get the source of the thread_worker decorator.""" + return THREAD_WORKER_SOURCE + + +if is_threading_available(): + + class UnifiedThumbnailWorker(AbstractThumbnailWorker): + """Unified thumbnail worker using @thread_worker decorator with caching.""" + + def __init__( + self, + item: Union["CopickRun", "CopickTomogram"], + thumbnail_id: str, + callback: Callable[[str, Optional[Any], Optional[str]], None], + force_regenerate: bool = False, + ): + super().__init__(item, thumbnail_id, callback, force_regenerate) + self._worker_func = None + self._cancelled = False + self._finished = False + + def start(self) -> None: + """Start the thumbnail loading work using thread_worker.""" + + # Create the worker function as a generator for better control + @thread_worker + def load_thumbnail(): + # Check cancellation before starting + if self._cancelled: + print(f"โš ๏ธ Worker: Cancelled '{self.thumbnail_id}' before starting") + return None, "Cancelled" + + try: + # Yield to allow interruption + yield "Starting thumbnail generation..." + + # Check cancellation again + if self._cancelled: + print(f"โš ๏ธ Worker: Cancelled '{self.thumbnail_id}' during execution") + return None, "Cancelled" + + # Use the shared caching system from AbstractThumbnailWorker + yield "Using shared caching system..." + if self._cancelled: + return None, "Cancelled" + + # Call the parent's generate_thumbnail_pixmap method which handles all caching logic + pixmap, error = self.generate_thumbnail_pixmap() + + if self._cancelled: + return None, "Cancelled" + + if error: + return None, error + + return pixmap, None + + except Exception as e: + print(f"๐Ÿ’ฅ Worker: Exception in worker for '{self.thumbnail_id}': {e}") + import traceback + + traceback.print_exc() + return None, str(e) + + # Connect worker signals + worker = load_thumbnail() + worker.returned.connect(self._on_worker_finished) + worker.errored.connect(self._on_worker_error) + + # Store reference to worker + self._worker_func = worker + + # Start the worker + worker.start() + + def cancel(self) -> None: + """Cancel the thumbnail loading work.""" + self._cancelled = True + + if self._worker_func: + # Use the quit method to abort the worker + if hasattr(self._worker_func, "quit"): + self._worker_func.quit() + else: + print(f"โš ๏ธ Worker: Worker for '{self.thumbnail_id}' has no quit method") + + def _on_worker_finished(self, result): + """Handle worker completion with low-priority callback to prevent event loop flooding.""" + self._finished = True + if self._cancelled: + return + + pixmap, error = result + + # Use immediate callback for unified worker - platform-specific optimizations + # are handled in the respective platform workers (ChimeraX, napari) + self.callback(self.thumbnail_id, pixmap, error) + + def _on_worker_error(self, error): + """Handle worker error.""" + if self._cancelled: + return + + self.callback(self.thumbnail_id, None, str(error)) + + def _setup_cache_image_interface(self) -> None: + """Set up platform-specific image interface for caching.""" + if self._cache: + try: + from copick_shared_ui.core.image_interface import QtImageInterface + + self._cache.set_image_interface(QtImageInterface()) + except Exception as e: + print(f"Warning: Could not set up image interface: {e}") + + def _array_to_pixmap(self, array: Any) -> Optional[QPixmap]: + """Convert numpy array to QPixmap.""" + if not QT_AVAILABLE: + return None + + try: + import numpy as np + + # Ensure array is uint8 + if array.dtype != np.uint8: + # Normalize to 0-255 range + array_min, array_max = array.min(), array.max() + if array_max > array_min: + array = ((array - array_min) / (array_max - array_min) * 255).astype(np.uint8) + else: + array = np.zeros_like(array, dtype=np.uint8) + + if array.ndim == 2: + # Grayscale image + height, width = array.shape + bytes_per_line = width + qimage = QImage(array.data, width, height, bytes_per_line, QImage.Format_Grayscale8) + pixmap = QPixmap.fromImage(qimage) + return pixmap + + elif array.ndim == 3 and array.shape[2] == 3: + # RGB image + height, width, channels = array.shape + bytes_per_line = width * channels + qimage = QImage(array.data, width, height, bytes_per_line, QImage.Format_RGB888) + pixmap = QPixmap.fromImage(qimage) + return pixmap + + else: + print(f"Unsupported array shape: {array.shape}") + return None + + except Exception as e: + print(f"Error converting array to pixmap: {e}") + return None + + class UnifiedDataWorker(AbstractDataWorker): + """Unified data worker using @thread_worker decorator.""" + + def __init__( + self, + run: "CopickRun", + data_type: str, + callback: Callable[[str, Optional[Any], Optional[str]], None], + ): + super().__init__(run, data_type, callback) + self._worker_func = None + self._finished = False + + def start(self) -> None: + """Start the data loading work using thread_worker.""" + # Capture variables for the worker function + worker_self = self # Capture self for the worker closure + worker_data_type = self.data_type + worker_cancelled = lambda: self._cancelled # noqa: E731 + + # Create the worker function as a generator for better control + @thread_worker + def load_data(): + if worker_cancelled(): + print(f"โš ๏ธ DataWorker: Cancelled '{worker_data_type}' before starting") + return None, "Cancelled" + + try: + # Yield to allow interruption + yield f"Starting {worker_data_type} loading..." + + # Check cancellation again + if worker_cancelled(): + print(f"โš ๏ธ DataWorker: Cancelled '{worker_data_type}' during execution") + return None, "Cancelled" + + # Use the base class load_data method which handles all the logic + data, error = worker_self.load_data() + + if worker_cancelled(): + return None, "Cancelled" + + return data, error + + except Exception as e: + import traceback + + traceback.print_exc() + return None, str(e) + + # Connect worker signals + worker = load_data() + worker.returned.connect(self._on_worker_finished) + worker.errored.connect(self._on_worker_error) + + # Store reference to worker + self._worker_func = worker + + # Start the worker + worker.start() + + def cancel(self) -> None: + """Cancel the data loading work.""" + self._cancelled = True + if self._worker_func: + # Use the quit method to abort the worker + if hasattr(self._worker_func, "quit"): + self._worker_func.quit() + else: + print(f"โš ๏ธ DataWorker: Worker for '{self.data_type}' has no quit method") + + def _on_worker_finished(self, result): + """Handle worker completion.""" + self._finished = True + if self._cancelled: + return + + data, error = result + self.callback(self.data_type, data, error) + + def _on_worker_error(self, error): + """Handle worker error.""" + self._finished = True + if self._cancelled: + return + + self.callback(self.data_type, None, str(error)) + + class UnifiedWorkerManager(AbstractWorkerManager): + """Unified worker manager for both napari and ChimeraX.""" + + def __init__(self, max_concurrent_workers: int = 8): + """Initialize unified worker manager. + + Args: + max_concurrent_workers: Maximum number of workers that can run simultaneously. + """ + super().__init__(max_concurrent_workers) + + def _create_thumbnail_worker( + self, + item: Union["CopickRun", "CopickTomogram"], + thumbnail_id: str, + callback: Callable[[str, Optional[Any], Optional[str]], None], + force_regenerate: bool = False, + ) -> UnifiedThumbnailWorker: + """Create a unified thumbnail worker.""" + return UnifiedThumbnailWorker(item, thumbnail_id, callback, force_regenerate) + + def _create_data_worker( + self, + run: "CopickRun", + data_type: str, + callback: Callable[[str, Optional[Any], Optional[str]], None], + ) -> UnifiedDataWorker: + """Create a unified data worker.""" + return UnifiedDataWorker(run, data_type, callback) + + def _start_worker(self, worker: Union[UnifiedThumbnailWorker, UnifiedDataWorker]) -> None: + """Start a unified worker.""" + worker.start() + + def _is_worker_active(self, worker: Union[UnifiedThumbnailWorker, UnifiedDataWorker]) -> bool: + """Check if a unified worker is still active.""" + return ( + hasattr(worker, "_worker_func") + and worker._worker_func is not None + and hasattr(worker, "_finished") + and not worker._finished + ) + + def _cancel_worker(self, worker: Union[UnifiedThumbnailWorker, UnifiedDataWorker]) -> None: + """Cancel a unified worker.""" + worker.cancel() + +else: + # Fallback classes when threading is not available + class UnifiedThumbnailWorker: + def __init__(self, *args, **kwargs): + pass + + def start(self): + pass + + def cancel(self): + pass + + class UnifiedDataWorker: + def __init__(self, *args, **kwargs): + pass + + def start(self): + pass + + def cancel(self): + pass + + class UnifiedWorkerManager: + def __init__(self, *args, **kwargs): + pass + + def start_thumbnail_worker(self, *args, **kwargs): + pass + + def start_data_worker(self, *args, **kwargs): + pass + + def clear_workers(self): + pass + + def shutdown_workers(self, timeout_ms=3000): + pass + + def get_status(self): + return { + "error": f"Threading not available - QT: {QT_AVAILABLE}, thread_worker: {thread_worker is not None}", + } + + +# Convenience functions for platform detection +def create_worker_manager(max_concurrent_workers: int = 8) -> UnifiedWorkerManager: + """Create a worker manager for the current platform.""" + return UnifiedWorkerManager(max_concurrent_workers) + + +def get_platform_info() -> dict: + """Get information about the current threading platform.""" + return { + "qt_available": QT_AVAILABLE, + "thread_worker_available": thread_worker is not None, + "thread_worker_source": THREAD_WORKER_SOURCE, + "threading_available": is_threading_available(), + } From 5e51f5dc8a1450ef4e2ba35f45f383af4bbb43a3 Mon Sep 17 00:00:00 2001 From: uermel Date: Sun, 6 Jul 2025 22:13:31 -0700 Subject: [PATCH 3/3] cache cleanup --- src/copick_shared_ui/core/thumbnail_cache.py | 96 ++++++++++++++++---- 1 file changed, 77 insertions(+), 19 deletions(-) diff --git a/src/copick_shared_ui/core/thumbnail_cache.py b/src/copick_shared_ui/core/thumbnail_cache.py index 8789126..5c46bf4 100644 --- a/src/copick_shared_ui/core/thumbnail_cache.py +++ b/src/copick_shared_ui/core/thumbnail_cache.py @@ -67,8 +67,8 @@ def __init__(self, config_path: Optional[str] = None, app_name: str = "copick"): self.cache_dir: Optional[Path] = None self._image_interface: Optional[ImageInterface] = None self._setup_cache_directory() - # Skip cache cleanup during initialization to avoid blocking main thread - # self._cleanup_old_cache_entries() + # Perform cache cleanup based on metadata file age (efficient) + self._cleanup_old_cache_entries() def set_image_interface(self, image_interface: ImageInterface) -> None: """Set the image interface for handling image operations. @@ -267,26 +267,81 @@ def _cleanup_old_cache_entries(self, max_age_days: int = 14) -> None: try: import time - # current_time = time.time() - # max_age_seconds = max_age_days * 24 * 60 * 60 # Convert days to seconds - # - # removed_count = 0 - # for thumbnail_file in self.cache_dir.glob("*.png"): - # try: - # # Get file modification time - # file_mtime = thumbnail_file.stat().st_mtime - # age_seconds = current_time - file_mtime - # - # if age_seconds > max_age_seconds: - # thumbnail_file.unlink() - # removed_count += 1 - - # except Exception as e: - # print(f"Warning: Could not process cache file {thumbnail_file}: {e}") + current_time = time.time() + max_age_seconds = max_age_days * 24 * 60 * 60 # Convert days to seconds + + # Check metadata file creation date instead of individual thumbnails + metadata_file = self.cache_dir / "cache_metadata.json" + if metadata_file.exists(): + try: + with open(metadata_file, "r") as f: + metadata = json.load(f) + + # Get cache creation time from metadata + cache_created_at = float(metadata.get("created_at", current_time)) + cache_age_seconds = current_time - cache_created_at + + # If the entire cache is older than max_age_days, clear it + if cache_age_seconds > max_age_seconds: + removed_count = 0 + + # Remove all thumbnail files + for thumbnail_file in self.cache_dir.glob("*.png"): + try: + thumbnail_file.unlink() + removed_count += 1 + except Exception as e: + print(f"Warning: Could not remove cache file {thumbnail_file}: {e}") + + # Remove all best tomogram info files + for best_tomo_file in self.cache_dir.glob("*_best_tomogram.json"): + try: + best_tomo_file.unlink() + except Exception as e: + print(f"Warning: Could not remove best tomogram file {best_tomo_file}: {e}") + + if removed_count > 0: + print( + f"๐Ÿงน Cleaned up {removed_count} old cache entries (cache age: {cache_age_seconds / (24 * 60 * 60):.1f} days)", + ) + + # Update metadata with new creation time + metadata["created_at"] = str(current_time) + with open(metadata_file, "w") as f: + json.dump(metadata, f, indent=2) + + except (json.JSONDecodeError, ValueError, KeyError) as e: + print(f"Warning: Could not parse cache metadata: {e}") + except Exception as e: + print(f"Warning: Could not process cache metadata: {e}") except Exception as e: print(f"Warning: Cache cleanup failed: {e}") + def _update_cache_timestamp(self) -> None: + """Update the cache timestamp to keep frequently used projects fresh.""" + if not self.cache_dir or not self.cache_dir.exists(): + return + + try: + import time + + metadata_file = self.cache_dir / "cache_metadata.json" + if metadata_file.exists(): + # Read existing metadata + with open(metadata_file, "r") as f: + metadata = json.load(f) + + # Update the created_at timestamp to current time + metadata["created_at"] = str(time.time()) + + # Write back the updated metadata + with open(metadata_file, "w") as f: + json.dump(metadata, f, indent=2) + + except Exception as e: + print(f"Warning: Could not update cache timestamp: {e}") + def clear_cache(self) -> bool: """Clear all thumbnails and best tomogram info from the cache. @@ -426,7 +481,10 @@ def update_config(self, config_path: str) -> None: self.config_path = config_path self._setup_cache_directory() # Skip cache cleanup to avoid blocking main thread - # self._cleanup_old_cache_entries() + self._cleanup_old_cache_entries() + + # Update the cache timestamp to keep frequently used projects fresh + self._update_cache_timestamp() class GlobalCacheManager: