Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Every feature under `negpy/features/<name>/` 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.

Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
228 changes: 228 additions & 0 deletions negpy/desktop/view/canvas/crop_guides.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading