From c90a267c59867f461f9f288c799646582ecd0be5 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Fri, 2 Jan 2026 20:53:10 -0800 Subject: [PATCH 1/2] fix histogram more --- faststack/app.py | 74 ++++++++++++++++++--- faststack/tests/dummy_images/faststack.json | 18 +++++ 2 files changed, 82 insertions(+), 10 deletions(-) create mode 100644 faststack/tests/dummy_images/faststack.json diff --git a/faststack/app.py b/faststack/app.py index a812d30..15ec9cf 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -549,6 +549,55 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: with self._last_image_lock: return self.last_displayed_image + def _get_decoded_image_safe(self, index: int) -> Optional[DecodedImage]: + """Thread-safe version of get_decoded_image for background workers. + + Does NOT update UI iteration or access QObjects. + """ + if not self.image_files or index < 0 or index >= len(self.image_files): + return None + + # Lock to ensure thread safety when reading shared state if necessary (though simple reads are usually safe) + # However, get_display_info reads 'self.is_zoomed' which is fine. + # Accessing self.image_files is safe as long as list isn't cleared concurrently, + # which only happens on directory change/refresh on main thread. + # Since we are in a worker, there's a small race risk if directory changes *while* we run, + # but the worker would likely just fail gracefully or get an old image. + + _, _, display_gen = self.get_display_info() + try: + image_path = self.image_files[index].path + except IndexError: + return None + + cache_key = build_cache_key(image_path, display_gen) + + # Check cache (thread-safe read) + if cache_key in self.image_cache: + # We don't update stats/hits here to avoid race conditions on those counters + return self.image_cache[cache_key] + + # Cache miss: decode synchronously (in this worker thread) + try: + # Submit with priority=True + # Note: prefetcher.submit_task logic needs to be thread-safe. + # Assuming futures dict access in submit_task handles strict GIL/thread safety or we might need locks there. + # But usually submitting to Executor is thread safe. + # The danger is 'self.futures' management in Prefetcher. + future = self.prefetcher.submit_task(index, self.prefetcher.generation, priority=True) + if future: + result = future.result(timeout=5.0) + if result: + decoded_path, decoded_display_gen = result + # Re-verify key + cache_key = build_cache_key(decoded_path, decoded_display_gen) + if cache_key in self.image_cache: + return self.image_cache[cache_key] + except Exception as e: + log.warning(f"_get_decoded_image_safe failed for index {index}: {e}") + + return None + def sync_ui_state(self): """Forces the UI to update by emitting all state change signals.""" self.ui_refresh_generation += 1 @@ -2649,15 +2698,15 @@ def _kick_histogram_worker(self): # We can try to peek at the image editor if _last_rendered_preview is unset. preview_data = self.image_editor.get_preview_data_cached(allow_compute=False) - # Fallback: If still no preview data (e.g. editor not open), use the main image + # Fallback: If still no preview data (e.g. editor not open), we need to fetch the main image. + # But doing get_decoded_image() here blocks the main thread. + # Instead, we pass the index to the worker and let it fetch/decode if needed. + target_index = -1 if not preview_data and 0 <= self.current_index < len(self.image_files): - # This ensures histogram works even if we haven't opened the editor - preview_data = self.get_decoded_image(self.current_index) + target_index = self.current_index - # If still no data, we cannot compute the histogram. - # Ensure we don't drop the request: keep _hist_pending set (it was cleared above, restore it?) - # Or just rely on the next preview update to trigger a histogram refresh. - if not preview_data: + # If no preview data AND no valid index, we can't compute. + if not preview_data and target_index == -1: self._hist_inflight = False # Restore pending args so the next timer tick (or preview completion) retries self._hist_pending = args @@ -2667,18 +2716,23 @@ def _kick_histogram_worker(self): return try: - fut = self._hist_executor.submit(self._compute_histogram_worker, token, args, preview_data) + # Pass simple data + controller reference + target_index + fut = self._hist_executor.submit(self._compute_histogram_worker, token, args, preview_data, self, target_index) fut.add_done_callback(self._on_histogram_done) except RuntimeError: log.warning("Histogram executor failed (shutting down?)") self._hist_inflight = False @staticmethod - def _compute_histogram_worker(token, args, decoded): + def _compute_histogram_worker(token, args, decoded, controller=None, target_index=-1): # IMPORTANT: do not touch QObjects here except thread-safe plain data zoom, pan_x, pan_y, image_scale = args - # Use explicitly passed decoded data + # If data wasn't provided, try to fetch it safely using the controller + if not decoded and controller and target_index >= 0: + decoded = controller._get_decoded_image_safe(target_index) + + # Use explicitly passed or fetched decoded data if not decoded: return token, None diff --git a/faststack/tests/dummy_images/faststack.json b/faststack/tests/dummy_images/faststack.json new file mode 100644 index 0000000..fead850 --- /dev/null +++ b/faststack/tests/dummy_images/faststack.json @@ -0,0 +1,18 @@ +{ + "version": 2, + "last_index": 0, + "entries": { + "test": { + "stack_id": null, + "stacked": false, + "stacked_date": null, + "uploaded": false, + "uploaded_date": null, + "edited": false, + "edited_date": null, + "restacked": false, + "restacked_date": null + } + }, + "stacks": [] +} \ No newline at end of file From 15140937e73af03601c329f934d1c63d38a3b7c9 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Fri, 2 Jan 2026 21:00:37 -0800 Subject: [PATCH 2/2] update --- .gitignore | 1 + docs/COLOR_PROFILE_FIX.md | 121 -------------------------------------- 2 files changed, 1 insertion(+), 121 deletions(-) delete mode 100644 docs/COLOR_PROFILE_FIX.md diff --git a/.gitignore b/.gitignore index ae93fd6..f48efaf 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ prompt.md WARP.md AGENTS.md ARCHITECTURE.md +docs/COLOR_PROFILE_FIX.md # Caches faststack/.mypy_cache/ diff --git a/docs/COLOR_PROFILE_FIX.md b/docs/COLOR_PROFILE_FIX.md deleted file mode 100644 index 54e2e69..0000000 --- a/docs/COLOR_PROFILE_FIX.md +++ /dev/null @@ -1,121 +0,0 @@ -# ICC Color Profile Support - Fix for Oversaturated Colors - -## Problem -Images displayed in FastStack appeared overly bright and "cartoonish" compared to the same images viewed in Photoshop. The colors looked unrealistic and oversaturated. - -## Root Cause -**FastStack was ignoring embedded ICC color profiles in JPEG files.** - -When digital cameras or photo editing software save JPEG files, they often embed an ICC (International Color Consortium) color profile that describes the color space of the image. Common profiles include: -- **sRGB** - Standard RGB, most common for web and general use -- **Adobe RGB (1998)** - Wider gamut, common in professional photography -- **ProPhoto RGB** - Even wider gamut, used in high-end photography - -The raw pixel values in a JPEG are meaningless without knowing what color space they're in. For example: -- RGB value (255, 0, 0) in sRGB is a different red than (255, 0, 0) in Adobe RGB -- Adobe RGB can represent more saturated colors than sRGB - -### What Was Happening -1. **Photoshop** reads the embedded ICC profile and correctly transforms colors to the display's color space (usually sRGB) -2. **FastStack (before fix)** was decoding JPEGs and displaying the raw pixel values without any color transformation -3. This caused colors to appear incorrect - typically oversaturated and too bright - -### Technical Details -Both TurboJPEG and Pillow's basic `Image.open()` extract raw RGB pixel values but **do not** automatically apply ICC profile transformations. The embedded profile is available via `Image.info['icc_profile']`, but must be explicitly processed. - -## Solution -Added proper ICC color management to the JPEG decoding pipeline using Pillow's `ImageCms` module (which wraps the industry-standard LittleCMS2 library). - -### Changes Made to `faststack/imaging/jpeg.py` - -1. **Added ICC Profile Support Functions:** - - `_get_srgb_profile()` - Creates/caches an sRGB display profile - - `_apply_icc_profile(img)` - Transforms images from their embedded color space to sRGB - -2. **Updated All Decode Functions:** - - `decode_jpeg_rgb()` - Now applies ICC transformation - - `decode_jpeg_thumb_rgb()` - Now applies ICC transformation - - `decode_jpeg_resized()` - Now applies ICC transformation BEFORE resizing - -3. **Color Transformation Process:** - ```python - # 1. Open JPEG and read embedded ICC profile - img = Image.open(io.BytesIO(jpeg_bytes)) - - # 2. Extract embedded profile from image metadata - source_profile = ImageCms.ImageCmsProfile(io.BytesIO(img.info['icc_profile'])) - - # 3. Create sRGB display profile - srgb_profile = ImageCms.createProfile('sRGB') - - # 4. Transform from source color space to sRGB for display - img_converted = ImageCms.profileToProfile( - img, - source_profile, - srgb_profile, - renderingIntent=ImageCms.Intent.PERCEPTUAL - ) - ``` - -4. **Rendering Intent:** - We use `PERCEPTUAL` rendering intent, which is designed for photographic images and preserves the overall appearance while mapping out-of-gamut colors intelligently. - -### Hybrid Approach: Best of Both Worlds -The implementation uses a **hybrid approach** that combines speed and accuracy: - -1. **ICC Profile Extraction**: First, Pillow quickly extracts the ICC profile metadata (very fast, no full decode) -2. **Fast Decoding**: TurboJPEG decodes the raw pixel data at maximum speed -3. **Color Transformation**: If an ICC profile exists, transform the decoded array to sRGB using ImageCms -4. **Smart Caching**: ICC profiles and transformations are cached - when all photos in a directory use the same profile (typical for camera photos), only the first image pays the full cost - -This gives us: -- ✅ **Fast decoding** with TurboJPEG (2-3x faster than Pillow for large images) -- ✅ **Accurate colors** with proper ICC profile handling -- ✅ **Smart caching** - 2.3x faster color transformation for subsequent images with same profile -- ✅ **Fallback to Pillow** if TurboJPEG is unavailable or fails - -### Performance Impact - -**First image with a new ICC profile (cold cache):** -- ICC profile extraction: ~0.5ms (metadata-only read) -- Profile object creation: ~5ms -- Transform creation: ~7ms -- Color transformation: ~10ms -- **Total overhead**: ~22ms - -**Subsequent images with same ICC profile (warm cache):** -- ICC profile extraction: ~0.5ms -- Profile hash lookup: <0.1ms -- Cached transform application: ~9ms -- **Total overhead**: ~10ms -- **Speedup**: 2.3x faster than cold cache - -**Images without ICC profiles:** -- No overhead at all - uses raw decoded data - -**Real-world scenario:** -- A typical photo shoot with 100 images from the same camera: - - First image: 22ms overhead - - Next 99 images: 10ms overhead each - - Average: 10.1ms per image -- Without caching, every image would be 22ms - -## Testing -Run `test_icc.py` to verify ICC profile handling: -```bash -python test_icc.py -``` - -The test will: -1. Check if the JPEG has an embedded ICC profile -2. Decode it with color management -3. Display statistics about the decoded image - -## References -- [ICC Color Management](https://en.wikipedia.org/wiki/ICC_profile) -- [Pillow ImageCms Documentation](https://pillow.readthedocs.io/en/stable/reference/ImageCms.html) -- [LittleCMS](https://www.littlecms.com/) -- [Understanding Color Spaces](https://www.cambridgeincolour.com/tutorials/color-spaces.htm) - -## Result -Images now display with accurate, natural-looking colors that match what you see in Photoshop and other color-managed applications.