diff --git a/debug_al.py b/debug_al.py new file mode 100644 index 0000000..5b9a0e0 --- /dev/null +++ b/debug_al.py @@ -0,0 +1,21 @@ + +import numpy as np +from PIL import Image +from faststack.imaging.editor import ImageEditor + +def debug_run(): + editor = ImageEditor() + w, h = 200, 200 + arr = np.zeros((h, w, 3), dtype=np.uint8) + arr[:] = 200 + arr[0, 0, 0] = 255 + + img = Image.fromarray(arr, 'RGB') + editor.original_image = img + editor._preview_image = img + + blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.1) + print(f"RESULT: p_high={p_high}") + +if __name__ == "__main__": + debug_run() diff --git a/faststack/app.py b/faststack/app.py index 15ec9cf..a659e35 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -459,7 +459,7 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: if not match: # Debug log if mismatch - log.debug(f"Path mismatch in preview. Editor: {editor_path}, File: {file_path}") + log.debug("Path mismatch in preview. Editor: %s, File: %s", editor_path, file_path) # Return background-rendered preview if Editor is open OR Cropping is active if match and self.image_editor.original_image: @@ -514,33 +514,47 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: try: # Submit with priority=True to cancel pending prefetch tasks and free up workers future = self.prefetcher.submit_task(index, self.prefetcher.generation, priority=True) - if future: - try: - # Wait for decode to complete (blocking but fast for JPEGs) - result = future.result(timeout=5.0) # 5 second timeout as safety - if result: - decoded_path, decoded_display_gen = result - cache_key = build_cache_key(decoded_path, decoded_display_gen) - if cache_key in self.image_cache: - decoded = self.image_cache[cache_key] - with self._last_image_lock: - self.last_displayed_image = decoded - if _debug_mode: - elapsed = time.perf_counter() - decode_start - log.info("Decoded image %d in %.3fs", index, elapsed) - return decoded - except concurrent.futures.TimeoutError: - log.exception("Timeout decoding image at index %d", index) - with self._last_image_lock: - return self.last_displayed_image - except concurrent.futures.CancelledError: - log.warning("Decode cancelled for index %d", index) - with self._last_image_lock: - return self.last_displayed_image - except Exception as e: - log.exception("Error decoding image at index %d", index) - with self._last_image_lock: - return self.last_displayed_image + if not future: + with self._last_image_lock: + return self.last_displayed_image + + try: + # Wait for decode to complete (blocking but fast for JPEGs) + result = future.result(timeout=5.0) # 5 second timeout as safety + except concurrent.futures.TimeoutError: + log.warning("Timeout decoding image at index %d", index) + with self._last_image_lock: + return self.last_displayed_image + except concurrent.futures.CancelledError: + log.debug("Decode cancelled for index %d", index) + with self._last_image_lock: + return self.last_displayed_image + except Exception: + log.exception("Error decoding image at index %d", index) + with self._last_image_lock: + return self.last_displayed_image + + if not result: + if _debug_mode: + log.debug("Decode returned no result for index %d", index) + with self._last_image_lock: + return self.last_displayed_image + + decoded_path, decoded_display_gen = result + cache_key = build_cache_key(decoded_path, decoded_display_gen) + if cache_key in self.image_cache: + decoded = self.image_cache[cache_key] + with self._last_image_lock: + self.last_displayed_image = decoded + if _debug_mode: + elapsed = time.perf_counter() - decode_start + log.info("Decoded image %d in %.3fs", index, elapsed) + return decoded + else: + if _debug_mode: + log.debug("Decode finished but cache_key missing (index=%d, key=%s)", index, cache_key) + with self._last_image_lock: + return self.last_displayed_image finally: # Hide decoding indicator if self.debug_cache: @@ -586,15 +600,23 @@ def _get_decoded_image_safe(self, index: int) -> Optional[DecodedImage]: # 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) + try: + result = future.result(timeout=5.0) + except concurrent.futures.TimeoutError: + log.warning(f"Timeout decoding image at index {index} (background)") + return None + except concurrent.futures.CancelledError: + log.debug(f"Decode cancelled for image at index {index} (background)") + return None + 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}") + except Exception: + log.exception("_get_decoded_image_safe failed for index %d", index) return None @@ -1300,14 +1322,14 @@ def set_cache_size(self, size): config.set('core', 'cache_size_gb', size) config.save() - old_max_bytes = self.image_cache.maxsize + old_max_bytes = self.image_cache.max_bytes new_max_bytes = int(size * 1024**3) if old_max_bytes == new_max_bytes: return log.info("Resizing decoded image cache from %.2f GB to %.2f GB", old_max_bytes / (1024**3), size) - self.image_cache.maxsize = new_max_bytes + self.image_cache.max_bytes = new_max_bytes # If the new size is smaller than current usage, evict until under limit while self.image_cache.currsize > new_max_bytes and len(self.image_cache) > 0: @@ -2244,7 +2266,7 @@ def _on_cache_evict(self): # Format usage info used_gb = self.image_cache.currsize / (1024**3) - max_gb = self.image_cache.maxsize / (1024**3) + max_gb = self.image_cache.max_bytes / (1024**3) msg = f"Cache thrashing! {len(self._eviction_timestamps)} evictions in {CACHE_THRASH_WINDOW_SECS}s. Usage: {used_gb:.1f}GB / {max_gb:.1f}GB." @@ -3225,47 +3247,51 @@ def auto_levels(self): return False # Calculate auto levels - blacks, whites = self.image_editor.auto_levels(self.auto_level_threshold) - - # Scale by strength - skipped_due_to_clipping = False + # Calculate auto levels - now returns (blacks, whites, p_low, p_high) + blacks, whites, p_low, p_high = self.image_editor.auto_levels(self.auto_level_threshold) + + # Auto-strength computation using stretch-factor capping + # + # Philosophy: threshold_percent defines acceptable clipping (e.g., 0.1% at each end). + # Auto-strength should NOT prevent that clipping - it's intentional. + # Instead, auto-strength prevents INSANE levels on low-dynamic-range images. + # + # Approach: Cap the stretch factor to a reasonable maximum (e.g., 3-4x). + # - Full strength: stretch = 255 / (p_high - p_low) + # - If stretch is reasonable (<= cap), use full strength + # - If stretch is extreme (> cap), blend to limit effective stretch to cap + # if self.auto_level_strength_auto: - # Calculate optimal strength to prevent pre-clipping - try: - # Use preview image if available to ignore single hot-pixel outliers - img = self.image_editor._preview_image if self.image_editor._preview_image else self.image_editor.original_image - if img: - # Get max value across all channels - extrema = img.getextrema() - if isinstance(extrema[0], tuple): - max_val = max(ch[1] for ch in extrema) - else: - max_val = extrema[1] - - log.debug(f"Auto levels auto-strength: max_val={max_val}") - - if max_val < 250: - denom = 40 * (250 * whites - 5 * blacks) - if abs(denom) > 0.001: - strength = (255 * (max_val - 250)) / denom - strength = max(0.0, min(1.0, strength)) - else: - strength = 0.0 - else: - strength = 0.0 - skipped_due_to_clipping = True + # Calculate full-strength stretch factor + dynamic_range = p_high - p_low + if dynamic_range < 1.0: + # Degenerate case: nearly flat image + strength = 0.0 + log.debug(f"Auto levels: degenerate dynamic range ({dynamic_range:.2f}), strength=0") + else: + stretch_full = 255.0 / dynamic_range + + # Cap stretch to prevent insane levels + # E.g., if image spans only 50-200 (range=150), full stretch would be 255/150 = 1.7x (fine) + # But if image spans 100-110 (range=10), full stretch would be 255/10 = 25.5x (insane!) + STRETCH_CAP = 4.0 # Maximum allowed stretch factor + + if stretch_full <= STRETCH_CAP: + # Reasonable stretch, use full strength + strength = 1.0 else: - strength = self.auto_level_strength - except Exception as e: - log.warning(f"Failed to calculate auto strength: {e}") - strength = self.auto_level_strength + # Excessive stretch - blend to cap it + # effective_stretch = 1 + strength * (stretch_full - 1) = STRETCH_CAP + # solving for strength: strength = (STRETCH_CAP - 1) / (stretch_full - 1) + strength = (STRETCH_CAP - 1.0) / (stretch_full - 1.0) + strength = max(0.0, min(1.0, strength)) + + log.debug(f"Auto levels: p_low={p_low:.1f}, p_high={p_high:.1f}, " + f"range={dynamic_range:.1f}, stretch_full={stretch_full:.2f}, strength={strength:.3f}") else: strength = self.auto_level_strength - if skipped_due_to_clipping: - self.update_status_message("No changes made to color levels to avoid clipping") - return False - + # Apply strength scaling to blacks and whites parameters blacks *= strength whites *= strength @@ -3283,9 +3309,28 @@ def auto_levels(self): if self.ui_state.isHistogramVisible: self.update_histogram() - self.update_status_message(f"Auto levels applied (preview only)") - log.info("Auto levels preview applied to %s (clip %.2f%%, str %.2f)", - filepath, self.auto_level_threshold, strength) + # Determine status message based on whether endpoints were pinned (clipping detected) + # We check p_high/p_low directly because whites/blacks might be small due to strength scaling + # even if not pinned. + msg = "Auto levels applied" + + # Check for essentially no-op (degenerate or already full range) + # Degenerate: dynamic range is tiny (< 1.0) + # Full range: p_low is near 0 and p_high near 255 + if abs(p_high - p_low) < 1.0: + msg = "Auto levels: no changes (degenerate range)" + elif p_low <= 0 and p_high >= 255: + # We already cover the full range + msg = "Auto levels: no changes (image already covers full range)" + # Check for pinning + elif p_high >= 255.0: + msg = "Auto levels: highlights already clipped; only adjusting shadows" + elif p_low <= 0.0: + msg = "Auto levels: shadows already clipped; only adjusting highlights" + + self.update_status_message(f"{msg} (preview only)") + log.info("Auto levels preview applied to %s (clip %.2f%%, str %.2f). Msg: %s", + filepath, self.auto_level_threshold, strength, msg) return True @Slot() diff --git a/faststack/imaging/cache.py b/faststack/imaging/cache.py index f37f078..4ec5ee9 100644 --- a/faststack/imaging/cache.py +++ b/faststack/imaging/cache.py @@ -24,6 +24,18 @@ def __init__( f"Initialized byte-aware LRU cache with {max_bytes / 1024**2:.2f} MB capacity." ) + @property + def max_bytes(self) -> int: + """Get the maximum cache size in bytes.""" + return self.maxsize + + @max_bytes.setter + def max_bytes(self, value: int) -> None: + """Set the maximum cache size in bytes.""" + v = max(0, int(value)) + self.maxsize = v + log.debug(f"Cache max_bytes updated to {v / 1024**2:.2f} MB") + def __setitem__(self, key, value): # Before adding a new item, we might need to evict others # This is handled by the parent class, which will call popitem if needed diff --git a/faststack/imaging/editor.py b/faststack/imaging/editor.py index ddeeac5..d05d476 100644 --- a/faststack/imaging/editor.py +++ b/faststack/imaging/editor.py @@ -485,51 +485,82 @@ def _apply_edits(self, img: Image.Image, edits: Optional[Dict[str, Any]] = None, return img - def auto_levels(self, threshold_percent: float = 0.1) -> Tuple[float, float]: + def auto_levels(self, threshold_percent: float = 0.1) -> Tuple[float, float, float, float]: """ - Automatically adjusts blacks and whites based on image histogram. - - Args: - threshold_percent: value 0.0-10.0, percentage of pixels to clip at each end. - - Returns: - Tuple of (blacks, whites) parameter values. + Returns (blacks, whites, p_low, p_high). + p_low/p_high are computed conservatively from RGB to avoid introducing new channel clipping. """ if self.original_image is None: - return 0.0, 0.0 - - # Use preview image for speed if available, otherwise original + return 0.0, 0.0, 0.0, 255.0 + + if np is None: + # Fallback: do nothing without numpy + return 0.0, 0.0, 0.0, 255.0 + + threshold_percent = max(0.0, min(10.0, threshold_percent)) img = self._preview_image if self._preview_image else self.original_image - - # Convert to numpy array for histogram analysis - arr = np.array(img.convert('L')) # Use luminance for levels - - # Calculate percentiles + + rgb = np.asarray(img.convert("RGB"), dtype=np.uint8) + # rgb shape: (H, W, 3) + low_p = threshold_percent high_p = 100.0 - threshold_percent - - p_low, p_high = np.percentile(arr, [low_p, high_p]) - - # Calculate parameters to map p_low->0 and p_high->255 - # Logic matches _apply_edits: - # black_point = -blacks * 40 - # white_point = 255 - whites * 40 - - # We want black_point to be p_low - # p_low = -blacks * 40 => blacks = -float(p_low) / 40.0 - blacks = -float(p_low) / 40.0 - - # We want white_point to be p_high - # p_high = 255 - whites * 40 => whites = (255.0 - float(p_high)) / 40.0 - whites = (255.0 - float(p_high)) / 40.0 - - # Update state + + # --- Detect pre-clipping (per-channel) --- + # If *any* channel already has clipped pixels, do not push that end further. + # eps_pct strategy: "Practical" - ignore tiny hot pixels (0.01%) but pin + # if there is any meaningful pre-clipping, even if below the full threshold. + eps_pct = min(threshold_percent, 0.01) + + total = rgb.shape[0] * rgb.shape[1] + clipped_low_pct = [] + clipped_high_pct = [] + p_lows = [] + p_highs = [] + + for c in range(3): + chan = rgb[:, :, c] + # Treat near-white/near-black as clipped (JPEG artifacts often land on 254/1) + clipped_low_pct.append(100.0 * float(np.count_nonzero(chan <= 1)) / float(total)) + clipped_high_pct.append(100.0 * float(np.count_nonzero(chan >= 254)) / float(total)) + + # Use discrete selection methods to avoid interpolation surprises on uint8. + # Fallback for older numpy (<1.22) that doesn't support method=. + try: + p_lows.append(float(np.percentile(chan, low_p, method="lower"))) + p_highs.append(float(np.percentile(chan, high_p, method="higher"))) + except TypeError: + p_lows.append(float(np.percentile(chan, low_p, interpolation="lower"))) + p_highs.append(float(np.percentile(chan, high_p, interpolation="higher"))) + + # Conservative anchors to avoid new channel clipping + p_low = min(p_lows) + p_high = max(p_highs) + + # Pin ends if pre-clipping exists (prevents making it worse) + if max(clipped_high_pct) > eps_pct: + p_high = 255.0 + if max(clipped_low_pct) > eps_pct: + p_low = 0.0 + + # Safety + p_low = max(0.0, min(255.0, p_low)) + p_high = max(0.0, min(255.0, p_high)) + + # Check for degenerate range (e.g. flat image) to prevent extreme stretching + if (p_high - p_low) < 1.0: + blacks = 0.0 + whites = 0.0 + else: + blacks = -p_low / 40.0 + whites = (255.0 - p_high) / 40.0 + with self._lock: - self.current_edits['blacks'] = blacks - self.current_edits['whites'] = whites + self.current_edits["blacks"] = blacks + self.current_edits["whites"] = whites self._edits_rev += 1 - - return blacks, whites + + return blacks, whites, float(p_low), float(p_high) def get_preview_data_cached(self, allow_compute: bool = True) -> Optional[DecodedImage]: """Return cached preview if available, otherwise compute and cache. diff --git a/faststack/imaging/prefetch.py b/faststack/imaging/prefetch.py index a43d8c9..c7ad671 100644 --- a/faststack/imaging/prefetch.py +++ b/faststack/imaging/prefetch.py @@ -160,6 +160,7 @@ def __init__(self, image_files: List[ImageFile], cache_put: Callable, prefetch_r max_workers=optimal_workers, thread_name_prefix="Prefetcher" ) + self._futures_lock = threading.RLock() self.futures: Dict[int, Future] = {} self.generation = 0 self._scheduled: Dict[int, set] = {} # generation -> set of scheduled indices @@ -190,10 +191,10 @@ def update_prefetch(self, current_index: int, is_navigation: bool = False, direc # zoom state, or color mode changes - events that actually invalidate cached images. # Navigation just shifts which indices to prefetch. - # Clean up old generation entries to prevent memory leak - old_generations = [g for g in self._scheduled if g < self.generation] - for g in old_generations: - del self._scheduled[g] + # OLD GENERATION CLEANUP MOVED TO INSIDE LOCK BELOW + # old_generations = [g for g in self._scheduled if g < self.generation] + # for g in old_generations: + # del self._scheduled[g] # Track navigation direction if direction is not None: @@ -227,36 +228,42 @@ def update_prefetch(self, current_index: int, is_navigation: bool = False, direc log.debug("Prefetch range: [%d, %d) for index %d (direction=%d, behind=%d, ahead=%d)", start, end, current_index, self._last_navigation_direction, behind, ahead) - # Get scheduled set for current generation - scheduled = self._scheduled.setdefault(self.generation, set()) - # Cancel stale futures and remove from scheduled - stale_keys = [] - for index, future in list(self.futures.items()): - if index < start or index >= end: - if future.cancel(): - stale_keys.append(index) - scheduled.discard(index) # Remove from scheduled set - for key in stale_keys: - del self.futures[key] - - # Submit new tasks - prioritize current image and direction of travel - - # Build priority order: current first, then in direction of travel - priority_order = [current_index] - if self._last_navigation_direction > 0: - priority_order.extend(range(current_index + 1, end)) - priority_order.extend(range(current_index - 1, start - 1, -1)) - else: - priority_order.extend(range(current_index - 1, start - 1, -1)) - priority_order.extend(range(current_index + 1, end)) - - for i in priority_order: - if i < 0 or i >= len(self.image_files): - continue - if i not in scheduled and i not in self.futures: - self.submit_task(i, self.generation) - scheduled.add(i) + with self._futures_lock: + # Clean up old generation entries to prevent memory leak + # MOVED INSIDE LOCK to prevent race with cancel_all() + old_generations = [g for g in self._scheduled if g < self.generation] + for g in old_generations: + del self._scheduled[g] + + # Get scheduled set for current generation (inside lock to prevent race) + scheduled = self._scheduled.setdefault(self.generation, set()) + stale_keys = [] + for index, future in list(self.futures.items()): + if index < start or index >= end: + if future.cancel(): + stale_keys.append(index) + scheduled.discard(index) # Remove from scheduled set + for key in stale_keys: + del self.futures[key] + + # Submit new tasks - prioritize current image and direction of travel + + # Build priority order: current first, then in direction of travel + priority_order = [current_index] + if self._last_navigation_direction > 0: + priority_order.extend(range(current_index + 1, end)) + priority_order.extend(range(current_index - 1, start - 1, -1)) + else: + priority_order.extend(range(current_index - 1, start - 1, -1)) + priority_order.extend(range(current_index + 1, end)) + + for i in priority_order: + if i < 0 or i >= len(self.image_files): + continue + if i not in scheduled and i not in self.futures: + self.submit_task(i, self.generation) + scheduled.add(i) def submit_task(self, index: int, generation: int, priority: bool = False) -> Optional[Future]: """Submits a decoding task for a given index. @@ -266,39 +273,40 @@ def submit_task(self, index: int, generation: int, priority: bool = False) -> Op generation: Generation number for cache invalidation priority: If True, cancels lower-priority pending tasks to free up workers """ - if index in self.futures and not self.futures[index].done(): - return self.futures[index] # Already submitted - - # For high-priority tasks (current image), cancel pending prefetch tasks - # to free up worker threads and reduce blocking time - if priority: - cancelled_count = 0 - # Don't cancel tasks that are very close to the requested index (e.g. +/- 2) - # This prevents thrashing when the user is navigating quickly - safe_radius = 2 - - for task_index, future in list(self.futures.items()): - # Skip the current task - if task_index == index: - continue + with self._futures_lock: + if index in self.futures and not self.futures[index].done(): + return self.futures[index] # Already submitted + + # For high-priority tasks (current image), cancel pending prefetch tasks + # to free up worker threads and reduce blocking time + if priority: + cancelled_count = 0 + # Don't cancel tasks that are very close to the requested index (e.g. +/- 2) + # This prevents thrashing when the user is navigating quickly + safe_radius = 2 - # Skip tasks within safe radius - if abs(task_index - index) <= safe_radius: - continue + for task_index, future in list(self.futures.items()): + # Skip the current task + if task_index == index: + continue + + # Skip tasks within safe radius + if abs(task_index - index) <= safe_radius: + continue - if not future.done() and future.cancel(): - cancelled_count += 1 - del self.futures[task_index] - if cancelled_count > 0: - log.debug("Cancelled %d pending prefetch tasks to prioritize index %d", cancelled_count, index) + if not future.done() and future.cancel(): + cancelled_count += 1 + del self.futures[task_index] + if cancelled_count > 0: + log.debug("Cancelled %d pending prefetch tasks to prioritize index %d", cancelled_count, index) - image_file = self.image_files[index] - display_width, display_height, display_generation = self.get_display_info() + image_file = self.image_files[index] + display_width, display_height, display_generation = self.get_display_info() - future = self.executor.submit(self._decode_and_cache, image_file, index, generation, display_width, display_height, display_generation) - self.futures[index] = future - log.debug("Submitted %s task for index %d", "priority" if priority else "prefetch", index) - return future + future = self.executor.submit(self._decode_and_cache, image_file, index, generation, display_width, display_height, display_generation) + self.futures[index] = future + log.debug("Submitted %s task for index %d", "priority" if priority else "prefetch", index) + return future def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, display_width: int, display_height: int, display_generation: int) -> Optional[tuple[Path, int]]: """The actual work done by the thread pool.""" @@ -384,8 +392,11 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, rgb = np.array(img, dtype=np.uint8) h, w, _ = rgb.shape - bytes_per_line = w * 3 - arr = rgb.reshape(-1).copy() + + # Memory Optimization: Avoid explicit copy + buffer = np.ascontiguousarray(rgb) + bytes_per_line = buffer.strides[0] + mv = memoryview(buffer).cast("B") t_after_copy = time.perf_counter() if self.debug: @@ -417,8 +428,12 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, t_after_fallback_decode = time.perf_counter() h, w, _ = buffer.shape - bytes_per_line = w * 3 - arr = buffer.reshape(-1).copy() + + # Memory Optimization: Avoid explicit copy + buffer = np.ascontiguousarray(buffer) + bytes_per_line = buffer.strides[0] + mv = memoryview(buffer).cast("B") + # Align with non-fallback paths for timing/logging t_after_copy = time.perf_counter() @@ -451,8 +466,12 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, t_after_decode = time.perf_counter() h, w, _ = buffer.shape - bytes_per_line = w * 3 - arr = buffer.reshape(-1).copy() + + # Memory Optimization: Avoid explicit copy + buffer = np.ascontiguousarray(buffer) + bytes_per_line = buffer.strides[0] + mv = memoryview(buffer).cast("B") + # Align with non-fallback paths for timing/logging t_after_copy = time.perf_counter() @@ -484,8 +503,12 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, t_after_decode = time.perf_counter() h, w, _ = buffer.shape - bytes_per_line = w * 3 - arr = buffer.reshape(-1).copy() + + # Memory Optimization: Avoid explicit copy + buffer = np.ascontiguousarray(buffer) + bytes_per_line = buffer.strides[0] + mv = memoryview(buffer).cast("B") + t_after_copy = time.perf_counter() # Apply saturation compensation if enabled @@ -517,7 +540,7 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, return None decoded_image = DecodedImage( - buffer=arr.data, + buffer=mv, width=w, height=h, bytes_per_line=bytes_per_line, @@ -547,12 +570,13 @@ def _is_in_prefetch_range(self, index: int, current_index: int, radius: Optional def cancel_all(self): """Cancels all pending prefetch tasks.""" - log.info("Cancelling all prefetch tasks.") - self.generation += 1 - for future in self.futures.values(): - future.cancel() - self.futures.clear() - self._scheduled.clear() # Clear scheduled indices when bumping generation + with self._futures_lock: + log.info("Cancelling all prefetch tasks.") + self.generation += 1 + for future in self.futures.values(): + future.cancel() + self.futures.clear() + self._scheduled.clear() # Clear scheduled indices when bumping generation def shutdown(self): """Shuts down the thread pool executor.""" diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 95dd106..dc27ad1 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -248,22 +248,6 @@ ApplicationWindow { height: 36 text: "Settings..." onClicked: { - if (uiState) { - settingsDialog.heliconPath = uiState.get_helicon_path() - settingsDialog.photoshopPath = uiState.get_photoshop_path() - settingsDialog.cacheSize = uiState.get_cache_size() - settingsDialog.prefetchRadius = uiState.get_prefetch_radius() - settingsDialog.theme = uiState.theme - settingsDialog.defaultDirectory = uiState.get_default_directory() - settingsDialog.optimizeFor = uiState.get_optimize_for() - settingsDialog.awbMode = uiState.awbMode - settingsDialog.awbStrength = uiState.awbStrength - settingsDialog.awbWarmBias = uiState.awbWarmBias - settingsDialog.awbLumaLowerBound = uiState.awbLumaLowerBound - settingsDialog.awbLumaUpperBound = uiState.awbLumaUpperBound - settingsDialog.awbRgbLowerBound = uiState.awbRgbLowerBound - settingsDialog.awbRgbUpperBound = uiState.awbRgbUpperBound - } settingsDialog.open() fileMenu.close() } @@ -871,10 +855,10 @@ ApplicationWindow { "Viewing:
" + "  Mouse Wheel: Zoom in/out
" + "  Left-click + Drag: Pan image
" + - "  Ctrl+0: Reset zoom and pan to fit window

