From 64337a2dae7de1ce175f7c35ed9c4d3060d729fb Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Tue, 30 Dec 2025 00:13:51 -0800 Subject: [PATCH 1/6] fix cropping --- .gitignore | 1 - faststack/faststack.egg-info/PKG-INFO | 4 +- faststack/faststack/app.py | 101 ++++++++++++++-- faststack/faststack/imaging/editor.py | 18 +++ faststack/faststack/qml/Components.qml | 161 ++++++++++++++++--------- faststack/faststack/read_req.py | 5 + 6 files changed, 216 insertions(+), 74 deletions(-) create mode 100644 faststack/faststack/read_req.py diff --git a/.gitignore b/.gitignore index a40c297..94a8940 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,3 @@ prompt.md WARP.md faststack/.mypy_cache/ .mypy_cache/ - diff --git a/faststack/faststack.egg-info/PKG-INFO b/faststack/faststack.egg-info/PKG-INFO index 7527397..f73718d 100644 --- a/faststack/faststack.egg-info/PKG-INFO +++ b/faststack/faststack.egg-info/PKG-INFO @@ -69,5 +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 +- `H`: Show RGB Histogram +- `I`: Show EXIF Metadata diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index d652ec8..c514c5b 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -361,11 +361,28 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: log.warning("get_decoded_image called with empty image_files or out of bounds index.") return None - # If editor is open for this image, return the live preview - if self.ui_state.isEditorOpen and self.image_editor.original_image and str(self.image_editor.current_filepath) == str(self.image_files[index].path): - preview_data = self.image_editor.get_preview_data() - if preview_data: - return preview_data + # Debug preview condition + if self.ui_state.isEditorOpen or self.ui_state.isCropping: + # Robust path comparison + editor_path = self.image_editor.current_filepath + file_path = self.image_files[index].path + + match = False + if editor_path and file_path: + try: + match = Path(editor_path).resolve() == Path(file_path).resolve() + except Exception: + match = str(editor_path) == str(file_path) + + if not match: + # Debug log if mismatch + log.debug(f"Path mismatch in preview. Editor: {editor_path}, File: {file_path}") + + # Return preview if Editor is open OR Cropping is active (for live rotation) + if (self.ui_state.isEditorOpen or self.ui_state.isCropping) and match and self.image_editor.original_image: + preview_data = self.image_editor.get_preview_data() + if preview_data: + return preview_data _, _, display_gen = self.get_display_info() image_path = self.image_files[index].path @@ -1258,6 +1275,50 @@ def set_awb_strength(self, value): self.prefetcher.update_prefetch(self.current_index) self.sync_ui_state() + @Slot(float) + def set_straighten_angle(self, angle: float): + """Sets the straighten angle for the image editor and updates current view.""" + """Sets the straighten angle for the image editor and updates current view.""" + if not (self.ui_state.isEditorOpen or self.ui_state.isCropping): + return + + # Ensure editor is loaded with current image so preview can update in crop mode + # self.get_decoded_image relies on this being loaded to return the preview + image_file = self.image_files[self.current_index] + filepath = image_file.path + editor_path = self.image_editor.current_filepath + + match = False + if editor_path: + try: + match = Path(editor_path).resolve() == Path(filepath).resolve() + except Exception: + match = str(editor_path) == str(filepath) + + if not match or not self.image_editor.original_image: + # We don't want to trigger a full recursive loop if get_decoded_image calls back here, + # but usually get_decoded_image calls get_preview_data only if loaded. + # Passing cached_preview=None forces a load from disk which is safer here. + # Or better, fetch the raw decoded image from cache if available (but get_decoded_image might recurse). + # We can peek into self.image_cache directly to avoid recursion risk. + + # Simple approach: just load it. + if not self.image_editor.load_image(str(filepath)): + return + + log.info(f"AppController.set_straighten_angle: {angle}") + # Invert angle because QML rotation is CW but PIL rotation (used in editor) handles direction logic internally + # (ImageEditor._apply_edits uses negative angle for PIL). + # We pass the raw angle from QML (degrees CW for UI rotation) to the editor. + # Editor takes care of sign. + self.image_editor.set_edit_param("straighten_angle", angle) + + # Trigger refresh. Since we are editing, we are viewing the preview. + # Incrementing display generation invalidates cache, but for preview it just ensures freshness if logic depends on it. + # Crucially, sync_ui_state emits currentImageSourceChanged, forcing QML to reload. + self.display_generation += 1 + self.sync_ui_state() + @Slot(result=int) def get_awb_warm_bias(self): return config.getint("awb", "warm_bias") @@ -2601,6 +2662,10 @@ def execute_crop(self): if not self.ui_state.isCropping: return + # Capture current rotation (straighten_angle) from editor state BEFORE any reload + # This is the single source of truth since set_straighten_angle updates it live. + current_rotation = float(self.image_editor.current_edits.get("straighten_angle", 0.0)) + # 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 # ... (validation code remains similar or can be simplified since UIState validates) ... @@ -2620,21 +2685,31 @@ def execute_crop(self): self.update_status_message("No crop area selected") return - # Ensure image is loaded in editor (crop mode might be active without editor open) + # Ensure image is loaded in editor image_file = self.image_files[self.current_index] - filepath = str(image_file.path) - 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 + filepath = image_file.path + + # Robust path comparison + editor_path = self.image_editor.current_filepath + paths_match = False + if editor_path: + try: + paths_match = Path(editor_path).resolve() == Path(filepath).resolve() + except Exception: + paths_match = str(editor_path) == str(filepath) + + if not paths_match: + log.info(f"execute_crop reloading image due to path mismatch. Editor: {editor_path}, File: {filepath}") cached_preview = self.get_decoded_image(self.current_index) - if not self.image_editor.load_image(filepath, cached_preview=cached_preview): + if not self.image_editor.load_image(str(filepath), cached_preview=cached_preview): self.update_status_message("Failed to load image for cropping") return 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) + # Re-apply the captured rotation. + # This handles cases where we reloaded the image (resetting edits) or where UI state sync was flaky. + self.image_editor.set_edit_param('straighten_angle', current_rotation) # Save via ImageEditor (handles rotation + crop correctly) save_result = self.image_editor.save_image() diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index 5505a32..999efb3 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -374,6 +374,24 @@ def get_preview_data(self) -> Optional[DecodedImage]: def set_edit_param(self, key: str, value: Any) -> bool: """Update a single edit parameter.""" + if key == 'rotation': + # Guard against arbitrary angles in 'rotation'. It expects 90-degree steps. + # For arbitrary rotation (drag to rotate), use 'straighten_angle'. + try: + # Round to nearest 90 degrees + val_deg = float(value) + rounded_deg = round(val_deg / 90.0) * 90 + final_val = int(rounded_deg) % 360 + + if abs(val_deg - rounded_deg) > 1.0: + print(f"Warning: 'rotation' received {value}. Rounding to {final_val}. Use 'straighten_angle' for free rotation.") + + self.current_edits[key] = final_val + return True + except (ValueError, TypeError): + print(f"Error: Invalid value for rotation: {value}") + return False + if key in self.current_edits and key != 'crop_box': self.current_edits[key] = value return True diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 58aea80..2b8e959 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -7,6 +7,29 @@ import QtQuick.Window Item { id: loupeView anchors.fill: parent + focus: true + + Keys.onEscapePressed: { + if (uiState && uiState.isCropping && mainMouseArea.isRotating) { + mainMouseArea.isRotating = false + mainMouseArea.cropDragMode = "none" + mainMouseArea.isCropDragging = false + event.accepted = true + } + } + + Keys.onReturnPressed: { + if (uiState && uiState.isCropping && controller) { + controller.execute_crop() + event.accepted = true + } + } + Keys.onEnterPressed: { + if (uiState && uiState.isCropping && controller) { + controller.execute_crop() + event.accepted = true + } + } // Connection to handle zoom/pan reset signal from Python @@ -160,6 +183,27 @@ Item { property real cropStartAngle: 0 property real cropStartRotation: 0 onCropRotationChanged: uiState.cropRotation = cropRotation + + Connections { + target: uiState + function onCropRotationChanged() { + if (mainMouseArea.cropRotation !== uiState.cropRotation) { + mainMouseArea.cropRotation = uiState.cropRotation + } + } + } + + onIsRotatingChanged: { + if (uiState) { + if (isRotating) { + uiState.statusMessage = "Press ESC to exit rotate mode" + } else { + if (uiState.statusMessage === "Press ESC to exit rotate mode") { + uiState.statusMessage = "" + } + } + } + } onPressed: function(mouse) { lastX = mouse.x @@ -199,49 +243,45 @@ Item { if (uiState && uiState.isCropping) { // Check if clicking on existing crop box - var cropRect = getCropRect() + var cropGeo = getCropRect() var box = uiState.currentCropBox var isFullImage = box && box.length === 4 && box[0] === 0 && box[1] === 0 && box[2] === 1000 && box[3] === 1000 var edgeThreshold = 10 * Screen.devicePixelRatio - var inside = mouse.x >= cropRect.x && mouse.x <= cropRect.x + cropRect.width && - mouse.y >= cropRect.y && mouse.y <= cropRect.y + cropRect.height - - // Hit test for rotation handle - var cropCenterX = cropRect.x + cropRect.width / 2 - var cropCenterY = cropRect.y + cropRect.height / 2 - var theta = mainMouseArea.cropRotation * Math.PI / 180 - // Handle is at bottom center + 25px - var handleOffset = cropRect.height / 2 + 25 - // Correct rotation handle placement (CW-positive UI) - var handleX = cropCenterX + handleOffset * Math.sin(theta) - var handleY = cropCenterY - handleOffset * Math.cos(theta) + var inside = mouse.x >= cropGeo.x && mouse.x <= cropGeo.x + cropGeo.width && + mouse.y >= cropGeo.y && mouse.y <= cropGeo.y + cropGeo.height - var dist = Math.sqrt(Math.pow(mouse.x - handleX, 2) + Math.pow(mouse.y - handleY, 2)) + // --- Hit test for rotation handle (robust: uses actual knob transform) --- + if (mainMouseArea.isRotating && cropOverlay.visible && rotateKnob.visible) { + // knob center in mainMouseArea coords (includes cropRect rotation) + var k = mainMouseArea.mapFromItem(rotateKnob, rotateKnob.width/2, rotateKnob.height/2) + var dxk = mouse.x - k.x + var dyk = mouse.y - k.y + var distk = Math.sqrt(dxk*dxk + dyk*dyk) - if (dist < 20) { - cropDragMode = "rotate" - cropStartAngle = Math.atan2(mouse.y - cropCenterY, mouse.x - cropCenterX) * 180 / Math.PI - cropStartRotation = cropRotation - } - else if (mainMouseArea.isRotating) { - cropDragMode = "rotate" - var cropCenterX = cropRect.x + cropRect.width / 2 - var cropCenterY = cropRect.y + cropRect.height / 2 - cropStartAngle = Math.atan2(mouse.y - cropCenterY, mouse.x - cropCenterX) * 180 / Math.PI - cropStartRotation = cropRotation + if (distk < 22) { // a little forgiving + cropDragMode = "rotate" + + // crop center in mainMouseArea coords (includes rotation) + var c = mainMouseArea.mapFromItem(cropRect, cropRect.width/2, cropRect.height/2) + cropStartAngle = Math.atan2(mouse.y - c.y, mouse.x - c.x) * 180 / Math.PI + cropStartRotation = cropRotation + + isCropDragging = true + return + } } // If crop box is full image, always start a new crop else if (isFullImage) { cropDragMode = "new" cropStartX = mouse.x cropStartY = mouse.y - } else if (inside && cropRect.width > 0 && cropRect.height > 0) { + } else if (inside && cropGeo.width > 0 && cropGeo.height > 0) { // Determine which edge/corner is being dragged - var nearLeft = Math.abs(mouse.x - cropRect.x) < edgeThreshold - var nearRight = Math.abs(mouse.x - (cropRect.x + cropRect.width)) < edgeThreshold - var nearTop = Math.abs(mouse.y - cropRect.y) < edgeThreshold - var nearBottom = Math.abs(mouse.y - (cropRect.y + cropRect.height)) < edgeThreshold + var nearLeft = Math.abs(mouse.x - cropGeo.x) < edgeThreshold + var nearRight = Math.abs(mouse.x - (cropGeo.x + cropGeo.width)) < edgeThreshold + var nearTop = Math.abs(mouse.y - cropGeo.y) < edgeThreshold + var nearBottom = Math.abs(mouse.y - (cropGeo.y + cropGeo.height)) < edgeThreshold if (nearLeft && nearTop) cropDragMode = "topleft" else if (nearRight && nearTop) cropDragMode = "topright" @@ -334,10 +374,15 @@ Item { // Update crop rectangle while dragging updateCropBox(cropStartX, cropStartY, mouse.x, mouse.y, true) } else if (cropDragMode === "rotate") { - 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) + var c = mainMouseArea.mapFromItem(cropRect, cropRect.width/2, cropRect.height/2) + var currentAngle = Math.atan2(mouse.y - c.y, mouse.x - c.x) * 180 / Math.PI + cropRotation = cropStartRotation + (currentAngle - cropStartAngle) + + // Update rotation in backend live + if (controller) { + console.log("Rotating: " + cropRotation) + controller.set_straighten_angle(cropRotation) + } } else if (cropDragMode !== "none") { var coords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) @@ -873,7 +918,7 @@ Item { && cropBox[1] === 0 && cropBox[2] === 1000 && cropBox[3] === 1000) - visible: uiState && uiState.isCropping && hasActiveCrop + visible: uiState && uiState.isCropping && (hasActiveCrop || mainMouseArea.isRotating) anchors.fill: parent z: 100 @@ -983,6 +1028,7 @@ Item { // Rotation Handle Line Rectangle { id: handleLine + visible: mainMouseArea.isRotating width: 2 height: 25 color: "white" @@ -992,6 +1038,8 @@ Item { // Rotation Handle Knob Rectangle { + id: rotateKnob + visible: mainMouseArea.isRotating width: 12 height: 12 radius: 6 @@ -1072,33 +1120,30 @@ Item { } } - Rectangle { - width: parent.width - height: 30 - color: "transparent" - radius: 3 - - Text { - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.verticalCenter: parent.verticalCenter - text: "Rotate" - color: aspectRatioWindow.isDark ? "white" : "black" - font.pixelSize: 11 - } - - MouseArea { - anchors.fill: parent - onClicked: { - mainMouseArea.isRotating = !mainMouseArea.isRotating - if(mainMouseArea.isRotating) { - mainMouseArea.cropDragMode = "rotate" - } else { + Rectangle { + width: parent.width + height: 30 + color: mainMouseArea.isRotating ? "#555555" : "transparent" + radius: 3 + + Text { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + text: "Rotate" + color: aspectRatioWindow.isDark ? "white" : "black" + font.pixelSize: 11 + font.bold: mainMouseArea.isRotating + } + + MouseArea { + anchors.fill: parent + onClicked: { + mainMouseArea.isRotating = !mainMouseArea.isRotating mainMouseArea.cropDragMode = "none" } } } - } } } diff --git a/faststack/faststack/read_req.py b/faststack/faststack/read_req.py new file mode 100644 index 0000000..9f4a21c --- /dev/null +++ b/faststack/faststack/read_req.py @@ -0,0 +1,5 @@ +try: + with open(r"c:\code\dikarya\part11_request.md", "r", encoding="utf-8") as f: + print(f.read()) +except Exception as e: + print(f"Error: {e}") From df3520bcc7787e5415968b8a3e02e2678ff86879 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Tue, 30 Dec 2025 15:21:18 -0800 Subject: [PATCH 2/6] fix cropping --- faststack/faststack/app.py | 162 ++++- faststack/faststack/imaging/editor.py | 118 +++- faststack/faststack/qml/Components.qml | 887 +++++++++++++------------ faststack/faststack/qml/Main.qml | 44 +- faststack/faststack/ui/provider.py | 12 + 5 files changed, 730 insertions(+), 493 deletions(-) diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index c514c5b..1195f51 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -127,6 +127,7 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: self.ui_state = UIState(self) self.ui_state.theme = self.get_theme() self.ui_state.debugCache = self.debug_cache + self.ui_state.debugMode = _debug_mode # Set debug mode from global self.keybinder = Keybinder(self) self.ui_state.debugCache = self.debug_cache # Pass debug_cache state to UI self.ui_state.isDecoding = False # Initialize decoding indicator @@ -271,16 +272,15 @@ def eventFilter(self, watched, event) -> bool: return False if watched == self.main_window and event.type() == QEvent.Type.KeyPress: - # Handle Enter key in crop mode - if self.ui_state.isCropping and (event.key() == Qt.Key_Enter or event.key() == Qt.Key_Return): - self.execute_crop() - return True - - # Handle ESC key to exit crop mode - if self.ui_state.isCropping and event.key() == Qt.Key_Escape: - self.cancel_crop_mode() - return True + # QML handles Crop Enter/Esc keys now. + # We defer to QML to avoid double-triggering or focus conflicts. + # handled = self.keybinder.handle_key_press(event) ... + # When cropping (or editing), let QML handle Enter/Esc and related keys. + # Otherwise keybinder can swallow them before QML sees them. + if getattr(self.ui_state, "isCropping", False) or getattr(self.ui_state, "isEditorOpen", False): + return False + handled = self.keybinder.handle_key_press(event) if handled: return True @@ -1276,35 +1276,86 @@ def set_awb_strength(self, value): self.sync_ui_state() @Slot(float) - def set_straighten_angle(self, angle: float): - """Sets the straighten angle for the image editor and updates current view.""" + @Slot(float, float) + def set_straighten_angle(self, angle: float, target_aspect_ratio: float = -1.0): """Sets the straighten angle for the image editor and updates current view.""" if not (self.ui_state.isEditorOpen or self.ui_state.isCropping): return - # Ensure editor is loaded with current image so preview can update in crop mode - # self.get_decoded_image relies on this being loaded to return the preview - image_file = self.image_files[self.current_index] - filepath = image_file.path - editor_path = self.image_editor.current_filepath - - match = False - if editor_path: - try: - match = Path(editor_path).resolve() == Path(filepath).resolve() - except Exception: - match = str(editor_path) == str(filepath) + # Optimization: Assume image is loaded by toggle_crop_mode or open_editor. + # Avoid disk I/O here to prevent stutter during drag. + if not self.image_editor.original_image: + return - if not match or not self.image_editor.original_image: - # We don't want to trigger a full recursive loop if get_decoded_image calls back here, - # but usually get_decoded_image calls get_preview_data only if loaded. - # Passing cached_preview=None forces a load from disk which is safer here. - # Or better, fetch the raw decoded image from cache if available (but get_decoded_image might recurse). - # We can peek into self.image_cache directly to avoid recursion risk. - - # Simple approach: just load it. - if not self.image_editor.load_image(str(filepath)): - return + # log.info(f"AppController.set_straighten_angle: {angle}, AR: {target_aspect_ratio}") + + # Update Aspect Ratio Compensation for Crop Box + # If we have a target aspect ratio, we need to adjust the normalized crop box + # because the underlying canvas aspect ratio changes with rotation (expand=True). + if target_aspect_ratio > 0 and self.ui_state.currentCropBox: + l, t, r, b = self.ui_state.currentCropBox + w_norm = r - l + h_norm = b - t + + if w_norm > 0 and h_norm > 0: + # Calculate new canvas dimensions + # PIL expand=True logic: + im_w, im_h = self.image_editor.original_image.size + import math + rad = math.radians(abs(angle)) + # New dimensions + new_w = abs(im_w * math.cos(rad)) + abs(im_h * math.sin(rad)) + new_h = abs(im_w * math.sin(rad)) + abs(im_h * math.cos(rad)) + + if new_w > 0 and new_h > 0: + canvas_aspect = new_w / new_h + + # We want PixelAspect = (w_norm * new_w/1000) / (h_norm * new_h/1000) = target_aspect + # (w_norm / h_norm) * (new_w / new_h) = target_aspect + # w_norm / h_norm = target_aspect / canvas_aspect + + target_norm_ratio = target_aspect_ratio / canvas_aspect + + # Adjust dimensions to match target_norm_ratio + # Simple: Preserve Width, adjust Height. + + new_h_norm = w_norm / target_norm_ratio + + # If new height exceeds bounds (1000), constrain and adjust width instead + if new_h_norm > 1000: + new_h_norm = 1000 + w_norm = new_h_norm * target_norm_ratio + # Recenter height + cy = (t + b) / 2 + t = cy - new_h_norm / 2 + b = cy + new_h_norm / 2 + + # Clamp vertical + if t < 0: + b -= t # shift down + t = 0 + if b > 1000: + t -= (b - 1000) # shift up + b = 1000 + if t < 0: t = 0 # double clamp + + # Recenter width (if changed) + cx = (l + r) / 2 + l = cx - w_norm / 2 + r = cx + w_norm / 2 + + # Clamp horizontal + if l < 0: + r -= l + l = 0 + if r > 1000: + l -= (r - 1000) + r = 1000 + if l < 0: l = 0 + + # IMPORTANT: Don't mutate currentCropBox here. + # Doing so during rotation causes the crop box to walk and jitter. + # self.ui_state.currentCropBox = [l, t, r, b] log.info(f"AppController.set_straighten_angle: {angle}") # Invert angle because QML rotation is CW but PIL rotation (used in editor) handles direction logic internally @@ -1316,8 +1367,8 @@ def set_straighten_angle(self, angle: float): # Trigger refresh. Since we are editing, we are viewing the preview. # Incrementing display generation invalidates cache, but for preview it just ensures freshness if logic depends on it. # Crucially, sync_ui_state emits currentImageSourceChanged, forcing QML to reload. - self.display_generation += 1 - self.sync_ui_state() + # self.display_generation += 1 + # self.sync_ui_state() # DISABLE TO PREVENT FLASHING - QML handles preview live @Slot(result=int) def get_awb_warm_bias(self): @@ -2501,6 +2552,20 @@ def update_histogram(self, zoom: float = 1.0, pan_x: float = 0.0, pan_y: float = log.exception("Failed to compute histogram: %s", e) self.update_status_message(f"Histogram error: {e}") + @Slot() + def cancel_crop_mode(self): + """Cancel crop mode without applying changes.""" + if self.ui_state.isCropping: + self.ui_state.isCropping = False + self.ui_state.currentCropBox = (0, 0, 1000, 1000) + # Ensure preview rotation is cleared + self.image_editor.set_edit_param("straighten_angle", 0.0) + # Force QML to refresh if it's showing provider preview frames + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + self.update_status_message("Crop cancelled") + log.info("Crop mode cancelled") + @Slot() def toggle_crop_mode(self): """Toggle crop mode on/off.""" @@ -2511,6 +2576,31 @@ def toggle_crop_mode(self): # Set aspect ratios for QML dropdown self.ui_state.aspectRatioNames = [r['name'] for r in ASPECT_RATIOS] self.ui_state.currentAspectRatioIndex = 0 + + # Pre-load image into editor to ensure smooth rotation + if self.image_files and self.current_index < len(self.image_files): + image_file = self.image_files[self.current_index] + filepath = image_file.path + editor_path = self.image_editor.current_filepath + + # Robust comparison + match = False + if editor_path: + try: + match = Path(editor_path).resolve() == Path(filepath).resolve() + except Exception: + match = str(editor_path) == str(filepath) + + if not match: + log.debug(f"toggle_crop_mode: Loading {filepath} into editor") + # Use cached preview if available to speed up using get_decoded_image(self.current_index) + # note: get_decoded_image verifies index bounds + cached_preview = self.get_decoded_image(self.current_index) + self.image_editor.load_image(str(filepath), cached_preview=cached_preview) + + # Reset rotation to 0 when starting fresh crop mode + self.image_editor.set_edit_param("straighten_angle", 0.0) + self.update_status_message("Crop mode: Drag to select area, Enter to crop") log.info("Crop mode enabled") else: # Exiting crop mode @@ -2699,7 +2789,7 @@ def execute_crop(self): paths_match = str(editor_path) == str(filepath) if not paths_match: - log.info(f"execute_crop reloading image due to path mismatch. Editor: {editor_path}, File: {filepath}") + log.debug(f"execute_crop reloading image due to path mismatch. Editor: {editor_path}, File: {filepath}") cached_preview = self.get_decoded_image(self.current_index) if not self.image_editor.load_image(str(filepath), cached_preview=cached_preview): self.update_status_message("Failed to load image for cropping") diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index 999efb3..4368c17 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -143,27 +143,103 @@ def _apply_edits(self, img: Image.Image, is_export: bool = False) -> Image.Image # 2. Free Rotation (Straighten) straighten_angle = self.current_edits['straighten_angle'] if abs(straighten_angle) > 0.001: - # 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 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) - - # 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)) + # Current crop_box is in SOURCE SPACE (0-1000 relative to un-rotated image) + # We must convert it to ROTATED SPACE (relative to the expanded rotated image) + crop_box = self.current_edits.get('crop_box') + if crop_box and len(crop_box) == 4: + w, h = img.size + + # 1. Get Source Pixels + l = int(crop_box[0] * w / 1000) + t = int(crop_box[1] * h / 1000) + r = int(crop_box[2] * w / 1000) + b = int(crop_box[3] * h / 1000) + + # 4 Corners relative to center + cx, cy = w / 2, h / 2 + corners = [ + (l - cx, t - cy), # TL + (r - cx, t - cy), # TR + (r - cx, b - cy), # BR + (l - cx, b - cy) # BL + ] + + # 2. Rotate Corners + rad = -np.deg2rad(straighten_angle) # UI is CW? rotate uses CCW? + # PIL rotate(-angle) means CW if angle is positive? + # Actually PIL rotate(theta) rotates CCW by theta. + # If UI sends positive for CW, we use -angle. + # To map coordinates, we use standard rotation matrix for CCW theta: + # x' = x cos - y sin + # y' = x sin + y cos + # Here theta is -straighten_angle (to match PIL rotate(-straighten_angle)) + + theta = np.deg2rad(-straighten_angle) + cos_t = np.cos(theta) + sin_t = np.sin(theta) + + rot_corners = [] + for x, y in corners: + rx = x * cos_t - y * sin_t + ry = x * sin_t + y * cos_t + rot_corners.append((rx, ry)) + + # 3. Calculate AABB of rotated corners + # This corresponds to the bounding box in the NEW rotated image space (relative to its center) + min_x = min(c[0] for c in rot_corners) + max_x = max(c[0] for c in rot_corners) + min_y = min(c[1] for c in rot_corners) + max_y = max(c[1] for c in rot_corners) + + # 4. Map to new image coordinates + # New image size (expand=True) + new_w = int(abs(w * np.cos(theta)) + abs(h * np.sin(theta))) + new_h = int(abs(w * np.sin(theta)) + abs(h * np.cos(theta))) + # Actually PIL calculation is slightly different/more precise, but we can just use the rotated image size + + # Perform the rotation on the image + img = img.rotate(-straighten_angle, resample=Image.Resampling.BICUBIC, expand=True) + real_new_w, real_new_h = img.size + + # Center offset + new_cx, new_cy = real_new_w / 2, real_new_h / 2 + + final_left = int(new_cx + min_x) + final_top = int(new_cy + min_y) + final_right = int(new_cx + max_x) + final_bottom = int(new_cy + max_y) + + # Clamp + final_left = max(0, min(real_new_w, final_left)) + final_top = max(0, min(real_new_h, final_top)) + final_right = max(final_left, min(real_new_w, final_right)) + final_bottom = max(final_top, min(real_new_h, final_bottom)) + + # Apply Crop + img = img.crop((final_left, final_top, final_right, final_bottom)) + + else: + # No crop, just rotate + img = img.rotate(-straighten_angle, resample=Image.Resampling.BICUBIC, expand=True) + + # 3. Cropping (Standard axis-aligned crop if NO straighten angle, or if crop applied separately?) + # If straighten angle was present, we already handled the crop above as a "Rotated Crop". + # If we didn't handle it above (e.g. angle=0), we handle it here. + elif 'crop_box' in self.current_edits and self.current_edits['crop_box']: + crop_box = self.current_edits['crop_box'] + if crop_box and len(crop_box) == 4: + 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) + + 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)) # 3. Exposure (gamma-based) exposure = self.current_edits['exposure'] diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 2b8e959..76f204a 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -9,23 +9,38 @@ Item { anchors.fill: parent focus: true - Keys.onEscapePressed: { - if (uiState && uiState.isCropping && mainMouseArea.isRotating) { - mainMouseArea.isRotating = false - mainMouseArea.cropDragMode = "none" - mainMouseArea.isCropDragging = false - event.accepted = true + // Height of the status bar footer in Main.qml + property int footerHeight: 60 + + Keys.onEscapePressed: (event) => { + if (uiState && uiState.isCropping) { + if (mainMouseArea.isRotating) { + // Revert rotation + mainMouseArea.cropRotation = mainMouseArea.cropStartRotation + if (controller) controller.set_straighten_angle(mainMouseArea.cropRotation, -1) + + mainMouseArea.isRotating = false + mainMouseArea.cropDragMode = "none" + mainMouseArea.isCropDragging = false + event.accepted = true + } else if (controller) { + controller.cancel_crop_mode() + mainMouseArea.cropRotation = 0 // Reset local rotation + event.accepted = true + } } } - Keys.onReturnPressed: { + Keys.onReturnPressed: (event) => { if (uiState && uiState.isCropping && controller) { + uiState.setZoomed(false) // Force unzoom to reset geometry/fit after crop controller.execute_crop() event.accepted = true } } - Keys.onEnterPressed: { + Keys.onEnterPressed: (event) => { if (uiState && uiState.isCropping && controller) { + uiState.setZoomed(false) // Force unzoom controller.execute_crop() event.accepted = true } @@ -36,103 +51,321 @@ Item { Connections { target: uiState function onResetZoomPanRequested() { - scaleTransform.xScale = 1.0 - scaleTransform.yScale = 1.0 + imageRotator.zoomScale = imageRotator.fitScale panTransform.x = 0 panTransform.y = 0 } } - // The main image display - Image { - id: mainImage - anchors.fill: parent - source: uiState && uiState.imageCount > 0 ? uiState.currentImageSource : "" - fillMode: Image.PreserveAspectFit - cache: false // We do our own caching in Python - smooth: uiState && !uiState.anySliderPressed && !isZooming - mipmap: uiState && !uiState.anySliderPressed && !isZooming - - property bool isZooming: false + // Container that handles Viewport Clipping and Sizing + Item { + id: imageViewport + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.bottomMargin: footerHeight + clip: true - Component.onCompleted: { - if (width > 0 && height > 0) { - var dpr = Screen.devicePixelRatio - uiState.onDisplaySizeChanged(Math.round(width * dpr), Math.round(height * dpr)) + // Container that handles Rotation (Straightening) + // This item represents the "Canvas" that expands when rotated. + Item { + id: imageRotator + anchors.centerIn: parent + + // Size matches the AABB of the rotated image + // W' = W*|cos| + H*|sin| + // Geometry is now updated atomically via updateRotatorGeometry() + property real implicitWidth: 0 + property real implicitHeight: 0 + property bool isUpdatingGeometry: false + + // Fix A: Atomic Zoom Scale + property real zoomScale: 1.0 + + onZoomScaleChanged: { + mainImage.updateZoomState() + if (cropOverlay.visible) cropOverlay.updateCropRect() } - } - onWidthChanged: { - if (width > 0 && height > 0) { - resizeDebounceTimer.restart() + // Fix B: Stable Logical Size + property real baseW: 0 + property real baseH: 0 + + function updateRotatorGeometry() { + if (!mainImage || mainImage.sourceSize.width <= 0) return + + isUpdatingGeometry = true + + var rad = mainMouseArea.cropRotation * (Math.PI / 180.0) + + // Use base size if available (stable during zoom), otherwise sourceSize + var w = (baseW > 0) ? baseW : mainImage.sourceSize.width + var h = (baseH > 0) ? baseH : mainImage.sourceSize.height + + var newW = Math.abs(w * Math.cos(rad)) + Math.abs(h * Math.sin(rad)) + var newH = Math.abs(w * Math.sin(rad)) + Math.abs(h * Math.cos(rad)) + + width = newW + height = newH + + // Atomically update mainImage size to prevent aspect ratio distortion + mainImage.width = w + mainImage.height = h + + isUpdatingGeometry = false + recomputeFitScale() } - } - onHeightChanged: { - if (width > 0 && height > 0) { - resizeDebounceTimer.restart() + Connections { + target: mainMouseArea + function onCropRotationChanged() { imageRotator.updateRotatorGeometry() } } - } + // Trigger initial update (moved to end) + + // NEW: fit-to-window scale (minimum zoom) + property real fitScale: 1.0 + + function recomputeFitScale() { + if (width <= 0 || height <= 0 || imageViewport.width <= 0 || imageViewport.height <= 0) + return; + + // Prevent jitter: Don't recompute fit scale while rotating or dragging + if (mainMouseArea.isRotating || mainMouseArea.isCropDragging) return; + + // Capture current relative zoom to preserve it during resize/reload + var oldFit = fitScale + var currentScale = imageRotator.zoomScale + var ratio = 1.0 + if (oldFit > 0) { + ratio = currentScale / oldFit + } + + // fit rotated canvas into viewport + var s = Math.min(imageViewport.width / width, imageViewport.height / height); + // Ensure fitScale is finite and positive + // Cap at 1.0 (don't upscale small images to fit) + if (!isFinite(s) || s <= 0) s = 1.0; + else if (s > 1.0) s = 1.0; - function updateZoomState() { - if (scaleTransform.xScale > 1.1 && !uiState.isZoomed) { - uiState.setZoomed(true); - } else if (scaleTransform.xScale <= 1.0 && uiState.isZoomed) { - uiState.setZoomed(false); + fitScale = s; + + // Restore zoom level relative to new fit (breaks cycle and preserves zoom) + imageRotator.zoomScale = fitScale * ratio; + // Preserve Pan (don't reset to 0) as pan is in screen pixels (mostly) } - - // Update histogram with zoom/pan info if histogram is visible - if (uiState && uiState.isHistogramVisible && controller) { - var zoom = scaleTransform.xScale - var panX = panTransform.x - var panY = panTransform.y - // Calculate image scale (painted size vs actual size) - var imageScale = mainImage.paintedWidth > 0 ? (mainImage.paintedWidth / mainImage.sourceSize.width) : 1.0 - controller.update_histogram(zoom, panX, panY, imageScale) + + onWidthChanged: if (!isUpdatingGeometry && !mainMouseArea.isRotating) recomputeFitScale() + onHeightChanged: if (!isUpdatingGeometry && !mainMouseArea.isRotating) recomputeFitScale() + Component.onCompleted: { + updateRotatorGeometry() + recomputeFitScale() } - } - - function updateHistogramWithZoom() { - if (uiState && uiState.isHistogramVisible && controller) { - var zoom = scaleTransform.xScale - var panX = panTransform.x - var panY = panTransform.y - var imageScale = mainImage.paintedWidth > 0 ? (mainImage.paintedWidth / mainImage.sourceSize.width) : 1.0 - controller.update_histogram(zoom, panX, panY, imageScale) + + Connections { + target: imageViewport + function onWidthChanged() { imageRotator.recomputeFitScale() } + function onHeightChanged() { imageRotator.recomputeFitScale() } } - } - property alias scaleTransform: scaleTransform - property alias panTransform: panTransform + transform: [ + Scale { + id: scaleTransform + origin.x: imageRotator.width / 2 + origin.y: imageRotator.height / 2 + xScale: imageRotator.zoomScale + yScale: imageRotator.zoomScale + }, + Translate { + id: panTransform + onXChanged: { + mainImage.updateHistogramWithZoom() + if (cropOverlay.visible) cropOverlay.updateCropRect() + } + onYChanged: { + mainImage.updateHistogramWithZoom() + if (cropOverlay.visible) cropOverlay.updateCropRect() + } + } + ] + + // The main image display + Image { + id: mainImage + anchors.centerIn: parent + + // Image size is now updated atomically in updateRotatorGeometry to prevent distortion + // width: sourceSize.width + // height: sourceSize.height + + rotation: mainMouseArea.cropRotation + + // Crop overlay - moved back to mainImage for Visual Orbit (Rotate Together) + // Coordinates are now Source Space, and backend handles conversion. + Item { + id: cropOverlay + property var cropBox: uiState ? uiState.currentCropBox : [0, 0, 1000, 1000] + property bool hasActiveCrop: cropBox && cropBox.length === 4 && !(cropBox[0]===0 && cropBox[1]===0 && cropBox[2]===1000 && cropBox[3]===1000) + + visible: uiState && uiState.isCropping && (hasActiveCrop || mainMouseArea.isRotating) + anchors.fill: parent // Fills mainImage (Source Space) + z: 100 + + onCropBoxChanged: { if (parent.source) updateCropRect() } + Component.onCompleted: { if (parent.source) updateCropRect() } + + Connections { + target: uiState + function onCurrentCropBoxChanged() { if (cropOverlay.visible && mainImage.source) cropOverlay.updateCropRect() } + } + + Connections { + target: mainImage + function onWidthChanged() { cropOverlay.updateCropRect() } + function onHeightChanged() { cropOverlay.updateCropRect() } + } + + function updateCropRect() { + if (!uiState || !uiState.currentCropBox || uiState.currentCropBox.length !== 4) return + var box = uiState.currentCropBox + + // Local coords in mainImage (Source Space) + var localLeft = (box[0] / 1000) * parent.width + var localTop = (box[1] / 1000) * parent.height + var localRight = (box[2] / 1000) * parent.width + var localBottom = (box[3] / 1000) * parent.height + + cropRect.x = localLeft + cropRect.y = localTop + cropRect.width = localRight - localLeft + cropRect.height = localBottom - localTop + } + + // Dimmer Rectangles + Rectangle { x: 0; y: 0; width: parent.width; height: cropRect.y; color: "black"; opacity: 0.3 } + Rectangle { x: 0; y: cropRect.y + cropRect.height; width: parent.width; height: parent.height - (cropRect.y + cropRect.height); color: "black"; opacity: 0.3 } + Rectangle { x: 0; y: cropRect.y; width: cropRect.x; height: cropRect.height; color: "black"; opacity: 0.3 } + Rectangle { x: cropRect.x + cropRect.width; y: cropRect.y; width: parent.width - (cropRect.x + cropRect.width); height: cropRect.height; color: "black"; opacity: 0.3 } + + Rectangle { + id: cropRect + color: "transparent" + border.color: "white" + border.width: 3 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) + + // Rotation Handle Line + Rectangle { + id: handleLine + visible: mainMouseArea.isRotating + width: 2 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) + height: 25 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) + color: "white" + anchors.top: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + } + + // Rotation Knob + Rectangle { + id: rotateKnob + visible: mainMouseArea.isRotating + width: 12 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) + height: 12 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) + radius: width / 2 + color: "white" + border.color: "black" + border.width: 1 / ((scaleTransform && scaleTransform.xScale) ? scaleTransform.xScale : 1.0) + anchors.verticalCenter: handleLine.bottom + anchors.horizontalCenter: handleLine.horizontalCenter + } + } + } + + source: uiState && uiState.imageCount > 0 ? uiState.currentImageSource : "" + onSourceSizeChanged: { + // Only set base size the first time, or when NOT zoomed + // (prevents hi-res swap from changing logical geometry) + if (!imageRotator.baseW || !imageRotator.baseH || (uiState && !uiState.isZoomed)) { + imageRotator.baseW = sourceSize.width + imageRotator.baseH = sourceSize.height + } + imageRotator.updateRotatorGeometry() + } + onStatusChanged: { + if (status === Image.Ready) imageRotator.updateRotatorGeometry() + } + onSourceChanged: { + // Reset base size for new image so we pick up the new sourceSize + imageRotator.baseW = 0 + imageRotator.baseH = 0 + + // Reset zoom/pan only when switching images (not zoomed), + // or if explicitly unzoomed. If zoomed (high-res load), preserve. + if (uiState && !uiState.isZoomed) { + mainMouseArea.cropRotation = 0 + mainMouseArea.isRotating = false + mainMouseArea.cropDragMode = "none" + + imageRotator.zoomScale = imageRotator.fitScale + panTransform.x = 0 + panTransform.y = 0 + } + } + fillMode: Image.PreserveAspectFit + cache: false // We do our own caching in Python + smooth: uiState && !uiState.anySliderPressed + mipmap: uiState && !uiState.anySliderPressed + + property bool isZooming: false - transform: [ - Scale { - id: scaleTransform - origin.x: mainImage.width / 2 - origin.y: mainImage.height / 2 - onXScaleChanged: { - mainImage.updateZoomState() - mainImage.updateHistogramWithZoom() - if (cropOverlay.visible) cropOverlay.updateCropRect() + // IMPORTANT: tell Python the *viewport* size, not the sourceSize size + function reportDisplaySize() { + if (imageViewport.width > 0 && imageViewport.height > 0) { + var dpr = Screen.devicePixelRatio + uiState.onDisplaySizeChanged( + Math.round(imageViewport.width * dpr), + Math.round(imageViewport.height * dpr) + ) + } } - onYScaleChanged: { - mainImage.updateZoomState() - mainImage.updateHistogramWithZoom() - if (cropOverlay.visible) cropOverlay.updateCropRect() + + Component.onCompleted: reportDisplaySize() + Connections { + target: imageViewport + function onWidthChanged() { mainImage.reportDisplaySize() } + function onHeightChanged() { mainImage.reportDisplaySize() } } - }, - Translate { - id: panTransform - onXChanged: { - mainImage.updateHistogramWithZoom() - if (cropOverlay.visible) cropOverlay.updateCropRect() + + // Removed direct onWidth/HeightChanged handlers for resizeDebounceTimer + // because we now drive size reporting via viewport changes. + + function updateZoomState() { + if (!uiState) return; + if (imageRotator.zoomScale > imageRotator.fitScale * 1.1) { + // Check isZoomed first to break binding loop (Source -> Width -> Scale -> Zoomed -> Source) + if (!uiState.isZoomed) { + uiState.setZoomed(true); + } + } + // Remove auto-unzoom to prevent flashing. Once high-res, stay high-res. + + updateHistogramWithZoom() } - onYChanged: { - mainImage.updateHistogramWithZoom() - if (cropOverlay.visible) cropOverlay.updateCropRect() + + function updateHistogramWithZoom() { + if (uiState && uiState.isHistogramVisible && controller) { + var zoom = imageRotator.zoomScale + var panX = panTransform.x + var panY = panTransform.y + var imageScale = imageRotator.zoomScale + controller.update_histogram(zoom, panX, panY, imageScale) + } } + + } - ] + + + } } // Zoom and Pan logic would go here @@ -182,17 +415,17 @@ Item { property bool isRotating: false property real cropStartAngle: 0 property real cropStartRotation: 0 - onCropRotationChanged: uiState.cropRotation = cropRotation - + property real cropStartAspect: -1 + + // Reset rotation when image changes or updates (e.g. after crop save) to avoid persistence Connections { target: uiState - function onCropRotationChanged() { - if (mainMouseArea.cropRotation !== uiState.cropRotation) { - mainMouseArea.cropRotation = uiState.cropRotation - } + function onCurrentIndexChanged() { + mainMouseArea.cropRotation = 0 } } + onIsRotatingChanged: { if (uiState) { if (isRotating) { @@ -205,6 +438,20 @@ Item { } } + property real pendingRotation: 0 + property real pendingAspect: -1 + + Timer { + id: rotationThrottleTimer + interval: 32 // ~30 fps + repeat: false + onTriggered: { + if (controller && uiState && uiState.isCropping) { + controller.set_straighten_angle(mainMouseArea.pendingRotation, mainMouseArea.pendingAspect) + } + } + } + onPressed: function(mouse) { lastX = mouse.x lastY = mouse.y @@ -242,46 +489,88 @@ Item { } if (uiState && uiState.isCropping) { - // Check if clicking on existing crop box - var cropGeo = getCropRect() + // Check if clicking on existing crop box - Using Image Space Hit Testing var box = uiState.currentCropBox var isFullImage = box && box.length === 4 && box[0] === 0 && box[1] === 0 && box[2] === 1000 && box[3] === 1000 - var edgeThreshold = 10 * Screen.devicePixelRatio - var inside = mouse.x >= cropGeo.x && mouse.x <= cropGeo.x + cropGeo.width && - mouse.y >= cropGeo.y && mouse.y <= cropGeo.y + cropGeo.height + var coords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) + var mx = coords.x * 1000 + var my = coords.y * 1000 + + // Calculate threshold in normalized units (approx 10 screen pixels) + // 10 px / (scale * size) * 1000 + var threshX = (10 / (scaleTransform.xScale * mainImage.width)) * 1000 + var threshY = (10 / (scaleTransform.yScale * mainImage.height)) * 1000 + var edgeThreshold = Math.max(10, Math.min(threshX, threshY)) // Fallback/Clamp + if (imageRotator.width > 0) { + // cleaner approx: just use mapToImage for a 10px delta? + // Let's stick to the mapped coords. + threshX = (10 / (scaleTransform.xScale * mainImage.width)) * 1000 + threshY = (10 / (scaleTransform.yScale * mainImage.height)) * 1000 + edgeThreshold = Math.max(threshX, threshY) + } + + var inside = mx >= box[0] && mx <= box[2] && my >= box[1] && my <= box[3] // --- Hit test for rotation handle (robust: uses actual knob transform) --- if (mainMouseArea.isRotating && cropOverlay.visible && rotateKnob.visible) { // knob center in mainMouseArea coords (includes cropRect rotation) + // Note: rotateKnob is now inside mainImage -> cropOverlay -> cropRect + // But mapFromItem should still work if we target the object properly. + // We need to resolve `rotateKnob` which is inside cropOverlay. + // If cropOverlay moves, we need to ensure this binding works. + // IMPORTANT: cropOverlay is not moved yet in this call. + // Current logic relies on existing structure. I will defer logic update if structure changes. + // But hit testing via mapFromItem(rotateKnob) is robust to hierarchy changes as long as rotateKnob exists. + var k = mainMouseArea.mapFromItem(rotateKnob, rotateKnob.width/2, rotateKnob.height/2) var dxk = mouse.x - k.x var dyk = mouse.y - k.y var distk = Math.sqrt(dxk*dxk + dyk*dyk) - if (distk < 22) { // a little forgiving + if (distk < 22 * Screen.devicePixelRatio) { // a little forgiving cropDragMode = "rotate" - // crop center in mainMouseArea coords (includes rotation) - var c = mainMouseArea.mapFromItem(cropRect, cropRect.width/2, cropRect.height/2) + // crop center in mainMouseArea coords -> Changed to IMAGE center to avoid feedback loop + var c = mainMouseArea.mapFromItem(mainImage, mainImage.width/2, mainImage.height/2) cropStartAngle = Math.atan2(mouse.y - c.y, mouse.x - c.x) * 180 / Math.PI cropStartRotation = cropRotation + + // Calculate start aspect ratio (in pixels) + if (mainImage.width > 0) { + var box = uiState.currentCropBox + if (box && box.length === 4) { + var boxW = (box[2] - box[0]) / 1000 * mainImage.width + var boxH = (box[3] - box[1]) / 1000 * mainImage.height + cropStartAspect = boxW / boxH + } + } + + + // Seed cropBoxStart variables + if (box && box.length === 4) { + cropBoxStartLeft = box[0] + cropBoxStartTop = box[1] + cropBoxStartRight = box[2] + cropBoxStartBottom = box[3] + } isCropDragging = true return } } + // If crop box is full image, always start a new crop else if (isFullImage) { cropDragMode = "new" cropStartX = mouse.x cropStartY = mouse.y - } else if (inside && cropGeo.width > 0 && cropGeo.height > 0) { - // Determine which edge/corner is being dragged - var nearLeft = Math.abs(mouse.x - cropGeo.x) < edgeThreshold - var nearRight = Math.abs(mouse.x - (cropGeo.x + cropGeo.width)) < edgeThreshold - var nearTop = Math.abs(mouse.y - cropGeo.y) < edgeThreshold - var nearBottom = Math.abs(mouse.y - (cropGeo.y + cropGeo.height)) < edgeThreshold + } else if (inside) { + // Determine which edge/corner is being dragged (Image Space) + var nearLeft = Math.abs(mx - box[0]) < edgeThreshold + var nearRight = Math.abs(mx - box[2]) < edgeThreshold + var nearTop = Math.abs(my - box[1]) < edgeThreshold + var nearBottom = Math.abs(my - box[3]) < edgeThreshold if (nearLeft && nearTop) cropDragMode = "topleft" else if (nearRight && nearTop) cropDragMode = "topright" @@ -294,8 +583,6 @@ Item { else cropDragMode = "move" // Store initial crop box - var box = uiState.currentCropBox - if (!box || box.length !== 4) return cropBoxStartLeft = box[0] cropBoxStartTop = box[1] cropBoxStartRight = box[2] @@ -306,67 +593,20 @@ Item { 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 + // Initialize anchors + cropBoxStartLeft = mx + cropBoxStartRight = mx + cropBoxStartTop = my + cropBoxStartBottom = my } isCropDragging = true } } - function getCropRect() { - if (!mainImage.source || !uiState || !uiState.currentCropBox || uiState.currentCropBox.length !== 4) { - return {x: 0, y: 0, width: 0, height: 0} - } - var imgWidth = mainImage.paintedWidth - var imgHeight = mainImage.paintedHeight - var imgX = (mainImage.width - imgWidth) / 2 - var imgY = (mainImage.height - imgHeight) / 2 - var box = uiState.currentCropBox - - // Account for zoom and pan transforms when displaying crop box - var scale = scaleTransform.xScale - var panX = panTransform.x - var panY = panTransform.y - - // Convert normalized crop box (0-1000) to image-local coordinates - var localX = (box[0] / 1000) * imgWidth - var localY = (box[1] / 1000) * imgHeight - var localWidth = (box[2] - box[0]) / 1000 * imgWidth - var localHeight = (box[3] - box[1]) / 1000 * imgHeight - - // Apply zoom and pan transforms to get screen coordinates - return { - x: imgX + (localX * scale) + panX, - y: imgY + (localY * scale) + panY, - width: localWidth * scale, - height: localHeight * scale - } - } + // Legacy getCropRect removed - using Image Space hit testing instead. + // mapToImageCoordinates now maps directly to mainImage function mapToImageCoordinates(screenPoint) { - var imgWidth = mainImage.paintedWidth - var imgHeight = mainImage.paintedHeight - var imgX = (mainImage.width - imgWidth) / 2 - var imgY = (mainImage.height - imgHeight) / 2 - - var scale = scaleTransform.xScale - var panX = panTransform.x - var panY = panTransform.y - - // 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} + var p = mainMouseArea.mapToItem(mainImage, screenPoint.x, screenPoint.y) + return {x: p.x / mainImage.width, y: p.y / mainImage.height} } onPositionChanged: function(mouse) { if (uiState && uiState.isCropping && isCropDragging) { @@ -374,14 +614,37 @@ Item { // Update crop rectangle while dragging updateCropBox(cropStartX, cropStartY, mouse.x, mouse.y, true) } else if (cropDragMode === "rotate") { - var c = mainMouseArea.mapFromItem(cropRect, cropRect.width/2, cropRect.height/2) + var c = mainMouseArea.mapFromItem(mainImage, mainImage.width/2, mainImage.height/2) var currentAngle = Math.atan2(mouse.y - c.y, mouse.x - c.x) * 180 / Math.PI - cropRotation = cropStartRotation + (currentAngle - cropStartAngle) + var delta = currentAngle - cropStartAngle + // Handle wrap-around + if (delta > 180) delta -= 360 + if (delta < -180) delta += 360 - // Update rotation in backend live + var newRotation = cropStartRotation + delta + + // Update rotation state + cropRotation = newRotation + + // Update rotation in backend live (throttled) + if (controller) { + pendingRotation = cropRotation + pendingAspect = cropStartAspect + + if (!rotationThrottleTimer.running) { + rotationThrottleTimer.start() + } + } + } else if (cropDragMode !== "none") { + + // Update rotation in backend live (throttled) if (controller) { - console.log("Rotating: " + cropRotation) - controller.set_straighten_angle(cropRotation) + pendingRotation = cropRotation + pendingAspect = cropStartAspect + + if (!rotationThrottleTimer.running) { + rotationThrottleTimer.start() + } } } else if (cropDragMode !== "none") { @@ -443,7 +706,7 @@ Item { globalPos.x > loupeView.width || globalPos.y > loupeView.height) { // Mouse is outside window - initiate drag-and-drop isDraggingOutside = true - controller.start_drag_current_image() + if (controller) controller.start_drag_current_image() return } } @@ -463,6 +726,8 @@ Item { if (uiState && uiState.isCropping && isCropDragging) { isCropDragging = false cropDragMode = "none" + // Settle zoom/pan after rotation ends + if (mainMouseArea.isRotating) imageRotator.recomputeFitScale() } } @@ -476,113 +741,46 @@ Item { var scaleFactor = isZoomingIn ? 1.1 : 1 / 1.1; // Calculate old and new scale - var oldScale = scaleTransform.xScale + var oldScale = imageRotator.zoomScale var newScale = oldScale * scaleFactor - newScale = Math.max(0.1, Math.min(20.0, newScale)) + // Allow zooming out past "Fit" to 5%. Cap max at 20x. + newScale = Math.max(0.05, Math.min(20.0, newScale)) + + // Current state + var currentPanX = panTransform.x + var currentPanY = panTransform.y - // Get the image's painted (displayed) bounds - var imgWidth = mainImage.paintedWidth - var imgHeight = mainImage.paintedHeight - var centerX = mainImage.width / 2 - var centerY = mainImage.height / 2 + // Screen center (Viewport center) + var centerX = imageViewport.width / 2 + var centerY = imageViewport.height / 2 + + // Fix C: Use Viewport Coordinates (account for footer offset etc) + var p = mainMouseArea.mapToItem(imageViewport, wheel.x, wheel.y) + var mouseX = p.x + var mouseY = p.y - if (isZoomingIn) { - // Zoom in: zoom towards cursor position - var mouseX = wheel.x - var mouseY = wheel.y - var imgX = (mainImage.width - imgWidth) / 2 - var imgY = (mainImage.height - imgHeight) / 2 - - // Calculate the point in the image that's under the cursor - var pointInImageX = mouseX - imgX - var pointInImageY = mouseY - imgY - - // Only zoom towards cursor if cursor is over the image - if (pointInImageX >= 0 && pointInImageX <= imgWidth && - pointInImageY >= 0 && pointInImageY <= imgHeight) { - - // Calculate offset from image center in screen coordinates - var centerOffsetX = pointInImageX - imgWidth / 2 - var centerOffsetY = pointInImageY - imgHeight / 2 - - // The current screen position of a point is: (imgPoint * oldScale) + oldPan + center - // We want to find what's currently under the cursor and keep it there - // Instead of dividing by oldScale (which loses precision), work with scaled values - - // Calculate what the scaled image point currently is (before zoom) - // This is: (centerOffset - pan) which represents (imgPoint * oldScale) - var scaledImagePointX = centerOffsetX - panTransform.x - var scaledImagePointY = centerOffsetY - panTransform.y - - // Adjust the scale origin to the cursor position - scaleTransform.origin.x = mouseX - scaleTransform.origin.y = mouseY - - // Apply the new scale first - scaleTransform.xScale = newScale - scaleTransform.yScale = newScale - - // After zoom, the scaled image point becomes: scaledImagePoint * (newScale / oldScale) - // We want it to stay at the same screen position, so: - // newPan = centerOffset - (scaledImagePoint * newScale / oldScale) - // Use scaleRatio to avoid precision loss from repeated division - var scaleRatio = newScale / oldScale - var newPanX = centerOffsetX - (scaledImagePointX * scaleRatio) - var newPanY = centerOffsetY - (scaledImagePointY * scaleRatio) - - // Apply the adjusted pan - panTransform.x = newPanX - panTransform.y = newPanY - } else { - // If cursor is outside image, zoom from center - scaleTransform.origin.x = centerX - scaleTransform.origin.y = centerY - scaleTransform.xScale = newScale - scaleTransform.yScale = newScale - } - } else { - // 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. - - // 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 - - 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 - } + var mouseOffsetFromCenterX = mouseX - centerX + var mouseOffsetFromCenterY = mouseY - centerY + + // Calculate the "image point" currently under the cursor (relative to image center, unscaled) + // ScreenPos = Center + Pan + (ImagePoint * Scale) + // ImagePoint = (ScreenPos - Center - Pan) / Scale + // ImagePoint = (MouseOffsetFromCenter - Pan) / Scale + var imagePointX = (mouseOffsetFromCenterX - currentPanX) / oldScale + var imagePointY = (mouseOffsetFromCenterY - currentPanY) / oldScale + + // We want to keep this ImagePoint under the cursor after scaling: + // MouseOffsetFromCenter = Pan_New + (ImagePoint * Scale_New) + // Pan_New = MouseOffsetFromCenter - (ImagePoint * Scale_New) + var newPanX = mouseOffsetFromCenterX - (imagePointX * newScale) + var newPanY = mouseOffsetFromCenterY - (imagePointY * newScale) + + // Apply updates + imageRotator.zoomScale = newScale + panTransform.x = newPanX + panTransform.y = newPanY + // Re-enable smooth rendering after a short delay zoomSmoothTimer.restart() } @@ -674,7 +872,7 @@ Item { var ratioName = uiState.aspectRatioNames[uiState.currentAspectRatioIndex]; var ratioPair = getAspectRatio(ratioName); - if (!ratioPair || !mainImage.sourceSize || mainImage.sourceSize.width === 0 || mainImage.sourceSize.height === 0) { + if (!ratioPair || !imageRotator.width || !imageRotator.height) { return [left, top, right, bottom]; } @@ -686,7 +884,8 @@ Item { // width_norm / height_norm = targetAspect * (imgH / imgW) var pixelAspect = ratioPair[0] / ratioPair[1]; - var imageAspect = mainImage.sourceSize.width / mainImage.sourceSize.height; + // Use mainImage (fixed canvas) for aspect ratio calculation + var imageAspect = mainImage.width / mainImage.height; var targetAspect = pixelAspect * (1.0 / imageAspect); // Normalized aspect ratio var currentWidth = right - left; @@ -908,149 +1107,7 @@ Item { } } - // Crop rectangle overlay - Item { - id: cropOverlay - property var cropBox: uiState ? uiState.currentCropBox : [0, 0, 1000, 1000] - property bool hasActiveCrop: cropBox - && cropBox.length === 4 - && !(cropBox[0] === 0 - && cropBox[1] === 0 - && cropBox[2] === 1000 - && cropBox[3] === 1000) - visible: uiState && uiState.isCropping && (hasActiveCrop || mainMouseArea.isRotating) - anchors.fill: parent - z: 100 - - onCropBoxChanged: { - if (!mainImage.source) return - updateCropRect() - } - - Component.onCompleted: { - if (mainImage.source) updateCropRect() - } - - Connections { - target: mainImage - function onPaintedWidthChanged() { if (cropOverlay.visible) cropOverlay.updateCropRect() } - function onPaintedHeightChanged() { if (cropOverlay.visible) cropOverlay.updateCropRect() } - } - - Connections { - target: uiState - function onCurrentCropBoxChanged() { - cropOverlay.cropBox = uiState.currentCropBox - if (cropOverlay.visible && mainImage.source) { - cropOverlay.updateCropRect() - } - } - } - - function updateCropRect() { - if (!mainImage.source) return - - var imgWidth = mainImage.paintedWidth - var imgHeight = mainImage.paintedHeight - var imgX = (mainImage.width - imgWidth) / 2 - var imgY = (mainImage.height - imgHeight) / 2 - - // Account for zoom and pan transforms when displaying crop box - var scale = mainImage.scaleTransform ? mainImage.scaleTransform.xScale : 1.0 - var panX = mainImage.panTransform ? mainImage.panTransform.x : 0 - var panY = mainImage.panTransform ? mainImage.panTransform.y : 0 - - // Convert normalized crop box (0-1000) to image-local coordinates - var localLeft = (cropBox[0] / 1000) * imgWidth - var localTop = (cropBox[1] / 1000) * imgHeight - var localRight = (cropBox[2] / 1000) * imgWidth - var localBottom = (cropBox[3] / 1000) * imgHeight - - // Apply zoom and pan transforms to get screen coordinates - var left = imgX + (localLeft * scale) + panX - var top = imgY + (localTop * scale) + panY - var right = imgX + (localRight * scale) + panX - var bottom = imgY + (localBottom * scale) + panY - - cropRect.x = left - cropRect.y = top - cropRect.width = right - left - cropRect.height = bottom - top - } - - // Semi-transparent overlay - draw 4 rectangles around the crop area - Rectangle { - // Top - x: 0 - y: 0 - width: parent.width - height: cropRect.y - color: "black" - opacity: 0.3 - } - Rectangle { - // Bottom - x: 0 - y: cropRect.y + cropRect.height - width: parent.width - height: parent.height - (cropRect.y + cropRect.height) - color: "black" - opacity: 0.3 - } - Rectangle { - // Left - x: 0 - y: cropRect.y - width: cropRect.x - height: cropRect.height - color: "black" - opacity: 0.3 - } - Rectangle { - // Right - x: cropRect.x + cropRect.width - y: cropRect.y - width: parent.width - (cropRect.x + cropRect.width) - height: cropRect.height - color: "black" - opacity: 0.3 - } - - // Crop rectangle with thick white border - Rectangle { - id: cropRect - color: "transparent" - border.color: "white" - border.width: 3 - rotation: mainMouseArea.cropRotation - transformOrigin: Item.Center - - // Rotation Handle Line - Rectangle { - id: handleLine - visible: mainMouseArea.isRotating - width: 2 - height: 25 - color: "white" - anchors.top: parent.bottom - anchors.horizontalCenter: parent.horizontalCenter - } - - // Rotation Handle Knob - Rectangle { - id: rotateKnob - visible: mainMouseArea.isRotating - width: 12 - height: 12 - radius: 6 - color: "white" - border.color: "black" - border.width: 1 - anchors.verticalCenter: handleLine.bottom - anchors.horizontalCenter: handleLine.horizontalCenter - } - } - } + // Crop rectangle overlay (Moved to mainImage) // Aspect ratio selector window (upper left corner) Rectangle { diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index 223ad88..a318fff 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -676,28 +676,29 @@ ApplicationWindow { } } } - } - // -------- FOOTER / STATUS BAR (old version) -------- - footer: Rectangle { - id: footerRect - // Keep footer height fixed so the main image area doesn't change size when - // stack/batch labels appear or disappear (prevents cache invalidations). - property int fixedHeight: 60 - height: fixedHeight - implicitHeight: fixedHeight - anchors.left: parent.left - anchors.right: parent.right - color: Qt.rgba(root.currentBackgroundColor.r, root.currentBackgroundColor.g, root.currentBackgroundColor.b, 0.8) - clip: true - - RowLayout { - id: footerRow - spacing: 10 - anchors.verticalCenter: parent.verticalCenter - - Label { - Layout.leftMargin: 10 + // -------- STATUS BAR OVERLAY -------- + Rectangle { + z: 100 + anchors.bottom: parent.bottom + id: footerRect + // Keep footer height fixed so the main image area doesn't change size when + // stack/batch labels appear or disappear (prevents cache invalidations). + property int fixedHeight: 60 + height: fixedHeight + implicitHeight: fixedHeight + anchors.left: parent.left + anchors.right: parent.right + color: Qt.rgba(root.currentBackgroundColor.r, root.currentBackgroundColor.g, root.currentBackgroundColor.b, 0.8) + clip: true + + RowLayout { + id: footerRow + spacing: 10 + anchors.verticalCenter: parent.verticalCenter + + Label { + Layout.leftMargin: 10 text: uiState ? `Image: ${uiState.currentIndex + 1} / ${uiState.imageCount}` : "Image: - / -" color: root.currentTextColor } @@ -830,6 +831,7 @@ ApplicationWindow { } } } + } // -------- DIALOGS -------- diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index c2f1dbd..6141b69 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -127,6 +127,7 @@ class UIState(QObject): debugCacheChanged = Signal(bool) cacheStatsChanged = Signal(str) isDecodingChanged = Signal(bool) + debugModeChanged = Signal(bool) # General debug mode signal def __init__(self, app_controller): super().__init__() @@ -148,6 +149,7 @@ def __init__(self, app_controller): self._white_balance_mg = 0.0 self._current_crop_box = (0, 0, 1000, 1000) self._crop_rotation = 0.0 + self._debug_mode = False self._aspect_ratio_names = [] self._current_aspect_ratio_index = 0 self._any_slider_pressed = False @@ -878,3 +880,13 @@ def isDecoding(self, value: bool): if self._is_decoding != value: self._is_decoding = value self.isDecodingChanged.emit(value) + + @Property(bool, notify=debugModeChanged) + def debugMode(self) -> bool: + return self._debug_mode + + @debugMode.setter + def debugMode(self, value: bool): + if self._debug_mode != value: + self._debug_mode = value + self.debugModeChanged.emit(value) From 32395075471e5404b254171185593029c5ee7921 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Wed, 31 Dec 2025 00:41:19 -0800 Subject: [PATCH 3/6] Fixed zoom and crop --- faststack/faststack/app.py | 89 ++++++-- faststack/faststack/imaging/editor.py | 129 +++-------- faststack/faststack/imaging/jpeg.py | 3 + faststack/faststack/imaging/prefetch.py | 114 ++++----- faststack/faststack/qml/Components.qml | 216 +++++++++++++----- faststack/faststack/qml/ImageEditorDialog.qml | 8 +- faststack/faststack/qml/Main.qml | 8 +- faststack/faststack/qml/SettingsDialog.qml | 8 +- faststack/faststack/read_req.py | 5 - faststack/faststack/ui/keystrokes.py | 12 +- faststack/faststack/ui/provider.py | 8 +- 11 files changed, 351 insertions(+), 249 deletions(-) delete mode 100644 faststack/faststack/read_req.py diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 1195f51..045090c 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -74,6 +74,7 @@ def make_hdrop(paths): class AppController(QObject): dataChanged = Signal() # New signal for general data changes + is_zoomed_changed = Signal(bool) # Signal for zoom state changes class ProgressReporter(QObject): progress_updated = Signal(int) @@ -93,7 +94,11 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: self.display_width = 0 self.display_height = 0 self.display_generation = 0 - self.is_zoomed = False + self._is_decoding = False + + # Cache Warning State + self._last_cache_warning_time = 0 + self._eviction_timestamps = [] # List of eviction timestamps for rate detection self.display_ready = False # Track if display size has been reported self.pending_prefetch_index: Optional[int] = None # Deferred prefetch index @@ -131,6 +136,7 @@ def __init__(self, image_dir: Path, engine: QQmlApplicationEngine, debug_cache: self.keybinder = Keybinder(self) self.ui_state.debugCache = self.debug_cache # Pass debug_cache state to UI self.ui_state.isDecoding = False # Initialize decoding indicator + self.is_zoomed = False # Track zoom state for high-res loading logic # -- Stacking State -- self.stack_start_index: Optional[int] = None @@ -250,13 +256,44 @@ def _handle_resize(self): self.sync_ui_state() # To refresh the image + @Slot(bool) def set_zoomed(self, zoomed: bool): - if self.is_zoomed == zoomed: - return - self.is_zoomed = zoomed + if self.is_zoomed != zoomed: + if _debug_mode: + log.info(f"AppController.set_zoomed: {self.is_zoomed} -> {zoomed}") + self.is_zoomed = zoomed + self.is_zoomed_changed.emit(zoomed) log.info("Zoom state changed to: %s", zoomed) self.display_generation += 1 # Invalidates old entries via cache key + # Invalidate current image to force reload with new resolution logic + if self.image_files and self.main_window: + # Force QML to reload the image by updating the URL generation + self.ui_refresh_generation += 1 + self.ui_state.currentImageSourceChanged.emit() + self.main_window.update() # Force repaint + + # -- Zoom Shortcuts -- + # -- Zoom Shortcuts -- + def zoom_100(self): + log.info("Zoom 100% requested") + self.ui_state.request_absolute_zoom(1.0) + # self.set_zoomed(True) - Handled by QML smart zoom logic + + def zoom_200(self): + log.info("Zoom 200% requested") + self.ui_state.request_absolute_zoom(2.0) + # self.set_zoomed(True) - Handled by QML smart zoom logic + + def zoom_300(self): + log.info("Zoom 300% requested") + self.ui_state.request_absolute_zoom(3.0) + # self.set_zoomed(True) - Handled by QML smart zoom logic + + def zoom_400(self): + log.info("Zoom 400% requested") + self.ui_state.request_absolute_zoom(4.0) + # self.set_zoomed(True) - Handled by QML smart zoom logic # NOTE: We don't clear the cache here. The generation increment is enough. # Cache keys include display_generation, so zoomed/unzoomed images become # naturally unreachable and LRU will evict them. This lets us instantly @@ -1357,7 +1394,7 @@ def set_straighten_angle(self, angle: float, target_aspect_ratio: float = -1.0): # Doing so during rotation causes the crop box to walk and jitter. # self.ui_state.currentCropBox = [l, t, r, b] - log.info(f"AppController.set_straighten_angle: {angle}") + log.debug(f"AppController.set_straighten_angle: {angle}") # Invert angle because QML rotation is CW but PIL rotation (used in editor) handles direction logic internally # (ImageEditor._apply_edits uses negative angle for PIL). # We pass the raw angle from QML (degrees CW for UI rotation) to the editor. @@ -2041,11 +2078,32 @@ def empty_recycle_bin(self): def _on_cache_evict(self): """Callback for when the image cache evicts an item.""" - if not self._has_warned_cache_full: - self._has_warned_cache_full = True - # Use QTimer.singleShot to ensure this runs on the main thread if called from a background thread - QTimer.singleShot(0, lambda: self.update_status_message("Cache full! Consider increasing cache size in settings.")) - log.warning("Cache full, eviction started. User warned.") + now = time.time() + + # 1. Record eviction timestamp + self._eviction_timestamps.append(now) + + # 2. Prune timestamps older than 2 seconds + # Keep list short + cutoff = now - 2.0 + self._eviction_timestamps = [t for t in self._eviction_timestamps if t > cutoff] + + # 3. Check for thrashing (e.g., > 5 evictions in 2 seconds) + if len(self._eviction_timestamps) > 5: + # 4. Rate limit the warning (once every 5 minutes = 300 seconds) + if now - self._last_cache_warning_time > 300: + self._last_cache_warning_time = now + self._has_warned_cache_full = True + + # Format usage info + used_gb = self.image_cache.currsize / (1024**3) + max_gb = self.image_cache.maxsize / (1024**3) + + msg = f"Cache thrashing! {len(self._eviction_timestamps)} evictions in 2s. Usage: {used_gb:.1f}GB / {max_gb:.1f}GB." + + # Use QTimer.singleShot to ensure this runs on the main thread + QTimer.singleShot(0, lambda: self.update_status_message(msg)) + log.warning(msg) def restore_all_from_recycle_bin(self): """Restores all files from recycle bin to working directory.""" @@ -2557,7 +2615,7 @@ def cancel_crop_mode(self): """Cancel crop mode without applying changes.""" if self.ui_state.isCropping: self.ui_state.isCropping = False - self.ui_state.currentCropBox = (0, 0, 1000, 1000) + self.ui_state.currentCropBox = [0, 0, 1000, 1000] # Ensure preview rotation is cleared self.image_editor.set_edit_param("straighten_angle", 0.0) # Force QML to refresh if it's showing provider preview frames @@ -2733,14 +2791,7 @@ def stack_source_raws(self): self.update_status_message("Failed to launch Helicon Focus.") - @Slot() - def cancel_crop_mode(self): - """Cancel crop mode without applying changes.""" - if self.ui_state.isCropping: - self.ui_state.isCropping = False - self.ui_state.currentCropBox = (0, 0, 1000, 1000) - self.update_status_message("Crop cancelled") - log.info("Crop mode cancelled") + @Slot() def execute_crop(self): diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index 4368c17..6a2c454 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -4,7 +4,10 @@ import re from pathlib import Path from typing import Optional, Dict, Any, Tuple -import numpy as np +try: + import numpy as np +except ImportError: + np = None from PIL import Image, ImageEnhance, ImageFilter from io import BytesIO @@ -140,106 +143,34 @@ def _apply_edits(self, img: Image.Image, is_export: bool = False) -> Image.Image elif rotation == 270: img = img.transpose(Image.Transpose.ROTATE_90) # 270 CW = 90 CCW - # 2. Free Rotation (Straighten) + # 2. Cropping (Standard axis-aligned crop) + # Semantics: Crop first (in original image coordinates), THEN rotate (straighten) + # This matches "applying rotation to the selected crop". + # If we rotate first, we get an AABB of the rotated content which is arguably wrong (too big). + + # Apply Crop if it exists + if 'crop_box' in self.current_edits and self.current_edits['crop_box']: + crop_box = self.current_edits['crop_box'] + if crop_box and len(crop_box) == 4: + width, height = img.size + l = int(crop_box[0] * width / 1000) + t = int(crop_box[1] * height / 1000) + r = int(crop_box[2] * width / 1000) + b = int(crop_box[3] * height / 1000) + + # Clamp coordinates to be within image bounds and ensure valid box + l = max(0, min(width - 1, l)) + t = max(0, min(height - 1, t)) + r = max(l + 1, min(width, r)) # Ensure r > l + b = max(t + 1, min(height, b)) # Ensure b > t + + img = img.crop((l, t, r, b)) + + # 3. Free Rotation (Straighten) straighten_angle = self.current_edits['straighten_angle'] if abs(straighten_angle) > 0.001: - # Current crop_box is in SOURCE SPACE (0-1000 relative to un-rotated image) - # We must convert it to ROTATED SPACE (relative to the expanded rotated image) - crop_box = self.current_edits.get('crop_box') - if crop_box and len(crop_box) == 4: - w, h = img.size - - # 1. Get Source Pixels - l = int(crop_box[0] * w / 1000) - t = int(crop_box[1] * h / 1000) - r = int(crop_box[2] * w / 1000) - b = int(crop_box[3] * h / 1000) - - # 4 Corners relative to center - cx, cy = w / 2, h / 2 - corners = [ - (l - cx, t - cy), # TL - (r - cx, t - cy), # TR - (r - cx, b - cy), # BR - (l - cx, b - cy) # BL - ] - - # 2. Rotate Corners - rad = -np.deg2rad(straighten_angle) # UI is CW? rotate uses CCW? - # PIL rotate(-angle) means CW if angle is positive? - # Actually PIL rotate(theta) rotates CCW by theta. - # If UI sends positive for CW, we use -angle. - # To map coordinates, we use standard rotation matrix for CCW theta: - # x' = x cos - y sin - # y' = x sin + y cos - # Here theta is -straighten_angle (to match PIL rotate(-straighten_angle)) - - theta = np.deg2rad(-straighten_angle) - cos_t = np.cos(theta) - sin_t = np.sin(theta) - - rot_corners = [] - for x, y in corners: - rx = x * cos_t - y * sin_t - ry = x * sin_t + y * cos_t - rot_corners.append((rx, ry)) - - # 3. Calculate AABB of rotated corners - # This corresponds to the bounding box in the NEW rotated image space (relative to its center) - min_x = min(c[0] for c in rot_corners) - max_x = max(c[0] for c in rot_corners) - min_y = min(c[1] for c in rot_corners) - max_y = max(c[1] for c in rot_corners) - - # 4. Map to new image coordinates - # New image size (expand=True) - new_w = int(abs(w * np.cos(theta)) + abs(h * np.sin(theta))) - new_h = int(abs(w * np.sin(theta)) + abs(h * np.cos(theta))) - # Actually PIL calculation is slightly different/more precise, but we can just use the rotated image size - - # Perform the rotation on the image - img = img.rotate(-straighten_angle, resample=Image.Resampling.BICUBIC, expand=True) - real_new_w, real_new_h = img.size - - # Center offset - new_cx, new_cy = real_new_w / 2, real_new_h / 2 - - final_left = int(new_cx + min_x) - final_top = int(new_cy + min_y) - final_right = int(new_cx + max_x) - final_bottom = int(new_cy + max_y) - - # Clamp - final_left = max(0, min(real_new_w, final_left)) - final_top = max(0, min(real_new_h, final_top)) - final_right = max(final_left, min(real_new_w, final_right)) - final_bottom = max(final_top, min(real_new_h, final_bottom)) - - # Apply Crop - img = img.crop((final_left, final_top, final_right, final_bottom)) - - else: - # No crop, just rotate - img = img.rotate(-straighten_angle, resample=Image.Resampling.BICUBIC, expand=True) - - # 3. Cropping (Standard axis-aligned crop if NO straighten angle, or if crop applied separately?) - # If straighten angle was present, we already handled the crop above as a "Rotated Crop". - # If we didn't handle it above (e.g. angle=0), we handle it here. - elif 'crop_box' in self.current_edits and self.current_edits['crop_box']: - crop_box = self.current_edits['crop_box'] - if crop_box and len(crop_box) == 4: - 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) - - 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)) + # expand=True ensures we don't clip corners of the rotated crop + img = img.rotate(-straighten_angle, resample=Image.Resampling.BICUBIC, expand=True) # 3. Exposure (gamma-based) exposure = self.current_edits['exposure'] diff --git a/faststack/faststack/imaging/jpeg.py b/faststack/faststack/imaging/jpeg.py index 262ad12..c135d97 100644 --- a/faststack/faststack/imaging/jpeg.py +++ b/faststack/faststack/imaging/jpeg.py @@ -162,6 +162,9 @@ def decode_jpeg_resized( from io import BytesIO img = Image.open(BytesIO(jpeg_bytes)) + if width == 0 or height == 0: + return np.array(img.convert("RGB")) + scale_factor_ratio = min(img.width / width, img.height / height) # Use faster BILINEAR for large downscales, LANCZOS for smaller diff --git a/faststack/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py index ed6476e..5cc76f7 100644 --- a/faststack/faststack/imaging/prefetch.py +++ b/faststack/faststack/imaging/prefetch.py @@ -1,21 +1,21 @@ -"""Handles prefetching and decoding of adjacent images in a background thread pool.""" - -import logging -import os -import io -import hashlib -from pathlib import Path -from concurrent.futures import ThreadPoolExecutor, Future -from typing import List, Dict, Optional, Callable -import mmap - -import numpy as np -from PIL import Image as PILImage, ImageCms - -from faststack.models import ImageFile, DecodedImage -from faststack.imaging.jpeg import decode_jpeg_rgb, decode_jpeg_resized, TURBO_AVAILABLE -from faststack.imaging.cache import build_cache_key -from faststack.config import config +"""Handles prefetching and decoding of adjacent images in a background thread pool.""" + +import logging +import os +import io +import hashlib +from pathlib import Path +from concurrent.futures import ThreadPoolExecutor, Future +from typing import List, Dict, Optional, Callable +import mmap + +import numpy as np +from PIL import Image as PILImage, ImageCms + +from faststack.models import ImageFile, DecodedImage +from faststack.imaging.jpeg import decode_jpeg_rgb, decode_jpeg_resized, TURBO_AVAILABLE +from faststack.imaging.cache import build_cache_key +from faststack.config import config log = logging.getLogger(__name__) @@ -290,7 +290,7 @@ def submit_task(self, index: int, generation: int, priority: bool = False) -> Op 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]]: + 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.""" import time @@ -307,6 +307,9 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, optimize_for = config.get('core', 'optimize_for', fallback='speed').lower() fast_dct = (optimize_for == 'speed') use_resized = (optimize_for == 'speed') # Use decode_jpeg_resized for speed, decode_jpeg_rgb for quality + + # Determine if we should resize + should_resize = (display_width > 0 and display_height > 0) # Option C: Full ICC pipeline - Use TurboJPEG for decode, Pillow only for ICC conversion if color_mode == "icc": @@ -319,12 +322,12 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, with open(image_file.path, "rb") as f: with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: # Pass mmap directly - no copy! Decoders accept bytes-like objects - if use_resized: + if use_resized and should_resize: buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) else: - # Quality mode: decode full image then resize with high quality + # Quality mode or Full Res: decode full image then resize with high quality buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) - if buffer is not None: + if buffer is not None and should_resize: img = PILImage.fromarray(buffer) img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) buffer = np.array(img) @@ -389,12 +392,12 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, with open(image_file.path, "rb") as f: with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: # Pass mmap directly - no copy! - if use_resized: + if use_resized and should_resize: buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) else: - # Quality mode: decode full image then resize with high quality + # Quality mode or Full Res: decode full image then resize with high quality buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) - if buffer is not None: + if buffer is not None and should_resize: img = PILImage.fromarray(buffer) img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) buffer = np.array(img) @@ -420,12 +423,12 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, with open(image_file.path, "rb") as f: with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: # Pass mmap directly - no copy! - if use_resized: + if use_resized and should_resize: buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) else: - # Quality mode: decode full image then resize with high quality + # Quality mode or Full Res: decode full image then resize with high quality buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) - if buffer is not None: + if buffer is not None and should_resize: img = PILImage.fromarray(buffer) img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) buffer = np.array(img) @@ -450,12 +453,12 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, with open(image_file.path, "rb") as f: with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mmapped: # Pass mmap directly - no copy! Decoders accept bytes-like objects - if use_resized: + if use_resized and should_resize: buffer = decode_jpeg_resized(mmapped, display_width, display_height, fast_dct=fast_dct) else: - # Quality mode: decode full image then resize with high quality + # Quality mode or Full Res: decode full image then resize with high quality buffer = decode_jpeg_rgb(mmapped, fast_dct=fast_dct) - if buffer is not None: + if buffer is not None and should_resize: img = PILImage.fromarray(buffer) img.thumbnail((display_width, display_height), PILImage.Resampling.LANCZOS) buffer = np.array(img) @@ -468,30 +471,29 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, bytes_per_line = w * 3 arr = buffer.reshape(-1).copy() t_after_copy = time.perf_counter() - - # Option A: Saturation compensation - if color_mode == "saturation": - try: - t_before_saturation = time.perf_counter() - factor = float(config.get('color', 'saturation_factor', fallback="1.0")) - apply_saturation_compensation(arr, w, h, bytes_per_line, factor) - t_after_saturation = time.perf_counter() - - if self.debug: - decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" - log.info("Saturation decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, saturation=%.3fs, total=%.3fs, size=%dx%d", - index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, - t_after_copy - t_after_decode, t_after_saturation - t_before_saturation, - t_after_saturation - t_start, w, h) - except (ValueError, AssertionError) as e: - log.warning("Failed to apply saturation compensation: %s", e) - else: - # No color management - log standard timing + + # Apply saturation compensation if enabled + if color_mode == "saturation": + try: + factor = float(config.get('color', 'saturation_factor', fallback="1.0")) + apply_saturation_compensation(arr, w, h, bytes_per_line, factor) + t_after_saturation = time.perf_counter() + if self.debug: decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" - log.info("Standard decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", + log.info("Saturation decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, saturation=%.3fs, total=%.3fs, size=%dx%d", index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, - t_after_copy - t_after_decode, t_after_copy - t_start, w, h) + t_after_copy - t_after_decode, t_after_saturation - t_after_copy, + t_after_saturation - t_start, w, h) + except (ValueError, AssertionError) as e: + log.warning("Failed to apply saturation compensation: %s", e) + else: + # No color management - log standard timing + if self.debug: + decoder = "TurboJPEG" if TURBO_AVAILABLE else "Pillow" + log.info("Standard decode timing for index %d (%s): read=%.3fs, decode=%.3fs, copy=%.3fs, total=%.3fs, size=%dx%d", + index, decoder, t_after_read - t_before_read, t_after_decode - t_after_read, + t_after_copy - t_after_decode, t_after_copy - t_start, w, h) # Re-check generation before caching (in case it changed during decode) if self.generation != generation: @@ -505,10 +507,10 @@ def _decode_and_cache(self, image_file: ImageFile, index: int, generation: int, bytes_per_line=bytes_per_line, format=None # Placeholder for QImage.Format.Format_RGB888 ) - cache_key = build_cache_key(image_file.path, display_generation) - self.cache_put(cache_key, decoded_image) - log.debug("Successfully decoded and cached image at index %d for display gen %d", index, display_generation) - return image_file.path, display_generation + cache_key = build_cache_key(image_file.path, display_generation) + self.cache_put(cache_key, decoded_image) + log.debug("Successfully decoded and cached image at index %d for display gen %d", index, display_generation) + return image_file.path, display_generation except Exception: log.exception("Error decoding image %s at index %d", image_file.path, index) diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 76f204a..cd848b2 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -9,9 +9,28 @@ Item { anchors.fill: parent focus: true + // Height of the status bar footer in Main.qml // Height of the status bar footer in Main.qml property int footerHeight: 60 + Connections { + target: uiState + function onCurrentIndexChanged() { + // Smart High-Res Logic: + // Before the new image loads, decide if we should keep high-res mode. + // Rule: Only keep high-res if we are currently "meaningfully zoomed" (> 1.1x fit). + // This prevents "sticky" high-res where zooming in once keeps it forever. + + if (imageRotator.zoomScale > imageRotator.fitScale * 1.1) { + // Keep high-res (setZoomed true if not already) + if (!uiState.isZoomed) uiState.setZoomed(true) + } else { + // Drop to low-res for the next image + if (uiState.isZoomed) uiState.setZoomed(false) + } + } + } + Keys.onEscapePressed: (event) => { if (uiState && uiState.isCropping) { if (mainMouseArea.isRotating) { @@ -38,15 +57,45 @@ Item { event.accepted = true } } - Keys.onEnterPressed: (event) => { - if (uiState && uiState.isCropping && controller) { + Keys.onPressed: (event) => { + // Zoom Shortcuts (Ctrl+1..4) + // Zoom Shortcuts (Ctrl+1..4) + if (event.modifiers & Qt.ControlModifier) { + if (event.key === Qt.Key_1) { + uiState.request_absolute_zoom(1.0) + event.accepted = true + return + } else if (event.key === Qt.Key_2) { + uiState.request_absolute_zoom(2.0) + event.accepted = true + return + } else if (event.key === Qt.Key_3) { + uiState.request_absolute_zoom(3.0) + event.accepted = true + return + } else if (event.key === Qt.Key_4) { + // 400% zoom + uiState.request_absolute_zoom(4.0) + event.accepted = true + return + } + } + + // Handle Enter for Crop Execution (formerly Keys.onEnterPressed) + // We only accept the event if we actually act on it. + if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return) && uiState && uiState.isCropping && controller) { uiState.setZoomed(false) // Force unzoom controller.execute_crop() event.accepted = true + return } + + // IMPORTANT: Allow unhandled keys to propagate to Python eventFilter logic + event.accepted = false } + // Connection to handle zoom/pan reset signal from Python Connections { target: uiState @@ -55,6 +104,18 @@ Item { panTransform.x = 0 panTransform.y = 0 } + function onAbsoluteZoomRequested(scale) { + console.log("QML: Absolute zoom requested: " + scale) + + imageRotator.zoomScale = scale + + // If we need to switch to high-res, flag this scale as the target + // for the incoming source change so recomputeFitScale doesn't clobber it. + if (uiState && !uiState.isZoomed) { + imageRotator.targetAbsoluteZoom = scale + uiState.setZoomed(true) + } + } } // Container that handles Viewport Clipping and Sizing @@ -83,6 +144,9 @@ Item { // Fix A: Atomic Zoom Scale property real zoomScale: 1.0 + // Fix C: Persist requested absolute zoom across source changes + property real targetAbsoluteZoom: -1.0 + onZoomScaleChanged: { mainImage.updateZoomState() if (cropOverlay.visible) cropOverlay.updateCropRect() @@ -126,12 +190,15 @@ Item { // NEW: fit-to-window scale (minimum zoom) property real fitScale: 1.0 - function recomputeFitScale() { + function recomputeFitScale(force) { + if (typeof force === 'undefined') force = false; + if (width <= 0 || height <= 0 || imageViewport.width <= 0 || imageViewport.height <= 0) return; - // Prevent jitter: Don't recompute fit scale while rotating or dragging - if (mainMouseArea.isRotating || mainMouseArea.isCropDragging) return; + // Prevent jitter: Don't recompute fit scale while dragging (resize, move, or rotate) + // Unless forced (e.g. on release) + if (!force && mainMouseArea.isCropDragging) return; // Capture current relative zoom to preserve it during resize/reload var oldFit = fitScale @@ -150,13 +217,21 @@ Item { fitScale = s; - // Restore zoom level relative to new fit (breaks cycle and preserves zoom) - imageRotator.zoomScale = fitScale * ratio; + // Restore zoom level + if (targetAbsoluteZoom > 0) { + // Check if we have a pending absolute zoom request (e.g. from Ctrl+1) + // If so, use it directly (1.0 = 1:1 pixels) and consume the flag. + imageRotator.zoomScale = targetAbsoluteZoom; + targetAbsoluteZoom = -1.0; + } else { + // Otherwise, preserve relative visual size (fit ratio) + imageRotator.zoomScale = fitScale * ratio; + } // Preserve Pan (don't reset to 0) as pan is in screen pixels (mostly) } - onWidthChanged: if (!isUpdatingGeometry && !mainMouseArea.isRotating) recomputeFitScale() - onHeightChanged: if (!isUpdatingGeometry && !mainMouseArea.isRotating) recomputeFitScale() + onWidthChanged: if (!isUpdatingGeometry) recomputeFitScale() + onHeightChanged: if (!isUpdatingGeometry) recomputeFitScale() Component.onCompleted: { updateRotatorGeometry() recomputeFitScale() @@ -281,25 +356,52 @@ Item { } source: uiState && uiState.imageCount > 0 ? uiState.currentImageSource : "" + + function _currentDpr() { + // Per-window DPR is the safest (multi-monitor setups) + if (mainImage.window && mainImage.window.devicePixelRatio) + return mainImage.window.devicePixelRatio + return Screen.devicePixelRatio + } + onSourceSizeChanged: { - // Only set base size the first time, or when NOT zoomed - // (prevents hi-res swap from changing logical geometry) - if (!imageRotator.baseW || !imageRotator.baseH || (uiState && !uiState.isZoomed)) { - imageRotator.baseW = sourceSize.width - imageRotator.baseH = sourceSize.height - } + if (sourceSize.width <= 0 || sourceSize.height <= 0) return + + const dpr = _currentDpr() + + // Treat baseW/baseH as *device-independent pixels* that correspond to 1:1 physical pixels at zoomScale=1 + imageRotator.baseW = sourceSize.width / dpr + imageRotator.baseH = sourceSize.height / dpr + + // Rebuild rotator + mainImage geometry based on the NEW resolution imageRotator.updateRotatorGeometry() + + // Force fit recompute so fitScale / zoom logic stabilizes immediately + imageRotator.recomputeFitScale(true) + + console.log("sourceSize changed:", sourceSize.width, sourceSize.height, + "dpr:", dpr, + "base:", imageRotator.baseW, imageRotator.baseH, + "zoomScale:", imageRotator.zoomScale) } + onStatusChanged: { - if (status === Image.Ready) imageRotator.updateRotatorGeometry() + if (status === Image.Ready) { + // Some backends update sourceSize right as status flips + mainImage.onSourceSizeChanged() + imageRotator.updateRotatorGeometry() + } } + + // Force reset when source changes (existing logic) onSourceChanged: { // Reset base size for new image so we pick up the new sourceSize imageRotator.baseW = 0 imageRotator.baseH = 0 - // Reset zoom/pan only when switching images (not zoomed), - // or if explicitly unzoomed. If zoomed (high-res load), preserve. + // Smart Zoom Reset: + // If we intended to keep high-res (isZoomed is true), preserve capabilities. + // If not (isZoomed is false), reset to "fit" state for speed and consistency. if (uiState && !uiState.isZoomed) { mainMouseArea.cropRotation = 0 mainMouseArea.isRotating = false @@ -312,8 +414,8 @@ Item { } fillMode: Image.PreserveAspectFit cache: false // We do our own caching in Python - smooth: uiState && !uiState.anySliderPressed - mipmap: uiState && !uiState.anySliderPressed + smooth: false // Crisp rendering for technical accuracy + mipmap: false // Crisp rendering property bool isZooming: false @@ -338,15 +440,42 @@ Item { // Removed direct onWidth/HeightChanged handlers for resizeDebounceTimer // because we now drive size reporting via viewport changes. + Timer { + id: lowResDebounceTimer + interval: 200 // 200ms debounce to prevent thrashing + repeat: false + onTriggered: { + if (uiState && uiState.isZoomed) { + uiState.setZoomed(false) + } + } + } + function updateZoomState() { if (!uiState) return; - if (imageRotator.zoomScale > imageRotator.fitScale * 1.1) { - // Check isZoomed first to break binding loop (Source -> Width -> Scale -> Zoomed -> Source) + + // Thresholds for hysteresis + var highResThreshold = imageRotator.fitScale * 1.1 + var lowResThreshold = imageRotator.fitScale * 1.02 + + // Enable High-Res if zoomed in significantly + if (imageRotator.zoomScale > highResThreshold) { + lowResDebounceTimer.stop() if (!uiState.isZoomed) { uiState.setZoomed(true); } + } + // Disable High-Res (return to low-res) if zoomed out to near-fit + // formatting note: added hysteresis check AND debounce + else if (imageRotator.zoomScale <= lowResThreshold) { + if (uiState.isZoomed) { + // Only drop to low-res after delay to handle wheel overshoot/jitter + if (!lowResDebounceTimer.running) lowResDebounceTimer.start() + } + } else { + // In hysteresis band: cancel any pending low-res switch + lowResDebounceTimer.stop() } - // Remove auto-unzoom to prevent flashing. Once high-res, stay high-res. updateHistogramWithZoom() } @@ -370,18 +499,7 @@ Item { // Zoom and Pan logic would go here // For example, using PinchArea or MouseArea - Timer { - id: resizeDebounceTimer - interval: 100 // milliseconds - running: false - onTriggered: { - if (mainImage.width > 0 && mainImage.height > 0) { - var dpr = Screen.devicePixelRatio - uiState.onDisplaySizeChanged(Math.round(mainImage.width * dpr), Math.round(mainImage.height * dpr)) - } - running = false - } - } + MouseArea { id: mainMouseArea @@ -498,17 +616,12 @@ Item { var my = coords.y * 1000 // Calculate threshold in normalized units (approx 10 screen pixels) - // 10 px / (scale * size) * 1000 var threshX = (10 / (scaleTransform.xScale * mainImage.width)) * 1000 var threshY = (10 / (scaleTransform.yScale * mainImage.height)) * 1000 - var edgeThreshold = Math.max(10, Math.min(threshX, threshY)) // Fallback/Clamp - if (imageRotator.width > 0) { - // cleaner approx: just use mapToImage for a 10px delta? - // Let's stick to the mapped coords. - threshX = (10 / (scaleTransform.xScale * mainImage.width)) * 1000 - threshY = (10 / (scaleTransform.yScale * mainImage.height)) * 1000 - edgeThreshold = Math.max(threshX, threshY) - } + + // Clamp threshold: min 5 normalized units (prevent too small), max 40 (prevent too large) + // This ensures handles remain usable at all zoom levels + var edgeThreshold = Math.max(5, Math.min(40, Math.max(threshX, threshY))) var inside = mx >= box[0] && mx <= box[2] && my >= box[1] && my <= box[3] @@ -635,18 +748,7 @@ Item { rotationThrottleTimer.start() } } - } else if (cropDragMode !== "none") { - // Update rotation in backend live (throttled) - if (controller) { - pendingRotation = cropRotation - pendingAspect = cropStartAspect - - if (!rotationThrottleTimer.running) { - rotationThrottleTimer.start() - } - } - } else if (cropDragMode !== "none") { var coords = mapToImageCoordinates(Qt.point(mouse.x, mouse.y)) @@ -726,8 +828,8 @@ Item { if (uiState && uiState.isCropping && isCropDragging) { isCropDragging = false cropDragMode = "none" - // Settle zoom/pan after rotation ends - if (mainMouseArea.isRotating) imageRotator.recomputeFitScale() + // Settle zoom/pan after rotation ends (Force recompute) + if (mainMouseArea.isRotating) imageRotator.recomputeFitScale(true) } } diff --git a/faststack/faststack/qml/ImageEditorDialog.qml b/faststack/faststack/qml/ImageEditorDialog.qml index b3a6b99..60225b8 100644 --- a/faststack/faststack/qml/ImageEditorDialog.qml +++ b/faststack/faststack/qml/ImageEditorDialog.qml @@ -9,14 +9,14 @@ Window { width: 720 height: 700 title: "Image Editor" - visible: uiState.isEditorOpen + visible: uiState ? uiState.isEditorOpen : false flags: Qt.Window | Qt.WindowTitleHint | Qt.WindowCloseButtonHint property int updatePulse: 0 property color backgroundColor: "red" // Placeholder, will be set from Main.qml property color textColor: "white" // Placeholder, will be set from Main.qml - Material.theme: uiState.theme === 0 ? Material.Dark : Material.Light + Material.theme: (uiState && uiState.theme === 0) ? Material.Dark : Material.Light Material.accent: "#4fb360" // When the dialog is closed by the user (e.g. clicking X), update the state @@ -33,7 +33,7 @@ Window { function getBackendValue(key) { var _dependency = updatePulse; - if (key in uiState) return uiState[key]; + if (uiState && key in uiState) return uiState[key]; return 0.0; } @@ -290,7 +290,7 @@ Window { height: 16 radius: 8 color: slider.pressed ? "#4fb360" : "#6fcf7c" - border.color: uiState.theme === 0 ? Qt.darker(Material.accent, 1.2) : Qt.lighter(Material.accent, 1.2) + border.color: (uiState && uiState.theme === 0) ? Qt.darker(Material.accent, 1.2) : Qt.lighter(Material.accent, 1.2) } } } diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index a318fff..26e3a22 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -635,6 +635,8 @@ ApplicationWindow { } } + property int footerHeight: 60 + // -------- MAIN VIEW -------- Item { id: contentArea @@ -645,6 +647,7 @@ ApplicationWindow { anchors.fill: parent source: "Components.qml" focus: true + onLoaded: item.footerHeight = Qt.binding(function() { return root.footerHeight }) // Key bindings implemented in old Main.qml Keys.onPressed: function(event) { @@ -684,9 +687,8 @@ ApplicationWindow { id: footerRect // Keep footer height fixed so the main image area doesn't change size when // stack/batch labels appear or disappear (prevents cache invalidations). - property int fixedHeight: 60 - height: fixedHeight - implicitHeight: fixedHeight + height: root.footerHeight + implicitHeight: root.footerHeight anchors.left: parent.left anchors.right: parent.right color: Qt.rgba(root.currentBackgroundColor.r, root.currentBackgroundColor.g, root.currentBackgroundColor.b, 0.8) diff --git a/faststack/faststack/qml/SettingsDialog.qml b/faststack/faststack/qml/SettingsDialog.qml index 9b6b635..684ea3d 100644 --- a/faststack/faststack/qml/SettingsDialog.qml +++ b/faststack/faststack/qml/SettingsDialog.qml @@ -123,7 +123,7 @@ Dialog { id: checkMarkLabel text: "✔" color: "lightgreen" - visible: uiState.check_path_exists(heliconPathField.text) + visible: uiState && uiState.check_path_exists(heliconPathField.text) } } @@ -147,7 +147,7 @@ Dialog { id: photoshopCheckMarkLabel text: "✔" color: "lightgreen" - visible: uiState.check_path_exists(photoshopPathField.text) + visible: uiState && uiState.check_path_exists(photoshopPathField.text) } } @@ -465,6 +465,8 @@ Dialog { interval: 1000 repeat: true running: false - onTriggered: settingsDialog.cacheUsage = uiState.get_cache_usage_gb() + onTriggered: { + if (uiState) settingsDialog.cacheUsage = uiState.get_cache_usage_gb() + } } } diff --git a/faststack/faststack/read_req.py b/faststack/faststack/read_req.py deleted file mode 100644 index 9f4a21c..0000000 --- a/faststack/faststack/read_req.py +++ /dev/null @@ -1,5 +0,0 @@ -try: - with open(r"c:\code\dikarya\part11_request.md", "r", encoding="utf-8") as f: - print(f.read()) -except Exception as e: - print(f"Error: {e}") diff --git a/faststack/faststack/ui/keystrokes.py b/faststack/faststack/ui/keystrokes.py index 6eb87e4..03da231 100644 --- a/faststack/faststack/ui/keystrokes.py +++ b/faststack/faststack/ui/keystrokes.py @@ -60,7 +60,12 @@ def __init__(self, controller): (Qt.Key_Z, Qt.ControlModifier): "undo_delete", (Qt.Key_E, Qt.ControlModifier): "toggle_edited", (Qt.Key_S, Qt.ControlModifier): "toggle_stacked", + (Qt.Key_S, Qt.ControlModifier): "toggle_stacked", (Qt.Key_B, Qt.ControlModifier | Qt.ShiftModifier): "quick_auto_white_balance", + (Qt.Key_1, Qt.ControlModifier): "zoom_100", + (Qt.Key_2, Qt.ControlModifier): "zoom_200", + (Qt.Key_3, Qt.ControlModifier): "zoom_300", + (Qt.Key_4, Qt.ControlModifier): "zoom_400", } def _call(self, method_name: str): @@ -81,11 +86,14 @@ def _call(self, method_name: str): def handle_key_press(self, event): key = event.key() text = event.text() - log.debug(f"Key pressed: {key} ({text!r}) with modifiers {event.modifiers()}") + modifiers = event.modifiers() + log.debug(f"Key pressed: {key} ({text!r}) with modifiers {modifiers}") # Check for modifier + key combinations for (mapped_key, mapped_modifier), method_name in self.modifier_key_map.items(): - if key == mapped_key and event.modifiers() & mapped_modifier: + # Check if required modifier is present in event modifiers + if key == mapped_key and (modifiers & mapped_modifier): + log.debug(f"Matched modifier key: {key} + {mapped_modifier} -> {method_name}") self._call(method_name) return True diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index 6141b69..aa8f059 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -80,6 +80,7 @@ class UIState(QObject): isZoomedChanged = Signal() statusMessageChanged = Signal() # New signal for status messages resetZoomPanRequested = Signal() # Signal to tell QML to reset zoom/pan + absoluteZoomRequested = Signal(float) # New: Request absolute zoom level (1.0, 2.0, etc.) stackSummaryChanged = Signal() # Signal for stack summary updates filterStringChanged = Signal() # Signal for filter string updates colorModeChanged = Signal() # Signal for color mode updates @@ -147,7 +148,7 @@ def __init__(self, app_controller): self._saturation = 0.0 self._white_balance_by = 0.0 self._white_balance_mg = 0.0 - self._current_crop_box = (0, 0, 1000, 1000) + self._current_crop_box = [0, 0, 1000, 1000] self._crop_rotation = 0.0 self._debug_mode = False self._aspect_ratio_names = [] @@ -191,6 +192,11 @@ def isZoomed(self): def setZoomed(self, zoomed: bool): self.app_controller.set_zoomed(zoomed) + @Slot(float) + def request_absolute_zoom(self, scale): + """Request the UI to set zoom to an absolute scale (1.0 = 100%).""" + self.absoluteZoomRequested.emit(scale) + # ---- PRELOADING ---- @Property(bool, notify=preloadingStateChanged) def isPreloading(self): From 8975418e8316cfb5fb89546cd3ca0ae46ac4428c Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Wed, 31 Dec 2025 23:01:30 -0800 Subject: [PATCH 4/6] Fixed zoom and crop for good this time --- faststack/faststack/imaging/editor.py | 203 ++++++++++++++++++++----- faststack/faststack/qml/Components.qml | 51 +++++-- faststack/faststack/ui/provider.py | 9 +- 3 files changed, 212 insertions(+), 51 deletions(-) diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index 6a2c454..4134997 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -2,6 +2,7 @@ import shutil import glob import re +import math from pathlib import Path from typing import Optional, Dict, Any, Tuple try: @@ -56,6 +57,110 @@ def create_backup_file(original_path: Path) -> Optional[Path]: print(f"Failed to create backup: {e}") return None +# ---------------------------- +# Rotate + Autocrop helper +# ---------------------------- + +def _rotated_rect_with_max_area(w: int, h: int, angle_rad: float) -> tuple[int, int]: + """ + Largest axis-aligned rectangle within a w x h rectangle rotated by angle_rad. + Returns (crop_w, crop_h) in pixels. + """ + if w <= 0 or h <= 0: + return 0, 0 + + # fold angle into [0, pi/2) + angle_rad = abs(angle_rad) % (math.pi / 2) + if angle_rad > math.pi / 4: + angle_rad = (math.pi / 2) - angle_rad + + sin_a = abs(math.sin(angle_rad)) + cos_a = abs(math.cos(angle_rad)) + + # if basically unrotated + if sin_a < 1e-12: + return w, h + + width_is_longer = w >= h + side_long = w if width_is_longer else h + side_short = h if width_is_longer else w + + # "half constrained" case + if side_short <= 2.0 * sin_a * cos_a * side_long or abs(sin_a - cos_a) < 1e-12: + x = 0.5 * side_short + if width_is_longer: + wr = x / sin_a + hr = x / cos_a + else: + wr = x / cos_a + hr = x / sin_a + else: + cos_2a = cos_a * cos_a - sin_a * sin_a + wr = (w * cos_a - h * sin_a) / cos_2a + hr = (h * cos_a - w * sin_a) / cos_2a + + cw = int(round(abs(wr))) + ch = int(round(abs(hr))) + cw = max(1, min(w, cw)) + ch = max(1, min(h, ch)) + return cw, ch + + +def rotate_autocrop_rgb(img: Image.Image, angle_deg: float, inset: int = 2) -> Image.Image: + """ + Rotate by any angle and then crop to the largest axis-aligned rectangle that contains + ONLY valid pixels (no wedges). Works for large angles. + """ + if abs(angle_deg) < 0.01: + return img.convert("RGB") + + img = img.convert("RGB") + w, h = img.size + + # Reduce angle for rectangle math (rotation by 120° has same inscribed rect as 60°) + a = abs(angle_deg) % 180.0 + if a > 90.0: + a = 180.0 - a + angle_rad = math.radians(a) + + # Largest rectangle inside the rotated original (in original pixel coordinates) + crop_w, crop_h = _rotated_rect_with_max_area(w, h, angle_rad) + crop_w = max(1, min(w, crop_w)) + crop_h = max(1, min(h, crop_h)) + + # Rotate with expand so content is preserved + rot = img.rotate( + -angle_deg, + resample=Image.Resampling.BICUBIC, + expand=True, + fillcolor=(0, 0, 0), + ) + + # Center-crop to the inscribed rectangle + cx = rot.width / 2.0 + cy = rot.height / 2.0 + left = int(round(cx - crop_w / 2.0)) + top = int(round(cy - crop_h / 2.0)) + right = left + crop_w + bottom = top + crop_h + + # Small inset to remove any bicubic edge contamination + if inset > 0 and (right - left) > 2 * inset and (bottom - top) > 2 * inset: + left += inset + top += inset + right -= inset + bottom -= inset + + # Clamp defensively + left = max(0, min(rot.width - 1, left)) + top = max(0, min(rot.height - 1, top)) + right = max(left + 1, min(rot.width, right)) + bottom = max(top + 1, min(rot.height, bottom)) + + out = rot.crop((left, top, right, bottom)).convert("RGB") + return out + + class ImageEditor: """Handles core image manipulation using PIL.""" def __init__(self): @@ -132,46 +237,66 @@ def load_image(self, filepath: str, cached_preview: Optional[DecodedImage] = Non self._preview_image = None return False + 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'] + + # 1. Rotation (90 degree steps) + # (This remains first as it changes the coordinate system basis) + rotation = self.current_edits.get('rotation', 0) if rotation == 90: - img = img.transpose(Image.Transpose.ROTATE_270) # 90 CW = 270 CCW + img = img.transpose(Image.Transpose.ROTATE_270) elif rotation == 180: img = img.transpose(Image.Transpose.ROTATE_180) elif rotation == 270: - img = img.transpose(Image.Transpose.ROTATE_90) # 270 CW = 90 CCW + img = img.transpose(Image.Transpose.ROTATE_90) + + # --------------------------------------------------------- + # CHANGE: Apply Free Rotation (Straighten) BEFORE Cropping + # --------------------------------------------------------- + straighten_angle = self.current_edits.get('straighten_angle', 0.0) + has_crop_box = 'crop_box' in self.current_edits and self.current_edits['crop_box'] - # 2. Cropping (Standard axis-aligned crop) - # Semantics: Crop first (in original image coordinates), THEN rotate (straighten) - # This matches "applying rotation to the selected crop". - # If we rotate first, we get an AABB of the rotated content which is arguably wrong (too big). - - # Apply Crop if it exists - if 'crop_box' in self.current_edits and self.current_edits['crop_box']: - crop_box = self.current_edits['crop_box'] - if crop_box and len(crop_box) == 4: - width, height = img.size - l = int(crop_box[0] * width / 1000) - t = int(crop_box[1] * height / 1000) - r = int(crop_box[2] * width / 1000) - b = int(crop_box[3] * height / 1000) - - # Clamp coordinates to be within image bounds and ensure valid box - l = max(0, min(width - 1, l)) - t = max(0, min(height - 1, t)) - r = max(l + 1, min(width, r)) # Ensure r > l - b = max(t + 1, min(height, b)) # Ensure b > t - - img = img.crop((l, t, r, b)) - - # 3. Free Rotation (Straighten) - straighten_angle = self.current_edits['straighten_angle'] if abs(straighten_angle) > 0.001: - # expand=True ensures we don't clip corners of the rotated crop - img = img.rotate(-straighten_angle, resample=Image.Resampling.BICUBIC, expand=True) - + if has_crop_box: + # Scenario A: Manual Crop. + # Just rotate the image (expanding canvas). The subsequent + # manual crop will trim off the black wedges. + img = img.convert("RGB").rotate( + -straighten_angle, + resample=Image.Resampling.BICUBIC, + expand=True, + fillcolor=(0, 0, 0) # These will be cropped out shortly + ) + else: + # Scenario B: Straighten Only (No manual crop). + # Use your existing helper to Rotate + Auto-Shrink to remove wedges. + img = rotate_autocrop_rgb(img, straighten_angle) + + # --------------------------------------------------------- + # CHANGE: Apply Cropping LAST + # --------------------------------------------------------- + if has_crop_box: + crop_box = self.current_edits['crop_box'] + if len(crop_box) == 4: + # Normalize coordinates (0-1000) to pixel coordinates + # Note: We calculate this based on the *current* img size, + # which might be larger now due to the rotation above. + w, h = img.size + l = int(crop_box[0] * w / 1000) + t = int(crop_box[1] * h / 1000) + r = int(crop_box[2] * w / 1000) + b = int(crop_box[3] * h / 1000) + + # Basic boundary checks + l = max(0, l) + t = max(0, t) + r = min(w, r) + b = min(h, b) + + if r > l and b > t: + img = img.crop((l, t, r, b)) + # 3. Exposure (gamma-based) exposure = self.current_edits['exposure'] if abs(exposure) > 0.001: @@ -196,16 +321,17 @@ def _apply_edits(self, img: Image.Image, is_export: bool = False) -> Image.Image # 5. Highlights/Shadows highlights = self.current_edits['highlights'] shadows = self.current_edits['shadows'] + if abs(highlights) > 0.001 or abs(shadows) > 0.001: arr = np.array(img, dtype=np.float32) if abs(shadows) > 0.001: shadow_mask = 1.0 - np.clip(arr / 128.0, 0, 1) arr += shadows * 60 * shadow_mask - + if highlights < -0.001: # Negative highlights (recovery) mask = np.clip((arr - 128) / 127.0, 0, 1) # targets bright pixels # highlights is negative here, so 1.0 + (negative * positive) = something less than 1.0 - factor = 1.0 + (highlights * 0.75 * mask) + factor = 1.0 + (highlights * 0.75 * mask) arr = arr * factor elif highlights > 0.001: # Positive highlights (keep existing) highlight_mask = np.clip((arr - 128) / 127.0, 0, 1) @@ -258,12 +384,12 @@ def _apply_edits(self, img: Image.Image, is_export: bool = False) -> Image.Image arr = np.array(img, dtype=np.float32) # Multiplicative White Balance (Gain-based) # This preserves black levels (0 * gain = 0) while adjusting the color balance of brighter pixels. - + # Temperature (Blue-Yellow): # Positive = Warm (Yellow/Red), Negative = Cool (Blue) r_gain = 1.0 + by_val b_gain = 1.0 - by_val - + # Tint (Magenta-Green): # Positive = Magenta (Red+Blue boost or Green cut), Negative = Green (Green boost) # Standard approach: Adjust Green channel opposite to the tint value. @@ -273,7 +399,7 @@ def _apply_edits(self, img: Image.Image, is_export: bool = False) -> Image.Image arr[:, :, 0] = arr[:, :, 0] * r_gain arr[:, :, 1] = arr[:, :, 1] * g_gain arr[:, :, 2] = arr[:, :, 2] * b_gain - + np.clip(arr, 0, 255, out=arr) img = Image.fromarray(arr.astype(np.uint8)) @@ -281,7 +407,7 @@ def _apply_edits(self, img: Image.Image, is_export: bool = False) -> Image.Image sharp_factor = 1.0 + self.current_edits['sharpness'] if abs(sharp_factor - 1.0) > 0.001: img = ImageEnhance.Sharpness(img).enhance(sharp_factor) - + # 13. Vignette vignette = self.current_edits['vignette'] if vignette > 0.001: @@ -314,6 +440,7 @@ def _apply_edits(self, img: Image.Image, is_export: bool = False) -> Image.Image arr[:,:,c] += local_details img = Image.fromarray(arr.clip(0, 255).astype(np.uint8)) + return img def auto_levels(self, threshold_percent: float = 0.1) -> Tuple[float, float]: diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index cd848b2..7bf8bfe 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -50,13 +50,20 @@ Item { } } + Keys.onReturnPressed: (event) => { if (uiState && uiState.isCropping && controller) { - uiState.setZoomed(false) // Force unzoom to reset geometry/fit after crop + // Force immediate rotation update before executing crop + if (mainMouseArea.cropRotation !== 0) { + controller.set_straighten_angle(mainMouseArea.cropRotation, -1) + } + + uiState.setZoomed(false) controller.execute_crop() event.accepted = true } } + Keys.onPressed: (event) => { // Zoom Shortcuts (Ctrl+1..4) // Zoom Shortcuts (Ctrl+1..4) @@ -211,9 +218,9 @@ Item { // fit rotated canvas into viewport var s = Math.min(imageViewport.width / width, imageViewport.height / height); // Ensure fitScale is finite and positive - // Cap at 1.0 (don't upscale small images to fit) + // Allow upscaling to fit window (necessary for HiDPI logical sizing) if (!isFinite(s) || s <= 0) s = 1.0; - else if (s > 1.0) s = 1.0; + // else if (s > 1.0) s = 1.0; // REMOVED: Cap prevented fitting small/logical images fitScale = s; @@ -364,14 +371,14 @@ Item { return Screen.devicePixelRatio } - onSourceSizeChanged: { - if (sourceSize.width <= 0 || sourceSize.height <= 0) return + function handleSourceSizeChange() { + if (mainImage.sourceSize.width <= 0 || mainImage.sourceSize.height <= 0) return const dpr = _currentDpr() // Treat baseW/baseH as *device-independent pixels* that correspond to 1:1 physical pixels at zoomScale=1 - imageRotator.baseW = sourceSize.width / dpr - imageRotator.baseH = sourceSize.height / dpr + imageRotator.baseW = mainImage.sourceSize.width / dpr + imageRotator.baseH = mainImage.sourceSize.height / dpr // Rebuild rotator + mainImage geometry based on the NEW resolution imageRotator.updateRotatorGeometry() @@ -379,16 +386,20 @@ Item { // Force fit recompute so fitScale / zoom logic stabilizes immediately imageRotator.recomputeFitScale(true) - console.log("sourceSize changed:", sourceSize.width, sourceSize.height, - "dpr:", dpr, - "base:", imageRotator.baseW, imageRotator.baseH, - "zoomScale:", imageRotator.zoomScale) + if (uiState && uiState.debugMode) { + console.log("sourceSize changed:", mainImage.sourceSize.width, mainImage.sourceSize.height, + "dpr:", dpr, + "base:", imageRotator.baseW, imageRotator.baseH, + "zoomScale:", imageRotator.zoomScale) + } } + onSourceSizeChanged: { handleSourceSizeChange() } + onStatusChanged: { if (status === Image.Ready) { // Some backends update sourceSize right as status flips - mainImage.onSourceSizeChanged() + mainImage.handleSourceSizeChange() imageRotator.updateRotatorGeometry() } } @@ -826,6 +837,22 @@ Item { onReleased: function(mouse) { isDraggingOutside = false if (uiState && uiState.isCropping && isCropDragging) { + // Fix: Prevent accidental tiny crops with Right Click + if (mouse.button === Qt.RightButton && cropDragMode === "new") { + var dx = Math.abs(mouse.x - cropStartX) + var dy = Math.abs(mouse.y - cropStartY) + var maxDim = Math.max(dx, dy) + var minDim = Math.min(dx, dy) + + // "at least 50 pixels in both dimensions" + if (maxDim < 50 || minDim < 50) { + if (controller) controller.cancel_crop_mode() + isCropDragging = false + cropDragMode = "none" + return + } + } + isCropDragging = false cropDragMode = "none" // Settle zoom/pan after rotation ends (Force recompute) diff --git a/faststack/faststack/ui/provider.py b/faststack/faststack/ui/provider.py index aa8f059..0726c96 100644 --- a/faststack/faststack/ui/provider.py +++ b/faststack/faststack/ui/provider.py @@ -151,7 +151,14 @@ def __init__(self, app_controller): self._current_crop_box = [0, 0, 1000, 1000] self._crop_rotation = 0.0 self._debug_mode = False - self._aspect_ratio_names = [] + self._aspect_ratio_names = [ + "Freeform", + "1:1 (Square)", + "4:5 (Portrait)", + "1.91:1 (Landscape)", + "16:9 (Wide)", + "9:16 (Story)" + ] self._current_aspect_ratio_index = 0 self._any_slider_pressed = False self._sharpness = 0.0 From 8e6fcb30a47421b5038c62ce4fdcf3675548e696 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 1 Jan 2026 01:02:53 -0800 Subject: [PATCH 5/6] updated documentation --- faststack/ChangeLog.md | 8 +++++- faststack/README.md | 40 ++++++++++++++++---------- faststack/faststack/app.py | 1 + faststack/faststack/qml/Components.qml | 4 ++- faststack/faststack/qml/Main.qml | 17 ++++++++--- 5 files changed, 49 insertions(+), 21 deletions(-) diff --git a/faststack/ChangeLog.md b/faststack/ChangeLog.md index f0bc62d..bed9d5d 100644 --- a/faststack/ChangeLog.md +++ b/faststack/ChangeLog.md @@ -2,6 +2,12 @@ Todo: Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. + +## [1.5.0] - 2025-12-01 + +- Fixed rotating images via the crop interface. +- Control-1 zooms to 1:1 magnification (100%). Control-2 to 200, etc to control-4 (400%). + ## [1.4.0] - 2025-12-01 - Changed how image caching works for even faster display. @@ -9,7 +15,7 @@ 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 +- **Auto-Levels:** Automatic image enhancement with configurable threshold and strength (L key) - **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 diff --git a/faststack/README.md b/faststack/README.md index 49ddd54..0950f89 100644 --- a/faststack/README.md +++ b/faststack/README.md @@ -1,6 +1,6 @@ # FastStack -# Version 1.4 - December 1, 2025 +# Version 1.5 - January 1, 2026 # By Alan Rockefeller Ultra-fast, caching JPG viewer designed for culling and selecting RAW or JPG files for focus stacking and website upload. @@ -9,20 +9,20 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive ## Features -- **Crop:** 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. +- **Crop:** Added the ability to crop and rotate images via the cr(O)p hotkey (or right mouse click). It can be a freeform crop, or constrained to several popular aspect ratios. - **Zoom & Pan:** Smooth zooming and panning. - **Stack Selection:** Group images into stacks (`[`, `]`) and select them for processing (`S`). - **Helicon Focus Integration:** Launch Helicon Focus with your selected RAW files with a single keypress (`Enter`). - **Instant Navigation:** Sub-10ms next/previous image switching, high performance decoding via `PyTurboJPEG`. - **Image Editor:** Built-in editor with exposure, contrast, white balance, sharpness, and more (E key) - **Quick Auto White Balance:** Press A to apply auto white balance and save automatically with undo support (Ctrl+Z). For better white balance load the raw into Photoshop with the P key. -- **Photoshop Integration:** Edit current image in Photoshop (P key) - always uses RAW files when available, even for backup files +- **Photoshop Integration:** Edit current image in Photoshop (P key) - always uses RAW files when available. - **Clipboard Support:** Copy image path to clipboard (Ctrl+C) - **Image Filtering:** Filter images by filename - **Drag & Drop:** Drag images to external applications. Press { and } to batch files to drag & drop multiple images. - **Theme Support:** Toggle between light and dark themes - **Delete & Undo:** Move images to recycle bin (Delete/Backspace) with undo support (Ctrl+Z) -- **Has Memory:** Starts where you left off, tells you which images have been edited, stacked and uploaded +- **Has Memory:** Starts where you left off, tells you which images have been edited, stacked and uploaded. - **RAW Pairing:** Automatically maps JPGs to their corresponding RAW files (`.CR3`, `.ARW`, `.NEF`, etc.). - **Configurable:** Adjust cache sizes, prefetch behavior, and Helicon Focus / Photoshop paths via a settings dialog and a persistent `.ini` file. - **Accurate Colors:** Uses monitor ICC profile to display colors correctly. @@ -44,24 +44,34 @@ This tool is optimized for speed, using `libjpeg-turbo` for decoding, aggressive - `J` / `Right Arrow`: Next Image - `K` / `Left Arrow`: Previous Image -- `G`: Go to image # -- `S`: Toggle selection of current image for stacking -- `B`: Toggle selection of current image for batch drag & drop +- `G`: Jump to Image Number +- `I`: Show EXIF Data +- `S`: Toggle current image in/out of stack +- `X`: Remove current image from batch/stack +- `B`: Toggle current image in/out of batch - `[`: Begin new stack group - `]`: End current stack group +- `C`: Clear all stacks - `{`: Begin new drag & drop batch - `}`: End current drag & drop batch -- '\': Clear drag & drop batch -- 'U': Toggle uploaded flag -- 'Ctrl+E': Toggle edited flag -- 'Ctrl+S': Toggle stacked flag +- `\`: Clear drag & drop batch +- `U`: Toggle uploaded flag +- `Ctrl+E`: Toggle edited flag +- `Ctrl+S`: Toggle stacked flag - `Enter`: Launch Helicon Focus with selected RAWs - `P`: Edit in Photoshop (uses RAW file if available) -- `O`: Toggle crop mode (cr(O)p hotkey; Enter to crop, Esc to cancel) +- `O` (or Right-Click): Toggle crop mode (Enter to execute, Esc to cancel) - `Delete` / `Backspace`: Move image to recycle bin -- `Ctrl+Z`: Undo last action (delete or auto white balance) +- `Ctrl+Z`: Undo last action (delete, auto white balance, or crop) - `A`: Quick auto white balance (saves automatically) +- `Ctrl+Shift+B`: Quick auto white balance (alternate) +- `L`: Quick auto levels (saves automatically) - `E`: Toggle Image Editor +- `Esc`: Close active dialog, editor, or cancel crop +- `H`: Toggle histogram window - `Ctrl+C`: Copy image path to clipboard -- `Ctrl+0`: Reset zoom and pan -- `C`: Clear all stacks +- `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% diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 045090c..4daf8a0 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -14,6 +14,7 @@ import threading import subprocess from faststack.ui.provider import ImageProvider, UIState +os.environ["QT_LOGGING_RULES"] = "qt.qpa.mime.warning=false" import PySide6 from PySide6.QtGui import QDrag, QPixmap from PySide6.QtCore import ( diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 7bf8bfe..4b63a27 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -112,7 +112,9 @@ Item { panTransform.y = 0 } function onAbsoluteZoomRequested(scale) { - console.log("QML: Absolute zoom requested: " + scale) + if (uiState && uiState.debugMode) { + console.log("QML: Absolute zoom requested: " + scale) + } imageRotator.zoomScale = scale diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index 26e3a22..5d40f28 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -865,15 +865,21 @@ ApplicationWindow { "  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+4: Zoom to 400%

