From fed05787efb0f485eb5a5f8bf120cb3018649ceb Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 7 Dec 2025 23:11:23 -0800 Subject: [PATCH 1/2] minor bug fixes --- faststack/ChangeLog.md | 6 +- faststack/faststack.egg-info/PKG-INFO | 2 + faststack/faststack/app.py | 262 +++++++------ faststack/faststack/config.py | 2 + faststack/faststack/imaging/editor.py | 132 ++++++- faststack/faststack/qml/Components.qml | 386 +++++++++++++++----- faststack/faststack/qml/HistogramWindow.qml | 3 +- faststack/faststack/qml/SettingsDialog.qml | 149 +++++++- faststack/faststack/ui/provider.py | 22 ++ 9 files changed, 719 insertions(+), 245 deletions(-) diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index 194f766..f0bc62d 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -9,13 +9,17 @@ Todo: Make it work on Linux / Mac. Create Windows .exe. Write better docum - Added batch delete with confirmation dialog. - Added the --cachedebug command line argument which gives info on the image cache in the status bar. Doesn't seem to slow down the program at all, just takes up room in the status bar.A - Added a setting that switches between image display optimized for speed or quality. +- **Auto-Levels:** Automatic image enhancement with configurable threshold and strength +- **Image Metadata:** Extract and display EXIF metadata (I key) +- **Image Processing:** Auto white balance, texture enhancement, and straightening +- **Crop Operations:** Fixed crop functionality with rotation support ## [1.3.0] - 2025-11-23 - Added the ability to crop images, via the cr(O)p hotkey. It can be a freeform crop, or constrained to several popular aspect ratios. - Sorts images by time. - Added the Stack Source Raws feature in the Action menu - if you import your images with stackcopy.py --lightroomimport (https://github.com/AlanRockefeller/faststack) and you are viewing a photo stacked in-camera, this feature will open the raw images that made this stack in Helicon Focus. -- Some fixes to the image cache - it doesn't expire when it shouldn't, does expire when it should, and warns you when the cache is full so you can consider increassing the cache size in settings. +- Some fixes to the image cache - it doesn't expire when it shouldn't, does expire when it should, and warns you when the cache is full so you can consider increasing the cache size in settings. ## [1.2.0] - 2025-11-22 diff --git a/faststack/faststack.egg-info/PKG-INFO b/faststack/faststack.egg-info/PKG-INFO index 2dfb203..7527397 100644 --- a/faststack/faststack.egg-info/PKG-INFO +++ b/faststack/faststack.egg-info/PKG-INFO @@ -69,3 +69,5 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - `E`: Edit in Photoshop - `Ctrl+C`: Copy image path to clipboard - `C`: Clear all stacks ++- `H`: Show RGB Histogram ++- `I`: Show EXIF Metadata diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 5041d73..d652ec8 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -166,6 +166,7 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: self.auto_level_threshold = config.getfloat('core', 'auto_level_threshold', 0.1) self.auto_level_strength = config.getfloat('core', 'auto_level_strength', 1.0) + self.auto_level_strength_auto = config.getboolean('core', 'auto_level_strength_auto', False) @Slot(str) @@ -480,6 +481,7 @@ def sync_ui_state(self): def next_image(self): if self.current_index < len(self.image_files) - 1: self.current_index += 1 + self._reset_crop_settings() self._do_prefetch(self.current_index, is_navigation=True, direction=1) self.sync_ui_state() # Update histogram if visible @@ -489,6 +491,7 @@ def next_image(self): def prev_image(self): if self.current_index > 0: self.current_index -= 1 + self._reset_crop_settings() self._do_prefetch(self.current_index, is_navigation=True, direction=-1) self.sync_ui_state() # Update histogram if visible @@ -504,6 +507,7 @@ def jump_to_image(self, index: int): return direction = 1 if index > self.current_index else -1 self.current_index = index + self._reset_crop_settings() self._do_prefetch(self.current_index, is_navigation=True, direction=direction) self.sync_ui_state() # Update histogram if visible @@ -967,8 +971,26 @@ def toggle_stack_membership(self): self.ui_state.stackSummaryChanged.emit() self.sync_ui_state() - - + def _reset_crop_settings(self): + """Resets crop settings to default (full image) and exits crop mode, and resets rotation.""" + if self.ui_state.isCropping: + self.ui_state.isCropping = False + self.update_status_message("Crop mode exited") + self.ui_state.currentCropBox = (0, 0, 1000, 1000) + # Also clear any editor-side crop box in case it's not fully synced yet + self.image_editor.set_crop_box((0, 0, 1000, 1000)) + # Reset rotation and straighten angle + self.image_editor.set_edit_param('rotation', 0) + self.image_editor.set_edit_param('straighten_angle', 0.0) + # Also update UI state for rotation values if they are exposed + if hasattr(self.ui_state, 'rotation'): + self.ui_state.rotation = 0 + if hasattr(self.ui_state, 'cropRotation'): # This is used by Components.qml for the overlay + self.ui_state.cropRotation = 0.0 + + # Also reset the straighten angle in current_edits since it affects rotation logic + if 'straighten_angle' in self.image_editor.current_edits: + self.image_editor.current_edits['straighten_angle'] = 0.0 def launch_helicon(self): """Launches Helicon with selected files (RAW preferred, JPG fallback) or stacks.""" @@ -1245,6 +1267,15 @@ def set_awb_warm_bias(self, value): config.set("awb", "warm_bias", value) config.save() + @Slot(result=int) + def get_awb_tint_bias(self): + return config.getint("awb", "tint_bias", fallback=0) + + @Slot(int) + def set_awb_tint_bias(self, value): + config.set("awb", "tint_bias", value) + config.save() + @Slot(result=int) def get_awb_luma_lower_bound(self): return config.getint("awb", "luma_lower_bound") @@ -1752,6 +1783,9 @@ def undo_delete(self): self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() + if self.ui_state.isHistogramVisible: + self.update_histogram() + self.update_status_message("Undid auto white balance") else: # This case should not be reached if glob finds files @@ -2228,17 +2262,12 @@ def save_edited_image(self): """Saves the edited image.""" save_result = self.image_editor.save_image() if not save_result: - QMessageBox.warning( - None, - "Save Failed", - "Failed to save edited image. Please check the log for details.", - QMessageBox.Ok, - ) self.update_status_message("Failed to save image") log.error("Failed to save edited image") return saved_path, _ = save_result + self.update_status_message(f"Edits saved to {saved_path.name}") # Clear the image editor state so it will reload fresh next time self.image_editor.clear() @@ -2262,13 +2291,6 @@ def save_edited_image(self): self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() - QMessageBox.information( - None, - "Save Successful", - f"Image saved to: {saved_path}. Original backed up.", - QMessageBox.Ok - ) - @Slot() def rotate_image_cw(self): @@ -2579,100 +2601,51 @@ def execute_crop(self): if not self.ui_state.isCropping: return - # Convert QJSValue to Python list if needed + # Ensure ImageEditor has the latest crop box (it should be synced via UIState, but good to be safe) crop_box_raw = self.ui_state.currentCropBox - try: - # Try to convert QJSValue to list - if hasattr(crop_box_raw, 'toVariant'): - # It's a QJSValue, convert to list - variant = crop_box_raw.toVariant() - if isinstance(variant, (list, tuple)): - crop_box = list(variant) - else: - # Try to iterate if it's iterable - crop_box = [variant[0], variant[1], variant[2], variant[3]] - elif isinstance(crop_box_raw, (list, tuple)): - crop_box = list(crop_box_raw) - else: - # Try direct access (might work for some QJSValue types) - crop_box = [crop_box_raw[0], crop_box_raw[1], crop_box_raw[2], crop_box_raw[3]] - except (TypeError, IndexError, AttributeError) as e: - self.update_status_message("Invalid crop box") - log.error("Failed to parse crop box (type: %s): %s", type(crop_box_raw), e) - return - - if len(crop_box) != 4: - self.update_status_message("Invalid crop box") - return - - if crop_box == [0, 0, 1000, 1000] or crop_box == (0, 0, 1000, 1000): + # ... (validation code remains similar or can be simplified since UIState validates) ... + # For robustness, we'll trust UIState's validation or do a quick check + if not isinstance(crop_box_raw, tuple) or len(crop_box_raw) != 4: + # Try to convert if it came as list + try: + crop_box_raw = tuple(crop_box_raw) if isinstance(crop_box_raw, list) else tuple(crop_box_raw.toVariant()) + except: + pass + + if not isinstance(crop_box_raw, tuple) or len(crop_box_raw) != 4: + self.update_status_message("Invalid crop box") + return + + if crop_box_raw == (0, 0, 1000, 1000): self.update_status_message("No crop area selected") return - + + # Ensure image is loaded in editor (crop mode might be active without editor open) image_file = self.image_files[self.current_index] filepath = str(image_file.path) - - try: - # Load the image - img = Image.open(filepath).convert("RGB") - width, height = img.size - - # Convert normalized crop box (0-1000) to pixel coordinates - left = int(crop_box[0] * width / 1000) - top = int(crop_box[1] * height / 1000) - right = int(crop_box[2] * width / 1000) - bottom = int(crop_box[3] * height / 1000) - - # Ensure valid crop box - left = max(0, min(left, width - 1)) - top = max(0, min(top, height - 1)) - right = max(left + 1, min(right, width)) - bottom = max(top + 1, min(bottom, height)) - - # Crop the image - cropped_img = img.crop((left, top, right, bottom)) - - # Create backup - original_path = Path(filepath) - - # Preserve original file modification time - original_mtime = original_path.stat().st_mtime - original_atime = original_path.stat().st_atime - - backup_path = create_backup_file(original_path) - if backup_path is None: - self.update_status_message("Failed to create backup") - log.error("Failed to create backup for crop operation") + if not self.image_editor.current_filepath or str(self.image_editor.current_filepath) != filepath: + # Load without preview if needed, but we likely have one cached + cached_preview = self.get_decoded_image(self.current_index) + if not self.image_editor.load_image(filepath, cached_preview=cached_preview): + self.update_status_message("Failed to load image for cropping") return - - # Save the cropped image - with Image.open(original_path) as original_img: - original_format = original_img.format or original_path.suffix.lstrip('.').upper() - exif_data = original_img.info.get('exif') - - save_kwargs = {} - if original_format == 'JPEG': - save_kwargs['format'] = 'JPEG' - save_kwargs['quality'] = 95 - if exif_data: - save_kwargs['exif'] = exif_data - else: - save_kwargs['format'] = original_format - - try: - cropped_img.save(original_path, **save_kwargs) - except Exception as e: - log.warning(f"Could not save with original format settings: {e}") - cropped_img.save(original_path) - - # Restore original modification and access times to preserve file position in sorted list - import os - os.utime(original_path, (original_atime, original_mtime)) + + self.image_editor.set_crop_box(crop_box_raw) + + # Sync straighten_angle (crop rotation) from UI to ImageEditor before saving + if hasattr(self.ui_state, 'cropRotation'): + self.image_editor.set_edit_param('straighten_angle', self.ui_state.cropRotation) + + # Save via ImageEditor (handles rotation + crop correctly) + save_result = self.image_editor.save_image() + + if save_result: + saved_path, backup_path = save_result # Track for undo import time timestamp = time.time() - self.undo_history.append(("crop", (str(original_path), str(backup_path)), timestamp)) + self.undo_history.append(("crop", (str(saved_path), str(backup_path)), timestamp)) # Exit crop mode self.ui_state.isCropping = False @@ -2681,9 +2654,9 @@ def execute_crop(self): # Refresh the view self.refresh_image_list() - # Find the edited image in the refreshed list + # Find the edited image for i, img_file in enumerate(self.image_files): - if img_file.path == original_path: + if img_file.path == saved_path: self.current_index = i break @@ -2694,44 +2667,83 @@ def execute_crop(self): self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() - # Reset zoom/pan to fit the new cropped image + # Reset zoom/pan self.ui_state.resetZoomPan() - # Update histogram if visible if self.ui_state.isHistogramVisible: self.update_histogram() self.update_status_message("Image cropped and saved") - log.info("Crop operation completed for %s", filepath) + log.info("Crop operation completed for %s", saved_path) - except Exception as e: - self.update_status_message(f"Crop failed: {e}") - log.exception("Failed to crop image") + # Force reload of editor to ensure subsequent edits operate on the cropped image + self.image_editor.clear() + self.reset_edit_parameters() + + else: + self.update_status_message("Failed to save cropped image") @Slot() def auto_levels(self): - """Calculates and applies auto levels (preview only).""" + """Calculates and applies auto levels (preview only). Returns False if skipped.""" if not self.image_files: self.update_status_message("No image to adjust") - return + return False image_file = self.image_files[self.current_index] filepath = str(image_file.path) # Ensure image is loaded in editor - # Only load if not already loaded to avoid resetting other edits if not self.image_editor.current_filepath or str(self.image_editor.current_filepath) != filepath: cached_preview = self.get_decoded_image(self.current_index) if not self.image_editor.load_image(filepath, cached_preview=cached_preview): self.update_status_message("Failed to load image") - return + return False # Calculate auto levels blacks, whites = self.image_editor.auto_levels(self.auto_level_threshold) # Scale by strength - blacks *= self.auto_level_strength - whites *= self.auto_level_strength + skipped_due_to_clipping = False + 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 + else: + strength = self.auto_level_strength + except Exception as e: + log.warning(f"Failed to calculate auto strength: {e}") + strength = self.auto_level_strength + 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 + + blacks *= strength + whites *= strength # Apply scaled values self.image_editor.set_edit_param('blacks', blacks) @@ -2749,7 +2761,8 @@ def auto_levels(self): 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, self.auto_level_strength) + filepath, self.auto_level_threshold, strength) + return True @Slot() def quick_auto_levels(self): @@ -2759,8 +2772,13 @@ def quick_auto_levels(self): return # Apply the preview first (loads image + sets params) - self.auto_levels() + applied = self.auto_levels() + # If in auto mode and no changes were made (skipped), don't save + if self.auto_level_strength_auto and not applied: + # Status message already set by auto_levels ("No changes made...") + return + # Save import time save_result = self.image_editor.save_image() @@ -2820,6 +2838,17 @@ def set_auto_level_strength(self, value: float): config.set('core', 'auto_level_strength', str(value)) config.save() + @Slot(result=bool) + def get_auto_level_strength_auto(self): + return self.auto_level_strength_auto + + @Slot(bool) + def set_auto_level_strength_auto(self, value: bool): + if self.auto_level_strength_auto != value: + self.auto_level_strength_auto = value + config.set('core', 'auto_level_strength_auto', str(value)) + config.save() + @Slot() def quick_auto_white_balance(self): """Quickly apply auto white balance, save the image, and track for undo.""" @@ -2973,7 +3002,8 @@ def auto_white_balance_lab(self): _LUMA_LOWER_BOUND = config.getint('awb', 'luma_lower_bound', 30) _LUMA_UPPER_BOUND = config.getint('awb', 'luma_upper_bound', 220) warm_bias = config.getint('awb', 'warm_bias', 6) - _TARGET_A_LAB = 128.0 + tint_bias = config.getint('awb', 'tint_bias', 0) + _TARGET_A_LAB = 128.0 + tint_bias _TARGET_B_LAB = 128.0 + warm_bias _SCALING_FACTOR_LAB_TO_SLIDER = 128.0 _CORRECTION_STRENGTH = config.getfloat('awb', 'strength', 0.7) diff --git a/faststack/faststack/config.py b/faststack/faststack/config.py index 513782d..6400992 100644 --- a/faststack/faststack/config.py +++ b/faststack/faststack/config.py @@ -17,6 +17,7 @@ "optimize_for": "speed", # "speed" or "quality" "auto_level_threshold": "0.1", "auto_level_strength": "1.0", + "auto_level_strength_auto": "False", }, "helicon": { "exe": "C:\\Program Files\\Helicon Software\\Helicon Focus 8\\HeliconFocus.exe", @@ -35,6 +36,7 @@ "mode": "lab", # "lab" or "rgb" "strength": "0.7", "warm_bias": "6", + "tint_bias": "0", "luma_lower_bound": "30", "luma_upper_bound": "220", "rgb_lower_bound": "5", diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index bf59b40..16883b3 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -143,17 +143,107 @@ def _apply_edits(self, img: Image.Image) -> Image.Image: # 2. Free Rotation (Straighten) straighten_angle = self.current_edits['straighten_angle'] if abs(straighten_angle) > 0.001: - img = img.rotate(straighten_angle, resample=Image.Resampling.BICUBIC, expand=True) + # PIL rotate is CCW, but our UI rotation is CW. Use negative angle. + img = img.rotate(-straighten_angle, resample=Image.Resampling.BICUBIC, expand=True) # 3. Cropping crop_box = self.current_edits.get('crop_box') if crop_box: - width, height = img.size - left = int(crop_box[0] * width / 1000) - top = int(crop_box[1] * height / 1000) - right = int(crop_box[2] * width / 1000) - bottom = int(crop_box[3] * height / 1000) + # crop_box is normalized (0-1000) relative to the *un-rotated* image (or rather, the image as seen in the UI). + # Since we rotated the image, we need to map this box into the rotated coordinate space. + + # Original dimensions (after discrete rotation but before free rotation) + # We don't have them stored directly, but we can infer. + # The 'img' here is already rotated and expanded. + # We need the dimensions *before* step 2. + + # Let's reconstruct dimensions. + # If we rotate back by -angle, we get original rect? + # Easier: Calculate the transformation of the crop box center. + + # 1. Get expanded dimensions + new_w, new_h = img.size + + # 2. Calculate original dimensions (approximate or exact?) + # Since we don't have the original object here easily without reloading or passing it, + # we can use the crop box normalization to work backward? No. + # Better approach: Store original dims before rotation. + # But we are inside _apply_edits where 'img' is mutated step-by-step. + # We need to know what 'img.size' was *before* the rotate(-straighten_angle) call. + # Since we overwrote 'img', we can't get it from 'img'. + + # Strategy: Create a temporary dummy image of the same size as the pre-rotated image to calculate bounds? + # Or just mathematically invert the rotation bounding box expansion? + # Simpler: Modify this method to track previous size. + pass + + # We need to refactor _apply_edits slightly to capture size before free rotation. + # Since I can't easily see the lines above "2. Free Rotation" in this REPLACE block without re-reading, + # I will assume I need to insert the size capture before the rotation. + + # The replace block spans from the rotation section. + # I will capture size before rotation. + + # BUT wait, I am replacing the existing block. + # I need to grab the size of 'img' *before* calling img.rotate(). + + w_prev, h_prev = img.size + + # Now rotate + img = img.rotate(-straighten_angle, resample=Image.Resampling.BICUBIC, expand=True) + new_w, new_h = img.size + + # Now map crop box + # De-normalize crop box using ORIGINAL (pre-rotation) dimensions + cx_norm = (crop_box[0] + crop_box[2]) / 2000 + cy_norm = (crop_box[1] + crop_box[3]) / 2000 + cw_norm = (crop_box[2] - crop_box[0]) / 1000 + ch_norm = (crop_box[3] - crop_box[1]) / 1000 + + cx = cx_norm * w_prev + cy = cy_norm * h_prev + cw = cw_norm * w_prev + ch = ch_norm * h_prev + + # Transform center from old coordinate system to new coordinate system + # Old center of image: (w_prev/2, h_prev/2) + # New center of image: (new_w/2, new_h/2) + # Point relative to old center: + dx = cx - w_prev / 2 + dy = cy - h_prev / 2 + + # Rotate this vector by -straighten_angle (CCW if angle is positive CW? No.) + # straighten_angle is CW degrees. + # We rotated image by -straighten_angle (CCW degrees). + # So the vector should be rotated by -straighten_angle? + # Yes, the image content rotated CCW. A point fixed on the image content also rotates CCW relative to center. + + import math + rad = math.radians(-straighten_angle) # CCW rotation in math + + # Standard rotation matrix for CCW angle 'rad': + # x' = x cos - y sin + # y' = x sin + y cos + dx_rot = dx * math.cos(rad) - dy * math.sin(rad) + dy_rot = dx * math.sin(rad) + dy * math.cos(rad) + + # New absolute center + cx_rot = new_w / 2 + dx_rot + cy_rot = new_h / 2 + dy_rot + + # Define crop rect centered at cx_rot, cy_rot with same dimensions (cw, ch) + # because we rotate the image to align with the box, so the box becomes axis-aligned + # and retains its dimensions relative to the image content. + + left = int(cx_rot - cw / 2) + top = int(cy_rot - ch / 2) + right = int(cx_rot + cw / 2) + bottom = int(cy_rot + ch / 2) + img = img.crop((left, top, right, bottom)) + elif abs(straighten_angle) > 0.001: + # No crop box but rotation? Just keep the rotated expanded image. + pass # 3. Exposure (gamma-based) exposure = self.current_edits['exposure'] @@ -402,14 +492,38 @@ def save_image(self) -> Optional[Tuple[Path, Path]]: # Re-open original to correctly detect format and get EXIF with Image.open(original_path) as original_img: original_format = original_img.format or original_path.suffix.lstrip('.').upper() - exif_data = original_img.info.get('exif') + + # Handle EXIF + exif_bytes = original_img.info.get('exif') + + # Try to reset orientation to Normal (1) if EXIF exists + if exif_bytes: + try: + # Load exif data as an object + exif = original_img.getexif() + # Tag 274 is Orientation. Set to 1 (Normal) + if 274 in exif: + exif[274] = 1 + # Serialize back to bytes - Pillow >= 8.2.0 required for tobytes() + # If tobytes() is missing, we might skip writing modified EXIF or write original + if hasattr(exif, 'tobytes'): + exif_bytes = exif.tobytes() + else: + # Fallback for older Pillow: skip writing EXIF if we can't sanitize it + # to avoid double-rotation bug. + print("Warning: Pillow too old to sanitize EXIF bytes. Skipping EXIF write to prevent double-rotation.") + exif_bytes = None + except Exception as e: + print(f"Warning: Failed to sanitize EXIF orientation: {e}") + # Fallback: safer to skip EXIF than write bad orientation + exif_bytes = None save_kwargs = {} if original_format == 'JPEG': save_kwargs['format'] = 'JPEG' save_kwargs['quality'] = 95 - if exif_data: - save_kwargs['exif'] = exif_data + if exif_bytes: + save_kwargs['exif'] = exif_bytes else: save_kwargs['format'] = original_format diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 34c30e4..bee88e9 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -461,24 +461,46 @@ Item { scaleTransform.yScale = newScale } } else { - // Zoom out: always zoom towards center of screen - scaleTransform.origin.x = centerX - scaleTransform.origin.y = centerY - - // When zooming out, we need to adjust pan to keep the center visible - // The pan values are in screen coordinates, but they represent image-space offsets - // When scale changes, we need to scale the pan proportionally to maintain - // the same visual position relative to the center - var scaleRatio = newScale / oldScale + // Zoom out: always zoom towards center of screen, but keep current origin logic + // The issue is switching origin abruptly causes jumps. + // If we are zoomed in, we should zoom out relative to the current view center or cursor. + + // If we simply zoom out without changing origin, it zooms out from wherever the origin currently is. + // If the origin was set to a specific point during zoom in, keeping it there is fine. + // Resetting origin to center (centerX, centerY) causes the jump because the image shifts to align its center with the new origin. - // Adjust pan to keep the center point fixed - // If we're zooming out (scaleRatio < 1), pan should be reduced proportionally - panTransform.x = panTransform.x * scaleRatio - panTransform.y = panTransform.y * scaleRatio + // Let's keep the current origin unless we are fully zoomed out. + // Or better: zoom out relative to the cursor just like zooming in, which feels most natural. + + var mouseX = wheel.x + var mouseY = wheel.y + + // Use cursor as origin for zoom out too + scaleTransform.origin.x = mouseX + scaleTransform.origin.y = mouseY + + // We need similar pan compensation to keep the point under cursor stable + var imgWidth = mainImage.paintedWidth + var imgHeight = mainImage.paintedHeight + var imgX = (mainImage.width - imgWidth) / 2 + var imgY = (mainImage.height - imgHeight) / 2 + var pointInImageX = mouseX - imgX + var pointInImageY = mouseY - imgY + + var centerOffsetX = pointInImageX - imgWidth / 2 + var centerOffsetY = pointInImageY - imgHeight / 2 + var scaledImagePointX = centerOffsetX - panTransform.x + var scaledImagePointY = centerOffsetY - panTransform.y - // Apply the new scale scaleTransform.xScale = newScale scaleTransform.yScale = newScale + + var scaleRatio = newScale / oldScale + var newPanX = centerOffsetX - (scaledImagePointX * scaleRatio) + var newPanY = centerOffsetY - (scaledImagePointY * scaleRatio) + + panTransform.x = newPanX + panTransform.y = newPanY } // Re-enable smooth rendering after a short delay @@ -499,34 +521,51 @@ Item { var imgCoord1 = mapToImageCoordinates(Qt.point(x1, y1)) var imgCoord2 = mapToImageCoordinates(Qt.point(x2, y2)) - // Clamp to image bounds + // Clamp to image bounds (normalized 0-1) var imgCoordX1 = Math.max(0, Math.min(1, imgCoord1.x)) var imgCoordY1 = Math.max(0, Math.min(1, imgCoord1.y)) var imgCoordX2 = Math.max(0, Math.min(1, imgCoord2.x)) var imgCoordY2 = Math.max(0, Math.min(1, imgCoord2.y)) - // Ensure left < right and top < bottom + // Calculate raw box in 0-1000 space var left = Math.min(imgCoordX1, imgCoordX2) * 1000 var right = Math.max(imgCoordX1, imgCoordX2) * 1000 var top = Math.min(imgCoordY1, imgCoordY2) * 1000 var bottom = Math.max(imgCoordY1, imgCoordY2) * 1000 - // Ensure minimum size - if (right - left < 10) { - if (right < 1000) right = left + 10 - else left = right - 10 - } - if (bottom - top < 10) { - if (bottom < 1000) bottom = top + 10 - else top = bottom - 10 - } - - if (applyAspectRatio) { - var constrainedBox = applyAspectRatioConstraint(left, top, right, bottom, "new") + // Determine primary drag direction for "new" mode (from anchor x1,y1 to mouse x2,y2) + // We need to know which corner is the anchor to apply aspect ratio correctly + // x1,y1 is anchor. x2,y2 is mouse. + + if (applyAspectRatio && mainImage.sourceSize) { + // We need to pass the specific corner being dragged to applyAspectRatioConstraint + // Since "new" creates a box from x1,y1 to x2,y2, we can infer the mode. + var mode = "new" + if (x2 >= x1 && y2 >= y1) mode = "bottomright" + else if (x2 < x1 && y2 >= y1) mode = "bottomleft" + else if (x2 >= x1 && y2 < y1) mode = "topright" + else if (x2 < x1 && y2 < y1) mode = "topleft" + + // Pass the raw coordinates of the "mouse" corner (x2, y2) and the "anchor" corner (x1, y1) + // But applyAspectRatioConstraint expects left, top, right, bottom. + // It assumes one corner is fixed based on mode. + // So we pass the current box, and it will adjust the moving corner. + + var constrainedBox = applyAspectRatioConstraint(left, top, right, bottom, mode) left = constrainedBox[0] top = constrainedBox[1] right = constrainedBox[2] bottom = constrainedBox[3] + } else { + // Just ensure minimum size + if (right - left < 10) { + if (right < 1000) right = Math.min(1000, left + 10) + else left = Math.max(0, right - 10) + } + if (bottom - top < 10) { + if (bottom < 1000) bottom = Math.min(1000, top + 10) + else top = Math.max(0, bottom - 10) + } } uiState.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] @@ -544,101 +583,248 @@ Item { function applyAspectRatioConstraint(left, top, right, bottom, dragMode) { if (uiState.currentAspectRatioIndex <= 0 || !uiState.aspectRatioNames || uiState.aspectRatioNames.length <= uiState.currentAspectRatioIndex) { - return [left, top, right, bottom]; + // No aspect ratio, just clamp to bounds + return [ + Math.max(0, Math.min(1000, left)), + Math.max(0, Math.min(1000, top)), + Math.max(0, Math.min(1000, right)), + Math.max(0, Math.min(1000, bottom)) + ]; } var ratioName = uiState.aspectRatioNames[uiState.currentAspectRatioIndex]; - var ratio = getAspectRatio(ratioName); - if (!ratio) { + var ratioPair = getAspectRatio(ratioName); + if (!ratioPair || !mainImage.sourceSize || mainImage.sourceSize.width === 0 || mainImage.sourceSize.height === 0) { return [left, top, right, bottom]; } - var targetAspect = ratio[0] / ratio[1]; - var width = right - left; - var height = bottom - top; - - // Adjust dimensions based on which edge/corner is being dragged - if (dragMode.includes("left") || dragMode.includes("right")) { - height = width / targetAspect; - } else if (dragMode.includes("top") || dragMode.includes("bottom")) { - width = height * targetAspect; - } else if (dragMode === "new") { - if(width / height > targetAspect) { - width = height * targetAspect; - } else { - height = width / targetAspect; - } - } - - - // Anchor the box to the correct edge/corner - if (dragMode.includes("top")) { - top = bottom - height; - } else { - bottom = top + height; - } + // Calculate effective aspect ratio in 0-1000 normalized space + // targetAspect (pixels) = width_px / height_px + // width_px = width_norm * imgW / 1000 + // height_px = height_norm * imgH / 1000 + // targetAspect = (width_norm * imgW) / (height_norm * imgH) + // width_norm / height_norm = targetAspect * (imgH / imgW) - if (dragMode.includes("left")) { - left = right - width; - } else { - right = left + width; - } + var pixelAspect = ratioPair[0] / ratioPair[1]; + var imageAspect = mainImage.sourceSize.width / mainImage.sourceSize.height; + var targetAspect = pixelAspect * (1.0 / imageAspect); // Normalized aspect ratio + var currentWidth = right - left; + var currentHeight = bottom - top; - // Clamp to image bounds and readjust - if (left < 0) { - left = 0; - right = width; - } - if (right > 1000) { - right = 1000; - left = 1000 - width; - } - if (top < 0) { - top = 0; - bottom = height; - } - if (bottom > 1000) { - bottom = 1000; - top = 1000 - height; - } + // For "new" drag (which we mapped to specific corners in updateCropBox) or corner drags - // Final check to ensure aspect ratio is maintained after clamping - var finalWidth = right - left; - var finalHeight = bottom - top; - - if(Math.abs(finalWidth / finalHeight - targetAspect) > 0.01) { - if (dragMode.includes("left") || dragMode.includes("right")) { - finalWidth = finalHeight * targetAspect; - if(dragMode.includes("left")) { - left = right - finalWidth; - } else { - right = left + finalWidth; + if (dragMode.includes("left") || dragMode.includes("right")) { + // Edge drag (Left/Right) or Corner drag (where Width drives Height) + // Standard behavior: Corner drags are driven by the dominant axis or strictly one axis? + // Let's use the explicit corner logic below. + // This block handles pure Edge drags. + + if (!dragMode.includes("top") && !dragMode.includes("bottom")) { + // Pure Left/Right drag: Adjust height symmetrically + var newWidth = right - left; + var newHeight = newWidth / targetAspect; + var vCenter = (cropBoxStartTop + cropBoxStartBottom) / 2; + + top = vCenter - newHeight / 2; + bottom = vCenter + newHeight / 2; + + // Clamp vertical + var clamped = false; + if (top < 0) { + top = 0; + bottom = newHeight; + if (bottom > 1000) { bottom = 1000; clamped = true; } } - } else { - finalHeight = finalWidth / targetAspect; - if(dragMode.includes("top")) { + if (bottom > 1000) { + bottom = 1000; + top = 1000 - newHeight; + if (top < 0) { top = 0; clamped = true; } + } + + // If height was clamped, recalculate width + if (clamped) { + var finalHeight = bottom - top; + var finalWidth = finalHeight * targetAspect; + // Adjust left/right to match final width (anchor opposite side) + if (dragMode.includes("left")) { + left = right - finalWidth; + } else { + right = left + finalWidth; + } + } + } + } + + if ((dragMode.includes("top") || dragMode.includes("bottom")) && !dragMode.includes("left") && !dragMode.includes("right")) { + // Pure Top/Bottom drag: Adjust width symmetrically + var newHeight = bottom - top; + var newWidth = newHeight * targetAspect; + var hCenter = (cropBoxStartLeft + cropBoxStartRight) / 2; + + left = hCenter - newWidth / 2; + right = hCenter + newWidth / 2; + + // Clamp horizontal + var clamped = false; + if (left < 0) { + left = 0; + right = newWidth; + if (right > 1000) { right = 1000; clamped = true; } + } + if (right > 1000) { + right = 1000; + left = 1000 - newWidth; + if (left < 0) { left = 0; clamped = true; } + } + + if (clamped) { + var finalWidth = right - left; + var finalHeight = finalWidth / targetAspect; + if (dragMode.includes("top")) { top = bottom - finalHeight; } else { bottom = top + finalHeight; } } } + + // Corner Drags + if (dragMode.includes("topleft")) { // Corner: Top-Left (Anchor: Bottom-Right) + var newW = right - left; + var newH = newW / targetAspect; + + // Check bounds + if (bottom - newH < 0) { // Top < 0 + newH = bottom; + newW = newH * targetAspect; + } + if (right - newW < 0) { // Left < 0 (shouldn't happen if we started inside, but good to check) + // If we are here, it means even with max height, width is too big? + // Just clamp to 0 + } + + left = right - newW; + top = bottom - newH; + + } else if (dragMode.includes("topright")) { // Corner: Top-Right (Anchor: Bottom-Left) + var newW = right - left; + var newH = newW / targetAspect; + + // Check bounds: top >= 0 + if (bottom - newH < 0) { + newH = bottom; + newW = newH * targetAspect; + } + // Check bounds: right <= 1000 + if (left + newW > 1000) { + newW = 1000 - left; + newH = newW / targetAspect; + } + + right = left + newW; + top = bottom - newH; + + } else if (dragMode.includes("bottomleft")) { // Corner: Bottom-Left (Anchor: Top-Right) + var newW = right - left; + var newH = newW / targetAspect; + + // Check bounds: bottom <= 1000 + if (top + newH > 1000) { + newH = 1000 - top; + newW = newH * targetAspect; + } + // Check bounds: left >= 0 + if (right - newW < 0) { + newW = right; + newH = newW / targetAspect; + } + + left = right - newW; + bottom = top + newH; + + } else if (dragMode.includes("bottomright")) { // Corner: Bottom-Right (Anchor: Top-Left) + var newW = right - left; + var newH = newW / targetAspect; + + // Check bounds: bottom <= 1000 + if (top + newH > 1000) { + newH = 1000 - top; + newW = newH * targetAspect; + } + // Check bounds: right <= 1000 + if (left + newW > 1000) { + newW = 1000 - left; + newH = newW / targetAspect; + } + + right = left + newW; + bottom = top + newH; + } - - return [left, top, right, bottom]; + return [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)]; } function updateCropBoxFromAspectRatio() { if (!uiState || !uiState.currentCropBox || uiState.currentCropBox.length !== 4) return var box = uiState.currentCropBox - updateCropBox( - box[0] / 1000 * mainImage.paintedWidth + (mainImage.width - mainImage.paintedWidth) / 2, - box[1] / 1000 * mainImage.paintedHeight + (mainImage.height - mainImage.paintedHeight) / 2, - box[2] / 1000 * mainImage.paintedWidth + (mainImage.width - mainImage.paintedWidth) / 2, - box[3] / 1000 * mainImage.paintedHeight + (mainImage.height - mainImage.paintedHeight) / 2, - true - ) + + // Start with center of current box + var cx = (box[0] + box[2]) / 2 + var cy = (box[1] + box[3]) / 2 + + // If current box is basically full image (default), use image center + if (box[0] <= 10 && box[1] <= 10 && box[2] >= 990 && box[3] >= 990) { + cx = 500 + cy = 500 + } + + var ratioName = uiState.aspectRatioNames[uiState.currentAspectRatioIndex]; + var ratioPair = getAspectRatio(ratioName); + + if (!ratioPair) { // Freeform selected + uiState.currentCropBox = [0, 0, 1000, 1000] // Reset to full image + mainMouseArea.cropRotation = 0 // Also reset visual rotation + mainMouseArea.isRotating = false + mainMouseArea.cropDragMode = "none" + return; + } + var targetAspect = ratioPair[0] / ratioPair[1]; + + // Maximize width/height within 0-1000 centered at cx, cy + // Distance to edges + var maxW_half = Math.min(cx, 1000 - cx) + var maxH_half = Math.min(cy, 1000 - cy) + + // Try fitting to width limits first + var width = maxW_half * 2 + var height = width / targetAspect + + // If height exceeds limits, scale down + if (height > maxH_half * 2) { + height = maxH_half * 2 + width = height * targetAspect + } + + // Also ensure we don't make a tiny box if cx,cy is near edge. + // If box is too small (<100), re-center to image center (500,500) + if (width < 100 || height < 100) { + cx = 500; cy = 500; + maxW_half = 500; maxH_half = 500; + width = 1000; + height = width / targetAspect; + if (height > 1000) { + height = 1000; + width = height * targetAspect; + } + } + + var left = cx - width / 2 + var right = cx + width / 2 + var top = cy - height / 2 + var bottom = cy + height / 2 + + uiState.currentCropBox = [Math.round(left), Math.round(top), Math.round(right), Math.round(bottom)] } } diff --git a/faststack/faststack/qml/HistogramWindow.qml b/faststack/faststack/qml/HistogramWindow.qml index 26a752b..e5967ad 100644 --- a/faststack/faststack/qml/HistogramWindow.qml +++ b/faststack/faststack/qml/HistogramWindow.qml @@ -19,9 +19,10 @@ Window { Keys.onPressed: function(event) { if (event.key === Qt.Key_H && controller) { - event.accepted = true controller.toggle_histogram() + event.accepted = true // Only accept if H is pressed } + // For other keys, event.accepted remains false, allowing propagation } } diff --git a/faststack/faststack/qml/SettingsDialog.qml b/faststack/faststack/qml/SettingsDialog.qml index 7240696..9b6b635 100644 --- a/faststack/faststack/qml/SettingsDialog.qml +++ b/faststack/faststack/qml/SettingsDialog.qml @@ -10,7 +10,7 @@ Dialog { closePolicy: Popup.CloseOnEscape focus: true width: 600 - height: 700 + height: 770 // Live cache usage value (updated by timer) property real cacheUsage: 0.0 @@ -32,12 +32,14 @@ Dialog { optimizeForComboBox.currentIndex = optimizeForComboBox.model.indexOf(settingsDialog.optimizeFor) autoLevelThresholdField.text = settingsDialog.autoLevelClippingThreshold.toFixed(4) settingsDialog.autoLevelStrength = uiState.autoLevelStrength + settingsDialog.autoLevelStrengthAuto = uiState.autoLevelStrengthAuto } property string heliconPath: "" property double cacheSize: 1.5 property double autoLevelClippingThreshold: 0.1 property double autoLevelStrength: 1.0 + property bool autoLevelStrengthAuto: false property int prefetchRadius: 4 property int theme: 0 property string defaultDirectory: "" @@ -47,6 +49,7 @@ Dialog { property string awbMode: "lab" property double awbStrength: 0.7 property int awbWarmBias: 6 + property int awbTintBias: 0 property int awbLumaLowerBound: 30 property int awbLumaUpperBound: 220 @@ -63,10 +66,12 @@ Dialog { uiState.set_optimize_for(optimizeFor) uiState.autoLevelClippingThreshold = autoLevelClippingThreshold uiState.autoLevelStrength = autoLevelStrength + uiState.autoLevelStrengthAuto = autoLevelStrengthAuto uiState.awbMode = awbMode uiState.awbStrength = awbStrength uiState.awbWarmBias = awbWarmBias + uiState.awbTintBias = awbTintBias uiState.awbLumaLowerBound = awbLumaLowerBound uiState.awbLumaUpperBound = awbLumaUpperBound @@ -241,14 +246,25 @@ Dialog { // Auto Levels Strength Label { text: "Auto Levels Strength:" } - Slider { - id: autoLevelStrengthSlider - from: 0.0 - to: 1.0 - stepSize: 0.05 - value: settingsDialog.autoLevelStrength - onValueChanged: settingsDialog.autoLevelStrength = value + 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 + } } Label { text: Math.round(settingsDialog.autoLevelStrength * 100) + "%" } } @@ -257,9 +273,17 @@ Dialog { columns: 3 // --- Auto White Balance --- - Label { - text: "Auto WB Mode:" + 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 @@ -272,7 +296,17 @@ Dialog { Layout.topMargin: 10 } - Label { text: "Auto WB Strength:" } + 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 @@ -282,22 +316,57 @@ Dialog { } Label { text: (awbStrengthSlider.value * 100).toFixed(0) + "%" } - Label { text: "Auto WB Warm Bias:" } + 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: -10 - to: 20 + 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." } GridLayout { @@ -306,38 +375,82 @@ Dialog { Layout.columnSpan: 3 Layout.fillWidth: true - Label { text: "Luma Lower Bound:" } + MouseArea { + width: lumaLowerLabel.implicitWidth + height: lumaLowerLabel.implicitHeight + hoverEnabled: true + Label { + id: lumaLowerLabel + text: "Luma Lower Bound:" + } + 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 {} - Label { text: "Luma Upper Bound:" } + MouseArea { + width: lumaUpperLabel.implicitWidth + height: lumaUpperLabel.implicitHeight + hoverEnabled: true + Label { + id: lumaUpperLabel + text: "Luma Upper Bound:" + } + 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 {} - Label { text: "RGB Lower Bound:" } + MouseArea { + width: rgbLowerLabel.implicitWidth + height: rgbLowerLabel.implicitHeight + hoverEnabled: true + Label { + id: rgbLowerLabel + text: "RGB Lower Bound:" + } + 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 {} - Label { text: "RGB Upper Bound:" } + MouseArea { + width: rgbUpperLabel.implicitWidth + height: rgbUpperLabel.implicitHeight + hoverEnabled: true + Label { + id: rgbUpperLabel + text: "RGB Upper Bound:" + } + 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 } Label {} diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 61fbb07..c2f1dbd 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -87,6 +87,7 @@ class UIState(QObject): awbModeChanged = Signal() awbStrengthChanged = Signal() awbWarmBiasChanged = Signal() + awbTintBiasChanged = Signal() awbLumaLowerBoundChanged = Signal() awbLumaUpperBoundChanged = Signal() awbRgbLowerBoundChanged = Signal() @@ -95,6 +96,7 @@ class UIState(QObject): isStackedJpgChanged = Signal() # New signal for isStackedJpg autoLevelClippingThresholdChanged = Signal(float) autoLevelStrengthChanged = Signal(float) + autoLevelStrengthAutoChanged = Signal(bool) # Image Editor Signals is_editor_open_changed = Signal(bool) is_cropping_changed = Signal(bool) @@ -337,6 +339,15 @@ def awbWarmBias(self, value: int): self.app_controller.set_awb_warm_bias(value) self.awbWarmBiasChanged.emit() + @Property(int, notify=awbTintBiasChanged) + def awbTintBias(self): + return self.app_controller.get_awb_tint_bias() + + @awbTintBias.setter + def awbTintBias(self, value: int): + self.app_controller.set_awb_tint_bias(value) + self.awbTintBiasChanged.emit() + @Property(int, notify=awbLumaLowerBoundChanged) def awbLumaLowerBound(self): return self.app_controller.get_awb_luma_lower_bound() @@ -497,6 +508,15 @@ def autoLevelStrength(self, value): self.app_controller.set_auto_level_strength(value) self.autoLevelStrengthChanged.emit(value) + @Property(bool, notify=autoLevelStrengthAutoChanged) + def autoLevelStrengthAuto(self): + return self.app_controller.get_auto_level_strength_auto() + + @autoLevelStrengthAuto.setter + def autoLevelStrengthAuto(self, value): + self.app_controller.set_auto_level_strength_auto(value) + self.autoLevelStrengthAutoChanged.emit(value) + @Slot() def open_folder(self): self.app_controller.open_folder() @@ -713,6 +733,8 @@ def currentCropBox(self, new_value): if self._current_crop_box != new_value: self._current_crop_box = new_value self.current_crop_box_changed.emit(new_value) + # Sync with ImageEditor + self.app_controller.image_editor.set_crop_box(new_value) @Property(float, notify=crop_rotation_changed) def cropRotation(self) -> float: From 15b3570bc5b260e8faeec8428ce3fe2df3981fb4 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Mon, 8 Dec 2025 16:56:01 -0800 Subject: [PATCH 2/2] minor bug fixes --- faststack/faststack/imaging/editor.py | 118 ++++--------------------- faststack/faststack/qml/Components.qml | 67 ++++++++++---- 2 files changed, 70 insertions(+), 115 deletions(-) diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index 16883b3..5505a32 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -129,121 +129,41 @@ def load_image(self, filepath: str, cached_preview: Optional[DecodedImage] = Non self._preview_image = None return False - def _apply_edits(self, img: Image.Image) -> Image.Image: + def _apply_edits(self, img: Image.Image, is_export: bool = False) -> Image.Image: """Applies all current edits to the provided PIL Image.""" # 1. Rotation rotation = self.current_edits['rotation'] if rotation == 90: - img = img.transpose(Image.Transpose.ROTATE_90) + img = img.transpose(Image.Transpose.ROTATE_270) # 90 CW = 270 CCW elif rotation == 180: img = img.transpose(Image.Transpose.ROTATE_180) elif rotation == 270: - img = img.transpose(Image.Transpose.ROTATE_270) + img = img.transpose(Image.Transpose.ROTATE_90) # 270 CW = 90 CCW # 2. Free Rotation (Straighten) straighten_angle = self.current_edits['straighten_angle'] if abs(straighten_angle) > 0.001: - # PIL rotate is CCW, but our UI rotation is CW. Use negative angle. + # PIL rotate is CCW. We want UI CW. Use negative. + # expand=True changes dimensions. img = img.rotate(-straighten_angle, resample=Image.Resampling.BICUBIC, expand=True) # 3. Cropping crop_box = self.current_edits.get('crop_box') - if crop_box: - # crop_box is normalized (0-1000) relative to the *un-rotated* image (or rather, the image as seen in the UI). - # Since we rotated the image, we need to map this box into the rotated coordinate space. + if crop_box and len(crop_box) == 4: + width, height = img.size + # Normalized 0-1000 to pixels + left = int(crop_box[0] * width / 1000) + top = int(crop_box[1] * height / 1000) + right = int(crop_box[2] * width / 1000) + bottom = int(crop_box[3] * height / 1000) - # Original dimensions (after discrete rotation but before free rotation) - # We don't have them stored directly, but we can infer. - # The 'img' here is already rotated and expanded. - # We need the dimensions *before* step 2. - - # Let's reconstruct dimensions. - # If we rotate back by -angle, we get original rect? - # Easier: Calculate the transformation of the crop box center. - - # 1. Get expanded dimensions - new_w, new_h = img.size - - # 2. Calculate original dimensions (approximate or exact?) - # Since we don't have the original object here easily without reloading or passing it, - # we can use the crop box normalization to work backward? No. - # Better approach: Store original dims before rotation. - # But we are inside _apply_edits where 'img' is mutated step-by-step. - # We need to know what 'img.size' was *before* the rotate(-straighten_angle) call. - # Since we overwrote 'img', we can't get it from 'img'. - - # Strategy: Create a temporary dummy image of the same size as the pre-rotated image to calculate bounds? - # Or just mathematically invert the rotation bounding box expansion? - # Simpler: Modify this method to track previous size. - pass - - # We need to refactor _apply_edits slightly to capture size before free rotation. - # Since I can't easily see the lines above "2. Free Rotation" in this REPLACE block without re-reading, - # I will assume I need to insert the size capture before the rotation. - - # The replace block spans from the rotation section. - # I will capture size before rotation. - - # BUT wait, I am replacing the existing block. - # I need to grab the size of 'img' *before* calling img.rotate(). - - w_prev, h_prev = img.size - - # Now rotate - img = img.rotate(-straighten_angle, resample=Image.Resampling.BICUBIC, expand=True) - new_w, new_h = img.size - - # Now map crop box - # De-normalize crop box using ORIGINAL (pre-rotation) dimensions - cx_norm = (crop_box[0] + crop_box[2]) / 2000 - cy_norm = (crop_box[1] + crop_box[3]) / 2000 - cw_norm = (crop_box[2] - crop_box[0]) / 1000 - ch_norm = (crop_box[3] - crop_box[1]) / 1000 - - cx = cx_norm * w_prev - cy = cy_norm * h_prev - cw = cw_norm * w_prev - ch = ch_norm * h_prev - - # Transform center from old coordinate system to new coordinate system - # Old center of image: (w_prev/2, h_prev/2) - # New center of image: (new_w/2, new_h/2) - # Point relative to old center: - dx = cx - w_prev / 2 - dy = cy - h_prev / 2 - - # Rotate this vector by -straighten_angle (CCW if angle is positive CW? No.) - # straighten_angle is CW degrees. - # We rotated image by -straighten_angle (CCW degrees). - # So the vector should be rotated by -straighten_angle? - # Yes, the image content rotated CCW. A point fixed on the image content also rotates CCW relative to center. - - import math - rad = math.radians(-straighten_angle) # CCW rotation in math - - # Standard rotation matrix for CCW angle 'rad': - # x' = x cos - y sin - # y' = x sin + y cos - dx_rot = dx * math.cos(rad) - dy * math.sin(rad) - dy_rot = dx * math.sin(rad) + dy * math.cos(rad) - - # New absolute center - cx_rot = new_w / 2 + dx_rot - cy_rot = new_h / 2 + dy_rot - - # Define crop rect centered at cx_rot, cy_rot with same dimensions (cw, ch) - # because we rotate the image to align with the box, so the box becomes axis-aligned - # and retains its dimensions relative to the image content. - - left = int(cx_rot - cw / 2) - top = int(cy_rot - ch / 2) - right = int(cx_rot + cw / 2) - bottom = int(cy_rot + ch / 2) + # Bounds check + left = max(0, min(width - 1, left)) + top = max(0, min(height - 1, top)) + right = max(left + 1, min(width, right)) + bottom = max(top + 1, min(height, bottom)) img = img.crop((left, top, right, bottom)) - elif abs(straighten_angle) > 0.001: - # No crop box but rotation? Just keep the rotated expanded image. - pass # 3. Exposure (gamma-based) exposure = self.current_edits['exposure'] @@ -440,7 +360,7 @@ def get_preview_data(self) -> Optional[DecodedImage]: # Always start from a fresh copy of the small preview image img = self._preview_image.copy() - img = self._apply_edits(img) + img = self._apply_edits(img, is_export=False) # The image is in RGB mode after _apply_edits buffer = img.tobytes() @@ -473,7 +393,7 @@ def save_image(self) -> Optional[Tuple[Path, Path]]: return None final_img = self.original_image.copy() - final_img = self._apply_edits(final_img) + final_img = self._apply_edits(final_img, is_export=True) original_path = self.current_filepath try: diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index bee88e9..58aea80 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -130,7 +130,7 @@ Item { MouseArea { id: mainMouseArea anchors.fill: parent - acceptedButtons: Qt.LeftButton + acceptedButtons: Qt.LeftButton | Qt.RightButton hoverEnabled: true cursorShape: { if (!uiState || !uiState.isCropping) return Qt.ArrowCursor @@ -168,6 +168,35 @@ Item { startY = mouse.y isDraggingOutside = false + if (mouse.button === Qt.RightButton) { + if (uiState && uiState.isCropping) { + // Cancel crop mode if already active + if (controller) controller.cancel_crop_mode() + } else if (uiState) { + // Enter crop mode and start new crop + uiState.isCropping = true + + // Set up new crop state + cropDragMode = "new" + cropStartX = mouse.x + cropStartY = mouse.y + + // Initialize anchors + var startCoords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) + // Clamp to [0, 1] and convert to [0, 1000] + var startNormX = Math.max(0, Math.min(1, startCoords.x)) * 1000 + var startNormY = Math.max(0, Math.min(1, startCoords.y)) * 1000 + + cropBoxStartLeft = startNormX + cropBoxStartRight = startNormX + cropBoxStartTop = startNormY + cropBoxStartBottom = startNormY + + isCropDragging = true + } + return + } + if (uiState && uiState.isCropping) { // Check if clicking on existing crop box var cropRect = getCropRect() @@ -184,9 +213,9 @@ Item { var theta = mainMouseArea.cropRotation * Math.PI / 180 // Handle is at bottom center + 25px var handleOffset = cropRect.height / 2 + 25 - // Rotated offset: x = -offset * sin(theta), y = offset * cos(theta) - var handleX = cropCenterX - handleOffset * Math.sin(theta) - var handleY = cropCenterY + handleOffset * Math.cos(theta) + // Correct rotation handle placement (CW-positive UI) + var handleX = cropCenterX + handleOffset * Math.sin(theta) + var handleY = cropCenterY - handleOffset * Math.cos(theta) var dist = Math.sqrt(Math.pow(mouse.x - handleX, 2) + Math.pow(mouse.y - handleY, 2)) @@ -236,6 +265,17 @@ Item { cropDragMode = "new" cropStartX = mouse.x cropStartY = mouse.y + + // Initialize anchors for aspect ratio constraint using normalized coordinates + var startCoords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) + // Clamp to [0, 1] and convert to [0, 1000] + var startNormX = Math.max(0, Math.min(1, startCoords.x)) * 1000 + var startNormY = Math.max(0, Math.min(1, startCoords.y)) * 1000 + + cropBoxStartLeft = startNormX + cropBoxStartRight = startNormX + cropBoxStartTop = startNormY + cropBoxStartBottom = startNormY } isCropDragging = true } @@ -279,17 +319,12 @@ Item { var panX = panTransform.x var panY = panTransform.y - var originX = scaleTransform.origin.x - var originY = scaleTransform.origin.y - - var x_no_pan = screenPoint.x - panX - var y_no_pan = screenPoint.y - panY - - var x_no_scale = originX + (x_no_pan - originX) / scale - var y_no_scale = originY + (y_no_pan - originY) / scale - - var localX = x_no_scale - imgX - var localY = y_no_scale - imgY + // Inverse of getCropRect transform: + // Screen = imgX + (Local * Scale) + Pan + // Local = (Screen - Pan - imgX) / Scale + + var localX = (screenPoint.x - panX - imgX) / scale + var localY = (screenPoint.y - panY - imgY) / scale return {x: localX / imgWidth, y: localY / imgHeight} } @@ -302,7 +337,7 @@ Item { var cropCenterX = getCropRect().x + getCropRect().width / 2 var cropCenterY = getCropRect().y + getCropRect().height / 2 var currentAngle = Math.atan2(mouse.y - cropCenterY, mouse.x - cropCenterX) * 180 / Math.PI - cropRotation = cropStartRotation + (currentAngle - cropStartAngle) + cropRotation = cropStartRotation - (currentAngle - cropStartAngle) } else if (cropDragMode !== "none") { var coords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y))