" + - "  Ctrl+1: Zoom to 100%

" + - "  Ctrl+2: Zoom to 200%

" + - "  Ctrl+3: Zoom to 300%

" + + "  Ctrl+0: Reset zoom and pan to fit window
" + + "  Ctrl+1: Zoom to 100%
" + + "  Ctrl+2: Zoom to 200%
" + + "  Ctrl+3: Zoom to 300%
" + "  Ctrl+4: Zoom to 400%

" + "Stacking:
" + "  [: Begin new stack
" + @@ -895,7 +879,7 @@ ApplicationWindow { "  {: Begin new batch
" + "  B: Toggle current image in/out of batch
" + "  }: End current batch
" + - "  \\: Clear all batches
" + + "  \\: Clear all batches

" + "Flag Toggles:
" + "  U: Toggle uploaded flag
" + "  Ctrl+E: Toggle edited flag
" + @@ -910,7 +894,7 @@ ApplicationWindow { "  A: Quick auto white balance (saves automatically)
" + "  L: Quick auto levels (saves automatically)
" + "  Ctrl+Shift+B: Quick auto white balance (saves automatically)
" + - "  O (or right mouse click): Toggle crop mode (Enter to execute crop, ESC to cancel)
" + + "  O (or right mouse click): Toggle crop mode (Enter to execute, ESC to cancel)
" + "  H: Toggle histogram window
" + "  E: Toggle Image Editor (closes without saving if open)
" + "  Ctrl+C: Copy image path to clipboard
" + diff --git a/faststack/qml/SettingsDialog.qml b/faststack/qml/SettingsDialog.qml index 684ea3d..9c52ace 100644 --- a/faststack/qml/SettingsDialog.qml +++ b/faststack/qml/SettingsDialog.qml @@ -1,40 +1,25 @@ -import QtQuick +import QtQuick 2.15 import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 import QtQuick.Layouts 1.15 +import QtQuick.Window 2.15 -Dialog { +Window { id: settingsDialog title: "Settings" - standardButtons: Dialog.Ok | Dialog.Cancel - modal: true - closePolicy: Popup.CloseOnEscape - focus: true - width: 600 - height: 770 - - // Live cache usage value (updated by timer) - property real cacheUsage: 0.0 - - onVisibleChanged: { - cacheUsageTimer.running = visible - if (visible) { - controller.dialog_opened() - } else { - controller.dialog_closed() - } - } + width: 700 + height: 800 + visible: false + flags: Qt.Window | Qt.WindowTitleHint | Qt.WindowCloseButtonHint + modality: Qt.ApplicationModal - onOpened: { - // Refresh text fields when dialog opens with current values - cacheSizeField.text = settingsDialog.cacheSize.toFixed(1) - heliconPathField.text = settingsDialog.heliconPath - photoshopPathField.text = settingsDialog.photoshopPath - optimizeForComboBox.currentIndex = optimizeForComboBox.model.indexOf(settingsDialog.optimizeFor) - autoLevelThresholdField.text = settingsDialog.autoLevelClippingThreshold.toFixed(4) - settingsDialog.autoLevelStrength = uiState.autoLevelStrength - settingsDialog.autoLevelStrengthAuto = uiState.autoLevelStrengthAuto + // Make window close button (X) behave like Cancel + onClosing: function(close) { + close.accepted = false + visible = false } - + + // Properties matching the original dialog property string heliconPath: "" property double cacheSize: 1.5 property double autoLevelClippingThreshold: 0.1 @@ -56,7 +41,73 @@ Dialog { property int awbRgbLowerBound: 5 property int awbRgbUpperBound: 250 - onAccepted: { + // Live cache usage value (updated by timer) + property real cacheUsage: 0.0 + + // Modern Color Palette (copied from ImageEditorDialog) + property color backgroundColor: "#1e1e1e" + property color textColor: "white" + readonly property color accentColor: "#6366f1" // Modern Indigo + readonly property color accentColorHover: "#818cf8" + readonly property color accentColorSubtle: "#306366f1" + readonly property color controlBg: "#10ffffff" + readonly property color controlBorder: "#30ffffff" + readonly property color separatorColor: "#20ffffff" + + Material.theme: Material.Dark + Material.accent: accentColor + color: backgroundColor + + // Helper to open the dialog + function open() { + // Reload all properties from uiState to ensure Cancel discards edits + if (uiState) { + heliconPath = uiState.get_helicon_path() + photoshopPath = uiState.get_photoshop_path() + cacheSize = uiState.get_cache_size() + prefetchRadius = uiState.get_prefetch_radius() + theme = uiState.theme + defaultDirectory = uiState.get_default_directory() + optimizeFor = uiState.get_optimize_for() + autoLevelClippingThreshold = uiState.autoLevelClippingThreshold + autoLevelStrength = uiState.autoLevelStrength + autoLevelStrengthAuto = uiState.autoLevelStrengthAuto + awbMode = uiState.awbMode + awbStrength = uiState.awbStrength + awbWarmBias = uiState.awbWarmBias + awbTintBias = uiState.awbTintBias + awbLumaLowerBound = uiState.awbLumaLowerBound + awbLumaUpperBound = uiState.awbLumaUpperBound + awbRgbLowerBound = uiState.awbRgbLowerBound + awbRgbUpperBound = uiState.awbRgbUpperBound + } + visible = true + raise() + requestActivate() + } + + Shortcut { + sequence: "Escape" + context: Qt.WindowShortcut + onActivated: visible = false + } + + onVisibleChanged: { + cacheUsageTimer.running = visible + if (visible) { + controller.dialog_opened() + // Reset all text fields from properties + if (heliconField.item) heliconField.item.text = settingsDialog.heliconPath + if (photoshopField.item) photoshopField.item.text = settingsDialog.photoshopPath + if (defaultDirField.item) defaultDirField.item.text = settingsDialog.defaultDirectory + if (cacheSizeField.item) cacheSizeField.item.text = settingsDialog.cacheSize.toFixed(1) + // Note: ComboBoxes and SpinBoxes update automatically via bindings/connections + } else { + controller.dialog_closed() + } + } + + function saveSettings() { uiState.set_helicon_path(heliconPath) uiState.set_photoshop_path(photoshopPath) uiState.set_cache_size(cacheSize) @@ -77,389 +128,910 @@ Dialog { uiState.awbLumaUpperBound = awbLumaUpperBound uiState.awbRgbLowerBound = awbRgbLowerBound uiState.awbRgbUpperBound = awbRgbUpperBound + + visible = false } - contentItem: ColumnLayout { - Row { - id: tabButtons - spacing: 5 - - Button { - text: "General" - highlighted: settingsStackLayout.currentIndex === 0 - onClicked: settingsStackLayout.currentIndex = 0 - } - Button { - text: "Auto White Balance" - highlighted: settingsStackLayout.currentIndex === 1 - onClicked: settingsStackLayout.currentIndex = 1 - } + // Component for Section Separator + Component { + id: sectionSeparator + Rectangle { + Layout.fillWidth: true + Layout.topMargin: 20 + Layout.bottomMargin: 5 + height: 1 + color: settingsDialog.separatorColor } + } - StackLayout { - id: settingsStackLayout - currentIndex: 0 + // Component for Section Header + Component { + id: sectionHeader + Label { + font.bold: true + font.pixelSize: 15 + font.letterSpacing: 1.0 + color: settingsDialog.accentColorHover + Layout.topMargin: 5 + Layout.bottomMargin: 10 + } + } - GridLayout { - columns: 3 + // Custom Styled TextField + Component { + id: styledTextField + TextField { + id: control + color: settingsDialog.textColor + placeholderTextColor: "#80ffffff" + selectionColor: settingsDialog.accentColor + selectedTextColor: "#ffffff" + font.pixelSize: 13 + background: Rectangle { + color: control.enabled ? "transparent" : "#05ffffff" + border.color: control.activeFocus ? settingsDialog.accentColor : settingsDialog.controlBorder + border.width: 1 + radius: 4 + } + } + } + + // Styled Slider Component + Component { + id: styledSlider + Slider { + id: control + + background: Item { + x: control.leftPadding + y: control.topPadding + control.availableHeight / 2 - height / 2 + width: control.availableWidth + height: 6 - // Helicon Path - Label { text: "Helicon Focus Path:" } - TextField { - id: heliconPathField - Layout.fillWidth: true - text: settingsDialog.heliconPath - onTextChanged: settingsDialog.heliconPath = text + Rectangle { + anchors.fill: parent + radius: 3 + color: settingsDialog.controlBg + border.color: settingsDialog.controlBorder + border.width: 1 } - RowLayout { - Button { - text: "Browse..." - onClicked: { - var path = uiState.open_file_dialog() - if (path) heliconPathField.text = path - } - } - Label { - id: checkMarkLabel - text: "✔" - color: "lightgreen" - visible: uiState && uiState.check_path_exists(heliconPathField.text) - } + + Rectangle { + width: control.visualPosition * parent.width + height: parent.height + radius: 3 + color: settingsDialog.accentColor + opacity: 0.8 } + } - // Photoshop Path - Label { text: "Photoshop Path:" } - TextField { - id: photoshopPathField - Layout.fillWidth: true - text: settingsDialog.photoshopPath - onTextChanged: settingsDialog.photoshopPath = text + handle: Rectangle { + x: control.leftPadding + control.visualPosition * (control.availableWidth - width) + y: control.topPadding + control.availableHeight / 2 - height / 2 + width: 16 + height: 16 + radius: 8 + color: control.pressed ? settingsDialog.accentColor : "white" + border.color: control.pressed ? "white" : settingsDialog.accentColor + border.width: 2 + } + } + } + + // Styled SpinBox Component + Component { + id: styledSpinBox + SpinBox { + id: control + editable: true + + contentItem: TextInput { + z: 2 + text: control.textFromValue(control.value, control.locale) + font.pixelSize: 13 + color: settingsDialog.textColor + selectionColor: settingsDialog.accentColor + selectedTextColor: "#ffffff" + horizontalAlignment: Qt.AlignHCenter + verticalAlignment: Qt.AlignVCenter + readOnly: !control.editable + validator: control.validator + inputMethodHints: Qt.ImhFormattedNumbersOnly + + // Update control.value when user finishes typing + onEditingFinished: { + control.value = control.valueFromText(text, control.locale) } - RowLayout { - Button { - text: "Browse..." - onClicked: { - var path = uiState.open_file_dialog() - if (path) photoshopPathField.text = path - } - } - Label { - id: photoshopCheckMarkLabel - text: "✔" - color: "lightgreen" - visible: uiState && uiState.check_path_exists(photoshopPathField.text) + } + + up.indicator: Item { + x: parent.width - width + height: parent.height + width: 20 + Rectangle { + anchors.centerIn: parent + width: 16; height: 16 + radius: 2 + color: control.up.pressed ? settingsDialog.accentColor : "transparent" + Text { + text: "+" + anchors.centerIn: parent + color: settingsDialog.textColor } } + } - // Cache Size - Label { text: "Cache Size (GB):" } - TextField { - id: cacheSizeField - Layout.fillWidth: true - - Component.onCompleted: { - text = settingsDialog.cacheSize.toFixed(1) - } - - onEditingFinished: { - var value = parseFloat(text) - if (!isNaN(value) && value >= 0.5 && value <= 16) { - settingsDialog.cacheSize = value - text = value.toFixed(1) // Format it - } else { - // Invalid input, reset to current value - text = settingsDialog.cacheSize.toFixed(1) - } + down.indicator: Item { + x: 0 + height: parent.height + width: 20 + Rectangle { + anchors.centerIn: parent + width: 16; height: 16 + radius: 2 + color: control.down.pressed ? settingsDialog.accentColor : "transparent" + Text { + text: "-" + anchors.centerIn: parent + color: settingsDialog.textColor } } - Label { - id: cacheUsageLabel - text: "In use: " + settingsDialog.cacheUsage.toFixed(2) + " GB" - color: "#1013e6" - } + } - // Prefetch Radius - Label { text: "Prefetch Radius:" } - SpinBox { - id: prefetchRadiusSpinBox - from: 1 - to: 20 - value: settingsDialog.prefetchRadius - onValueChanged: settingsDialog.prefetchRadius = value - } - Label {} // Placeholder - - // Theme - Label { text: "Theme:" } - ComboBox { - id: themeComboBox - model: ["Dark", "Light"] - currentIndex: settingsDialog.theme - onCurrentIndexChanged: settingsDialog.theme = currentIndex - } - Label {} // Placeholder + background: Rectangle { + implicitWidth: 100 + color: "transparent" + border.color: settingsDialog.controlBorder + border.width: 1 + radius: 4 + } + } + } + + // State + property int currentTab: 0 + + // Component for Tab Button + Component { + id: tabButton + Rectangle { + property string text + property int index + + anchors.fill: parent + color: "transparent" + + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 2 + color: settingsDialog.currentTab === index ? settingsDialog.accentColor : "transparent" + Behavior on color { ColorAnimation { duration: 200 } } + } + + Text { + anchors.centerIn: parent + text: parent.text + color: settingsDialog.currentTab === index ? settingsDialog.accentColor : "#80ffffff" + font.bold: settingsDialog.currentTab === index + font.pixelSize: 14 + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: settingsDialog.currentTab = index + } + } + } - // Default Directory - Label { text: "Default Image Directory:" } - TextField { - id: defaultDirectoryField + // Main Layout container + ColumnLayout { + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: bottomBar.top + spacing: 0 + + // --- Custom Tab Bar --- + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 50 + color: "#1e1e1e" + z: 10 + + RowLayout { + anchors.fill: parent + anchors.margins: 20 + anchors.bottomMargin: 0 + spacing: 20 + + Loader { Layout.fillWidth: true - text: settingsDialog.defaultDirectory - onTextChanged: settingsDialog.defaultDirectory = text + Layout.fillHeight: true + sourceComponent: tabButton + onLoaded: { item.text = "General"; item.index = 0 } } - Button { - text: "Browse..." - onClicked: { - var path = uiState.open_directory_dialog() - if (path) defaultDirectoryField.text = path - } - } - - // Optimize For - Label { text: "Optimize For:" } - ComboBox { - id: optimizeForComboBox - model: ["speed", "quality"] - currentIndex: model.indexOf(settingsDialog.optimizeFor) - onCurrentIndexChanged: settingsDialog.optimizeFor = model[currentIndex] + Loader { Layout.fillWidth: true + Layout.fillHeight: true + sourceComponent: tabButton + onLoaded: { item.text = "Auto Adjustments"; item.index = 1 } } - Label {} // Placeholder + } + + // Bottom border for tab bar + Rectangle { + anchors.bottom: parent.bottom + width: parent.width + height: 1 + color: "#20ffffff" + } + } - // Auto Levels Clip Threshold - Label { text: "Auto Levels Clip %:" } - TextField { - id: autoLevelThresholdField - Layout.fillWidth: true - - onEditingFinished: { - var value = parseFloat(text) - if (!isNaN(value) && value >= 0.0 && value <= 10.0) { - settingsDialog.autoLevelClippingThreshold = value - text = value.toFixed(4) - } else { - text = settingsDialog.autoLevelClippingThreshold.toFixed(4) + // --- Content Stack --- + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: settingsDialog.currentTab + + // --- TAB 1: GENERAL --- + Item { + ScrollView { + anchors.fill: parent + anchors.margins: 20 + clip: true + contentWidth: availableWidth + + ColumnLayout { + width: parent.width + spacing: 15 + + Loader { + sourceComponent: sectionHeader + onLoaded: item.text = "General Settings" } - } - } - Label {} // Placeholder - // Auto Levels Strength - Label { text: "Auto Levels Strength:" } - RowLayout { - Layout.fillWidth: true - Slider { - id: autoLevelStrengthSlider - from: 0.0 - to: 1.0 - stepSize: 0.05 - value: settingsDialog.autoLevelStrength - onValueChanged: settingsDialog.autoLevelStrength = value - enabled: !autoLevelStrengthAutoCheckBox.checked - Layout.fillWidth: true - opacity: enabled ? 1.0 : 0.5 - } - CheckBox { - id: autoLevelStrengthAutoCheckBox - text: "Auto" - checked: settingsDialog.autoLevelStrengthAuto - onCheckedChanged: settingsDialog.autoLevelStrengthAuto = checked + // Helicon Path + Label { text: "Helicon Focus Path"; color: "#aaaaaa"; font.pixelSize: 12 } + RowLayout { + Layout.fillWidth: true + Loader { + id: heliconField + sourceComponent: styledTextField + Layout.fillWidth: true + onLoaded: { + // Text is set once in onVisibleChanged + item.text = settingsDialog.heliconPath + item.textEdited.connect(function() { settingsDialog.heliconPath = item.text }) + } + } + Button { + text: "Browse" + flat: true + onClicked: { + var path = uiState.open_file_dialog() + if (path) { + settingsDialog.heliconPath = path + if (heliconField.item) heliconField.item.text = path + } + } + background: Rectangle { color: parent.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } + contentItem: Text { text: parent.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + } + Label { + text: "✔" + color: "#4ade80" + visible: uiState && uiState.check_path_exists(settingsDialog.heliconPath) + } + } + + // Photoshop Path + Label { text: "Photoshop Path"; color: "#aaaaaa"; font.pixelSize: 12; Layout.topMargin: 5 } + RowLayout { + Layout.fillWidth: true + Loader { + id: photoshopField + sourceComponent: styledTextField + Layout.fillWidth: true + onLoaded: { + // Text is set once in onVisibleChanged + item.text = settingsDialog.photoshopPath + item.textEdited.connect(function() { settingsDialog.photoshopPath = item.text }) + } + } + Button { + text: "Browse" + flat: true + onClicked: { + var path = uiState.open_file_dialog() + if (path) { + settingsDialog.photoshopPath = path + if (photoshopField.item) photoshopField.item.text = path + } + } + background: Rectangle { color: parent.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } + contentItem: Text { text: parent.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + } + Label { + text: "✔" + color: "#4ade80" + visible: uiState && uiState.check_path_exists(settingsDialog.photoshopPath) + } + } + + // Default Directory + Label { text: "Default Image Directory"; color: "#aaaaaa"; font.pixelSize: 12; Layout.topMargin: 5 } + RowLayout { + Layout.fillWidth: true + Loader { + id: defaultDirField + sourceComponent: styledTextField + Layout.fillWidth: true + onLoaded: { + // Text is set once in onVisibleChanged + item.text = settingsDialog.defaultDirectory + item.textEdited.connect(function() { settingsDialog.defaultDirectory = item.text }) + } + } + Button { + text: "Browse" + flat: true + onClicked: { + var path = uiState.open_directory_dialog() + if (path) { + settingsDialog.defaultDirectory = path + if (defaultDirField.item) defaultDirField.item.text = path + } + } + background: Rectangle { color: parent.pressed ? "#20ffffff" : "#10ffffff"; radius: 4 } + contentItem: Text { text: parent.text; color: settingsDialog.textColor; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter } + } + } + + Loader { sourceComponent: sectionSeparator } + + // Grid for Cache/Theme/Etc + GridLayout { + columns: 2 + columnSpacing: 20 + rowSpacing: 15 + Layout.fillWidth: true + Layout.topMargin: 5 + + // Cache + Label { + text: "Cache Size (GB)" + color: settingsDialog.textColor + + MouseArea { + id: cacheSizeHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: cacheSizeHover.containsMouse + ToolTip.text: "Decoded images are cached in RAM for faster browsing. Higher values allow more images to be kept in memory, reducing re-decode times. Lower values use less RAM. Recommended: 2-8 GB depending on available memory." + } + RowLayout { + Loader { + id: cacheSizeField + sourceComponent: styledTextField + Layout.preferredWidth: 80 + onLoaded: { + // Text is set once in onVisibleChanged + item.text = settingsDialog.cacheSize.toFixed(1) + item.editingFinished.connect(function() { + var value = parseFloat(item.text) + if (!isNaN(value) && value >= 0.5 && value <= 16) { + settingsDialog.cacheSize = value + // Reformat to show consistent precision + item.text = settingsDialog.cacheSize.toFixed(1) + } else { + // Reset to valid value if invalid input + item.text = settingsDialog.cacheSize.toFixed(1) + } + }) + } + } + Label { + text: "In use: " + settingsDialog.cacheUsage.toFixed(2) + " GB" + color: settingsDialog.accentColorHover + font.pixelSize: 11 + } + } + + // Prefetch + Label { + text: "Prefetch Radius" + color: settingsDialog.textColor + + MouseArea { + id: prefetchHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: prefetchHover.containsMouse + ToolTip.text: "Number of images around the current image to pre-load in the background. Higher values make browsing smoother but use more CPU/RAM. Lower values reduce resource usage. Recommended: 4-8 for smooth navigation." + } + Loader { + sourceComponent: styledSpinBox + onLoaded: { + item.from = 1; item.to = 20 + item.value = settingsDialog.prefetchRadius + item.valueChanged.connect(function() { settingsDialog.prefetchRadius = item.value }) + } + } + + // Optimize For + Label { + text: "Optimize For" + color: settingsDialog.textColor + + MouseArea { + id: optimizeHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: optimizeHover.containsMouse + ToolTip.text: "Speed: Faster JPEG decoding using hardware acceleration (may have slight quality loss). Quality: Slower but pixel-perfect decoding. Choose Speed for general browsing, Quality for critical image inspection." + } + ComboBox { + model: ["speed", "quality"] + currentIndex: Math.max(0, model.indexOf(settingsDialog.optimizeFor)) + onActivated: settingsDialog.optimizeFor = model[currentIndex] + Layout.preferredWidth: 150 + delegate: ItemDelegate { + width: parent.width + contentItem: Text { text: modelData; color: settingsDialog.textColor; font: parent.font; elide: Text.ElideRight; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: parent.highlighted ? "#20ffffff" : "transparent" } + } + contentItem: Text { text: parent.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 } + background: Rectangle { color: "#10ffffff"; border.color: settingsDialog.controlBorder; radius: 4 } + } + + // Theme + Label { text: "Theme"; color: settingsDialog.textColor } + ComboBox { + model: ["Dark", "Light"] + currentIndex: settingsDialog.theme + onActivated: settingsDialog.theme = currentIndex + Layout.preferredWidth: 150 + delegate: ItemDelegate { + width: parent.width + contentItem: Text { text: modelData; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: parent.highlighted ? "#20ffffff" : "transparent" } + } + contentItem: Text { text: parent.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 } + background: Rectangle { color: "#10ffffff"; border.color: settingsDialog.controlBorder; radius: 4 } + } + } + + Item { Layout.fillHeight: true } // Spacer } } - Label { text: Math.round(settingsDialog.autoLevelStrength * 100) + "%" } } + + // --- TAB 2: AUTO ADJUSTMENTS --- + Item { + ScrollView { + anchors.fill: parent + anchors.margins: 20 + clip: true + contentWidth: availableWidth - GridLayout { - columns: 3 - - // --- Auto White Balance --- - MouseArea { - width: awbModeLabel.implicitWidth - height: awbModeLabel.implicitHeight - hoverEnabled: true - Layout.topMargin: 10 - Label { - id: awbModeLabel - text: "Auto WB Mode:" - } - ToolTip.visible: containsMouse - ToolTip.text: "Choose the algorithm for Auto White Balance.\n'lab': Uses Lab color space (recommended).\n'rgb': Uses simple Grey World assumption." - } - ComboBox { - id: awbModeComboBox - model: ["lab", "rgb"] - currentIndex: Math.max(0, model.indexOf(settingsDialog.awbMode)) - onCurrentIndexChanged: settingsDialog.awbMode = model[currentIndex] - Layout.topMargin: 10 - } - Label { - Layout.topMargin: 10 - } + ColumnLayout { + width: parent.width + spacing: 15 - MouseArea { - width: awbStrengthLabel.implicitWidth - height: awbStrengthLabel.implicitHeight - hoverEnabled: true - Label { - id: awbStrengthLabel - text: "Auto WB Strength:" - } - ToolTip.visible: containsMouse - ToolTip.text: "How strongly to apply the calculated white balance correction (0.0 - 1.0)." - } - Slider { - id: awbStrengthSlider - from: 0.3 - to: 1.0 - value: settingsDialog.awbStrength - onValueChanged: settingsDialog.awbStrength = value - } - Label { text: (awbStrengthSlider.value * 100).toFixed(0) + "%" } - - MouseArea { - width: awbWarmBiasLabel.implicitWidth - height: awbWarmBiasLabel.implicitHeight - hoverEnabled: true - Label { - id: awbWarmBiasLabel - text: "Auto WB Warm Bias:" - } - ToolTip.visible: containsMouse - ToolTip.text: "Adjusts the target Yellow/Blue balance.\nPositive values make the result warmer (more yellow).\nNegative values make it cooler (more blue)." - } - SpinBox { - id: awbWarmBiasSpinBox - from: -50 - to: 50 - value: settingsDialog.awbWarmBias - editable: true - onValueChanged: settingsDialog.awbWarmBias = value - } - Label {} // Placeholder - - MouseArea { - width: awbTintBiasLabel.implicitWidth - height: awbTintBiasLabel.implicitHeight - hoverEnabled: true - Label { - id: awbTintBiasLabel - text: "Auto WB Tint Bias:" - } - ToolTip.visible: containsMouse - ToolTip.text: "Adjusts the target Magenta/Green balance.\nPositive values add magenta tint.\nNegative values add green tint." - } - SpinBox { - id: awbTintBiasSpinBox - from: -50 - to: 50 - value: settingsDialog.awbTintBias - editable: true - onValueChanged: settingsDialog.awbTintBias = value - } - Label {} // Placeholder - - // --- Advanced AWB Settings --- - CheckBox { - id: advancedAwbCheckBox - text: "Advanced Settings" - checked: false - Layout.columnSpan: 3 - hoverEnabled: true - ToolTip.visible: hovered - ToolTip.text: "Configure thresholds for pixel selection in AWB calculation." - } + // --- Auto Levels --- + Loader { + sourceComponent: sectionHeader + onLoaded: item.text = "Auto Levels" + } - GridLayout { - visible: advancedAwbCheckBox.checked - columns: 3 - Layout.columnSpan: 3 - Layout.fillWidth: true - - MouseArea { - width: lumaLowerLabel.implicitWidth - height: lumaLowerLabel.implicitHeight - hoverEnabled: true - Label { - id: lumaLowerLabel - text: "Luma Lower Bound:" + GridLayout { + columns: 2 + columnSpacing: 20 + rowSpacing: 10 + Layout.fillWidth: true + + Label { + text: "Clip Threshold %" + color: settingsDialog.textColor + + MouseArea { + id: clipThresholdHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: clipThresholdHover.containsMouse + ToolTip.text: "Percentage of pixels to clip at the dark and light ends of the histogram when auto-levels is applied. Higher values (e.g., 5%) increase contrast but risk hard clipping. Lower values (e.g., 0.1%) preserve more dynamic range. Default: 0.1%" + } + Loader { + sourceComponent: styledTextField + Layout.preferredWidth: 80 + onLoaded: { + item.text = settingsDialog.autoLevelClippingThreshold.toFixed(4) + item.editingFinished.connect(function() { + var value = parseFloat(item.text) + if (!isNaN(value) && value >= 0.0 && value <= 10.0) settingsDialog.autoLevelClippingThreshold = value + item.text = settingsDialog.autoLevelClippingThreshold.toFixed(4) + }) + } + Binding { + target: parent.item + property: "text" + value: settingsDialog.autoLevelClippingThreshold.toFixed(4) + when: parent.item && !parent.item.activeFocus + } + } + + Label { + text: "Strength" + color: settingsDialog.textColor + + MouseArea { + id: autoLevelStrengthHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: autoLevelStrengthHover.containsMouse + ToolTip.text: "How much of the auto-levels correction to apply. 1.0 applies the full mathematical correction, lower values blend with the original for a subtler effect. The 'Auto' checkbox enables automatic strength reduction to avoid excessive clipping." + } + RowLayout { + Layout.fillWidth: true + Loader { + sourceComponent: styledSlider + Layout.fillWidth: true + onLoaded: { + item.from = 0.0; item.to = 1.0; item.stepSize = 0.05 + item.value = settingsDialog.autoLevelStrength + item.valueChanged.connect(function() { settingsDialog.autoLevelStrength = item.value }) + item.enabled = Qt.binding(function() { return !autoLvlAuto.checked }) + item.opacity = Qt.binding(function() { return (!autoLvlAuto.checked) ? 1.0 : 0.5 }) + } + Binding { + target: parent.item + property: "value" + value: settingsDialog.autoLevelStrength + when: parent.item && !parent.item.pressed + } + } + CheckBox { + id: autoLvlAuto + text: "Auto" + checked: settingsDialog.autoLevelStrengthAuto + onCheckedChanged: settingsDialog.autoLevelStrengthAuto = checked + contentItem: Text { text: parent.text; color: settingsDialog.textColor; leftPadding: parent.indicator.width + parent.spacing; verticalAlignment: Text.AlignVCenter } + indicator: Rectangle { + implicitWidth: 18; implicitHeight: 18 + x: parent.leftPadding; y: parent.height / 2 - height / 2 + radius: 3 + border.color: settingsDialog.accentColor + color: parent.checked ? settingsDialog.accentColor : "transparent" + Text { text: "✓"; color: "white"; anchors.centerIn: parent; visible: parent.parent.checked; font.bold: true } + } + } + } } - ToolTip.visible: containsMouse - ToolTip.text: "Ignore pixels darker than this brightness (0-255) when calculating AWB." - } - SpinBox { - from: 0 - to: 255 - value: settingsDialog.awbLumaLowerBound - editable: true - onValueChanged: settingsDialog.awbLumaLowerBound = value - } - Label {} - - MouseArea { - width: lumaUpperLabel.implicitWidth - height: lumaUpperLabel.implicitHeight - hoverEnabled: true - Label { - id: lumaUpperLabel - text: "Luma Upper Bound:" + + Loader { sourceComponent: sectionSeparator } + + // --- Auto White Balance --- + Loader { + sourceComponent: sectionHeader + onLoaded: item.text = "Auto White Balance" } - ToolTip.visible: containsMouse - ToolTip.text: "Ignore pixels brighter than this brightness (0-255) when calculating AWB." - } - SpinBox { - from: 0 - to: 255 - value: settingsDialog.awbLumaUpperBound - editable: true - onValueChanged: settingsDialog.awbLumaUpperBound = value - } - Label {} - - MouseArea { - width: rgbLowerLabel.implicitWidth - height: rgbLowerLabel.implicitHeight - hoverEnabled: true - Label { - id: rgbLowerLabel - text: "RGB Lower Bound:" + + GridLayout { + columns: 2 + columnSpacing: 20 + rowSpacing: 15 + Layout.fillWidth: true + + // AWB Mode + Label { + text: "Algorithm" + color: settingsDialog.textColor + + MouseArea { + id: awbAlgorithmHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: awbAlgorithmHover.containsMouse + ToolTip.text: "Algorithm for auto white balance. 'lab' analyzes in LAB color space for perceptually uniform results. 'rgb' works directly in RGB space. Most users should use 'lab'." + } + ComboBox { + model: ["lab", "rgb"] + currentIndex: Math.max(0, model.indexOf(settingsDialog.awbMode)) + onActivated: settingsDialog.awbMode = model[currentIndex] + Layout.preferredWidth: 150 + delegate: ItemDelegate { + width: parent.width + contentItem: Text { text: modelData; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter } + background: Rectangle { color: parent.highlighted ? "#20ffffff" : "transparent" } + } + contentItem: Text { text: parent.displayText; color: settingsDialog.textColor; verticalAlignment: Text.AlignVCenter; leftPadding: 10 } + background: Rectangle { color: "#10ffffff"; border.color: settingsDialog.controlBorder; radius: 4 } + } + + // Strength + Label { + text: "Strength (" + (awbStrSlider.item ? Math.round(awbStrSlider.item.value * 100) : 0) + "%)" + color: settingsDialog.textColor + + MouseArea { + id: awbStrengthHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: awbStrengthHover.containsMouse + ToolTip.text: "How aggressively to apply the auto white balance correction. 100% applies full correction, lower values blend with original. Range: 30-100%. Recommended: 70%" + } + Loader { + id: awbStrSlider + sourceComponent: styledSlider + Layout.fillWidth: true + onLoaded: { + item.from = 0.3; item.to = 1.0 + item.value = settingsDialog.awbStrength + item.valueChanged.connect(function() { settingsDialog.awbStrength = item.value }) + } + Binding { + target: parent.item + property: "value" + value: settingsDialog.awbStrength + when: parent.item && !parent.item.pressed + } + } + + // Warm Bias + Label { + text: "Warm Bias (Yel/Blu)" + color: settingsDialog.textColor + + MouseArea { + id: warmBiasHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: warmBiasHover.containsMouse + ToolTip.text: "Shifts the white balance warmer (yellow, positive values) or cooler (blue, negative values) after auto correction. Useful to compensate for systematic color casts. Range: -50 to +50. Default: +6" + } + Loader { + sourceComponent: styledSpinBox + onLoaded: { + item.from = -50; item.to = 50 + item.value = settingsDialog.awbWarmBias + item.valueChanged.connect(function() { settingsDialog.awbWarmBias = item.value }) + } + Binding { + target: parent.item + property: "value" + value: settingsDialog.awbWarmBias + when: parent.item && !parent.item.down // SpinBox uses down, not pressed? Or implicit pressed? SpinBox interaction is complex. + // Actually SpinBox 'value' property should be bound. SpinBox breaks binding on user input. + // 'down' property exists for internal buttons but maybe not the whole control. + // Let's assume standard Binding restoration behavior works or checking activeFocus might correspond to editing. + // Standard QtQuick Controls 2 SpinBox has 'down'. + } + } + + // Tint Bias + Label { + text: "Tint Bias (Mag/Grn)" + color: settingsDialog.textColor + + MouseArea { + id: tintBiasHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: tintBiasHover.containsMouse + ToolTip.text: "Shifts the color tint toward magenta (positive values) or green (negative values) after auto correction. Compensates for tint issues in the white balance. Range: -50 to +50. Default: 0" + } + Loader { + sourceComponent: styledSpinBox + onLoaded: { + item.from = -50; item.to = 50 + item.value = settingsDialog.awbTintBias + item.valueChanged.connect(function() { settingsDialog.awbTintBias = item.value }) + } + Binding { + target: parent.item + property: "value" + value: settingsDialog.awbTintBias + when: parent.item + } + } } - ToolTip.visible: containsMouse - ToolTip.text: "Ignore pixels where any channel is below this value (0-255)." - } - SpinBox { - from: 0 - to: 255 - value: settingsDialog.awbRgbLowerBound - editable: true - onValueChanged: settingsDialog.awbRgbLowerBound = value - } - Label {} - - MouseArea { - width: rgbUpperLabel.implicitWidth - height: rgbUpperLabel.implicitHeight - hoverEnabled: true - Label { - id: rgbUpperLabel - text: "RGB Upper Bound:" + + Loader { sourceComponent: sectionSeparator } + + Loader { + sourceComponent: sectionHeader + onLoaded: item.text = "Advanced Thresholds" } - ToolTip.visible: containsMouse - ToolTip.text: "Ignore pixels where any channel is above this value (0-255)." - } - SpinBox { - from: 0 - to: 255 - value: settingsDialog.awbRgbUpperBound - editable: true - onValueChanged: settingsDialog.awbRgbUpperBound = value + + GridLayout { + columns: 2 + columnSpacing: 20 + rowSpacing: 10 + Layout.fillWidth: true + + Label { + text: "Luma Lower" + color: settingsDialog.textColor + + MouseArea { + id: lumaLowerHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: lumaLowerHover.containsMouse + ToolTip.text: "Minimum luminance (brightness) threshold for pixels to be included in AWB gray-point calculation. Pixels darker than this are excluded. Range: 0-255. Default: 30. Increase to ignore very dark areas." + } + Loader { + sourceComponent: styledSpinBox + onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbLumaLowerBound; item.valueChanged.connect(function(){ settingsDialog.awbLumaLowerBound=item.value})} + Binding { + target: parent.item + property: "value" + value: settingsDialog.awbLumaLowerBound + when: parent.item + } + } + + Label { + text: "Luma Upper" + color: settingsDialog.textColor + + MouseArea { + id: lumaUpperHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: lumaUpperHover.containsMouse + ToolTip.text: "Maximum luminance (brightness) threshold for pixels to be included in AWB gray-point calculation. Pixels brighter than this are excluded. Range: 0-255. Default: 220. Decrease to ignore very bright areas." + } + Loader { + sourceComponent: styledSpinBox + onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbLumaUpperBound; item.valueChanged.connect(function(){ settingsDialog.awbLumaUpperBound=item.value})} + Binding { + target: parent.item + property: "value" + value: settingsDialog.awbLumaUpperBound + when: parent.item + } + } + + Label { + text: "RGB Lower" + color: settingsDialog.textColor + + MouseArea { + id: rgbLowerHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: rgbLowerHover.containsMouse + ToolTip.text: "Minimum RGB channel value for pixels to be included in AWB calculation. Pixels with any channel below this are excluded. Range: 0-255. Default: 5. Increase to ignore very saturated colors." + } + Loader { + sourceComponent: styledSpinBox + onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbRgbLowerBound; item.valueChanged.connect(function(){ settingsDialog.awbRgbLowerBound=item.value})} + Binding { + target: parent.item + property: "value" + value: settingsDialog.awbRgbLowerBound + when: parent.item + } + } + + Label { + text: "RGB Upper" + color: settingsDialog.textColor + + MouseArea { + id: rgbUpperHover + anchors.fill: parent + hoverEnabled: true + } + + ToolTip.visible: rgbUpperHover.containsMouse + ToolTip.text: "Maximum RGB channel value for pixels to be included in AWB calculation. Pixels with any channel above this are excluded. Range: 0-255. Default: 250. Decrease to ignore near-white areas." + } + Loader { + sourceComponent: styledSpinBox + onLoaded: { item.from=0; item.to=255; item.value=settingsDialog.awbRgbUpperBound; item.valueChanged.connect(function(){ settingsDialog.awbRgbUpperBound=item.value})} + Binding { + target: parent.item + property: "value" + value: settingsDialog.awbRgbUpperBound + when: parent.item + } + } + } + + Item { Layout.fillHeight: true } // Spacer } - Label {} } } } } - // Poll cache usage periodically while the dialog is open + // Bottom Action Bar + Rectangle { + id: bottomBar + anchors.bottom: parent.bottom + anchors.left: parent.left + anchors.right: parent.right + height: 70 + color: "#1e1e1e" // matches background + // Gradient separator + Rectangle { width: parent.width; height: 1; color: "#20ffffff"; anchors.top: parent.top } + + RowLayout { + anchors.fill: parent + anchors.margins: 20 + spacing: 15 + + Item { Layout.fillWidth: true } // Spacer left + + Button { + text: "Cancel" + Layout.preferredWidth: 100 + onClicked: settingsDialog.visible = false + + contentItem: Text { + text: parent.text + font: parent.font + color: settingsDialog.textColor + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? "#40ffffff" : "#20ffffff" + radius: 4 + border.color: parent.hovered ? "#60ffffff" : "transparent" + } + } + + Button { + text: "Save" + Layout.preferredWidth: 100 + highlighted: true + onClicked: settingsDialog.saveSettings() + + contentItem: Text { + text: parent.text + font: parent.font + color: "white" + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } + background: Rectangle { + color: parent.pressed ? Qt.darker(settingsDialog.accentColor, 1.1) : settingsDialog.accentColor + radius: 4 + } + } + } + } + Timer { id: cacheUsageTimer interval: 1000 diff --git a/faststack/tests/test_auto_levels.py b/faststack/tests/test_auto_levels.py new file mode 100644 index 0000000..a6ed3b9 --- /dev/null +++ b/faststack/tests/test_auto_levels.py @@ -0,0 +1,137 @@ + +import pytest +import numpy as np +from PIL import Image +from faststack.imaging.editor import ImageEditor + + +def test_auto_levels_pins_highlights_if_clipped(): + editor = ImageEditor() + # 10x10 image + w, h = 10, 10 + arr = np.zeros((h, w, 3), dtype=np.uint8) + arr[:] = 100 + + # Clip Blue: Set last pixel to 255 + arr[9, 9, 2] = 255 + + img = Image.fromarray(arr, 'RGB') + editor.original_image = img + editor._preview_image = img + + # Use threshold 0.0 to make p_low deterministic (min value) + # This prevents fragility with per-channel percentiles on small arrays + blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.0) + + # 1 pixel of 255 in 100 is 1%. Eps (from threshold 0.0) would be 0.0. + # Actually logic is eps = min(threshold, 0.01). If threshold 0.0, eps=0.0. + # 1% > 0.0% -> Pins. + + assert p_high == 255.0 + assert whites == 0.0 + + # p_low should be the strict minimum (100) + assert p_low == 100.0 + +def test_auto_levels_pins_shadows_if_clipped(): + editor = ImageEditor() + w, h = 10, 10 + arr = np.zeros((h, w, 3), dtype=np.uint8) + arr[:] = 100 + + # Clip Red shadow: 1 pixel at 0. + arr[0, 0, 0] = 0 + + img = Image.fromarray(arr, 'RGB') + editor.original_image = img + editor._preview_image = img + + # Threshold 0.0 -> eps=0.0. 1% detected > 0.0 -> Pins. + blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.0) + + assert p_low == 0.0 + assert blacks == 0.0 + + # Whites should be normal (max is 100) + assert p_high == 100.0 + assert whites == (255.0 - 100.0) / 40.0 + +def test_auto_levels_tiny_hot_pixel_ignored(): + """ + Verify that a very small number of clipped pixels (below eps check) + does NOT trigger pinning, and does NOT get picked up by percentile + if strictly below the threshold. + """ + editor = ImageEditor() + # 200x200 = 40,000 pixels + w, h = 200, 200 + arr = np.zeros((h, w, 3), dtype=np.uint8) + + # Base: 150 + arr[:] = 150 + + # Set top ~2.5% pixels to 200 (1000 pixels) + # This ensures the 99.9th percentile lands on 200, not 150. + # Flattening for easier assignment + flat = arr.reshape(-1, 3) + flat[0:1000, :] = 200 + arr = flat.reshape(h, w, 3) + + # Add ONE hot pixel at 255 in Red channel + arr[0, 0, 0] = 255 + + img = Image.fromarray(arr, 'RGB') + editor.original_image = img + editor._preview_image = img + + # Threshold 0.1%. Eps = 0.01%. + # 1 pixel / 40000 = 0.0025%. + # 0.0025% < 0.01%. Should NOT pin. + + blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.1) + + # p_high should be 200 (from the 200-level plateau), ignoring the 255. + assert p_high == 200.0 + assert p_high != 255.0 # Check explicitly not pinned + assert whites > 0.0 + +def test_auto_levels_degenerate_image(): + editor = ImageEditor() + w, h = 10, 10 + arr = np.zeros((h, w, 3), dtype=np.uint8) + arr[:] = 128 + + img = Image.fromarray(arr, 'RGB') + editor.original_image = img + editor._preview_image = img + + blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.1) + + assert p_high == 128.0 + assert p_low == 128.0 + assert blacks == 0.0 + assert whites == 0.0 + +def test_auto_levels_normal_range(): + editor = ImageEditor() + w, h = 10, 10 + arr = np.zeros((h, w, 3), dtype=np.uint8) + arr[:] = 128 + arr[0, 0, :] = 50 # Low + arr[9, 9, :] = 200 # High + + img = Image.fromarray(arr, 'RGB') + editor.original_image = img + editor._preview_image = img + + blacks, whites, p_low, p_high = editor.auto_levels(threshold_percent=0.0) + + assert p_high == 200.0 + assert p_low == 50.0 + + assert p_high != 255.0 # Not pinned + assert p_low != 0.0 # Not pinned + + assert whites == (255.0 - 200.0) / 40.0 + assert blacks == -50.0 / 40.0 + diff --git a/faststack/tests/test_cache.py b/faststack/tests/test_cache.py index f36d4dc..fdc8652 100644 --- a/faststack/tests/test_cache.py +++ b/faststack/tests/test_cache.py @@ -15,7 +15,7 @@ def __sizeof__(self) -> int: def test_cache_init(): """Tests cache initialization.""" cache = ByteLRUCache(max_bytes=1000, size_of=lambda x: x.__sizeof__()) - assert cache.maxsize == 1000 + assert cache.max_bytes == 1000 assert cache.currsize == 0 def test_cache_add_items(): diff --git a/faststack/tests/test_new_features.py b/faststack/tests/test_new_features.py index a2ddcc3..eec4363 100644 --- a/faststack/tests/test_new_features.py +++ b/faststack/tests/test_new_features.py @@ -21,17 +21,24 @@ def test_auto_levels_strength(self): self.editor.original_image = img self.editor._preview_image = img - # Calculate auto levels - blacks, whites = self.editor.auto_levels(0.1) + # Calculate auto levels - now returns (blacks, whites, p_low, p_high) + blacks, whites, p_low, p_high = self.editor.auto_levels(0.1) # With range [50, 200], we expect: + # p_low should be around 50, p_high around 200 (with 0.1% percentile) # blacks approx -50/40 = -1.25 - # whites approx (200-255)/40 = -1.375 + # whites approx (255-200)/40 = 1.375 self.assertNotEqual(blacks, 0.0) self.assertNotEqual(whites, 0.0) self.assertLess(blacks, 0.0) self.assertGreater(whites, 0.0) + # Verify percentile values are reasonable + self.assertGreater(p_low, 45) # Should be close to 50 + self.assertLess(p_low, 55) + self.assertGreater(p_high, 195) # Should be close to 200 + self.assertLess(p_high, 205) + # Mock strength application matching app.py logic strength = 0.5 b_scaled = blacks * strength @@ -85,5 +92,150 @@ def test_straighten_angle(self): print(f"Original size: {self.img.size}, Rotated size: {res.size}") self.assertNotEqual(res.size, self.img.size) + def test_auto_levels_stretch_capping(self): + """ + Regression test: Verify that auto-strength uses stretch-factor capping + to prevent insane levels on low-dynamic-range images. + + Tests: + 1. Reasonable dynamic range: should use full strength (strength=1.0) + 2. Low dynamic range: should cap stretch at 4x maximum + 3. Very low dynamic range: should set strength=0 + """ + threshold_percent = 0.1 + + # Test case 1: Reasonable dynamic range (50-200, range=150) + # Expected: stretch = 255/150 = 1.7x (< 4x cap) => strength = 1.0 + arr_reasonable = np.linspace(50, 200, 10000, dtype=np.uint8).reshape(100, 100) + img_reasonable = Image.fromarray(arr_reasonable) + self.editor.original_image = img_reasonable + self.editor._preview_image = img_reasonable + + blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) + + # Calculate what strength should be based on stretch factor + dynamic_range = p_high - p_low + stretch_full = 255.0 / dynamic_range + STRETCH_CAP = 4.0 + + if stretch_full <= STRETCH_CAP: + expected_strength = 1.0 + else: + expected_strength = (STRETCH_CAP - 1.0) / (stretch_full - 1.0) + + print(f"Reasonable range: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}, " + f"stretch={stretch_full:.2f}, expected_strength={expected_strength:.3f}") + + # For reasonable range, should use full strength + self.assertAlmostEqual(expected_strength, 1.0, places=2) + + # Test case 2: Low dynamic range (100-140, range=40) + # Expected: stretch = 255/40 = 6.375x (> 4x cap) => strength = 3/5.375 ≈ 0.558 + arr_low_range = np.clip(np.linspace(100, 140, 10000, dtype=np.uint8), 100, 140).reshape(100, 100) + img_low_range = Image.fromarray(arr_low_range) + self.editor.original_image = img_low_range + self.editor._preview_image = img_low_range + + blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) + + dynamic_range = p_high - p_low + stretch_full = 255.0 / dynamic_range if dynamic_range >= 1.0 else 255.0 + + if stretch_full <= STRETCH_CAP: + expected_strength = 1.0 + else: + expected_strength = (STRETCH_CAP - 1.0) / (stretch_full - 1.0) + + print(f"Low range: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}, stretch={stretch_full:.2f}, expected_strength={expected_strength:.3f}") + + # Stretch should exceed cap, strength should be reduced + self.assertGreater(stretch_full, STRETCH_CAP) + self.assertLess(expected_strength, 1.0) + self.assertGreater(expected_strength, 0.3) # Should still be reasonable + + # Test case 3: Very low dynamic range (120-121, range≈1) + # Expected: strength = 0 (degenerate case) + arr_flat = np.full((100, 100), 120, dtype=np.uint8) + # Add tiny variation to avoid completely flat + arr_flat[0, 0] = 119 + arr_flat[99, 99] = 121 + img_flat = Image.fromarray(arr_flat) + self.editor.original_image = img_flat + self.editor._preview_image = img_flat + + blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) + + dynamic_range = p_high - p_low + + print(f"Flat image: p_low={p_low:.1f}, p_high={p_high:.1f}, range={dynamic_range:.1f}") + + # For very low range, should be near 0 or exactly 0 + self.assertLess(dynamic_range, 3.0) + + def test_auto_levels_clipping_tolerance(self): + """ + Regression test: Verify that auto-levels respects the threshold setting + and doesn't introduce excessive clipping beyond the configured tolerance. + + Uses deterministic synthetic images to verify clipping stays within bounds. + """ + threshold_percent = 0.1 + + # Create a deterministic image with known distribution + # Use a beta distribution to create realistic luminance distribution + # Beta(2, 5) gives a left-skewed distribution (more shadows, fewer highlights) + np.random.seed(42) # Deterministic + beta_samples = np.random.beta(2, 5, size=10000) + arr = (beta_samples * 255).astype(np.uint8).reshape(100, 100) + img = Image.fromarray(arr) + + self.editor.original_image = img + self.editor._preview_image = img + + blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) + + # Apply at full strength + self.editor.set_edit_param('blacks', blacks) + self.editor.set_edit_param('whites', whites) + result = self.editor._apply_edits(img.convert('RGB')) + result_arr = np.array(result.convert('L')) + + # Count pixels at extremes + total_pixels = result_arr.size + clipped_low = np.sum(result_arr == 0) + clipped_high = np.sum(result_arr == 255) + + pct_clipped_low = (clipped_low / total_pixels) * 100.0 + pct_clipped_high = (clipped_high / total_pixels) * 100.0 + + print(f"Beta distribution: Low clip: {pct_clipped_low:.2f}%, High clip: {pct_clipped_high:.2f}%") + + # Allow small tolerance for rounding and integer quantization + # The threshold defines the percentiles, but due to discrete pixel values + # and the mapping, we may end up with slightly different clipping + tolerance = 0.5 # 0.1% threshold + 0.5% tolerance = 0.6% max + + self.assertLessEqual(pct_clipped_low, threshold_percent + tolerance, + f"Excessive shadow clipping: {pct_clipped_low:.2f}% > {threshold_percent + tolerance}%") + self.assertLessEqual(pct_clipped_high, threshold_percent + tolerance, + f"Excessive highlight clipping: {pct_clipped_high:.2f}% > {threshold_percent + tolerance}%") + + # Verify mapping is monotonic (sanity check) + # Create a gradient and verify it maps monotonically + gradient = np.arange(256, dtype=np.uint8) + gradient_img = Image.fromarray(gradient.reshape(1, 256)) + self.editor.original_image = gradient_img + self.editor._preview_image = gradient_img + + blacks, whites, p_low, p_high = self.editor.auto_levels(threshold_percent) + self.editor.set_edit_param('blacks', blacks) + self.editor.set_edit_param('whites', whites) + result = self.editor._apply_edits(gradient_img.convert('RGB')) + result_arr = np.array(result.convert('L'))[0, :] + + # Check monotonicity + diffs = np.diff(result_arr.astype(np.int16)) + self.assertTrue(np.all(diffs >= 0), "Mapping is not monotonic") + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/pyproject.toml b/pyproject.toml index ad86184..8c74ffc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "cachetools>=5.0,<6.0", "watchdog>=4.0,<5.0", "Pillow>=10.0,<11.0", + "opencv-python>=4.0,<5.0", ] [project.optional-dependencies] diff --git a/reproduce_config_issue.py b/reproduce_config_issue.py new file mode 100644 index 0000000..d5b86b9 --- /dev/null +++ b/reproduce_config_issue.py @@ -0,0 +1,64 @@ + +import sys +import os +from pathlib import Path +import configparser + +# Update sys.path to include the project root +sys.path.append(r"c:\code\faststack") + +# Mock logging setup to avoid creating real logs/directories +import faststack.logging_setup +import faststack.config + +def test_config_persistence(): + print("Testing config persistence...") + + # Use a temporary file for testing + test_config_dir = Path("c:/code/faststack/test_config_dir") + test_config_dir.mkdir(exist_ok=True) + + # Monkeypatch get_app_data_dir to use local dir + faststack.config.get_app_data_dir = lambda: test_config_dir + + # 1. Initialize config (should create defaults) + app_config = faststack.config.AppConfig() + print(f"Config path: {app_config.config_path}") + + # Verify default + initial_val = app_config.get('core', 'auto_level_threshold') + print(f"Initial value: {initial_val}") + if initial_val != "0.1": + print("FAIL: Default value unexpected") + + # 2. Modify value + new_val = "0.05" + print(f"Setting value to: {new_val}") + app_config.set('core', 'auto_level_threshold', new_val) + app_config.save() + + # 3. Reload config from disk directly to verify file content + raw_config = configparser.ConfigParser() + raw_config.read(app_config.config_path) + file_val = raw_config.get('core', 'auto_level_threshold') + print(f"Value in file: {file_val}") + + # 4. Re-initialize AppConfig (simulate app restart) + # We must clear the global instance or create a new one to force reload + # AppConfig.__init__ calls self.load() + app_config_2 = faststack.config.AppConfig() + loaded_val = app_config_2.get('core', 'auto_level_threshold') + print(f"Loaded value: {loaded_val}") + + if loaded_val == new_val: + print("SUCCESS: Value persisted correctly") + else: + print(f"FAIL: Value did not persist. Got {loaded_val}, expected {new_val}") + + # Clean up + if (test_config_dir / "faststack.ini").exists(): + (test_config_dir / "faststack.ini").unlink() + test_config_dir.rmdir() + +if __name__ == "__main__": + test_config_persistence() diff --git a/test_cachetools_api.py b/test_cachetools_api.py new file mode 100644 index 0000000..b3b0c03 --- /dev/null +++ b/test_cachetools_api.py @@ -0,0 +1,16 @@ +"""Quick test to check cachetools.LRUCache API.""" +from cachetools import LRUCache + +# Create a basic LRUCache +cache = LRUCache(maxsize=100) + +# Check if maxsize is a property or method +print(f"Type of maxsize: {type(cache.maxsize)}") +print(f"maxsize value: {cache.maxsize}") + +# Check if we can access the internal attribute +if hasattr(cache, '_Cache__maxsize'): + print(f"Internal _Cache__maxsize: {cache._Cache__maxsize}") + +# List all attributes +print(f"\nAll cache attributes: {[attr for attr in dir(cache) if not attr.startswith('_')]}") diff --git a/test_max_bytes.py b/test_max_bytes.py new file mode 100644 index 0000000..0f517eb --- /dev/null +++ b/test_max_bytes.py @@ -0,0 +1,36 @@ +"""Quick test to verify ByteLRUCache.max_bytes works correctly.""" +from faststack.imaging.cache import ByteLRUCache + +class MockItem: + def __init__(self, size: int): + self._size = size + + def __sizeof__(self) -> int: + return self._size + +# Test 1: Initialize cache +cache = ByteLRUCache(max_bytes=1000, size_of=lambda x: x.__sizeof__()) +print(f"Initial max_bytes: {cache.max_bytes}") +assert cache.max_bytes == 1000, "Initial max_bytes should be 1000" + +# Test 2: Add items +cache["a"] = MockItem(50) +cache["b"] = MockItem(40) +print(f"Current size: {cache.currsize}, Max bytes: {cache.max_bytes}") +assert cache.currsize == 90, "Current size should be 90" + +# Test 3: Change max_bytes and verify eviction works +cache.max_bytes = 80 +print(f"New max_bytes: {cache.max_bytes}") +assert cache.max_bytes == 80, "max_bytes should be updated to 80" + +# Test 4: Add an item that triggers eviction +cache["c"] = MockItem(50) +print(f"After eviction - Current size: {cache.currsize}, Items: {list(cache.keys())}") + +# "a" should have been evicted (LRU) +assert "a" not in cache, "Item 'a' should have been evicted" +assert "b" in cache or "c" in cache, "At least one of 'b' or 'c' should be in cache" +assert cache.currsize <= cache.max_bytes, f"Current size {cache.currsize} should be <= max_bytes {cache.max_bytes}" + +print("\n✓ All tests passed! ByteLRUCache.max_bytes works correctly.") diff --git a/test_output.txt b/test_output.txt new file mode 100644 index 0000000..688f167 Binary files /dev/null and b/test_output.txt differ diff --git a/tests/test_prefetch_concurrency.py b/tests/test_prefetch_concurrency.py new file mode 100644 index 0000000..5c0e704 --- /dev/null +++ b/tests/test_prefetch_concurrency.py @@ -0,0 +1,129 @@ + +import threading +import time +import pytest +import numpy as np +from pathlib import Path +from concurrent.futures import Future + +from faststack.imaging.prefetch import Prefetcher +from faststack.models import ImageFile + +# Mock objects to isolate Prefetcher logic +class MockImageFile: + def __init__(self, index): + self.path = Path(f"/mock/image_{index}.jpg") + +def mock_get_display_info(): + return 1920, 1080, 1 + +def mock_cache_put(key, value): + pass + +@pytest.fixture +def prefetcher(): + image_files = [MockImageFile(i) for i in range(100)] + # Use a small radius to force more activity + p = Prefetcher(image_files, mock_cache_put, prefetch_radius=5, get_display_info=mock_get_display_info, debug=False) + + # Mock the internal decode method to avoid actual I/O and processing + # We just return a dummy result after a tiny sleep + def mock_decode_and_cache(*args, **kwargs): + time.sleep(0.0001) # fast sleep + return Path("/mock/image_x.jpg"), 1 + + p._decode_and_cache = mock_decode_and_cache + + yield p + p.shutdown() + +def test_prefetch_concurrency(prefetcher): + """ + Stress test for race conditions in Prefetcher. + Simulates concurrent navigation (update_prefetch), cancellation (cancel_all), + and file list updates (set_image_files). + """ + + # Configuration + num_loops = 5000 + num_threads = 4 + + # Shared state for error tracking + errors = [] + + # Barrier to synchronize start + barrier = threading.Barrier(num_threads) + + stop_event = threading.Event() + + def worker_update(): + try: + barrier.wait() + for i in range(num_loops): + if stop_event.is_set(): break + # Randomly jump around + idx = i % 100 + prefetcher.update_prefetch(idx, is_navigation=True, direction=1) + except Exception as e: + errors.append(e) + stop_event.set() + + def worker_cancel(): + try: + barrier.wait() + for i in range(num_loops): + if stop_event.is_set(): break + if i % 10 == 0: # Cancel less frequently + prefetcher.cancel_all() + except Exception as e: + errors.append(e) + stop_event.set() + + def worker_set_files(): + try: + barrier.wait() + # Generate two lists to toggle between + list1 = [MockImageFile(i) for i in range(100)] + list2 = [MockImageFile(i) for i in range(50)] # Different size + + for i in range(num_loops): + if stop_event.is_set(): break + if i % 100 == 0: # Reload files occasionally + new_list = list2 if i % 200 == 0 else list1 + prefetcher.set_image_files(new_list) + except Exception as e: + errors.append(e) + stop_event.set() + + # Create threads + threads = [ + threading.Thread(target=worker_update), + threading.Thread(target=worker_update), # Two updaters + threading.Thread(target=worker_cancel), + threading.Thread(target=worker_set_files) + ] + + # Start threads + for t in threads: + t.start() + + # Wait for completion + for t in threads: + t.join() + + # Assertions + if errors: + pytest.fail(f"Exceptions occurred in worker threads: {errors}") + + # Verify internal consistency + with prefetcher._futures_lock: + # Check that scheduled matches generation (basic check) + for gen, scheduled_set in prefetcher._scheduled.items(): + if gen > prefetcher.generation: + pytest.fail(f"Found scheduled set for future generation {gen} > {prefetcher.generation}") + + # Check futures dict consistency + # It's hard to assert exact size since threads stopped at random times, + # but we can check if keys in futures are valid integers roughly + assert isinstance(prefetcher.futures, dict) +