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
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
## 0.34.0

- Change: **Dodge & Burn moved into the print exposure** — masks now adjust exposure before the paper curve instead of scaling the finished image, which is how dodging and burning physically work. Strong burns and dodges roll off through the paper's toe and shoulder instead of clipping flat. The section moved to the Exposure tab; existing dodge/burn edits will render slightly differently.
- Fix: **dodge/burn mask overlay now shows the real feathered falloff** instead of a hard-edged polygon — the on-canvas mask previously didn't match the soft edge the pipeline actually renders.
- Change: **Selenium and Sepia work on print density** — both toners now convert silver density instead of tinting by brightness, matching the real baths. Selenium acts on the densest areas (deeper blacks, cooler shadows); sepia acts on the thinnest (warmer highlights, shadows hold). Existing toned edits will render differently.
- Fix: **exporting all RGB-scan triplets no longer fails** with "Input/output error" on most frames. Batch export was reusing stale saved paths for each frame's green/blue exposures instead of the ones the triplet was actually built from, so it tried to read files that weren't there; it now uses each frame's own exposures, the same as exporting one at a time.

Expand Down
55 changes: 47 additions & 8 deletions negpy/desktop/view/canvas/overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from negpy.desktop.session import AppState, ToolMode
from negpy.desktop.view.styles.theme import THEME
from negpy.features.geometry.logic import translate_manual_crop_rect
from negpy.features.local.logic import _rasterise_mask
from negpy.kernel.system.config import APP_CONFIG
from negpy.services.view.coordinate_mapping import CoordinateMapping

Expand All @@ -18,13 +19,31 @@
_CROP_MIN_SCREEN_PX = 24.0
_ROTATION_GRID_DIVISIONS = 10
_GRID_ALPHA = 70
_MASK_RASTER_MAX = 384 # px cap for feathered overlay rasters


def grid_interior_fractions(divisions: int) -> List[float]:
"""Interior division fractions, e.g. 3 -> [1/3, 2/3], 10 -> [.1 .. .9]."""
return [i / divisions for i in range(1, divisions)]


def feathered_mask_image(local_pts: List[Tuple[float, float]], w: int, h: int, sigma_px: float, color: QColor, max_alpha: int) -> QImage:
"""Tinted premultiplied-alpha QImage of a feathered polygon.

`local_pts` in raster pixel coords; `sigma_px` in raster pixels.
"""
norm = [(x / w, y / h) for x, y in local_pts]
alpha = _rasterise_mask(norm, h, w, sigma_px)
a = alpha * (max_alpha / 255.0)
buf = np.empty((h, w, 4), dtype=np.uint8)
buf[..., 0] = (color.red() * a).astype(np.uint8)
buf[..., 1] = (color.green() * a).astype(np.uint8)
buf[..., 2] = (color.blue() * a).astype(np.uint8)
buf[..., 3] = (a * 255.0).astype(np.uint8)
img = QImage(buf.data, w, h, w * 4, QImage.Format.Format_RGBA8888_Premultiplied)
return img.copy() # QImage-from-buffer does not own the memory


