diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index aee515f3..92877e1c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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. diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index 87d8016b..ec0f0169 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -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 @@ -18,6 +19,7 @@ _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]: @@ -25,6 +27,23 @@ def grid_interior_fractions(divisions: int) -> List[float]: 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. @@ -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 @@ -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([]) @@ -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: diff --git a/tests/test_local_overlay.py b/tests/test_local_overlay.py new file mode 100644 index 00000000..fcd2476e --- /dev/null +++ b/tests/test_local_overlay.py @@ -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)