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
9 changes: 9 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,15 @@ Paint polygon masks and lighten or darken just those areas.
* **Strength** (-1 to 1 EV): dodge (+) or burn (−) for the selected mask.
* **Feather** (0.0 to 0.15): edge softness for the selected mask, as a fraction of the frame's short side.

**Printing Notes** (Export tab, or **Shift+N**) turns the frame into the printer's marked-up work print. Each mask is outlined and labelled with its number and its value in stops, and a card in the corner carries the print recipe: paper, Print Density, ISO-R Grade (with the split-grade trims when they are set), filtration, toe/shoulder, Snap, edge burn, and the dodge/burn list.

Two conventions worth knowing, both borrowed from the darkroom rather than from the sliders:

* **Burns are hatched, dodges are left open** — shading marks where the paper gets *extra* exposure.
* **The numbers are exposure, not brightness.** A burn reads `+`, a dodge reads `−`, so a mask set to −1.00 EV on the Strength slider is written `Burn +1`. Values land on ⅓/½/¼ fractions where they are close enough, otherwise they print as decimals.

Every mask is on the map, including ones whose outline you hid with the eye: that eye is there to unclutter editing, and a printing record that quietly omits a burn would be wrong. The overlay steps aside while a test strip, the flat peek, the before/after baseline, or the crop and analysis tools own the canvas. Both the preview and its export live in the Export tab's **Printing Notes** section.

---

## 7. Colour tab
Expand Down
31 changes: 31 additions & 0 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ class AppController(QObject):
dust_overlay_changed = pyqtSignal()
zones_overlay_changed = pyqtSignal(bool)
grain_focuser_changed = pyqtSignal(bool)
printing_notes_changed = pyqtSignal(bool)
printing_notes_requested = pyqtSignal() # the canvas holds the annotated pixels
strip_requested = pyqtSignal(TestStripTask)
test_strip_changed = pyqtSignal(bool) # True = mosaic is up, False = cleared or building
zone_pins_changed = pyqtSignal()
Expand Down Expand Up @@ -1509,6 +1511,35 @@ def toggle_grain_focuser(self, force: Optional[bool] = None) -> None:
self.state.grain_focuser = (not self.state.grain_focuser) if force is None else bool(force)
self.grain_focuser_changed.emit(self.state.grain_focuser)

def toggle_printing_notes(self, force: Optional[bool] = None) -> None:
"""Printing-notes overlay (dodge/burn map + print recipe). Repaint only — every
number it shows is already in the config, so no re-render is needed."""
self.state.printing_notes = (not self.state.printing_notes) if force is None else bool(force)
self.printing_notes_changed.emit(self.state.printing_notes)

def request_printing_notes_export(self) -> None:
"""Save the marked-up work print as its own file. The annotated pixels live in the
canvas, so the view answers the signal (the print itself is never touched)."""
if not self.state.current_file_path:
return
self.printing_notes_requested.emit()

def printing_notes_target_path(self) -> Optional[str]:
"""Next free `<stem>_notes.jpg` in the export folder."""
export_path = self._ensure_valid_export_path()
if not export_path or not self.state.current_file_path:
return None
if self.state.config.export.output_mode == ExportPresetOutputMode.SAME_AS_SOURCE:
export_path = os.path.dirname(self.state.current_file_path)
stem = os.path.splitext(os.path.basename(self.state.current_file_path))[0]
os.makedirs(export_path, exist_ok=True)
path = os.path.join(export_path, f"{stem}_notes.jpg")
counter = 2
while os.path.exists(path):
path = os.path.join(export_path, f"{stem}_notes_{counter}.jpg")
counter += 1
return path

def arm_zone_target(self, zone: float) -> None:
"""Zone picked on the strip: the next canvas click prints that spot there.
Picking the armed zone again disarms."""
Expand Down
4 changes: 4 additions & 0 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ class AppState:
# Grain focuser: 1:1-ish loupe following the cursor; display-only, session-only.
grain_focuser: bool = False

# Printing notes: dodge/burn map + print recipe over the frame; display-only,
# session-only — never persisted.
printing_notes: bool = False

# Zone-placement pins (ZonePin: probed spot + target zone); session-only, dropped
# by any real render like the test strip. Never persisted.
zone_pins: List[Any] = field(default_factory=list)
Expand Down
80 changes: 51 additions & 29 deletions negpy/desktop/view/canvas/overlay.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import math
import os
import sys
from typing import Any, Dict, List, Optional, Tuple