class CanvasOverlay(QWidget):
"""
Transparent overlay for image interaction (crop, guides) and CPU rendering fallback.
Expand Down Expand Up @@ -61,6 +80,7 @@ def __init__(self, state: AppState, parent=None):
self._lasso_pts: List[QPointF] = []
self._lasso_drawing: bool = False
self._local_mask_screen_polys: List[List[QPointF]] = []
self._mask_img_cache: Dict[tuple, QImage] = {}

self.zoom_level: float = 1.0
self.pan_x: float = 0.0
Expand Down Expand Up @@ -483,6 +503,7 @@ def _draw_local_masks(self, painter: QPainter) -> None:
return

selected = getattr(self.state, "local_selected_mask", -1)
fresh_cache: Dict[tuple, QImage] = {}
for i, mask in enumerate(masks):
if len(mask.vertices) < 3:
self._local_mask_screen_polys.append([])
Expand All @@ -492,19 +513,37 @@ def _draw_local_masks(self, painter: QPainter) -> None:

is_selected = i == selected
outline = QColor(232, 200, 74) if mask.strength >= 0 else QColor(74, 143, 232)
poly = QPolygonF(screen_pts)
max_alpha = 70 if is_selected else 32

sigma_screen = mask.feather * min(self._view_rect.width(), self._view_rect.height())
pad = 3.0 * sigma_screen + 2.0
xs = [p.x() for p in screen_pts]
ys = [p.y() for p in screen_pts]
x0, y0 = min(xs) - pad, min(ys) - pad
bw, bh = max(xs) + pad - x0, max(ys) + pad - y0
scale = min(1.0, _MASK_RASTER_MAX / max(bw, bh, 1.0))
rw, rh = max(int(bw * scale), 2), max(int(bh * scale), 2)
# Bbox-relative points are pan-invariant, so panning reuses the cache.
local = tuple((round((p.x() - x0) * scale, 1), round((p.y() - y0) * scale, 1)) for p in screen_pts)

key = (local, rw, rh, round(sigma_screen * scale, 2), outline.rgb(), max_alpha)
img = self._mask_img_cache.get(key)
if img is None:
img = feathered_mask_image(local, rw, rh, sigma_screen * scale, outline, max_alpha)
fresh_cache[key] = img
painter.drawImage(QRectF(x0, y0, bw, bh), img)

if is_selected:
fill = QColor(outline)
fill.setAlpha(60)
painter.setBrush(fill)
pen = QPen(outline, 2.0, Qt.PenStyle.SolidLine)
outline_color = QColor(outline)
outline_color.setAlpha(160)
pen = QPen(outline_color, 2.0, Qt.PenStyle.SolidLine)
else:
painter.setBrush(Qt.BrushStyle.NoBrush)
pen = QPen(QColor(255, 255, 255, 140), 1.0, Qt.PenStyle.SolidLine)
pen = QPen(QColor(255, 255, 255, 100), 1.0, Qt.PenStyle.SolidLine)
pen.setCosmetic(True)
painter.setPen(pen)
painter.drawPolygon(poly)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawPolygon(QPolygonF(screen_pts))
self._mask_img_cache = fresh_cache

def _draw_lasso_in_progress(self, painter: QPainter) -> None:
if not self._lasso_drawing or not self._lasso_pts:
Expand Down
48 changes: 48 additions & 0 deletions tests/test_local_overlay.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import numpy as np

from negpy.desktop.view.canvas.overlay import feathered_mask_image
from negpy.features.local.logic import _rasterise_mask
from PyQt6.QtGui import QColor, QImage

DODGE = QColor(232, 200, 74)
SQUARE = [(20.0, 20.0), (80.0, 20.0), (80.0, 80.0), (20.0, 80.0)]
W = H = 100


def _to_array(img: QImage) -> np.ndarray:
bits = img.bits()
bits.setsize(img.sizeInBytes())
return np.frombuffer(bits, np.uint8).reshape(img.height(), img.bytesPerLine() // 4, 4)[:, : img.width()]


def test_interior_fully_tinted():
img = feathered_mask_image(SQUARE, W, H, sigma_px=6.0, color=DODGE, max_alpha=70)
arr = _to_array(img)
center = arr[50, 50]
assert center[3] == 70
expected = [int(c * 70 / 255) for c in (DODGE.red(), DODGE.green(), DODGE.blue())]
assert list(center[:3]) == expected


def test_edge_is_feathered():
img = feathered_mask_image(SQUARE, W, H, sigma_px=6.0, color=DODGE, max_alpha=70)
alpha = _to_array(img)[..., 3]
inside, edge, outside = int(alpha[50, 26]), int(alpha[50, 20]), int(alpha[50, 14])
assert inside > edge > outside
assert abs(edge - 35) <= 10


def test_zero_sigma_hard_edge():
img = feathered_mask_image(SQUARE, W, H, sigma_px=0.0, color=DODGE, max_alpha=70)
alpha = _to_array(img)[..., 3]
assert alpha[50, 50] == 70
assert alpha[50, 17] == 0


def test_parity_with_pipeline_rasteriser():
sigma = 4.0
img = feathered_mask_image(SQUARE, W, H, sigma_px=sigma, color=DODGE, max_alpha=70)
alpha = _to_array(img)[..., 3]
norm = [(x / W, y / H) for x, y in SQUARE]
expected = (_rasterise_mask(norm, H, W, sigma) * 70).astype(np.uint8)
assert np.array_equal(alpha, expected)
Loading