diff --git a/CLAUDE.md b/CLAUDE.md index 5003ecce..44ac77c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ Every feature under `negpy/features//` follows this structure: **CPU parallelism.** The hot per-pixel Numba kernels are multithreaded with `prange` over the outer (row/pixel) loop, so a CPU render scales across all cores (Numba uses `NUMBA_NUM_THREADS`, default = all logical cores; the threading layer is pinned to `workqueue` in `desktop/main.py`). Every such kernel is decorated with **`parallel_njit`** (`kernel/system/parallel.py`), *never* raw `@njit(parallel=True)` — it compiles serial **and** parallel variants of the kernel and dispatches per call on a runtime flag, and it serializes parallel invocations behind a process-wide lock (the workqueue layer aborts the process on concurrent parallel-kernel entry from multiple threads — render worker, export worker and the CPU display ICC LUT can all overlap). Default policy: parallel on, **off on macOS** (crash reports pending verification); override with `cpu_parallel = true/false` under `[performance]` in override.toml (`configure_cpu_parallel` at startup). Two rules when adding kernels: any per-row scratch array (e.g. `dens`) **must be allocated inside the `prange` loop** to stay thread-local, and never pass `cache=True` expecting it on the serial variant — numba's disk cache is keyed by source location, so the two variants of one kernel would share a slot and load each other's binaries (`parallel_njit` forces the serial variant cache-less for this reason; see `test_parallel_dispatch.py`). Covered kernels: print curve (`exposure/logic.py`), chemical toning (`toning/logic.py`), CIELAB + working OETF (`kernel/image/logic.py`), display/export ICC LUT (`infrastructure/display/icc_lut.py`). The Lab stage parallelises via OpenCV (`cv2`), and the meter prefilter via a `ThreadPoolExecutor` (`exposure/normalization.py`). -**Manual crop rect is in transformed (display) space.** `GeometryConfig.manual_crop_rect` is normalized to the **already-transformed** image (post rotation/fine-rotation/flip/distortion) — the space the user draws it on in the canvas overlay — so `get_manual_rect_coords` is a plain axis-aligned slice (`rect × transformed dims`), **not** a corner-map through `map_coords_to_geometry` + AABB (that inflated the crop as fine rotation tilted the mapped rect). The overlay maps screen↔rect linearly through its view rect (no uv_grid). Autocrop/retouch/dodge-burn placements still round-trip through `map_coords_to_geometry`/uv_grid; only the manual crop is display-space. Because flips are applied *before* fine rotation, toggling a flip goes through `toggle_flip` (`geometry/logic.py`): it negates `fine_rotation` and mirrors the crop/analysis rects so the result is a true mirror of the current render. The crop tool also has four edge **rotation handles** (outside the box) that drive `fine_rotation` directly (`rotation_drag_angle` → `crop_rotation_changed` → `AppController.handle_crop_rotation_changed`); the shared slider/handle range is `FINE_ROTATION_LIMIT` (±45°) in `geometry/models.py`. +**Manual crop rect is in transformed (display) space.** `GeometryConfig.manual_crop_rect` is normalized to the **already-transformed** image (post rotation/fine-rotation/flip/distortion) — the space the user draws it on in the canvas overlay — so `get_manual_rect_coords` is a plain axis-aligned slice (`rect × transformed dims`), **not** a corner-map through `map_coords_to_geometry` + AABB (that inflated the crop as fine rotation tilted the mapped rect). The overlay maps screen↔rect linearly through its view rect (no uv_grid). Autocrop/retouch/dodge-burn placements still round-trip through `map_coords_to_geometry`/uv_grid; only the manual crop is display-space. Because flips are applied *before* fine rotation, toggling a flip goes through `toggle_flip` (`geometry/logic.py`): it negates `fine_rotation` and mirrors the crop/analysis rects so the result is a true mirror of the current render. The crop tool also has four edge **rotation handles** (outside the box) that drive `fine_rotation` directly (`rotation_drag_angle` → `crop_rotation_changed` → `AppController.handle_crop_rotation_changed`); the shared slider/handle range is `FINE_ROTATION_LIMIT` (±45°) in `geometry/models.py`. The guide drawn inside the crop rect is selectable (thirds, phi grid, diagonals, golden triangles/spiral, armature, diagonal method, grid — pure generators in `desktop/view/canvas/crop_guides.py`); the choice is a display-only sticky global (`AppState.crop_guide`, **not** `GeometryConfig`), with `O`/`Shift+O` cycling guide/orientation via the geometry sidebar combo. **Freehand analysis region.** `ProcessConfig.analysis_rect` (optional, transformed-normalized like the crop rect) overrides the centered `analysis_buffer` inset for the exposure meters: `resolve_analysis_region` (`exposure/normalization.py`) turns it into the analysis ROI with buffer 0, applied at the single chokepoint in `NormalizationProcessor` (CPU) and the GPU analysis path. It keys the CPU `base_key` and rides in `settings.process` for the GPU `_analysis_cache_key`. UI: a "draw region" tool (`ToolMode.ANALYSIS_DRAW`) on the Process page; the tool shows the uncropped preview (`crop_preview_full`) like the crop tool. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2843212d..04cd3b30 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -8,6 +8,7 @@ - New: **Per-layer trims (crossover correction)** — a **Global / Red / Green / Blue** selector on the Tone and Process pages scopes the curve controls (Grade, Toe, Shoulder, Widths, Snap) and the White/Black Point sliders to a single colour layer. Filtration can only *shift* a layer; these trims re-shape it, fixing casts that differ between shadows, mids and highlights. The H&D chart tracks the per-layer curves live. - New: **Deeper control over the print curve**, grouped under a **Paper Response** header on the Tone page: **Snap** adjusts the paper's midtone punch, **Shadows / Highlights Density** darken or brighten each zone while rolling into the paper's black and white limits instead of clipping, and **True Black** (off by default) anchors the deepest print black to true display black instead of ~5% grey. The **Toe** slider is recalibrated so its full throw is felt as clearly as the Shoulder's — frames without a manual toe render identically. - New: **Gold toner** — a third chemical toner on the Toning page (B&W only). Alone it works like the archival gold bath: a cool blue-black shift in the highlights and mids with a slight density boost, dense shadows hold. Over sepia it recreates the classic gold-over-sepia combination, pushing the toned highlights from yellow-brown toward orange-red. +- New: **Crop composition guides** — the crop tool's fixed thirds grid becomes a **Guide** dropdown: Thirds, Phi Grid, Diagonals, Golden Triangles, Golden Spiral, Armature, Diagonal Method or Grid. `O` cycles guides, `Shift+O` rotates the spiral/triangles. - New: **Crop rotation handles** — four handles at the edges of the crop box spin the frame freehand, composing with the Fine Rotation slider for fine-tuning; both now range ±45°. @linkmodo - New: **Reverse scroll-to-zoom** — an optional toggle in Customize Shortcuts for users who expect scroll-up to zoom out. @linkmodo - Change: **One visual language across the app** — all panels share the same section cards, button styles and sizes; every active tool and toggle uses the same red armed state; edited sliders, selectors and tabs are marked with a small red dot instead of coloured text; tooltips word-wrap; the Metadata page uses collapsible sections like the rest; the Analysis stats read-out shows plain values without the qualitative labels. diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index c57090a9..9283091f 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -134,6 +134,7 @@ class AppController(QObject): normalization_requested = pyqtSignal(NormalizationTask) analysis_buffer_preview_requested = pyqtSignal(float) rotation_guide_requested = pyqtSignal() + crop_guide_changed = pyqtSignal() asset_discovery_requested = pyqtSignal(AssetDiscoveryTask) thumbnail_requested = pyqtSignal(list) thumbnail_update_requested = pyqtSignal(ThumbnailUpdateTask) @@ -766,6 +767,14 @@ def show_rotation_guide(self) -> None: """Request the canvas show the fine-rotation alignment grid.""" self.rotation_guide_requested.emit() + def set_crop_guide(self, guide: str) -> None: + self.session.set_crop_guide(guide) + self.crop_guide_changed.emit() + + def cycle_crop_guide_orientation(self) -> None: + self.session.set_crop_guide_orientation((self.state.crop_guide_orientation + 1) % 8) + self.crop_guide_changed.emit() + def handle_crop_rect_changed(self, nx1: float, ny1: float, nx2: float, ny2: float, persist: bool) -> None: """Live-updates (persist=False) or commits (persist=True) the manual crop rect while the crop tool is open. The tool stays active afterwards — darktable-style diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index d4f78198..52aa6194 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -7,6 +7,7 @@ from PyQt6.QtCore import QAbstractListModel, QModelIndex, QObject, Qt, pyqtSignal +from negpy.desktop.view.canvas.crop_guides import CropGuide from negpy.domain.models import ExportPreset, WorkspaceConfig from negpy.features.rgbscan.models import RgbScanConfig from negpy.infrastructure.storage.repository import StorageRepository @@ -81,6 +82,10 @@ class AppState: # Canvas background color swatch index (0=Black, 1=Dark Grey, 2=Mid Grey) canvas_bg_index: int = 0 + # Crop tool composition guide (CropGuide value); display-only, so not in GeometryConfig + crop_guide: str = "thirds" + crop_guide_orientation: int = 0 + # Reverse scroll-wheel zoom direction on the image viewer (scroll up = zoom out). invert_zoom_scroll: bool = False @@ -405,6 +410,13 @@ def __init__(self, repo: StorageRepository): if saved_bg is not None: self.state.canvas_bg_index = int(saved_bg) + saved_guide = self.repo.get_global_setting("crop_guide") + if saved_guide in set(CropGuide): + self.state.crop_guide = str(saved_guide) + saved_guide_orient = self.repo.get_global_setting("crop_guide_orientation") + if saved_guide_orient is not None: + self.state.crop_guide_orientation = int(saved_guide_orient) % 8 + saved_invert_zoom = self.repo.get_global_setting("invert_zoom_scroll") if saved_invert_zoom is not None: self.state.invert_zoom_scroll = bool(saved_invert_zoom) @@ -458,6 +470,18 @@ def set_canvas_bg(self, index: int) -> None: self.state.canvas_bg_index = index self.repo.save_global_setting("canvas_bg_index", index) + def set_crop_guide(self, guide: str) -> None: + """Updates and persists the crop composition guide.""" + if self.state.crop_guide != guide: + self.state.crop_guide = guide + self.repo.save_global_setting("crop_guide", guide) + + def set_crop_guide_orientation(self, orientation: int) -> None: + """Updates and persists the crop guide orientation step.""" + if self.state.crop_guide_orientation != orientation: + self.state.crop_guide_orientation = orientation + self.repo.save_global_setting("crop_guide_orientation", orientation) + def save_icc_prefs(self) -> None: """Persists current ICC profile settings.""" self.repo.save_global_setting("icc_input_path", self.state.icc_input_path) diff --git a/negpy/desktop/view/canvas/crop_guides.py b/negpy/desktop/view/canvas/crop_guides.py new file mode 100644 index 00000000..25baaddf --- /dev/null +++ b/negpy/desktop/view/canvas/crop_guides.py @@ -0,0 +1,228 @@ +"""Composition guide geometry for the crop tool overlay. + +Qt-free; all shapes are polylines in pixel space (0,0)-(w,h) so angles stay aspect-true. +""" + +import math +from enum import StrEnum +from typing import List, Tuple + +Point = Tuple[float, float] +Polyline = List[Point] + +PHI = (1.0 + math.sqrt(5.0)) / 2.0 +_SPIRAL_ARCS = 10 +_ARC_SAMPLES = 12 +_GRID_CELLS = 8 + + +class CropGuide(StrEnum): + THIRDS = "thirds" + PHI = "phi" + DIAGONALS = "diagonals" + TRIANGLES = "triangles" + SPIRAL = "spiral" + ARMATURE = "armature" + DIAGONAL_METHOD = "diagonal_method" + GRID = "grid" + OFF = "off" + + +GUIDE_LABELS: dict[CropGuide, str] = { + CropGuide.THIRDS: "Thirds", + CropGuide.PHI: "Phi Grid", + CropGuide.DIAGONALS: "Diagonals", + CropGuide.TRIANGLES: "Golden Triangles", + CropGuide.SPIRAL: "Golden Spiral", + CropGuide.ARMATURE: "Armature", + CropGuide.DIAGONAL_METHOD: "Diagonal Method", + CropGuide.GRID: "Grid", + CropGuide.OFF: "Off", +} + +ORIENTATION_COUNT: dict[CropGuide, int] = { + CropGuide.SPIRAL: 8, + CropGuide.TRIANGLES: 2, +} + + +def _fraction_lines(w: float, h: float, fractions: List[float]) -> List[Polyline]: + lines: List[Polyline] = [] + for f in fractions: + lines.append([(w * f, 0.0), (w * f, h)]) + for f in fractions: + lines.append([(0.0, h * f), (w, h * f)]) + return lines + + +def _grid(w: float, h: float) -> List[Polyline]: + step = min(w, h) / _GRID_CELLS + lines: List[Polyline] = [] + eps = step * 1e-6 # keep float-rounded edge lines off the border + xs = {w / 2.0} + k = 1 + while w / 2.0 + k * step < w - eps: + xs.add(w / 2.0 + k * step) + xs.add(w / 2.0 - k * step) + k += 1 + ys = {h / 2.0} + k = 1 + while h / 2.0 + k * step < h - eps: + ys.add(h / 2.0 + k * step) + ys.add(h / 2.0 - k * step) + k += 1 + for x in sorted(xs): + lines.append([(x, 0.0), (x, h)]) + for y in sorted(ys): + lines.append([(0.0, y), (w, y)]) + return lines + + +def _diagonals(w: float, h: float) -> List[Polyline]: + return [ + [(0.0, 0.0), (w, h)], + [(w, 0.0), (0.0, h)], + [(w / 2.0, 0.0), (w / 2.0, h)], + [(0.0, h / 2.0), (w, h / 2.0)], + ] + + +def _project_foot(px: float, py: float, dx: float, dy: float) -> Point: + """Foot of the perpendicular from (px,py) onto the line through origin with direction (dx,dy).""" + t = (px * dx + py * dy) / (dx * dx + dy * dy) + return (t * dx, t * dy) + + +def _triangles(w: float, h: float, orientation: int) -> List[Polyline]: + if orientation % 2 == 0: + f1 = _project_foot(w, 0.0, w, h) + f2 = _project_foot(0.0, h, w, h) + return [[(0.0, 0.0), (w, h)], [(w, 0.0), f1], [(0.0, h), f2]] + # orientation 1 mirrored across x + fx1, fy1 = _project_foot(w, 0.0, w, h) + fx2, fy2 = _project_foot(0.0, h, w, h) + return [[(w, 0.0), (0.0, h)], [(0.0, 0.0), (w - fx1, fy1)], [(w, h), (w - fx2, fy2)]] + + +def _ray_to_boundary(px: float, py: float, dx: float, dy: float, w: float, h: float) -> Point: + """Endpoint where the ray from (px,py) along (dx,dy) first exits the rect.""" + t_best = math.inf + if dx > 0: + t_best = min(t_best, (w - px) / dx) + elif dx < 0: + t_best = min(t_best, -px / dx) + if dy > 0: + t_best = min(t_best, (h - py) / dy) + elif dy < 0: + t_best = min(t_best, -py / dy) + return (px + dx * t_best, py + dy * t_best) + + +def _armature(w: float, h: float) -> List[Polyline]: + # Bouleau's 14 lines: diagonals, corner reciprocals, corner-to-far-midpoint lines. + lines: List[Polyline] = [ + [(0.0, 0.0), (w, h)], + [(w, 0.0), (0.0, h)], + ] + reciprocals = [ + ((0.0, 0.0), (h, w)), + ((w, 0.0), (-h, w)), + ((w, h), (-h, -w)), + ((0.0, h), (h, -w)), + ] + for (px, py), (dx, dy) in reciprocals: + lines.append([(px, py), _ray_to_boundary(px, py, dx, dy, w, h)]) + mid_top, mid_bottom = (w / 2.0, 0.0), (w / 2.0, h) + mid_left, mid_right = (0.0, h / 2.0), (w, h / 2.0) + lines += [ + [(0.0, 0.0), mid_right], + [(0.0, 0.0), mid_bottom], + [(w, 0.0), mid_left], + [(w, 0.0), mid_bottom], + [(w, h), mid_left], + [(w, h), mid_top], + [(0.0, h), mid_right], + [(0.0, h), mid_top], + ] + return lines + + +def _diagonal_method(w: float, h: float) -> List[Polyline]: + m = min(w, h) + return [ + [(0.0, 0.0), (m, m)], + [(w, 0.0), (w - m, m)], + [(w, h), (w - m, h - m)], + [(0.0, h), (m, h - m)], + ] + + +def _spiral_unit_points() -> List[Point]: + """Fibonacci quarter-arc spiral inside the unit golden rectangle (1 x 1/phi), y-down.""" + pts: List[Point] = [] + x, y, w, h = 0.0, 0.0, 1.0, 1.0 / PHI + angle = math.pi + for i in range(_SPIRAL_ARCS): + phase = i % 4 + if phase == 0: # left + s = h + cx, cy = x + s, y + s + x, w = x + s, w - s + elif phase == 1: # top + s = w + cx, cy = x, y + s + y, h = y + s, h - s + elif phase == 2: # right + s = h + cx, cy = x + w - s, y + w = w - s + else: # bottom + s = w + cx, cy = x + s, y + h - s + h = h - s + for j in range(_ARC_SAMPLES + 1): + t = angle + (math.pi / 2.0) * (j / _ARC_SAMPLES) + pts.append((cx + s * math.cos(t), cy + s * math.sin(t))) + angle += math.pi / 2.0 + return pts + + +def _orient_unit(pts: List[Point], orientation: int) -> List[Point]: + """Apply one of 8 symmetries (4 rotations x mirror) of the unit square.""" + rot = orientation % 4 + mirror = (orientation // 4) % 2 + out: List[Point] = [] + for px, py in pts: + if mirror: + px = 1.0 - px + for _ in range(rot): + px, py = py, 1.0 - px + out.append((px, py)) + return out + + +def _spiral(w: float, h: float, orientation: int) -> List[Polyline]: + unit = [(px, py * PHI) for px, py in _spiral_unit_points()] # golden rect -> unit square + oriented = _orient_unit(unit, orientation) + return [[(px * w, py * h) for px, py in oriented]] + + +def guide_shapes(guide: CropGuide, w: float, h: float, orientation: int = 0) -> List[Polyline]: + """Polylines for `guide` in pixel space (0,0)-(w,h); a 2-point polyline is a segment.""" + if w <= 0 or h <= 0 or guide == CropGuide.OFF: + return [] + if guide == CropGuide.THIRDS: + return _fraction_lines(w, h, [1.0 / 3.0, 2.0 / 3.0]) + if guide == CropGuide.PHI: + return _fraction_lines(w, h, [1.0 / PHI**2, 1.0 / PHI]) + if guide == CropGuide.DIAGONALS: + return _diagonals(w, h) + if guide == CropGuide.TRIANGLES: + return _triangles(w, h, orientation) + if guide == CropGuide.SPIRAL: + return _spiral(w, h, orientation) + if guide == CropGuide.ARMATURE: + return _armature(w, h) + if guide == CropGuide.DIAGONAL_METHOD: + return _diagonal_method(w, h) + return _grid(w, h) diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index 7b0ab2c4..bf662d35 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -9,6 +9,7 @@ from negpy.desktop.converters import ImageConverter from negpy.desktop.session import AppState, ToolMode +from negpy.desktop.view.canvas.crop_guides import CropGuide, guide_shapes from negpy.desktop.view.styles.theme import THEME from negpy.features.geometry.logic import rotation_drag_angle, translate_manual_crop_rect from negpy.features.local.logic import _rasterise_mask @@ -376,6 +377,19 @@ def _draw_rotation_grid(self, painter: QPainter, visible_rect: QRectF) -> None: """Dense leveling grid shown while Fine Rot is adjusted (Lightroom-style).""" self._draw_grid(painter, visible_rect, _ROTATION_GRID_DIVISIONS, _GRID_ALPHA) + def _draw_crop_guides(self, painter: QPainter, rect: QRectF) -> None: + """Selected composition guide (thirds, phi, spiral, ...) inside the crop rect.""" + shapes = guide_shapes(CropGuide(self.state.crop_guide), rect.width(), rect.height(), self.state.crop_guide_orientation) + if not shapes: + return + pen = QPen(QColor(255, 255, 255, _GRID_ALPHA), 1, Qt.PenStyle.SolidLine) + pen.setCosmetic(True) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(pen) + ox, oy = rect.left(), rect.top() + for poly in shapes: + painter.drawPolyline(QPolygonF([QPointF(ox + x, oy + y) for x, y in poly])) + def _draw_compare_badge(self, painter: QPainter, visible_rect: QRectF) -> None: badge = QRectF(visible_rect.x() + 12, visible_rect.y() + 12, 78, 22) painter.setBrush(QColor(0, 0, 0, 170)) @@ -663,7 +677,7 @@ def _draw_crop_tool(self, painter: QPainter) -> None: painter.setBrush(Qt.BrushStyle.NoBrush) painter.setPen(pen) painter.drawRect(rect) - self._draw_grid(painter, rect, 3, _GRID_ALPHA) + self._draw_crop_guides(painter, rect) return corners = self._crop_corner_screen_points() @@ -686,7 +700,7 @@ def _draw_crop_tool(self, painter: QPainter) -> None: painter.setPen(pen) painter.drawPolygon(poly) - self._draw_grid(painter, QRectF(corners["tl"], corners["br"]), 3, _GRID_ALPHA) + self._draw_crop_guides(painter, QRectF(corners["tl"], corners["br"])) handle_pen = QPen(Qt.GlobalColor.white, 1.5, Qt.PenStyle.SolidLine) handle_pen.setCosmetic(True) diff --git a/negpy/desktop/view/keyboard_shortcuts.py b/negpy/desktop/view/keyboard_shortcuts.py index e8090ba4..d351094f 100644 --- a/negpy/desktop/view/keyboard_shortcuts.py +++ b/negpy/desktop/view/keyboard_shortcuts.py @@ -45,6 +45,8 @@ def _build_actions(self) -> dict[str, Callable[[], None]]: "lock_bounds_toggle": lambda: controls.process_sidebar.lock_bounds_btn.toggle(), "pick_wb": lambda: controls.colour_sidebar.pick_wb_btn.toggle(), "manual_crop": lambda: controls.geometry_sidebar.manual_crop_btn.toggle(), + "crop_guide_next": lambda: controls.geometry_sidebar.cycle_guide(), + "crop_guide_orient": controller.cycle_crop_guide_orientation, "auto_crop": lambda: controls.geometry_sidebar.reset_crop_btn.toggle(), "pick_dust": lambda: controls.retouch_sidebar.pick_dust_btn.toggle(), "cancel_tool": controller.cancel_active_tool, diff --git a/negpy/desktop/view/main_window.py b/negpy/desktop/view/main_window.py index 83c95ce1..0a302203 100644 --- a/negpy/desktop/view/main_window.py +++ b/negpy/desktop/view/main_window.py @@ -338,6 +338,7 @@ def _connect_signals(self) -> None: self.controller.compare_changed.connect(lambda _on: self.canvas.overlay.update()) self.controller.analysis_buffer_preview_requested.connect(self.canvas.overlay.show_analysis_buffer) self.controller.rotation_guide_requested.connect(self.canvas.overlay.show_rotation_grid) + self.controller.crop_guide_changed.connect(self.canvas.overlay.update) self.controller.status_message_requested.connect(self.canvas.hud.showMessage) self.controller.status_progress_requested.connect(self.canvas.hud.set_progress) diff --git a/negpy/desktop/view/shortcut_registry.py b/negpy/desktop/view/shortcut_registry.py index 966dfd51..0e3cf50c 100644 --- a/negpy/desktop/view/shortcut_registry.py +++ b/negpy/desktop/view/shortcut_registry.py @@ -23,6 +23,8 @@ class ShortcutEntry: "fine_rot_inc": ShortcutEntry("Alt+R", "Fine rotation up", "Geometry"), "pick_wb": ShortcutEntry("Shift+W", "Toggle WB picker", "Tools"), "manual_crop": ShortcutEntry("Shift+C", "Toggle manual crop", "Tools"), + "crop_guide_next": ShortcutEntry("O", "Next crop guide overlay", "Geometry"), + "crop_guide_orient": ShortcutEntry("Shift+O", "Rotate crop guide orientation", "Geometry"), "auto_crop": ShortcutEntry("Shift+A", "Toggle autocrop", "Geometry"), "pick_dust": ShortcutEntry("Shift+D", "Toggle heal tool", "Tools"), "cancel_tool": ShortcutEntry("Esc", "Cancel active tool", "Tools"), diff --git a/negpy/desktop/view/sidebar/geometry.py b/negpy/desktop/view/sidebar/geometry.py index 8d50fed8..cfb0988c 100644 --- a/negpy/desktop/view/sidebar/geometry.py +++ b/negpy/desktop/view/sidebar/geometry.py @@ -4,14 +4,16 @@ from PyQt6.QtWidgets import ( QComboBox, QHBoxLayout, + QLabel, QPushButton, QSizePolicy, ) from negpy.desktop.session import ToolMode +from negpy.desktop.view.canvas.crop_guides import GUIDE_LABELS, ORIENTATION_COUNT, CropGuide from negpy.desktop.view.shortcut_registry import tooltip_with_shortcut from negpy.desktop.view.sidebar.base import BaseSidebar -from negpy.desktop.view.styles.templates import EditedDot, default_button_height +from negpy.desktop.view.styles.templates import EditedDot, default_button_height, field_label, section_subheader from negpy.desktop.view.styles.theme import THEME from negpy.desktop.view.widgets.sliders import CompactSlider from negpy.domain.models import AspectRatio @@ -35,11 +37,20 @@ class GeometrySidebar(BaseSidebar): Panel for cropping and fine adjustments. """ + @staticmethod + def _field_label(text: str) -> QLabel: + # fixed width so the Ratio/Guide combos left-align + lbl = field_label(text) + lbl.setFixedWidth(42) + return lbl + def _init_ui(self) -> None: conf = self.state.config.geometry - # First row: Ratio combo + detect button + self.layout.addWidget(section_subheader("CROP")) + ratio_row = QHBoxLayout() + ratio_row.addWidget(self._field_label("Ratio")) self.ratio_combo = QComboBox() # Filter out 'Original' as it's not a crop ratio (usually 'Free' is used for no constraint) ratios = [r.value for r in AspectRatio if r != AspectRatio.ORIGINAL] @@ -53,7 +64,6 @@ def _init_ui(self) -> None: self.layout.addLayout(ratio_row) - # Buttons side by side btn_row = QHBoxLayout() self.manual_crop_btn = CropToolButton(" Crop") self.manual_crop_btn.setCheckable(True) @@ -64,17 +74,37 @@ def _init_ui(self) -> None: self.clear_crop_btn.setIcon(qta.icon("fa5s.undo", color=THEME.text_primary)) self.clear_crop_btn.setToolTip("Reset crop: clear the manual crop and disable auto crop") - self.reset_crop_btn = CropToolButton(" Auto") - self.reset_crop_btn.setCheckable(True) - self.reset_crop_btn.setIcon(qta.icon("fa5s.magic", color=THEME.text_primary, color_on="#FFFFFF", color_disabled=THEME.text_muted)) - self.reset_crop_btn.setFixedHeight(default_button_height()) - self.reset_crop_btn.setToolTip(tooltip_with_shortcut("Apply automatic crop using the current ratio and offset", "auto_crop")) btn_row.addWidget(self.manual_crop_btn, 1) btn_row.addWidget(self.clear_crop_btn, 1) self.layout.addLayout(btn_row) + guide_row = QHBoxLayout() + guide_row.addWidget(self._field_label("Guide")) + self.guide_combo = QComboBox() + for guide, label in GUIDE_LABELS.items(): + self.guide_combo.addItem(label, guide.value) + self.guide_combo.setCurrentIndex(self.guide_combo.findData(self.state.crop_guide)) + self.guide_combo.setToolTip( + tooltip_with_shortcut("Composition guide shown in the crop tool", ("crop_guide_next", "crop_guide_orient")) + ) + guide_row.addWidget(self.guide_combo, 1) + self.guide_orient_btn = self._icon_action( + "fa5s.redo", tooltip_with_shortcut("Rotate the guide orientation (spiral, triangles)", "crop_guide_orient") + ) + guide_row.addWidget(self.guide_orient_btn) + self._sync_guide_orient_btn() + self.layout.addLayout(guide_row) + + self.layout.addWidget(section_subheader("AUTO CROP")) + # Auto crop toggle + mode: crop to exposed image, or keep full film incl. rebate auto_row = QHBoxLayout() + self.reset_crop_btn = CropToolButton(" Auto") + self.reset_crop_btn.setCheckable(True) + self.reset_crop_btn.setIcon(qta.icon("fa5s.magic", color=THEME.text_primary, color_on="#FFFFFF", color_disabled=THEME.text_muted)) + self.reset_crop_btn.setFixedHeight(default_button_height()) + self.reset_crop_btn.setToolTip(tooltip_with_shortcut("Apply automatic crop using the current ratio and offset", "auto_crop")) + self.mode_combo = QComboBox() self.mode_combo.addItem("Image only", AutocropMode.IMAGE.value) self.mode_combo.addItem("Film edge", AutocropMode.FILM.value) @@ -86,8 +116,6 @@ def _init_ui(self) -> None: auto_row.addWidget(self.mode_combo, 1) self.layout.addLayout(auto_row) - # Sliders (2 columns) - slider_row = QHBoxLayout() self.offset_slider = CompactSlider( "Crop Offset", -5.0, @@ -98,15 +126,27 @@ def _init_ui(self) -> None: unit=" px", ) self.offset_slider.setToolTip(tooltip_with_shortcut("Insets the crop border from the auto-detected film edge (px)", "offset_inc")) + self.layout.addWidget(self.offset_slider) + + self.layout.addWidget(section_subheader("ALIGNMENT")) + self.fine_rot_slider = CompactSlider("Fine Rotation", -FINE_ROTATION_LIMIT, FINE_ROTATION_LIMIT, conf.fine_rotation, unit="°") self.fine_rot_slider.setToolTip( "Fine-tunes rotation to correct tilt (degrees). For quick rotation, drag the round handles outside the crop box in the Crop tool." ) - slider_row.addWidget(self.offset_slider) - slider_row.addWidget(self.fine_rot_slider) - self.layout.addLayout(slider_row) + self.layout.addWidget(self.fine_rot_slider) + + def cycle_guide(self) -> None: + self.guide_combo.setCurrentIndex((self.guide_combo.currentIndex() + 1) % self.guide_combo.count()) + + def _sync_guide_orient_btn(self) -> None: + guide = self.guide_combo.currentData() + self.guide_orient_btn.setEnabled(ORIENTATION_COUNT.get(CropGuide(guide), 1) > 1 if guide else False) def _connect_signals(self) -> None: + self.guide_combo.currentIndexChanged.connect(lambda _i: self.controller.set_crop_guide(self.guide_combo.currentData())) + self.guide_combo.currentIndexChanged.connect(lambda _i: self._sync_guide_orient_btn()) + self.guide_orient_btn.clicked.connect(self.controller.cycle_crop_guide_orientation) self.ratio_combo.currentTextChanged.connect(self._on_ratio_changed) self.mode_combo.currentIndexChanged.connect(self._on_mode_changed) self.detect_ratio_btn.clicked.connect(self.controller.detect_aspect_ratio) @@ -168,6 +208,8 @@ def sync_ui(self) -> None: self.block_signals(True) try: + self.guide_combo.setCurrentIndex(self.guide_combo.findData(self.state.crop_guide)) + self._sync_guide_orient_btn() self.ratio_combo.setCurrentText(conf.autocrop_ratio) self.mode_combo.setCurrentIndex(self.mode_combo.findData(conf.autocrop_mode)) @@ -182,6 +224,8 @@ def sync_ui(self) -> None: self.block_signals(False) def block_signals(self, blocked: bool) -> None: + self.guide_combo.blockSignals(blocked) + self.guide_orient_btn.blockSignals(blocked) self.ratio_combo.blockSignals(blocked) self.mode_combo.blockSignals(blocked) self.detect_ratio_btn.blockSignals(blocked) diff --git a/tests/test_crop_guides.py b/tests/test_crop_guides.py new file mode 100644 index 00000000..4adc8453 --- /dev/null +++ b/tests/test_crop_guides.py @@ -0,0 +1,134 @@ +import math + +import pytest + +from negpy.desktop.view.canvas.crop_guides import ( + GUIDE_LABELS, + ORIENTATION_COUNT, + PHI, + CropGuide, + guide_shapes, +) + +SIZES = [(600.0, 400.0), (500.0, 500.0), (650.0, 240.0), (240.0, 650.0)] + + +def _points(shapes): + return [p for poly in shapes for p in poly] + + +@pytest.mark.parametrize("guide", list(CropGuide)) +@pytest.mark.parametrize("w,h", SIZES) +def test_all_points_inside_rect(guide, w, h): + for orientation in range(8): + for x, y in _points(guide_shapes(guide, w, h, orientation)): + assert -1e-6 <= x <= w + 1e-6 + assert -1e-6 <= y <= h + 1e-6 + + +def test_off_and_degenerate_empty(): + assert guide_shapes(CropGuide.OFF, 600, 400) == [] + assert guide_shapes(CropGuide.THIRDS, 0, 400) == [] + assert guide_shapes(CropGuide.THIRDS, 600, 0) == [] + + +def test_thirds_fractions(): + shapes = guide_shapes(CropGuide.THIRDS, 300, 300) + xs = sorted({p[0] for poly in shapes[:2] for p in poly}) + assert xs == [100.0, 200.0] + assert len(shapes) == 4 + + +def test_phi_fractions(): + shapes = guide_shapes(CropGuide.PHI, 1000, 1000) + xs = sorted({poly[0][0] for poly in shapes[:2]}) + assert xs[0] == pytest.approx(1000 / PHI**2, abs=1e-6) + assert xs[1] == pytest.approx(1000 / PHI, abs=1e-6) + assert xs[0] == pytest.approx(381.966, abs=1e-3) + + +def test_diagonals_endpoints(): + w, h = 600.0, 400.0 + shapes = guide_shapes(CropGuide.DIAGONALS, w, h) + assert [(0, 0), (w, h)] in [[tuple(p) for p in poly] for poly in shapes] + assert [(w, 0), (0, h)] in [[tuple(p) for p in poly] for poly in shapes] + assert [(w / 2, 0), (w / 2, h)] in [[tuple(p) for p in poly] for poly in shapes] + assert [(0, h / 2), (w, h / 2)] in [[tuple(p) for p in poly] for poly in shapes] + + +@pytest.mark.parametrize("w,h", SIZES) +@pytest.mark.parametrize("orientation", [0, 1]) +def test_triangles_perpendicular(w, h, orientation): + diag, seg1, seg2 = guide_shapes(CropGuide.TRIANGLES, w, h, orientation) + dx, dy = diag[1][0] - diag[0][0], diag[1][1] - diag[0][1] + for seg in (seg1, seg2): + sx, sy = seg[1][0] - seg[0][0], seg[1][1] - seg[0][1] + assert abs(sx * dx + sy * dy) < 1e-6 * (w * w + h * h) + fx, fy = seg[1][0] - diag[0][0], seg[1][1] - diag[0][1] + assert abs(fx * dy - fy * dx) < 1e-6 * (w * w + h * h) + + +def test_triangles_orientation_period_two(): + a = guide_shapes(CropGuide.TRIANGLES, 600, 400, 0) + b = guide_shapes(CropGuide.TRIANGLES, 600, 400, 2) + c = guide_shapes(CropGuide.TRIANGLES, 600, 400, 1) + assert a == b + assert a != c + + +@pytest.mark.parametrize("w,h", SIZES) +def test_armature_fourteen_lines(w, h): + shapes = guide_shapes(CropGuide.ARMATURE, w, h) + assert len(shapes) == 14 + diag_tlbr = (w, h) + diag_trbl = (-w, h) + # Reciprocals (shapes 2..5) are perpendicular to the diagonal not through their corner. + for poly, diag in zip(shapes[2:6], [diag_trbl, diag_tlbr, diag_trbl, diag_tlbr]): + dx, dy = poly[1][0] - poly[0][0], poly[1][1] - poly[0][1] + assert abs(dx * diag[0] + dy * diag[1]) < 1e-6 * (w * w + h * h) + + +def test_diagonal_method_45_degrees(): + for w, h in SIZES: + shapes = guide_shapes(CropGuide.DIAGONAL_METHOD, w, h) + assert len(shapes) == 4 + for (x1, y1), (x2, y2) in shapes: + assert abs(abs(x2 - x1) - abs(y2 - y1)) < 1e-9 + assert abs(x2 - x1) == pytest.approx(min(w, h)) + + +def test_grid_cells_square(): + shapes = guide_shapes(CropGuide.GRID, 640.0, 480.0) + xs = sorted({poly[0][0] for poly in shapes if poly[0][0] == poly[1][0]}) + ys = sorted({poly[0][1] for poly in shapes if poly[0][1] == poly[1][1]}) + step = 480.0 / 8 + assert all(b - a == pytest.approx(step) for a, b in zip(xs, xs[1:])) + assert all(b - a == pytest.approx(step) for a, b in zip(ys, ys[1:])) + + +def test_spiral_single_continuous_polyline(): + (poly,) = guide_shapes(CropGuide.SPIRAL, 600, 400) + assert len(poly) > 100 + diag = math.hypot(600, 400) + for (x1, y1), (x2, y2) in zip(poly, poly[1:]): + assert math.hypot(x2 - x1, y2 - y1) < diag * 0.2 + + +def test_spiral_orientations_distinct_period_eight(): + variants = [tuple(guide_shapes(CropGuide.SPIRAL, 600, 400, o)[0]) for o in range(8)] + assert len(set(variants)) == 8 + assert guide_shapes(CropGuide.SPIRAL, 600, 400, 8) == guide_shapes(CropGuide.SPIRAL, 600, 400, 0) + + +def test_orientation_invariant_guides(): + for guide in (CropGuide.THIRDS, CropGuide.PHI, CropGuide.DIAGONALS, CropGuide.ARMATURE, CropGuide.DIAGONAL_METHOD, CropGuide.GRID): + base = guide_shapes(guide, 600, 400, 0) + for o in range(1, 8): + assert guide_shapes(guide, 600, 400, o) == base + + +def test_labels_cover_all_guides(): + assert set(GUIDE_LABELS) == set(CropGuide) + for guide, count in ORIENTATION_COUNT.items(): + assert count > 1 + assert guide in GUIDE_LABELS