From 742a042a41c4705bc68dbc78860894483cd2ec27 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Mon, 3 Nov 2025 02:24:12 -0800 Subject: [PATCH] =?UTF-8?q?Release=20v0.5=20=E2=80=94=20more=20improvement?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- faststack/ChangeLog.md | 32 ++++++ faststack/faststack/app.py | 133 ++++++++++++------------ faststack/faststack/imaging/jpeg.py | 71 ++++++++++--- faststack/faststack/imaging/prefetch.py | 11 +- faststack/faststack/io/watcher.py | 22 +++- faststack/faststack/qml/Components.qml | 86 ++++++++++----- faststack/faststack/qml/Main.qml | 100 ++---------------- faststack/faststack/ui/provider.py | 9 ++ faststack/pyproject.toml | 2 +- 9 files changed, 264 insertions(+), 202 deletions(-) diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index 73bd74a..54ea029 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -1,7 +1,39 @@ # ChangeLog +## [0.5.0] - 2025-11-03 + +### Added +- Load full-resolution images when zooming in for maximum detail. +- Call Helicon Focus for each defined stack when multiple stacks are present. + +### Changed +- The filesystem watcher is now less sensitive to spurious modification events, reducing unnecessary refreshes. +- The preloading process now shares the same thread pool as the prefetcher for better resource utilization. +- Stacks are now cleared automatically after being sent to Helicon Focus. + +### Fixed +- Corrected a `ValueError` in `PyTurboJPEG` caused by unsupported scaling factors. +- Resolved an `AttributeError` in the JPEG scaling factor calculation. +- Fixed an issue where panning the image was not working correctly. +- Addressed a bug where panning speed was incorrect at high zoom levels. +- Ensured that stale prefetcher futures are cancelled when the display size changes. + +### Performance +- Improved image decoding performance by using `PyTurboJPEG` for resized decoding. +- Tuned the number of prefetcher thread pool workers based on system CPU cores. +- Replaced synchronous file reads with memory-mapped I/O for faster image loading. +- Optimized image resizing by using `BILINEAR` resampling for large downscales. +- Debounced display size change notifications to reduce redundant UI updates. + ## Version 0.4 +### Todo + +Make it use the full res image when zooming in +When multiple stacks are selected, call Helicon multiple times +After Helicon is called, clear the stacks +Fix S key - I guess it should remove an image from the stack? Clarify what it does now. + ### New Features - **Two-tier caching system:** Implemented a two-tier caching system to prefetch display-sized images, significantly improving performance and reducing GPU memory usage. - **"Preload All Images" feature:** Added a new menu option under "Actions" to preload all images in the current directory into the cache, ensuring quick access even for unviewed images. diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 43601c6..8526463 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -46,6 +46,7 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): self.display_width = 0 self.display_height = 0 self.display_generation = 0 + self.is_zoomed = False # -- Backend Components -- self.watcher = Watcher(self.image_dir, self.refresh_image_list) @@ -61,7 +62,6 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): prefetch_radius=config.getint('core', 'prefetch_radius', 4), get_display_info=self.get_display_info ) - self.preload_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="PreloadAll") # -- UI State -- self.ui_state = UIState(self) @@ -73,9 +73,8 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine): self.selected_raws: set[Path] = set() def get_display_info(self): - return self.display_width, self.display_height, self.display_generation - - def get_display_info(self): + if self.is_zoomed: + return 0, 0, self.display_generation return self.display_width, self.display_height, self.display_generation def on_display_size_changed(self, width: int, height: int): @@ -87,9 +86,22 @@ def on_display_size_changed(self, width: int, height: int): self.display_height = height self.display_generation += 1 self.image_cache.clear() + self.prefetcher.cancel_all() # Clear existing prefetch tasks self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() # To refresh the image + def set_zoomed(self, zoomed: bool): + if self.is_zoomed == zoomed: + return + self.is_zoomed = zoomed + log.info(f"Zoom state changed to: {zoomed}") + self.display_generation += 1 # Invalidate cache + self.image_cache.clear() + self.prefetcher.cancel_all() + self.prefetcher.update_prefetch(self.current_index) + self.sync_ui_state() + self.ui_state.isZoomedChanged.emit() + def eventFilter(self, watched: QObject, event: QEvent) -> bool: if watched == self.main_window and event.type() == QEvent.Type.KeyPress: handled = self.keybinder.handle_key_press(event) @@ -246,47 +258,53 @@ def toggle_selection(self): def launch_helicon(self): """Launches Helicon Focus with selected RAWs or all RAWs in defined stacks.""" - raw_files_to_process = [] if self.selected_raws: log.info(f"Launching Helicon with {len(self.selected_raws)} selected RAW files.") - raw_files_to_process.extend(sorted(list(self.selected_raws))) # Sort for consistent order + self._launch_helicon_with_files(sorted(list(self.selected_raws))) + self.selected_raws.clear() + elif self.stacks: - log.info("No selection, launching Helicon with all defined stacks.") + log.info(f"Launching Helicon for {len(self.stacks)} defined stacks.") for start, end in self.stacks: + raw_files_to_process = [] for idx in range(start, end + 1): if idx < len(self.image_files) and self.image_files[idx].raw_pair: raw_files_to_process.append(self.image_files[idx].raw_pair) + + if raw_files_to_process: + self._launch_helicon_with_files(raw_files_to_process) + else: + log.warning(f"No valid RAW files found for stack [{start}, {end}].") + + self.clear_all_stacks() + else: log.warning("No selection or stacks defined to launch Helicon Focus.") return - if raw_files_to_process: - log.info(f"Launching Helicon Focus with {len(raw_files_to_process)} RAW files.") - # Remove duplicates that might arise from stacks - unique_raw_files = sorted(list(set(raw_files_to_process))) - success, tmp_path = launch_helicon_focus(unique_raw_files) - if success and tmp_path: - # Schedule delayed deletion of the temporary file - QTimer.singleShot(5000, lambda: self._delete_temp_file(tmp_path)) - - # Record stacking metadata - today = date.today().isoformat() - for raw_path in unique_raw_files: - # Find the corresponding image file to get the stem - for img_file in self.image_files: - if img_file.raw_pair == raw_path: - stem = img_file.path.stem - meta = self.sidecar.get_metadata(stem) - meta.stacked = True - meta.stacked_date = today - break - self.sidecar.save() - - # Clear selection after launching - self.selected_raws.clear() - self.sync_ui_state() - else: - log.warning("No valid RAW files found to launch Helicon.") + self.sync_ui_state() + + def _launch_helicon_with_files(self, raw_files: List[Path]): + """Helper to launch Helicon with a specific list of files.""" + log.info(f"Launching Helicon Focus with {len(raw_files)} RAW files.") + unique_raw_files = sorted(list(set(raw_files))) + success, tmp_path = launch_helicon_focus(unique_raw_files) + if success and tmp_path: + # Schedule delayed deletion of the temporary file + QTimer.singleShot(5000, lambda: self._delete_temp_file(tmp_path)) + + # Record stacking metadata + today = date.today().isoformat() + for raw_path in unique_raw_files: + # Find the corresponding image file to get the stem + for img_file in self.image_files: + if img_file.raw_pair == raw_path: + stem = img_file.path.stem + meta = self.sidecar.get_metadata(stem) + meta.stacked = True + meta.stacked_date = today + break + self.sidecar.save() def _delete_temp_file(self, tmp_path: Path): if tmp_path.exists(): @@ -371,38 +389,22 @@ def preload_all_images(self): self.reporter.progress_updated.connect(self._update_preload_progress) self.reporter.finished.connect(self._finish_preloading) - def _preload_and_report_progress(): - log.info(f"Preloading images.") - - futures = [] - for i in range(len(self.image_files)): - future = self.prefetcher.submit_task(i, self.prefetcher.generation) - if future: - futures.append(future) - - num_futures = len(futures) - if num_futures == 0: + # Use existing prefetch executor (better resource utilization) + total = len(self.image_files) + completed = 0 + + def _on_done(future): + nonlocal completed + completed += 1 + progress = int((completed / total) * 100) + self.reporter.progress_updated.emit(progress) + if completed == total: self.reporter.finished.emit() - return - - log.info(f"Submitted {num_futures} preloading tasks.") - completed_count = 0 - lock = threading.Lock() - - def _on_future_done(future): - nonlocal completed_count - with lock: - completed_count += 1 - progress = int((completed_count / num_futures) * 100) - self.reporter.progress_updated.emit(progress) - - if completed_count == num_futures: - self.reporter.finished.emit() - - for future in futures: - future.add_done_callback(_on_future_done) - - self.preload_executor.submit(_preload_and_report_progress) + + for i in range(total): + future = self.prefetcher.submit_task(i, self.prefetcher.generation) + if future: + future.add_done_callback(_on_done) def _update_preload_progress(self, progress: int): log.debug(f"Updating preload progress in UI: {progress}%") @@ -422,7 +424,6 @@ def shutdown(self): self.watcher.stop() self.prefetcher.shutdown() - self.preload_executor.shutdown(wait=False) self.sidecar.set_last_index(self.current_index) self.sidecar.save() diff --git a/faststack/faststack/imaging/jpeg.py b/faststack/faststack/imaging/jpeg.py index 1bcb390..a8faf21 100644 --- a/faststack/faststack/imaging/jpeg.py +++ b/faststack/faststack/imaging/jpeg.py @@ -26,7 +26,7 @@ def decode_jpeg_rgb(jpeg_bytes: bytes) -> Optional[np.ndarray]: # The flags prevent upsampling of chroma channels, which is faster. return jpeg_decoder.decode(jpeg_bytes, pixel_format=TJPF_RGB, flags=TJFLAG_FASTDCT) except Exception as e: - log.error(f"PyTurboJPEG failed to decode image: {e}. Trying Pillow.") + log.exception(f"PyTurboJPEG failed to decode image: {e}. Trying Pillow.") # Fall through to Pillow fallback # Fallback to Pillow @@ -35,7 +35,7 @@ def decode_jpeg_rgb(jpeg_bytes: bytes) -> Optional[np.ndarray]: img = Image.open(BytesIO(jpeg_bytes)).convert("RGB") return np.array(img) except Exception as e: - log.error(f"Pillow also failed to decode image: {e}") + log.exception(f"Pillow also failed to decode image: {e}") return None def decode_jpeg_thumb_rgb( @@ -53,7 +53,7 @@ def decode_jpeg_thumb_rgb( return jpeg_decoder.decode(jpeg_bytes, scaling_factor=scaling_factor, pixel_format=TJPF_RGB, flags=TJFLAG_FASTDCT) except Exception as e: - log.error(f"PyTurboJPEG failed to decode thumbnail: {e}. Trying Pillow.") + log.exception(f"PyTurboJPEG failed to decode thumbnail: {e}. Trying Pillow.") # Fallback to Pillow try: @@ -62,16 +62,27 @@ def decode_jpeg_thumb_rgb( img.thumbnail((max_dim, max_dim)) return np.array(img.convert("RGB")) except Exception as e: - log.error(f"Pillow also failed to decode thumbnail: {e}") + log.exception(f"Pillow also failed to decode thumbnail: {e}") return None def _get_turbojpeg_scaling_factor(width: int, height: int, max_dim: int) -> Optional[Tuple[int, int]]: """Finds the best libjpeg-turbo scaling factor to get a thumbnail <= max_dim.""" - # libjpeg-turbo supports scaling factors of N/8 for N in [1, 16] - for n in range(8, 0, -1): - if (width * n / 8) <= max_dim and (height * n / 8) <= max_dim: - return (n, 8) - return None # Should not happen if max_dim is reasonable + if not TURBO_AVAILABLE or not jpeg_decoder: + return None + + # PyTurboJPEG provides a set of supported scaling factors + supported_factors = sorted( + jpeg_decoder.scaling_factors, + key=lambda x: x[0] / x[1], + reverse=True, + ) + + for num, den in supported_factors: + if (width * num / den) <= max_dim and (height * num / den) <= max_dim: + return (num, den) + + # If no suitable factor is found, return the smallest one + return supported_factors[-1] if supported_factors else None def decode_jpeg_resized( @@ -79,15 +90,49 @@ def decode_jpeg_resized( ) -> Optional[np.ndarray]: """Decodes and resizes a JPEG to fit within the given dimensions.""" if width == 0 or height == 0: - # Fallback to full decode if size is not specified return decode_jpeg_rgb(jpeg_bytes) + if TURBO_AVAILABLE and jpeg_decoder: + try: + # Get image header to determine dimensions + img_width, img_height, _, _ = jpeg_decoder.decode_header(jpeg_bytes) + + # Calculate best scaling factor for TurboJPEG (supports 1/8, 1/4, 1/2, etc.) + scale_factor = _get_turbojpeg_scaling_factor(img_width, img_height, max(width, height)) + + if scale_factor: + decoded = jpeg_decoder.decode( + jpeg_bytes, + scaling_factor=scale_factor, + pixel_format=TJPF_RGB, + flags=TJFLAG_FASTDCT + ) + + # Only use Pillow for final resize if needed + if decoded.shape[0] > height or decoded.shape[1] > width: + from io import BytesIO + img = Image.fromarray(decoded) + img.thumbnail((width, height), Image.Resampling.LANCZOS) + return np.array(img) + return decoded + except Exception as e: + log.exception(f"PyTurboJPEG failed: {e}") + + # Fallback to Pillow (existing code) try: from io import BytesIO - img = Image.open(BytesIO(jpeg_bytes)) - img.thumbnail((width, height), Image.Resampling.LANCZOS) # High quality downsampling + + scale_factor_ratio = min(img.width / width, img.height / height) + + # Use faster BILINEAR for large downscales, LANCZOS for smaller + if scale_factor_ratio > 4: + resampling = Image.Resampling.BILINEAR # Much faster + else: + resampling = Image.Resampling.LANCZOS # Higher quality + + img.thumbnail((width, height), resampling) return np.array(img.convert("RGB")) except Exception as e: - log.error(f"Pillow failed to decode and resize image: {e}") + log.exception(f"Pillow failed to decode and resize image: {e}") return None diff --git a/faststack/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py index 4e01487..24a7491 100644 --- a/faststack/faststack/imaging/prefetch.py +++ b/faststack/faststack/imaging/prefetch.py @@ -4,6 +4,7 @@ import os from concurrent.futures import ThreadPoolExecutor, Future from typing import List, Dict, Optional, Callable +import mmap from faststack.models import ImageFile, DecodedImage from faststack.imaging.jpeg import decode_jpeg_rgb, decode_jpeg_resized @@ -16,8 +17,12 @@ def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_r self.cache_put = cache_put self.prefetch_radius = prefetch_radius self.get_display_info = get_display_info + # Use CPU count for I/O-bound JPEG decoding + # Rule of thumb: 2x CPU cores for I/O bound, 1x for CPU bound + optimal_workers = min((os.cpu_count() or 1) * 2, 8) # Cap at 8 + self.executor = ThreadPoolExecutor( - max_workers=min(4, os.cpu_count() or 1), + max_workers=optimal_workers, thread_name_prefix="Prefetcher" ) self.futures: Dict[int, Future] = {} @@ -72,8 +77,10 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, return None try: + # Memory-mapped file reading (faster than traditional read) with open(image_file.path, "rb") as f: - jpeg_bytes = f.read() + with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: + jpeg_bytes = mmapped[:] buffer = decode_jpeg_resized(jpeg_bytes, display_width, display_height) if buffer is not None: diff --git a/faststack/faststack/io/watcher.py b/faststack/faststack/io/watcher.py index 1062138..938c169 100644 --- a/faststack/faststack/io/watcher.py +++ b/faststack/faststack/io/watcher.py @@ -15,13 +15,29 @@ def __init__(self, callback): super().__init__() self.callback = callback - def on_any_event(self, event): - # Ignore temporary files created during atomic saves and the sidecar file itself + def on_created(self, event): if event.src_path.endswith(".tmp") or event.src_path.endswith("faststack.json"): return - log.info(f"Detected filesystem change: {event}. Triggering refresh.") + log.info(f"Detected file creation: {event}. Triggering refresh.") self.callback() + def on_deleted(self, event): + if event.src_path.endswith(".tmp") or event.src_path.endswith("faststack.json"): + return + log.info(f"Detected file deletion: {event}. Triggering refresh.") + self.callback() + + def on_moved(self, event): + if event.src_path.endswith(".tmp") or event.src_path.endswith("faststack.json"): + return + log.info(f"Detected file move: {event}. Triggering refresh.") + self.callback() + + def on_modified(self, event): + # This is a no-op to prevent spurious refreshes from file modifications + # that don't change the content (e.g., antivirus scans). + pass + class Watcher: """Manages the filesystem observer.""" def __init__(self, directory: Path, callback): diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 7a626ee..7e9f381 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -23,48 +23,80 @@ Item { onWidthChanged: { if (width > 0 && height > 0) { - uiState.onDisplaySizeChanged(width, height) + resizeDebounceTimer.restart() } } onHeightChanged: { if (width > 0 && height > 0) { - uiState.onDisplaySizeChanged(width, height) + resizeDebounceTimer.restart() } } - // Zoom and Pan logic would go here - // For example, using PinchArea or MouseArea - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.LeftButton - - // Simple drag-to-pan placeholder - property real lastX: 0 - property real lastY: 0 + onScaleChanged: { + if (scaleTransform.xScale > 1.1 && !uiState.isZoomed) { + uiState.setZoomed(true); + } else if (scaleTransform.xScale <= 1.0 && uiState.isZoomed) { + uiState.setZoomed(false); + } + } - onPressed: { - lastX = mouseX - lastY = mouseY + transform: [ + Scale { + id: scaleTransform + origin.x: mainImage.width / 2 + origin.y: mainImage.height / 2 + }, + Translate { + id: panTransform } + ] + } - onPositionChanged: { - if (pressed) { - mainImage.x += (mouseX - lastX) - mainImage.y += (mouseY - lastY) - lastX = mouseX - lastY = mouseY - } + // Zoom and Pan logic would go here + // For example, using PinchArea or MouseArea + Timer { + id: resizeDebounceTimer + interval: 100 // milliseconds + running: false + onTriggered: { + if (mainImage.width > 0 && mainImage.height > 0) { + uiState.onDisplaySizeChanged(mainImage.width, mainImage.height) } + running = false + } + } - // Wheel for zoom - onWheel: { - // A real implementation would be more complex, zooming - // into the cursor position. - var scaleFactor = wheel.angleDelta.y > 0 ? 1.2 : 1 / 1.2; - mainImage.scale *= scaleFactor; + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.LeftButton + + // Simple drag-to-pan placeholder + property real lastX: 0 + property real lastY: 0 + + onPressed: function(mouse) { + lastX = mouse.x + lastY = mouse.y + } + + onPositionChanged: function(mouse) { + if (pressed) { + panTransform.x += (mouse.x - lastX) + panTransform.y += (mouse.y - lastY) + lastX = mouse.x + lastY = mouse.y } } + + // Wheel for zoom + onWheel: function(wheel) { + // A real implementation would be more complex, zooming + // into the cursor position. + var scaleFactor = wheel.angleDelta.y > 0 ? 1.2 : 1 / 1.2; + scaleTransform.xScale *= scaleFactor; + scaleTransform.yScale *= scaleFactor; + } } diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index 813075e..287319b 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -138,128 +138,48 @@ ApplicationWindow { - MenuBar { - - - - - - - - id: menuBar - - - - - - - - Layout.preferredWidth: 300 // Give it some width - - - - - - - - background: Rectangle { - - - - color: root.currentBackgroundColor - - - - } - - - - - - - - palette.buttonText: root.currentTextColor - - - - palette.button: root.currentBackgroundColor - - - - palette.window: root.currentBackgroundColor - - - - palette.text: root.currentTextColor - - - - - - + MenuBar { + id: menuBar + Layout.preferredWidth: 300 // Give it some width + background: Rectangle { + color: root.currentBackgroundColor + } + palette.buttonText: root.currentTextColor + palette.button: root.currentBackgroundColor + palette.window: root.currentBackgroundColor + palette.text: root.currentTextColor Menu { - title: "&File" - Action { text: "&Open Folder..." } - Action { - text: "&Settings..." - onTriggered: { - settingsDialog.heliconPath = uiState.get_helicon_path() - settingsDialog.cacheSize = uiState.get_cache_size() - settingsDialog.prefetchRadius = uiState.get_prefetch_radius() - settingsDialog.theme = uiState.get_theme() - settingsDialog.defaultDirectory = uiState.get_default_directory() - settingsDialog.open() - } - } - Action { text: "&Exit"; onTriggered: Qt.quit() } - } - Menu { - title: "&View" - Action { text: "Toggle Light/Dark Mode"; onTriggered: root.toggleTheme() } - } - Menu { - title: "&Actions" - Action { text: "Run Stacks"; onTriggered: uiState.launch_helicon() } - Action { text: "Clear Stacks"; onTriggered: uiState.clear_all_stacks() } - Action { text: "Show Stacks"; onTriggered: showStacksDialog.open() } - Action { text: "Preload All Images"; onTriggered: uiState.preloadAllImages() } - } - Menu { - title: "&Help" - Action { text: "&Key Bindings"; onTriggered: aboutDialog.open() } - } - } diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 940a0b4..8e7dbdb 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -59,6 +59,7 @@ class UIState(QObject): themeChanged = Signal() preloadingStateChanged = Signal() preloadProgressChanged = Signal() + isZoomedChanged = Signal() def __init__(self, app_controller): super().__init__() @@ -66,6 +67,14 @@ def __init__(self, app_controller): self._is_preloading = False self._preload_progress = 0 + @Property(bool, notify=isZoomedChanged) + def isZoomed(self): + return self.app_controller.is_zoomed + + @Slot(bool) + def setZoomed(self, zoomed: bool): + self.app_controller.set_zoomed(zoomed) + @Property(bool, notify=preloadingStateChanged) def isPreloading(self): return self._is_preloading diff --git a/faststack/pyproject.toml b/faststack/pyproject.toml index ea635e3..4000db7 100644 --- a/faststack/pyproject.toml +++ b/faststack/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "faststack" -version = "0.4" +version = "0.5" authors = [ { name="Alan Rockefeller", email="alanrockefeller@gmail.com" }, ]