" + "Stacking:
" + "  [: Begin new stack
" + "  ]: End current stack
" + - "  C: Clear all stacks

" + + "  C: Clear all stacks
" + + "  S: Toggle current image in/out of stack
" + + "  X: Remove current image from batch/stack

" + "Batch Selection (for drag-and-drop):
" + "  {: Begin new batch
" + + "  B: Toggle current image in/out of batch
" + "  }: End current batch
" + "  \\: Clear all batches
" + - "  X or S: Remove current image from batch/stack

" + "Flag Toggles:
" + "  U: Toggle uploaded flag
" + "  Ctrl+E: Toggle edited flag
" + @@ -884,12 +890,15 @@ ApplicationWindow { "Actions:
" + "  Enter: Launch Helicon Focus
" + "  P: Edit in Photoshop
" + + "  Backspace/Del: Move current image to recycle bin
" + "  A: Quick auto white balance (saves automatically)
" + + "  L: Quick auto levels (saves automatically)
" + "  Ctrl+Shift+B: Quick auto white balance (saves automatically)
" + - "  O: Toggle crop mode (Enter to execute crop, ESC to cancel)
" + + "  O (or right mouse click): Toggle crop mode (Enter to execute crop, ESC to cancel)
" + "  H: Toggle histogram window
" + "  E: Toggle Image Editor (closes without saving if open)
" + - "  Ctrl+C: Copy image path to clipboard" + "  Ctrl+C: Copy image path to clipboard
" + + "  Esc: Close active dialog, editor, or cancel crop" padding: 10 wrapMode: Text.WordWrap color: root.currentTextColor From 2d2cdb507e804c63db4eb1629580feffae2209bb Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 1 Jan 2026 01:50:45 -0800 Subject: [PATCH 6/6] fix final bugs --- faststack/faststack/app.py | 124 ++++++------ faststack/faststack/imaging/editor.py | 33 ++- faststack/faststack/imaging/jpeg.py | 4 +- faststack/faststack/imaging/prefetch.py | 10 +- faststack/faststack/qml/Components.qml | 24 +-- faststack/faststack/qml/Main.qml | 114 ++++++----- .../faststack/tests/test_editor_rotation.py | 188 ++++++++++++++++++ faststack/faststack/ui/keystrokes.py | 2 +- 8 files changed, 353 insertions(+), 146 deletions(-) create mode 100644 faststack/faststack/tests/test_editor_rotation.py diff --git a/faststack/faststack/app.py b/faststack/faststack/app.py index 4daf8a0..945a6c2 100644 --- a/faststack/faststack/app.py +++ b/faststack/faststack/app.py @@ -2,6 +2,7 @@ import logging import sys +import math import struct import shlex import time @@ -10,11 +11,13 @@ from typing import Optional, List, Dict, Any, Tuple from datetime import date import os +# Must set before importing PySide6 +os.environ["QT_LOGGING_RULES"] = "qt.qpa.mime.warning=false" + import concurrent.futures import threading import subprocess from faststack.ui.provider import ImageProvider, UIState -os.environ["QT_LOGGING_RULES"] = "qt.qpa.mime.warning=false" import PySide6 from PySide6.QtGui import QDrag, QPixmap from PySide6.QtCore import ( @@ -73,6 +76,12 @@ def make_hdrop(paths): # Global flag for debug mode - set by main() _debug_mode = False +# Cache Thrashing Detection Constants +CACHE_THRASH_WINDOW_SECS = 2.0 +CACHE_THRASH_THRESHOLD = 5 +CACHE_WARNING_COOLDOWN_SECS = 300 + + class AppController(QObject): dataChanged = Signal() # New signal for general data changes is_zoomed_changed = Signal(bool) # Signal for zoom state changes @@ -274,7 +283,6 @@ def set_zoomed(self, zoomed: bool): self.ui_state.currentImageSourceChanged.emit() self.main_window.update() # Force repaint - # -- Zoom Shortcuts -- # -- Zoom Shortcuts -- def zoom_100(self): log.info("Zoom 100% requested") @@ -401,26 +409,26 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: # Debug preview condition if self.ui_state.isEditorOpen or self.ui_state.isCropping: - # Robust path comparison - editor_path = self.image_editor.current_filepath - file_path = self.image_files[index].path - - match = False - if editor_path and file_path: - try: - match = Path(editor_path).resolve() == Path(file_path).resolve() - except Exception: - match = str(editor_path) == str(file_path) - - if not match: - # Debug log if mismatch - log.debug(f"Path mismatch in preview. Editor: {editor_path}, File: {file_path}") - - # Return preview if Editor is open OR Cropping is active (for live rotation) - if (self.ui_state.isEditorOpen or self.ui_state.isCropping) and match and self.image_editor.original_image: - preview_data = self.image_editor.get_preview_data() - if preview_data: - return preview_data + # Robust path comparison + editor_path = self.image_editor.current_filepath + file_path = self.image_files[index].path + + match = False + if editor_path and file_path: + try: + match = Path(editor_path).resolve() == Path(file_path).resolve() + except (OSError, ValueError): + match = str(editor_path) == str(file_path) + + if not match: + # Debug log if mismatch + log.debug(f"Path mismatch in preview. Editor: {editor_path}, File: {file_path}") + + # Return preview if Editor is open OR Cropping is active (for live rotation) + if match and self.image_editor.original_image: + preview_data = self.image_editor.get_preview_data() + if preview_data: + return preview_data _, _, display_gen = self.get_display_info() image_path = self.image_files[index].path @@ -1331,15 +1339,15 @@ def set_straighten_angle(self, angle: float, target_aspect_ratio: float = -1.0): # If we have a target aspect ratio, we need to adjust the normalized crop box # because the underlying canvas aspect ratio changes with rotation (expand=True). if target_aspect_ratio > 0 and self.ui_state.currentCropBox: - l, t, r, b = self.ui_state.currentCropBox - w_norm = r - l - h_norm = b - t + left, top, right, bottom = self.ui_state.currentCropBox + w_norm = right - left + h_norm = bottom - top if w_norm > 0 and h_norm > 0: # Calculate new canvas dimensions # PIL expand=True logic: im_w, im_h = self.image_editor.original_image.size - import math + # math imported at top level rad = math.radians(abs(angle)) # New dimensions new_w = abs(im_w * math.cos(rad)) + abs(im_h * math.sin(rad)) @@ -1364,36 +1372,34 @@ def set_straighten_angle(self, angle: float, target_aspect_ratio: float = -1.0): new_h_norm = 1000 w_norm = new_h_norm * target_norm_ratio # Recenter height - cy = (t + b) / 2 - t = cy - new_h_norm / 2 - b = cy + new_h_norm / 2 + cy = (top + bottom) / 2 + top = cy - new_h_norm / 2 + bottom = cy + new_h_norm / 2 # Clamp vertical - if t < 0: - b -= t # shift down - t = 0 - if b > 1000: - t -= (b - 1000) # shift up - b = 1000 - if t < 0: t = 0 # double clamp + if top < 0: + bottom -= top # shift down + top = 0 + if bottom > 1000: + top -= (bottom - 1000) # shift up + bottom = 1000 + if top < 0: + top = 0 # double clamp # Recenter width (if changed) - cx = (l + r) / 2 - l = cx - w_norm / 2 - r = cx + w_norm / 2 + cx = (left + right) / 2 + left = cx - w_norm / 2 + right = cx + w_norm / 2 # Clamp horizontal - if l < 0: - r -= l - l = 0 - if r > 1000: - l -= (r - 1000) - r = 1000 - if l < 0: l = 0 - - # IMPORTANT: Don't mutate currentCropBox here. - # Doing so during rotation causes the crop box to walk and jitter. - # self.ui_state.currentCropBox = [l, t, r, b] + if left < 0: + right -= left + left = 0 + if right > 1000: + left -= (right - 1000) + right = 1000 + if left < 0: + left = 0 log.debug(f"AppController.set_straighten_angle: {angle}") # Invert angle because QML rotation is CW but PIL rotation (used in editor) handles direction logic internally @@ -2084,15 +2090,15 @@ def _on_cache_evict(self): # 1. Record eviction timestamp self._eviction_timestamps.append(now) - # 2. Prune timestamps older than 2 seconds + # 2. Prune timestamps older than window # Keep list short - cutoff = now - 2.0 + cutoff = now - CACHE_THRASH_WINDOW_SECS self._eviction_timestamps = [t for t in self._eviction_timestamps if t > cutoff] - # 3. Check for thrashing (e.g., > 5 evictions in 2 seconds) - if len(self._eviction_timestamps) > 5: - # 4. Rate limit the warning (once every 5 minutes = 300 seconds) - if now - self._last_cache_warning_time > 300: + # 3. Check for thrashing (e.g., > threshold evictions in window) + if len(self._eviction_timestamps) > CACHE_THRASH_THRESHOLD: + # 4. Rate limit the warning + if now - self._last_cache_warning_time > CACHE_WARNING_COOLDOWN_SECS: self._last_cache_warning_time = now self._has_warned_cache_full = True @@ -2100,7 +2106,7 @@ def _on_cache_evict(self): used_gb = self.image_cache.currsize / (1024**3) max_gb = self.image_cache.maxsize / (1024**3) - msg = f"Cache thrashing! {len(self._eviction_timestamps)} evictions in 2s. Usage: {used_gb:.1f}GB / {max_gb:.1f}GB." + msg = f"Cache thrashing! {len(self._eviction_timestamps)} evictions in {CACHE_THRASH_WINDOW_SECS}s. Usage: {used_gb:.1f}GB / {max_gb:.1f}GB." # Use QTimer.singleShot to ensure this runs on the main thread QTimer.singleShot(0, lambda: self.update_status_message(msg)) @@ -2647,7 +2653,7 @@ def toggle_crop_mode(self): if editor_path: try: match = Path(editor_path).resolve() == Path(filepath).resolve() - except Exception: + except (OSError, ValueError): match = str(editor_path) == str(filepath) if not match: @@ -2837,7 +2843,7 @@ def execute_crop(self): if editor_path: try: paths_match = Path(editor_path).resolve() == Path(filepath).resolve() - except Exception: + except (OSError, ValueError): paths_match = str(editor_path) == str(filepath) if not paths_match: diff --git a/faststack/faststack/imaging/editor.py b/faststack/faststack/imaging/editor.py index 4134997..6b6fa7d 100644 --- a/faststack/faststack/imaging/editor.py +++ b/faststack/faststack/imaging/editor.py @@ -5,10 +5,7 @@ import math from pathlib import Path from typing import Optional, Dict, Any, Tuple -try: - import numpy as np -except ImportError: - np = None +import numpy as np from PIL import Image, ImageEnhance, ImageFilter from io import BytesIO @@ -99,8 +96,8 @@ def _rotated_rect_with_max_area(w: int, h: int, angle_rad: float) -> tuple[int, wr = (w * cos_a - h * sin_a) / cos_2a hr = (h * cos_a - w * sin_a) / cos_2a - cw = int(round(abs(wr))) - ch = int(round(abs(hr))) + cw = round(abs(wr)) + ch = round(abs(hr)) cw = max(1, min(w, cw)) ch = max(1, min(h, ch)) return cw, ch @@ -139,8 +136,8 @@ def rotate_autocrop_rgb(img: Image.Image, angle_deg: float, inset: int = 2) -> I # Center-crop to the inscribed rectangle cx = rot.width / 2.0 cy = rot.height / 2.0 - left = int(round(cx - crop_w / 2.0)) - top = int(round(cy - crop_h / 2.0)) + left = round(cx - crop_w / 2.0) + top = round(cy - crop_h / 2.0) right = left + crop_w bottom = top + crop_h @@ -238,7 +235,7 @@ def load_image(self, filepath: str, cached_preview: Optional[DecodedImage] = Non return False - def _apply_edits(self, img: Image.Image, is_export: bool = False) -> Image.Image: + def _apply_edits(self, img: Image.Image, *, for_export: bool = False) -> Image.Image: """Applies all current edits to the provided PIL Image.""" # 1. Rotation (90 degree steps) @@ -254,10 +251,12 @@ def _apply_edits(self, img: Image.Image, is_export: bool = False) -> Image.Image # --------------------------------------------------------- # CHANGE: Apply Free Rotation (Straighten) BEFORE Cropping # --------------------------------------------------------- - straighten_angle = self.current_edits.get('straighten_angle', 0.0) + straighten_angle = float(self.current_edits.get('straighten_angle', 0.0)) has_crop_box = 'crop_box' in self.current_edits and self.current_edits['crop_box'] - if abs(straighten_angle) > 0.001: + # Only apply rotation if it's significant AND we are exporting. + # During preview (for_export=False), QML handles the visual rotation. + if for_export and abs(straighten_angle) > 0.001: if has_crop_box: # Scenario A: Manual Crop. # Just rotate the image (expanding canvas). The subsequent @@ -283,19 +282,19 @@ def _apply_edits(self, img: Image.Image, is_export: bool = False) -> Image.Image # Note: We calculate this based on the *current* img size, # which might be larger now due to the rotation above. w, h = img.size - l = int(crop_box[0] * w / 1000) + left = int(crop_box[0] * w / 1000) t = int(crop_box[1] * h / 1000) r = int(crop_box[2] * w / 1000) b = int(crop_box[3] * h / 1000) # Basic boundary checks - l = max(0, l) + left = max(0, left) t = max(0, t) r = min(w, r) b = min(h, b) - if r > l and b > t: - img = img.crop((l, t, r, b)) + if r > left and b > t: + img = img.crop((left, t, r, b)) # 3. Exposure (gamma-based) exposure = self.current_edits['exposure'] @@ -494,7 +493,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, is_export=False) + img = self._apply_edits(img, for_export=False) # The image is in RGB mode after _apply_edits buffer = img.tobytes() @@ -545,7 +544,7 @@ def save_image(self) -> Optional[Tuple[Path, Path]]: return None final_img = self.original_image.copy() - final_img = self._apply_edits(final_img, is_export=True) + final_img = self._apply_edits(final_img, for_export=True) original_path = self.current_filepath try: diff --git a/faststack/faststack/imaging/jpeg.py b/faststack/faststack/imaging/jpeg.py index c135d97..34855f0 100644 --- a/faststack/faststack/imaging/jpeg.py +++ b/faststack/faststack/imaging/jpeg.py @@ -162,8 +162,10 @@ def decode_jpeg_resized( from io import BytesIO img = Image.open(BytesIO(jpeg_bytes)) + + if width == 0 or height == 0: - return np.array(img.convert("RGB")) + return np.array(img.convert("RGB")) scale_factor_ratio = min(img.width / width, img.height / height) diff --git a/faststack/faststack/imaging/prefetch.py b/faststack/faststack/imaging/prefetch.py index 5cc76f7..fde93d0 100644 --- a/faststack/faststack/imaging/prefetch.py +++ b/faststack/faststack/imaging/prefetch.py @@ -34,8 +34,12 @@ # Thread lock for all ICC caches _icc_cache_lock = threading.Lock() -def get_icc_transform(src_profile: ImageCms.ImageCmsProfile, monitor_profile: ImageCms.ImageCmsProfile, - src_profile_key: str, monitor_profile_path: str): +def get_icc_transform( + src_profile: ImageCms.ImageCmsProfile, + monitor_profile: ImageCms.ImageCmsProfile, + src_profile_key: str, + monitor_profile_path: str, +) -> ImageCms.ImageCmsTransform: """Get or create a cached ICC transform. Building transforms is expensive, so we cache them by stable keys: @@ -60,7 +64,7 @@ def clear_icc_caches(): _monitor_profile_warning_logged = False log.info("Cleared ICC profile and transform caches") -def get_monitor_profile(): +def get_monitor_profile() -> Optional[ImageCms.ImageCmsProfile]: """Dynamically load monitor ICC profile based on current config. Caches the profile by path to reduce overhead and log spam. diff --git a/faststack/faststack/qml/Components.qml b/faststack/faststack/qml/Components.qml index 4b63a27..d1f4b3d 100644 --- a/faststack/faststack/qml/Components.qml +++ b/faststack/faststack/qml/Components.qml @@ -9,7 +9,6 @@ Item { anchors.fill: parent focus: true - // Height of the status bar footer in Main.qml // Height of the status bar footer in Main.qml property int footerHeight: 60 @@ -51,21 +50,9 @@ Item { } - Keys.onReturnPressed: (event) => { - if (uiState && uiState.isCropping && controller) { - // Force immediate rotation update before executing crop - if (mainMouseArea.cropRotation !== 0) { - controller.set_straighten_angle(mainMouseArea.cropRotation, -1) - } - - uiState.setZoomed(false) - controller.execute_crop() - event.accepted = true - } - } + Keys.onPressed: (event) => { - // Zoom Shortcuts (Ctrl+1..4) // Zoom Shortcuts (Ctrl+1..4) if (event.modifiers & Qt.ControlModifier) { if (event.key === Qt.Key_1) { @@ -91,6 +78,11 @@ Item { // Handle Enter for Crop Execution (formerly Keys.onEnterPressed) // We only accept the event if we actually act on it. if ((event.key === Qt.Key_Enter || event.key === Qt.Key_Return) && uiState && uiState.isCropping && controller) { + // Force immediate rotation update before executing crop + if (mainMouseArea.cropRotation !== 0) { + controller.set_straighten_angle(mainMouseArea.cropRotation, -1) + } + uiState.setZoomed(false) // Force unzoom controller.execute_crop() event.accepted = true @@ -200,7 +192,7 @@ Item { property real fitScale: 1.0 function recomputeFitScale(force) { - if (typeof force === 'undefined') force = false; + if (force === undefined) force = false; if (width <= 0 || height <= 0 || imageViewport.width <= 0 || imageViewport.height <= 0) return; @@ -1003,7 +995,7 @@ Item { var ratioName = uiState.aspectRatioNames[uiState.currentAspectRatioIndex]; var ratioPair = getAspectRatio(ratioName); - if (!ratioPair || !imageRotator.width || !imageRotator.height) { + if (!ratioPair || !mainImage || !imageRotator.width || !imageRotator.height) { return [left, top, right, bottom]; } diff --git a/faststack/faststack/qml/Main.qml b/faststack/faststack/qml/Main.qml index 5d40f28..78a4d42 100644 --- a/faststack/faststack/qml/Main.qml +++ b/faststack/faststack/qml/Main.qml @@ -845,7 +845,7 @@ ApplicationWindow { modal: true closePolicy: Popup.CloseOnEscape focus: true - width: 600 + width: 1000 height: 750 background: Rectangle { @@ -854,54 +854,70 @@ ApplicationWindow { contentItem: ScrollView { clip: true - Text { - text: "FastStack Keyboard and Mouse Commands

" + - "Navigation:
" + - "  J / Right Arrow: Next Image
" + - "  K / Left Arrow: Previous Image
" + - "  G: Jump to Image Number
" + - "  I: Show EXIF Data

" + - "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+4: Zoom to 400%

" + - "Stacking:
" + - "  [: Begin new stack
" + - "  ]: End current stack
" + - "  C: Clear all stacks
" + - "  S: Toggle current image in/out of stack
" + - "  X: Remove current image from batch/stack

" + - "Batch Selection (for drag-and-drop):
" + - "  {: Begin new batch
" + - "  B: Toggle current image in/out of batch
" + - "  }: End current batch
" + - "  \\: Clear all batches
" + - "Flag Toggles:
" + - "  U: Toggle uploaded flag
" + - "  Ctrl+E: Toggle edited flag
" + - "  Ctrl+S: Toggle stacked flag

" + - "File Management:
" + - "  Delete: Move current image to recycle bin
" + - "  Ctrl+Z: Undo last action (delete, auto white balance, or crop)

" + - "Actions:
" + - "  Enter: Launch Helicon Focus
" + - "  P: Edit in Photoshop
" + - "  Backspace/Del: Move current image to recycle bin
" + - "  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)
" + - "  H: Toggle histogram window
" + - "  E: Toggle Image Editor (closes without saving if open)
" + - "  Ctrl+C: Copy image path to clipboard
" + - "  Esc: Close active dialog, editor, or cancel crop" - padding: 10 - wrapMode: Text.WordWrap - color: root.currentTextColor + + Row { + spacing: 20 + + // Column 1 + Text { + width: 450 + text: "FastStack Keyboard and Mouse Commands

" + + "Navigation:
" + + "  J / Right Arrow: Next Image
" + + "  K / Left Arrow: Previous Image
" + + "  G: Jump to Image Number
" + + "  I: Show EXIF Data

" + + "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+4: Zoom to 400%

" + + "Stacking:
" + + "  [: Begin new stack
" + + "  ]: End current stack
" + + "  C: Clear all stacks
" + + "  S: Toggle current image in/out of stack
" + + "  X: Remove current image from batch/stack" + padding: 10 + wrapMode: Text.WordWrap + color: root.currentTextColor + } + + // Column 2 + Text { + width: 450 + text: "

" + // Spacer to align with first section under title + "Batch Selection (for drag-and-drop):
" + + "  {: Begin new batch
" + + "  B: Toggle current image in/out of batch
" + + "  }: End current batch
" + + "  \\: Clear all batches
" + + "Flag Toggles:
" + + "  U: Toggle uploaded flag
" + + "  Ctrl+E: Toggle edited flag
" + + "  Ctrl+S: Toggle stacked flag

" + + "File Management:
" + + "  Delete: Move current image to recycle bin
" + + "  Ctrl+Z: Undo last action (delete, auto white balance, or crop)

" + + "Actions:
" + + "  Enter: Launch Helicon Focus
" + + "  P: Edit in Photoshop
" + + "  Backspace/Del: Move current image to recycle bin
" + + "  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)
" + + "  H: Toggle histogram window
" + + "  E: Toggle Image Editor (closes without saving if open)
" + + "  Ctrl+C: Copy image path to clipboard
" + + "  Esc: Close active dialog, editor, or cancel crop" + padding: 10 + wrapMode: Text.WordWrap + color: root.currentTextColor + } } } } diff --git a/faststack/faststack/tests/test_editor_rotation.py b/faststack/faststack/tests/test_editor_rotation.py new file mode 100644 index 0000000..36fb76d --- /dev/null +++ b/faststack/faststack/tests/test_editor_rotation.py @@ -0,0 +1,188 @@ + +import pytest +import math +from PIL import Image +import numpy as np +from faststack.imaging.editor import _rotated_rect_with_max_area, rotate_autocrop_rgb, ImageEditor + +def test_rotated_rect_edge_cases(): + """Test fundamental edge cases for the rectangle calculation.""" + # Zero dimensions + assert _rotated_rect_with_max_area(0, 100, 0.5) == (0, 0) + assert _rotated_rect_with_max_area(100, 0, 0.5) == (0, 0) + assert _rotated_rect_with_max_area(-10, 100, 0.5) == (0, 0) + + # Near zero angle (should be close to original dimensions) + w, h = 100, 50 + cw, ch = _rotated_rect_with_max_area(w, h, 0.0000001) + assert cw == w + assert ch == h + + # Near 90 degree angle (should swap Dimensions roughly) + # The function expects radians. pi/2 is 90 degrees. + # Note: The function folds angle into [0, pi/2) + # If we pass exactly pi/2, math.sin(pi/2) = 1. + # However, our function folds: angle_rad = abs(angle_rad) % (math.pi / 2). + # So 90 deg becomes 0 deg effectively for rect calculation purposes in this specific helper + # because a 90 deg rotated rect inscribed in a 90 deg rotated image is the same rect. + # Let's test 89.9 degrees converted to radians + angle_rad = math.radians(89.9) + # Logic in function: if angle > pi/4, it subtracts from pi/2. + # So 89.9 becomes 0.1 deg. + cw, ch = _rotated_rect_with_max_area(w, h, angle_rad) + # Should be very close to swapping w and h if we were inscribing, but wait - + # The function finds largest axis-aligned rect *within* the rotated w x h. + # If we rotate 100x50 by 90deg, we have a 50x100 bounding box. + # The largest axis aligned rect in a 50x100 box is 50x100. + # But let's stick to the simpler assertion: it returns something valid [1, w] x [1, h] + # (The function clamps to original w/h, which might be a bit counter-intuitive for 90deg + # if we wanted the swapped dims, but for small-angle straightening it's fine). + assert 1 <= cw <= w + assert 1 <= ch <= h + +@pytest.mark.parametrize("w,h,angle_deg", [ + (100, 100, 0), # Unrotated + (200, 100, 45), # Diagonal Square (Fully constrained case often) + (1000, 500, 15), # Half constrained case likely + (500, 1000, 15), # Tall half constrained +]) +def test_rotated_rect_calculation_branches(w, h, angle_deg): + """Exercise different geometric branches of the calculation.""" + angle_rad = math.radians(angle_deg) + cw, ch = _rotated_rect_with_max_area(w, h, angle_rad) + + assert cw > 0 + assert ch > 0 + assert cw <= w + assert ch <= h + + if angle_deg == 0: + assert cw == w + assert ch == h + else: + # Non-zero rotation always reduces the inscribed axis-aligned box + assert cw * ch < w * h + +def test_rotate_autocrop_rgb_behavior(): + """Test actual image formatting and cropping.""" + # Create valid RGB image + w, h = 100, 100 + img = Image.new("RGB", (w, h), color=(255, 0, 0)) # Red + + # 1. Test no rotation + res = rotate_autocrop_rgb(img, 0.0) + assert res.size == (100, 100) + + # 2. Test rotation with inset + angle = 45.0 + inset = 2 + res = rotate_autocrop_rgb(img, angle, inset=inset) + + # At 45 deg, a square becomes a diamond. The max inscribed rect is w/(sqrt(2)) ~ 0.707*w + # 100 * 0.707 = 70. + # We expect roughly 70x70 minus inset. + expected_approx = 70.0 + assert 60 < res.width < 80 + assert 60 < res.height < 80 + + # Verify no black wedges (since original was all red) + # Center pixel should definitely be red + cx, cy = res.width // 2, res.height // 2 + assert res.getpixel((cx, cy)) == (255, 0, 0) + + # Corner pixels should also be red if cropped correctly + assert res.getpixel((0, 0)) == (255, 0, 0) + assert res.getpixel((res.width-1, res.height-1)) == (255, 0, 0) + + +def test_boundary_clamping(): + """Test internal clamping logic.""" + img = Image.new("RGB", (10, 10), (255, 255, 255)) + + # Very small image, 45 deg rotation + # Inscribed rect will be small. + # high inset could theoretically reduce it to < 0. + res = rotate_autocrop_rgb(img, 45, inset=50) # Huge inset + + # It should clamp to at least 1x1 or similar valid image, not crash + assert res.width > 0 + assert res.height > 0 + +def test_integration_straighten_modes(): + """ + Integration test comparing Scenario A (Manual Crop) vs Scenario B (Straighten Only). + + Scenario A: User rotates + manually crops. The rotation expands canvas, user picks crop. + Scenario B: User rotates only. We autocrop to remove wedges. + """ + # Create image with specific pattern to verify content + w, h = 200, 100 + img = Image.new("RGB", (w, h), (0, 255, 0)) # Green + + editor = ImageEditor() + editor.original_image = img + editor.current_filepath = "dummy.jpg" # Needed for save, but not here + + angle = 10.0 + + # --- Scenario B: Straighten Only --- + editor.current_edits['straighten_angle'] = angle + editor.current_edits['crop_box'] = None + + res_b = editor._apply_edits(img.copy()) + + # Should define a specific size based on autocrop + w_b, h_b = res_b.size + + # --- Scenario A: Manual Crop --- + # We want to simulate the logic where we replicate what autocrop would have done, + # but manually via crop_box. + # 1. Calculate what the autocrop rect would be relative to the *rotated* canvas. + # Note: _rotated_rect yields dims in *original* pixel space generally, + # but let's look at how app.py handles normalization or how editor applies it. + + # Actually, let's just assert that if we manually crop to the SAME pixels + # that autocrop found, we get the same result. + + # Re-use the helper to find the crop box + angle_rad = math.radians(angle) + cw, ch = _rotated_rect_with_max_area(w, h, angle_rad) + + # rotate_autocrop_rgb logic: + # It rotates with expand=True. The new center is center of rotated image. + # It crops centered rect of size (cw, ch). + + # So if we emulate this in editor: + editor.current_edits['straighten_angle'] = angle + + # We need to compute the 'crop_box' (normalized 0-1000) that corresponds + # to that center crop on the ROTATED image. + + # Get rotated size + rot_temp = img.rotate(-angle, expand=True) + rw, rh = rot_temp.size + + cx, cy = rw / 2.0, rh / 2.0 + left = cx - cw / 2.0 + top = cy - ch / 2.0 + right = left + cw + bottom = top + ch + + # Normalize to 0-1000 relative to rotated size + # (Editor applies crop_box relative to the current (rotated) image size) + n_left = int(left / rw * 1000) + n_top = int(top / rh * 1000) + n_right = int(right / rw * 1000) + n_bottom = int(bottom / rh * 1000) + + editor.current_edits['crop_box'] = (n_left, n_top, n_right, n_bottom) + + res_a = editor._apply_edits(img.copy()) + + # Allow for 1-2 pixel differences due to int/round conversions in normalization + assert abs(res_a.width - res_b.width) < 5 + assert abs(res_a.height - res_b.height) < 5 + + # Verify both are Green (center pixel) + assert res_a.getpixel((res_a.width//2, res_a.height//2)) == (0, 255, 0) + diff --git a/faststack/faststack/ui/keystrokes.py b/faststack/faststack/ui/keystrokes.py index 03da231..926fc78 100644 --- a/faststack/faststack/ui/keystrokes.py +++ b/faststack/faststack/ui/keystrokes.py @@ -60,7 +60,7 @@ def __init__(self, controller): (Qt.Key_Z, Qt.ControlModifier): "undo_delete", (Qt.Key_E, Qt.ControlModifier): "toggle_edited", (Qt.Key_S, Qt.ControlModifier): "toggle_stacked", - (Qt.Key_S, Qt.ControlModifier): "toggle_stacked", + (Qt.Key_B, Qt.ControlModifier | Qt.ShiftModifier): "quick_auto_white_balance", (Qt.Key_1, Qt.ControlModifier): "zoom_100", (Qt.Key_2, Qt.ControlModifier): "zoom_200",