diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index d8a0c5e7..440e917c 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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 diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index d5f549aa..0f79a1c8 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -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() @@ -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 `_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.""" diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index ea8e0a25..96295d89 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -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) diff --git a/negpy/desktop/view/canvas/overlay.py b/negpy/desktop/view/canvas/overlay.py index 639b276e..fb980795 100644 --- a/negpy/desktop/view/canvas/overlay.py +++ b/negpy/desktop/view/canvas/overlay.py @@ -1,4 +1,5 @@ import math +import os import sys from typing import Any, Dict, List, Optional, Tuple @@ -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 ( @@ -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 @@ -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 @@ -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) @@ -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()) @@ -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) diff --git a/negpy/desktop/view/canvas/printing_notes.py b/negpy/desktop/view/canvas/printing_notes.py new file mode 100644 index 00000000..705be88f --- /dev/null +++ b/negpy/desktop/view/canvas/printing_notes.py @@ -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 diff --git a/negpy/desktop/view/keyboard_shortcuts.py b/negpy/desktop/view/keyboard_shortcuts.py index 547e8c83..76a35acd 100644 --- a/negpy/desktop/view/keyboard_shortcuts.py +++ b/negpy/desktop/view/keyboard_shortcuts.py @@ -112,6 +112,7 @@ def _build_actions(self) -> dict[str, Callable[[], None]]: "toggle_test_strip": controller.toggle_test_strip, "toggle_ring_around": controller.toggle_ring_around, "toggle_grain_focuser": controller.toggle_grain_focuser, + "toggle_printing_notes": controller.toggle_printing_notes, "cancel_tool": lambda: _context_cancel(controller, self.window), "show_library": self.window.session_panel.show_library, "browse_parent": self.window.session_panel.browse_parent, diff --git a/negpy/desktop/view/main_window.py b/negpy/desktop/view/main_window.py index 0d83baee..313395e6 100644 --- a/negpy/desktop/view/main_window.py +++ b/negpy/desktop/view/main_window.py @@ -458,6 +458,8 @@ def _connect_signals(self) -> None: self.controller.dust_overlay_changed.connect(self.canvas.overlay.update) self.controller.zones_overlay_changed.connect(lambda _on: self.canvas.overlay.update()) self.controller.grain_focuser_changed.connect(lambda _on: self.canvas.overlay.update()) + self.controller.printing_notes_changed.connect(lambda _on: self.canvas.overlay.update()) + self.controller.printing_notes_requested.connect(self._save_printing_notes) self.controller.test_strip_changed.connect(lambda _up: self.canvas.overlay.on_test_strip_changed()) self.canvas.test_strip_picked.connect(self.controller.apply_test_strip_pick) self.controller.zone_pins_changed.connect(self.canvas.overlay.update) @@ -480,6 +482,21 @@ def _connect_signals(self) -> None: def _refresh_dashboard(self) -> None: self.toolbar.refresh_gpu_status() + def _save_printing_notes(self) -> None: + """Write the marked-up work print. The annotated pixels are the canvas's own + render, so the composing happens here rather than in an export worker.""" + sheet = self.canvas.overlay.printing_notes_sheet() + if sheet is None: + self.controller.set_status("Printing notes need a rendered frame", 4000) + return + path = self.controller.printing_notes_target_path() + if not path: + return + if sheet.save(path, "JPEG", 95): + self.controller.set_status(f"Printing notes saved: {os.path.basename(path)}", 4000) + else: + self.controller.set_status(f"Could not write {path}", 4000) + def _display_buffer_for_canvas(self, buffer): if isinstance(buffer, GPUTexture): buffer = buffer.readback() diff --git a/negpy/desktop/view/shortcut_registry.py b/negpy/desktop/view/shortcut_registry.py index 0db7f789..9f148849 100644 --- a/negpy/desktop/view/shortcut_registry.py +++ b/negpy/desktop/view/shortcut_registry.py @@ -45,6 +45,7 @@ class ShortcutEntry: "toggle_test_strip": ShortcutEntry("Shift+T", "Density × grade test strip", "Tools"), "toggle_ring_around": ShortcutEntry("Shift+F", "Colour ring-around (M/Y filtration)", "Tools"), "toggle_grain_focuser": ShortcutEntry("Shift+L", "Grain focuser loupe", "Tools"), + "toggle_printing_notes": ShortcutEntry("Shift+N", "Printing notes (dodge/burn map + print recipe)", "Tools"), "cancel_tool": ShortcutEntry("Esc", "Cancel active tool (first press clears in-progress points)", "Tools"), "cyan_dec": ShortcutEntry("", "Cyan down", "Exposure"), "cyan_inc": ShortcutEntry("", "Cyan up", "Exposure"), diff --git a/negpy/desktop/view/sidebar/export.py b/negpy/desktop/view/sidebar/export.py index c05099eb..823ae7f5 100644 --- a/negpy/desktop/view/sidebar/export.py +++ b/negpy/desktop/view/sidebar/export.py @@ -66,6 +66,7 @@ def _init_ui(self) -> None: self._add_presets_section() self._add_sidecars_section() self._add_contact_sheet_section() + self._add_printing_notes_section() self._add_preview_section() self._sync_flat_enabled() @@ -98,6 +99,9 @@ def _connect_signals(self) -> None: self.controller.flat_peek_changed.connect(self._on_flat_peek_changed) self.contact_sheet_btn.clicked.connect(self.controller.request_contact_sheet) + self.printing_notes_btn.clicked.connect(self.controller.request_printing_notes_export) + self.printing_notes_preview_btn.toggled.connect(lambda checked: self.controller.toggle_printing_notes(force=checked)) + self.controller.printing_notes_changed.connect(self._on_printing_notes_changed) self.cs_save_template_btn.clicked.connect(self._on_save_contact_sheet_template) self.cs_delete_template_btn.clicked.connect(self._on_delete_contact_sheet_template) self.cs_template_combo.currentTextChanged.connect(self._on_contact_sheet_template_changed) @@ -153,6 +157,57 @@ def _add_presets_section(self) -> None: self._presets_section.expanded_changed.connect(lambda checked: repo.save_global_setting("section_expanded_export_presets", checked)) self.layout.addWidget(self._presets_section) + # --- Printing notes ------------------------------------------------------ + + def _add_printing_notes_section(self) -> None: + """Collapsible PRINTING NOTES section: the canvas preview toggle + the export.""" + content = QWidget() + content_layout = QVBoxLayout(content) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.setSpacing(6) + + self.printing_notes_preview_btn = self._tool_toggle( + "fa5s.eye", + "Preview", + "Show the marked-up work print over the frame: burns hatched, dodges open, each mask " + "labelled with its value in stops, plus a card with the print recipe. Display only.", + ) + self.printing_notes_preview_btn.setChecked(self.state.printing_notes) + self.printing_notes_preview_btn.setFixedHeight(default_button_height()) + + self.printing_notes_btn = QPushButton(" Export") + self.printing_notes_btn.setObjectName("printing_notes_btn") + self.printing_notes_btn.setProperty("primary", True) + self.printing_notes_btn.setFixedHeight(default_button_height()) + self.printing_notes_btn.setIcon(qta.icon("mdi.playlist-edit", color="white")) + self.printing_notes_btn.setToolTip( + "Save this frame as a marked-up work print — the map plus the print recipe below it — as its " + "own JPEG in the export folder. The print itself is untouched. Resolution follows the " + "preview, so turn HQ on for a full-resolution sheet." + ) + + btn_row = QHBoxLayout() + btn_row.addWidget(self.printing_notes_preview_btn, 1) + btn_row.addWidget(self.printing_notes_btn, 1) + content_layout.addLayout(btn_row) + + repo = self.controller.session.repo + expanded = bool(repo.get_global_setting("section_expanded_printing_notes", default=False)) + self.printing_notes_section = CollapsibleSection( + "Printing Notes", expanded=expanded, icon=qta.icon("mdi.playlist-edit", color="#aaa") + ) + self.printing_notes_section.setToolTip("The printer's record for this frame: dodge/burn map + print recipe.") + self.printing_notes_section.set_content(content) + self.printing_notes_section.expanded_changed.connect( + lambda checked: repo.save_global_setting("section_expanded_printing_notes", checked) + ) + self.layout.addWidget(self.printing_notes_section) + + def _on_printing_notes_changed(self, active: bool) -> None: + self.printing_notes_preview_btn.blockSignals(True) + self.printing_notes_preview_btn.setChecked(active) + self.printing_notes_preview_btn.blockSignals(False) + # --- Contact sheet ------------------------------------------------------- def _add_contact_sheet_section(self) -> None: @@ -1236,6 +1291,7 @@ def sync_ui(self) -> None: self._update_cs_colors_btn_tooltip() self.cs_output_path_edit.setText(conf.contact_sheet_output_path) self.sidecars_enabled_btn.setChecked(conf.export_sidecars_enabled) + self.printing_notes_preview_btn.setChecked(self.state.printing_notes) self._refresh_contact_sheet_templates() saved_template = conf.contact_sheet_template.strip() if saved_template and saved_template in ContactSheetTemplates.list_templates(): @@ -1277,6 +1333,7 @@ def block_signals(self, blocked: bool) -> None: self.cs_template_combo, self.sidecars_enabled_btn, self.flat_peek_btn, + self.printing_notes_preview_btn, self.linear_wb_checkbox, self.linear_flatfield_checkbox, self.linear_sensor_checkbox, diff --git a/negpy/services/view/coordinate_mapping.py b/negpy/services/view/coordinate_mapping.py index f77d5d97..b68767ed 100644 --- a/negpy/services/view/coordinate_mapping.py +++ b/negpy/services/view/coordinate_mapping.py @@ -70,3 +70,33 @@ def map_click_to_raw(nx: float, ny: float, uv_grid: np.ndarray) -> Tuple[float, py = int(np.clip(ny * (h_uv - 1), 0, h_uv - 1)) raw_uv = uv_grid[py, px] return float(raw_uv[0]), float(raw_uv[1]) + + @staticmethod + def map_raw_to_viewport(rx: float, ry: float, uv_grid: np.ndarray, buckets: int = 100) -> Tuple[float, float]: + """ + Raw (0-1) -> Viewport (0-1): the inverse of map_click_to_raw. + + 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)) + vy, vx = divmod(idx, small.shape[1]) + + # 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]) + + return min((x0 + wx + 0.5) / w_uv, 1.0), min((y0 + wy + 0.5) / h_uv, 1.0) diff --git a/negpy/services/view/printing_notes.py b/negpy/services/view/printing_notes.py new file mode 100644 index 00000000..2c040717 --- /dev/null +++ b/negpy/services/view/printing_notes.py @@ -0,0 +1,95 @@ +"""Printer's notes: the print recipe and the dodge/burn map as a darkroom printer writes them. + +Everything here is text, in the *exposure* domain a printing record uses: a burn adds +exposure and reads `+`, a dodge withholds it and reads `−`. `PolygonMask.strength` is +the opposite convention (positive = dodge = brighter print), so `mask_notes` is the one +place that inverts it. +""" + +from dataclasses import dataclass +from typing import List + +from negpy.features.exposure.models import ExposureConfig +from negpy.features.exposure.papers import resolve_paper +from negpy.features.finish.models import FinishConfig +from negpy.features.local.models import LocalAdjustmentsConfig + +# Vulgar fractions a printer would actually write; anything else prints as a decimal. +_FRACTIONS = ((0.25, "¼"), (1.0 / 3.0, "⅓"), (0.5, "½"), (2.0 / 3.0, "⅔"), (0.75, "¾")) +_FRAC_TOLERANCE = 0.02 + + +def stops_label(stops: float) -> str: + """Exposure difference in stops, darkroom-signed: + = more exposure (burn).""" + mag = abs(float(stops)) + if mag < 0.005: + return "0" + sign = "−" if stops < 0 else "+" + whole = int(mag) + frac = mag - whole + for value, glyph in _FRACTIONS: + if abs(frac - value) <= _FRAC_TOLERANCE: + return f"{sign}{whole or ''}{glyph}" + if frac <= _FRAC_TOLERANCE: + return f"{sign}{whole}" + return f"{sign}{mag:.2f}" + + +@dataclass(frozen=True) +class MaskNote: + number: int # 1-based, matching the Dodge & Burn mask list + is_burn: bool + stops: str + + @property + def kind(self) -> str: + return "Burn" if self.is_burn else "Dodge" + + +def mask_notes(local: LocalAdjustmentsConfig) -> List[MaskNote]: + """One note per mask, in list order, with strength re-signed as printing exposure.""" + return [MaskNote(number=i + 1, is_burn=m.strength < 0, stops=stops_label(-m.strength)) for i, m in enumerate(local.masks)] + + +def recipe_lines(exposure: ExposureConfig, local: LocalAdjustmentsConfig, finish: FinishConfig, *, frame: str = "") -> List[str]: + """The printing record: one line per decision that is not at its default.""" + lines: List[str] = [] + if frame: + lines.append(frame) + + paper = resolve_paper(exposure.paper_profile).label + flags = [name for name, on in (("Paper White", exposure.paper_dmin), ("Paper Black", exposure.paper_black)) if on] + lines.append(" · ".join([paper, *flags])) + + density = f"Print Density {exposure.density:.2f}" + if exposure.auto_exposure: + density += " (auto)" + lines.append(density) + + if exposure.shadow_density or exposure.highlight_density: + lines.append(f"Zone density: shadows {exposure.shadow_density:+.2f} · highlights {exposure.highlight_density:+.2f}") + + grade = f"Grade ISO-R {exposure.grade:.0f}" + if exposure.auto_normalize_contrast: + grade += " (auto)" + if exposure.shadow_grade or exposure.highlight_grade: + grade += f" · split {exposure.shadow_grade:+.0f}/{exposure.highlight_grade:+.0f}" + lines.append(grade) + + if exposure.wb_cyan or exposure.wb_magenta or exposure.wb_yellow: + lines.append(f"Filtration C{exposure.wb_cyan:+.2f} M{exposure.wb_magenta:+.2f} Y{exposure.wb_yellow:+.2f}") + + if exposure.toe or exposure.shoulder: + lines.append(f"Toe {exposure.toe:+.2f} · Shoulder {exposure.shoulder:+.2f}") + + if exposure.midtone_gamma: + lines.append(f"Snap {exposure.midtone_gamma:+.2f}") + + if finish.vignette_stops: + lines.append(f"Edge burn {stops_label(finish.vignette_stops)} stop") + + notes = mask_notes(local) + if notes: + lines.append("Dodge & burn: " + " · ".join(f"{n.number} {n.kind} {n.stops}" for n in notes)) + + return lines diff --git a/tests/test_printing_notes.py b/tests/test_printing_notes.py new file mode 100644 index 00000000..0f5d9cb7 --- /dev/null +++ b/tests/test_printing_notes.py @@ -0,0 +1,82 @@ +"""The printing record's text: stops are written in the exposure domain a printer uses, +so a burn (which adds exposure) reads +, a dodge reads −.""" + +from dataclasses import replace + +from negpy.features.exposure.models import ExposureConfig +from negpy.features.finish.models import FinishConfig +from negpy.features.local.models import LocalAdjustmentsConfig, PolygonMask +from negpy.services.view.printing_notes import mask_notes, recipe_lines, stops_label + +SQUARE = ((0.2, 0.2), (0.8, 0.2), (0.8, 0.8), (0.2, 0.8)) + + +def _local(*strengths: float) -> LocalAdjustmentsConfig: + return LocalAdjustmentsConfig(masks=tuple(PolygonMask(vertices=SQUARE, strength=s) for s in strengths)) + + +def test_stops_are_written_as_darkroom_fractions() -> None: + assert stops_label(1.0) == "+1" + assert stops_label(0.5) == "+½" + assert stops_label(-1.0 / 3.0) == "−⅓" + assert stops_label(1.25) == "+1¼" + assert stops_label(0.0) == "0" + + +def test_an_unfractional_value_falls_back_to_decimals() -> None: + assert stops_label(0.20) == "+0.20" + assert stops_label(-0.15) == "−0.15" + # A slider step of 0.05 never lands on an exact third, so near-thirds still get the glyph. + assert stops_label(0.35) == "+⅓" + + +def test_a_burn_reads_plus_and_a_dodge_reads_minus() -> None: + burn, dodge = mask_notes(_local(-0.5, 0.5)) + + assert (burn.is_burn, burn.kind, burn.stops) == (True, "Burn", "+½") + assert (dodge.is_burn, dodge.kind, dodge.stops) == (False, "Dodge", "−½") + assert (burn.number, dodge.number) == (1, 2) + + +def test_the_recipe_keeps_quiet_about_defaults() -> None: + lines = recipe_lines(ExposureConfig(), LocalAdjustmentsConfig(), FinishConfig(), frame="roll1_04.tif") + + assert lines[0] == "roll1_04.tif" + assert any("Neutral" in line for line in lines) + assert any(line.startswith("Print Density 1.00") for line in lines) + assert any(line.startswith("Grade ISO-R 115") for line in lines) + assert not [line for line in lines if line.startswith(("Filtration", "Toe", "Snap", "Edge burn", "Dodge & burn", "Zone density"))] + + +def test_the_recipe_reports_every_decision_that_moved() -> None: + exposure = replace( + ExposureConfig(), + density=1.2, + grade=95.0, + shadow_grade=-12.0, + highlight_grade=8.0, + shadow_density=0.3, + wb_magenta=0.1, + toe=0.4, + midtone_gamma=0.2, + auto_exposure=False, + auto_normalize_contrast=False, + ) + lines = recipe_lines(exposure, _local(-1.0, 0.25), replace(FinishConfig(), vignette_stops=0.5)) + joined = "\n".join(lines) + + assert "Print Density 1.20" in joined and "(auto)" not in joined + assert "Grade ISO-R 95 · split -12/+8" in joined + assert "Zone density: shadows +0.30 · highlights +0.00" in joined + assert "Filtration C+0.00 M+0.10 Y+0.00" in joined + assert "Toe +0.40 · Shoulder +0.00" in joined + assert "Snap +0.20" in joined + assert "Edge burn +½ stop" in joined + assert "Dodge & burn: 1 Burn +1 · 2 Dodge −¼" in joined + + +def test_auto_flags_are_marked() -> None: + lines = recipe_lines(ExposureConfig(), LocalAdjustmentsConfig(), FinishConfig()) + + assert "Print Density 1.00 (auto)" in lines + assert "Grade ISO-R 115 (auto)" in lines diff --git a/tests/test_printing_notes_overlay.py b/tests/test_printing_notes_overlay.py new file mode 100644 index 00000000..dd222278 --- /dev/null +++ b/tests/test_printing_notes_overlay.py @@ -0,0 +1,147 @@ +"""Canvas side of the printing notes: when the map is painted, what it hatches, and the +exported sheet — which must be baked from the same frame and mapping the canvas shows.""" + +from dataclasses import replace +from unittest.mock import patch + +import numpy as np +from PyQt6.QtCore import QPointF, QRectF +from PyQt6.QtGui import QImage, QPainter, QPixmap + +from negpy.desktop.session import AppState, ToolMode +from negpy.desktop.view.canvas.overlay import CanvasOverlay +from negpy.desktop.view.canvas.printing_notes import card_size, notes_sheet +from negpy.features.local.models import LocalAdjustmentsConfig, PolygonMask + +W = H = 200 +BURN = PolygonMask(vertices=((0.05, 0.05), (0.45, 0.05), (0.45, 0.45), (0.05, 0.45)), strength=-1.0) +DODGE = PolygonMask(vertices=((0.55, 0.55), (0.95, 0.55), (0.95, 0.95), (0.55, 0.95)), strength=0.5) + + +def _uv_grid(h: int = H, w: int = W) -> np.ndarray: + u, v = np.meshgrid(np.linspace(0, 1, w, dtype=np.float32), np.linspace(0, 1, h, dtype=np.float32)) + return np.ascontiguousarray(np.stack([u, v], axis=-1)) + + +def _overlay(notes: bool = True, masks=(BURN, DODGE)) -> CanvasOverlay: + state = AppState() + state.printing_notes = notes + state.config = replace(state.config, local=LocalAdjustmentsConfig(masks=tuple(masks))) + state.last_metrics = {"uv_grid": _uv_grid()} + overlay = CanvasOverlay(state) + overlay._view_rect = QRectF(0, 0, W, H) + overlay._current_size = (W, H) + overlay._qimage = QImage(W, H, QImage.Format.Format_RGB32) + overlay._qimage.fill(0x00808080) + return overlay + + +def _paint(overlay: CanvasOverlay, method: str): + pixmap = QPixmap(W, H) + painter = QPainter(pixmap) + with patch.object(overlay, method) as spy: + overlay._draw_ui(painter) + painter.end() + return spy + + +def _to_array(img: QImage) -> np.ndarray: + rgb = img.convertToFormat(QImage.Format.Format_RGB32) + bits = rgb.bits() + bits.setsize(rgb.sizeInBytes()) + return np.frombuffer(bits, np.uint8).reshape(rgb.height(), rgb.bytesPerLine() // 4, 4)[:, : rgb.width()] + + +def test_the_map_paints_when_the_toggle_is_on() -> None: + assert _paint(_overlay(), "_draw_printing_notes").called + assert not _paint(_overlay(notes=False), "_draw_printing_notes").called + + +def test_a_proof_or_a_tool_owning_the_canvas_hides_the_map() -> None: + strip = _overlay() + strip.state.test_strip = True + strip.state.test_strip_mosaic = np.zeros((80, 80, 3), dtype=np.float32) + assert not _paint(strip, "_draw_printing_notes").called + + peek = _overlay() + peek.state.flat_peek = True + assert not _paint(peek, "_draw_printing_notes").called + + for mode in (ToolMode.CROP_MANUAL, ToolMode.ANALYSIS_DRAW): + overlay = _overlay() + overlay.set_tool_mode(mode) + assert not _paint(overlay, "_draw_printing_notes").called + + +def test_the_compare_baseline_gets_no_map() -> None: + """The baseline render has no masks applied, so a map on it would mark burns that + are not in the picture underneath.""" + overlay = _overlay() + overlay.state.last_metrics["compare"] = True + assert not _paint(overlay, "_draw_printing_notes").called + + +def test_hidden_masks_are_still_on_the_record() -> None: + overlay = _overlay() + overlay.state.current_file_hash = "abc" + overlay.state.local_hidden_masks = {0} + sheet = overlay.printing_notes_sheet() + assert sheet is not None + # Both masks in the recipe, and the hidden burn still hatched on the map. + assert "1 Burn +1 · 2 Dodge −½" in "\n".join(overlay._recipe_lines()) + assert _to_array(sheet)[40, 40].tolist() != [128, 128, 128, 255] + + +def test_the_sheet_hatches_the_burn_and_leaves_the_dodge_open() -> None: + frame = QImage(W, H, QImage.Format.Format_RGB32) + frame.fill(0x00808080) + local = LocalAdjustmentsConfig(masks=(BURN, DODGE)) + + sheet = notes_sheet(frame, None, local, _uv_grid(), []) + arr = _to_array(sheet) + + # Sampled off-centre in both masks so the stop badge is not what is being measured. + burn_interior = arr[20:45, 20:45] + dodge_interior = arr[118:136, 118:136] + grey = np.array([128, 128, 128, 255], dtype=np.uint8) + assert (burn_interior != grey).any(axis=-1).mean() > 0.05 # hatch lines + assert (dodge_interior != grey).any(axis=-1).mean() == 0.0 # open + + +def test_the_sheet_carries_the_recipe_in_a_band_below_the_frame() -> None: + frame = QImage(W, H, QImage.Format.Format_RGB32) + frame.fill(0x00808080) + lines = ["roll1_04.tif", "Print Density 1.00"] + + sheet = notes_sheet(frame, None, LocalAdjustmentsConfig(), _uv_grid(), lines) + + assert sheet.width() == W + assert sheet.height() == H + round(card_size(lines)[1]) + + +def test_no_render_means_no_sheet() -> None: + overlay = _overlay() + overlay._qimage = None + assert overlay.printing_notes_sheet() is None + + unmapped = _overlay() + unmapped.state.last_metrics = {} + assert unmapped.printing_notes_sheet() is None + + +def test_a_frame_without_masks_still_exports_its_recipe() -> None: + overlay = _overlay(masks=()) + overlay.state.last_metrics = {} + sheet = overlay.printing_notes_sheet() + assert sheet is not None and sheet.height() > H + + +def test_the_card_sits_inside_the_picture() -> None: + overlay = _overlay() + with patch("negpy.desktop.view.canvas.overlay.paint_card") as spy: + pixmap = QPixmap(W, H) + painter = QPainter(pixmap) + overlay._draw_ui(painter) + painter.end() + anchor: QPointF = spy.call_args[0][1] + assert overlay._content_view_rect().contains(anchor) diff --git a/tests/test_printing_notes_panel.py b/tests/test_printing_notes_panel.py new file mode 100644 index 00000000..7843e319 --- /dev/null +++ b/tests/test_printing_notes_panel.py @@ -0,0 +1,48 @@ +"""Export panel's Printing Notes section: one collapsible home for the canvas preview +and the export, and the preview toggle mirrors the controller without echoing back.""" + +from types import SimpleNamespace + +from negpy.desktop.view.sidebar.export import ExportSidebar +from tests.conftest import FakeController, FakeRepo + + +def _sidebar() -> ExportSidebar: + controller = FakeController(repo=FakeRepo()) + return ExportSidebar(controller) + + +def test_the_preview_and_the_export_share_one_section() -> None: + sidebar = _sidebar() + + assert sidebar.printing_notes_section._title_text == "Printing Notes" + content = sidebar.printing_notes_section.findChildren(type(sidebar.printing_notes_btn)) + assert sidebar.printing_notes_btn in content + assert sidebar.printing_notes_preview_btn in content + + +def test_the_preview_toggle_drives_the_controller() -> None: + sidebar = _sidebar() + + sidebar.printing_notes_preview_btn.click() + + sidebar.controller.toggle_printing_notes.assert_called_once_with(force=True) + + +def test_the_export_button_asks_for_the_sheet() -> None: + sidebar = _sidebar() + + sidebar.printing_notes_btn.click() + + sidebar.controller.request_printing_notes_export.assert_called_once() + + +def test_a_state_sync_does_not_echo_back_as_a_toggle() -> None: + stub = SimpleNamespace(printing_notes_preview_btn=_sidebar().printing_notes_preview_btn) + calls: list = [] + stub.printing_notes_preview_btn.toggled.connect(lambda checked: calls.append(checked)) + + ExportSidebar._on_printing_notes_changed(stub, True) + + assert stub.printing_notes_preview_btn.isChecked() + assert calls == []