Expand All @@ -12,6 +13,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.canvas.printing_notes import notes_sheet, paint_card, paint_map
from negpy.desktop.view.styles.theme import THEME
from negpy.desktop.view.widgets.stats import PIN_COLOURS
from negpy.features.exposure.analysis import (
Expand All @@ -34,6 +36,8 @@
from negpy.features.geometry.logic import rotation_drag_angle, smooth_polyline, straighten_delta_degrees, translate_manual_crop_rect
from negpy.features.local.logic import _rasterise_mask
from negpy.features.retouch.models import HEAL_SIZE_REF
from negpy.services.view.coordinate_mapping import CoordinateMapping
from negpy.services.view.printing_notes import mask_notes, recipe_lines

_LASSO_SNAP_PX = 12.0
_CROP_HANDLE_PX = 10.0
Expand Down Expand Up @@ -62,6 +66,9 @@
_STRIP_LABEL_MIN_PX = 34.0 # below this patch size the two axis labels overlap
_STRIP_LABEL_INSET_PX = 6.0

_NOTES_CARD_INSET_PX = 12.0
_NOTES_CARD_TOP_PX = 40.0 # clears the HUD's top-left filename pill

_PIN_RADIUS_PX = 7.0 # zone-placement pin ring
_PIN_GRAB_PX = 16.0 # grab radius, wider than the drawn ring

Expand Down Expand Up @@ -573,6 +580,16 @@ def _draw_ui(self, painter: QPainter) -> None:
if self.state.zone_pins and content_aligned and not self.state.test_strip:
self._draw_zone_pins(painter)

# Not over the compare baseline: that render has no masks applied, so a map
# drawn on it would mark burns the picture underneath hasn't had.
if (
self.state.printing_notes
and content_aligned
and not self.state.test_strip
and not self.state.last_metrics.get("compare", False)
):
self._draw_printing_notes(painter)

if self._rotation_grid_visible:
self._draw_rotation_grid(painter, visible_rect)

Expand Down Expand Up @@ -1205,35 +1222,8 @@ def _content_view_rect(self) -> QRectF:
return QRectF(self._view_rect.x() + off_x * sx, self._view_rect.y() + off_y * sy, cw * sx, ch * sy)

def _raw_to_screen(self, rx: float, ry: float, uv_grid: np.ndarray, buckets: int = 100) -> QPointF:
"""
Inverse UV-grid lookup: raw-normalised (0-1) -> screen position.

Two-stage nearest-neighbour: a coarse pass over a `buckets`-decimated grid
locates the neighbourhood cheaply, then a full-resolution pass over that
bucket's window pins the exact pixel. The coarse pass alone snapped results
to bucket centres (± step/2 grid pixels ≈ 3-20px depending on preview size,
magnified by zoom) — enough to draw a heal outline entirely off the healed
spot even though the heal itself landed exactly where clicked.
"""
h_uv, w_uv = uv_grid.shape[:2]
step = max(1, h_uv // buckets)
small = uv_grid[::step, ::step]
dist = (small[..., 0] - rx) ** 2 + (small[..., 1] - ry) ** 2
idx = int(np.argmin(dist))
h_s, w_s = small.shape[:2]
vy, vx = divmod(idx, w_s)

# Refine: exact search across the coarse cell and its neighbours.
py, px = vy * step, vx * step
y0, y1 = max(0, py - step), min(h_uv, py + step + 1)
x0, x1 = max(0, px - step), min(w_uv, px + step + 1)
window = uv_grid[y0:y1, x0:x1]
wdist = (window[..., 0] - rx) ** 2 + (window[..., 1] - ry) ** 2
widx = int(np.argmin(wdist))
wy, wx = divmod(widx, window.shape[1])

nx = min((x0 + wx + 0.5) / w_uv, 1.0)
ny = min((y0 + wy + 0.5) / h_uv, 1.0)
"""Inverse UV-grid lookup: raw-normalised (0-1) -> screen position."""
nx, ny = CoordinateMapping.map_raw_to_viewport(rx, ry, uv_grid, buckets)
rect = self._content_view_rect()
return QPointF(rect.x() + nx * rect.width(), rect.y() + ny * rect.height())

Expand Down Expand Up @@ -1591,6 +1581,38 @@ def _draw_local_masks(self, painter: QPainter) -> None:
self._draw_local_handles(painter, draw_ctrl, outline)
self._mask_img_cache = fresh_cache

def _frame_name(self) -> str:
path = self.state.current_file_path
return os.path.basename(path) if path else ""

def _recipe_lines(self) -> List[str]:
conf = self.state.config
return recipe_lines(conf.exposure, conf.local, conf.finish, frame=self._frame_name())

def _draw_printing_notes(self, painter: QPainter) -> None:
"""The printer's marked-up work print: hatched burns, open dodges, ±stop badges,
and the print recipe. Every mask is on the map, hidden ones included — the eye
unclutters editing, but a record that omits a burn is wrong."""
polys = [
([QPointF(x, y) for x, y in smooth_polyline([(p.x(), p.y()) for p in pts], closed=True)], note)
for pts, note in zip(self._local_mask_screen_polys, mask_notes(self.state.config.local))
if len(pts) >= 3
]
paint_map(painter, polys)
rect = self._content_view_rect()
paint_card(painter, QPointF(rect.x() + _NOTES_CARD_INSET_PX, rect.y() + _NOTES_CARD_TOP_PX), self._recipe_lines())

def printing_notes_sheet(self) -> Optional[QImage]:
"""The exportable notes sheet: the frame the canvas rendered, the map baked on
it, and the recipe in a band below. None when there is nothing to annotate."""
if self._qimage is None:
return None
with self.state.metrics_lock:
uv_grid = self.state.last_metrics.get("uv_grid")
if uv_grid is None and self.state.config.local.masks:
return None
return notes_sheet(self._qimage, self._content_rect, self.state.config.local, uv_grid, self._recipe_lines())

def _draw_local_handles(self, painter: QPainter, ctrl_pts: List[QPointF], color: QColor) -> None:
"""Draggable vertices + '+' discs on edge midpoints for the selected mask."""
n = len(ctrl_pts)
Expand Down
195 changes: 195 additions & 0 deletions negpy/desktop/view/canvas/printing_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"""Painting for the Printing Notes overlay — the marked-up work print.

One implementation, two targets: the canvas paints into screen coordinates, the
exported sheet into the rendered frame's own pixels. Both hand `paint_map` polygons
that are already mapped, so the two can never drift apart.

Hatching marks a burn (shaded = extra exposure, the darkroom convention); a dodge is
left open. `scale` sizes pens, hatch spacing and type for the exported sheet, where a
hairline would vanish.
"""

from typing import List, Optional, Sequence, Tuple

import numpy as np
from PyQt6.QtCore import QPointF, QRectF, Qt
from PyQt6.QtGui import QColor, QFont, QFontMetricsF, QImage, QPainter, QPainterPath, QPen, QPolygonF

from negpy.features.geometry.logic import smooth_polyline
from negpy.features.local.models import LocalAdjustmentsConfig
from negpy.services.view.coordinate_mapping import CoordinateMapping
from negpy.services.view.printing_notes import MaskNote, mask_notes

# Same amber/blue the Dodge & Burn outlines already use, so a mask reads the same
# in the notes as it does while editing.
_DODGE = QColor(232, 200, 74)
_BURN = QColor(74, 143, 232)
_INK = QColor(242, 242, 242)
_CARD_BG = QColor(10, 10, 10, 195)
_BAND_BG = QColor(16, 16, 16)
_BADGE_BG = QColor(0, 0, 0, 190)

_HATCH_SPACING_PX = 9.0
_CARD_PAD_PX = 8.0
_CARD_LEADING = 1.3
_SHEET_SCALE_REF = 1400.0 # px long edge the on-screen line weights were drawn for

Poly = Tuple[List[QPointF], MaskNote]


def notes_font(scale: float = 1.0, px: float = 12.0) -> QFont:
font = QFont()
font.setBold(True)
font.setPixelSize(max(9, round(px * scale)))
return font


def _badge_anchor(poly: QPolygonF) -> QPointF:
"""Centre of the mask, or a vertex when the centre falls outside a concave shape."""
centre = poly.boundingRect().center()
if poly.containsPoint(centre, Qt.FillRule.OddEvenFill):
return centre
return poly.first()


def _hatch(painter: QPainter, poly: QPolygonF, colour: QColor, scale: float) -> None:
path = QPainterPath()
path.addPolygon(poly)
rect = poly.boundingRect()
painter.save()
painter.setClipPath(path)
pen = QPen(colour, max(1.0, scale))
pen.setCosmetic(scale <= 1.0)
painter.setPen(pen)
spacing = _HATCH_SPACING_PX * scale
x = rect.left() - rect.height()
while x < rect.right():
painter.drawLine(QPointF(x, rect.bottom()), QPointF(x + rect.height(), rect.top()))
x += spacing
painter.restore()


def _draw_badge(painter: QPainter, pos: QPointF, text: str, colour: QColor, scale: float) -> None:
font = notes_font(scale)
metrics = QFontMetricsF(font)
pad = 5.0 * scale
w = metrics.horizontalAdvance(text) + 2 * pad
h = metrics.height() + pad
rect = QRectF(pos.x() - w / 2.0, pos.y() - h / 2.0, w, h)
painter.save()
painter.setFont(font)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(_BADGE_BG)
painter.drawRoundedRect(rect, 3.0 * scale, 3.0 * scale)
painter.setPen(colour)
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text)
painter.restore()


def paint_map(painter: QPainter, polys: Sequence[Poly], scale: float = 1.0) -> None:
"""Outline every mask, hatch the burns, and badge each with its stop value."""
for pts, note in polys:
if len(pts) < 3:
continue
poly = QPolygonF(pts)
colour = _BURN if note.is_burn else _DODGE
if note.is_burn:
_hatch(painter, poly, colour, scale)

painter.save()
pen = QPen(colour, max(1.8, 1.8 * scale))
pen.setCosmetic(scale <= 1.0)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawPolygon(poly)
painter.restore()

_draw_badge(painter, _badge_anchor(poly), f"{note.number} {note.stops}", colour, scale)


def card_size(lines: Sequence[str], scale: float = 1.0) -> Tuple[float, float]:
"""(width, height) the recipe card needs for `lines`."""
if not lines:
return 0.0, 0.0
metrics = QFontMetricsF(notes_font(scale))
pad = _CARD_PAD_PX * scale
leading = metrics.height() * _CARD_LEADING
width = max(metrics.horizontalAdvance(line) for line in lines) + 2 * pad
return width, leading * len(lines) + 2 * pad


def paint_card(
painter: QPainter,
anchor: QPointF,
lines: Sequence[str],
scale: float = 1.0,
background: Optional[QColor] = _CARD_BG,
) -> QRectF:
"""The printing record, one line per row, anchored at its top-left."""
if not lines:
return QRectF()
font = notes_font(scale)
metrics = QFontMetricsF(font)
pad = _CARD_PAD_PX * scale
leading = metrics.height() * _CARD_LEADING
w, h = card_size(lines, scale)
rect = QRectF(anchor.x(), anchor.y(), w, h)

painter.save()
painter.setFont(font)
if background is not None:
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(background)
painter.drawRoundedRect(rect, 4.0 * scale, 4.0 * scale)
painter.setPen(_INK)
y = rect.top() + pad + metrics.ascent()
for line in lines:
painter.drawText(QPointF(rect.left() + pad, y), line)
y += leading
painter.restore()
return rect


def mapped_polys(local: LocalAdjustmentsConfig, uv_grid: Optional[np.ndarray], content: QRectF) -> List[Poly]:
"""Mask vertices as smoothed polygons inside `content`, paired with their notes."""
polys: List[Poly] = []
if uv_grid is None:
return polys
for mask, note in zip(local.masks, mask_notes(local)):
if len(mask.vertices) < 3:
continue
ctrl = [CoordinateMapping.map_raw_to_viewport(rx, ry, uv_grid) for rx, ry in mask.vertices]
curve = smooth_polyline([(content.x() + nx * content.width(), content.y() + ny * content.height()) for nx, ny in ctrl], closed=True)
polys.append(([QPointF(x, y) for x, y in curve], note))
return polys


def notes_sheet(
frame: QImage,
content_rect: Optional[Tuple[int, int, int, int]],
local: LocalAdjustmentsConfig,
uv_grid: Optional[np.ndarray],
lines: Sequence[str],
) -> QImage:
"""The rendered frame with the map drawn on it and the recipe in a band below."""
scale = max(1.0, max(frame.width(), frame.height()) / _SHEET_SCALE_REF)
_, card_h = card_size(lines, scale)
band = int(round(card_h)) if lines else 0

sheet = QImage(frame.width(), frame.height() + band, QImage.Format.Format_RGB32)
sheet.fill(_BAND_BG)
painter = QPainter(sheet)
try:
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.drawImage(0, 0, frame)
if content_rect:
off_x, off_y, cw, ch = content_rect
content = QRectF(off_x, off_y, cw, ch)
else:
content = QRectF(0, 0, frame.width(), frame.height())
paint_map(painter, mapped_polys(local, uv_grid, content), scale)
if lines:
paint_card(painter, QPointF(_CARD_PAD_PX * scale, frame.height()), lines, scale, background=None)
finally:
painter.end()
return sheet
Loading
Loading