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
3 changes: 2 additions & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

## 0.45.0

- New: **Test strip** — the Tone panel prints the frame as a 6×6 grid, Print Density increasing left to right and ISO-R Grade softening top to bottom, so the diagonals read light-to-dark and soft-to-hard like a split-filter test strip. Each patch is a real render at its own settings; click one to keep it. Escape or a second press clears it, and any edit drops it.
- New: **Colour ring-around** — the Filtration panel (or Shift+F) prints the frame as a 5×5 mosaic stepping 1cc at a time out to ±2cc on the magenta and yellow axes, so the direction of a colour cast is visible instead of guessed. Each patch is a real render; click one to keep its filtration. Like the test strip the ladder is absolute and centred on neutral, so a ring printed off one frame compares to the next and picking a patch doesn't force a reprint. The two proofs share the canvas — asking for one while the other is up swaps it.
- New: **Test strip** — the Tone panel prints the frame as a 5×5 grid, Print Density increasing left to right and ISO-R Grade softening top to bottom, each ladder centred on its default so the settings you already have are one of the patches, so the diagonals read light-to-dark and soft-to-hard like a split-filter test strip. Each patch is a real render at its own settings; click one to keep it. Escape or a second press clears it, and any edit drops it.

## 0.44.0

Expand Down
96 changes: 66 additions & 30 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,15 @@
)
from negpy.services.assets.half_frame import base_hash, slice_half
from negpy.services.assets.sidecar import load_or_promote, write_sidecar
from negpy.features.exposure.analysis import STRIP_DENSITIES, STRIP_GRADES, strip_cells
from negpy.features.exposure.analysis import (
RING_GRID,
STRIP_DENSITIES,
STRIP_GRADES,
STRIP_GRID,
ring_cells,
ring_overrides,
strip_overrides,
)
from negpy.features.exposure.logic import (
calculate_wb_shifts,
calculate_wb_shifts_from_log,
Expand Down Expand Up @@ -925,16 +933,19 @@ def _render_memo_key(self, config: Optional[WorkspaceConfig] = None) -> str:
)
return hashlib.md5(repr(parts).encode()).hexdigest()

def _strip_memo_key(self) -> str:
"""The render key with density and grade pinned to fixed values.
def _strip_memo_key(self, kind: str = "tone") -> str:
"""The render key for a proof mosaic, prefixed by kind so the two can't collide.

The strip supplies those two itself, one per patch, so the mosaic is invariant
to whatever they currently are — which makes the print/pick/print-again loop a
cache hit, since picking a patch changes nothing else. Any other edit (crop,
filtration, paper, toning...) lands on a different key and re-prints.
Both ladders are absolute, so each proof supplies the fields it varies and its mosaic
is invariant to whatever those currently are. Pinning them makes print/pick/print again
a cache hit. Any other edit (crop, paper, toning...) lands on a different key.
"""
pinned = replace(self.state.config.exposure, density=1.0, grade=115.0)
return self._render_memo_key(replace(self.state.config, exposure=pinned))
exposure = self.state.config.exposure
if kind == "colour":
exposure = replace(exposure, wb_magenta=0.0, wb_yellow=0.0)
else:
exposure = replace(exposure, density=1.0, grade=115.0)
return f"{kind}:{self._render_memo_key(replace(self.state.config, exposure=exposure))}"

def load_file(self, file_path: str, preserve_zoom: bool = False, force_detect: bool = False) -> None:
"""
Expand Down Expand Up @@ -1201,39 +1212,58 @@ def toggle_zones_overlay(self, force: Optional[bool] = None) -> None:
self.state.zones_overlay = (not self.state.zones_overlay) if force is None else bool(force)
self.zones_overlay_changed.emit(self.state.zones_overlay)

def toggle_test_strip(self, force: Optional[bool] = None) -> None:
"""Print (or clear) the density × grade test strip. Unlike the zone overlay this
needs pixels the canvas doesn't have, so entering dispatches one strip job."""
target = (not (self.state.test_strip or self.state.test_strip_pending)) if force is None else bool(force)
def toggle_ring_around(self, force: Optional[bool] = None) -> None:
"""Print (or clear) the colour ring-around — the M/Y filtration proof."""
self.toggle_test_strip(force, kind="colour")

def toggle_test_strip(self, force: Optional[bool] = None, kind: str = "tone") -> None:
"""Print (or clear) a proof mosaic: the density × grade strip, or the colour ring-around.
Entering dispatches one job, since these need pixels the canvas doesn't have.

Both share the one proof slot, so asking for the other kind swaps it.
"""
showing = (self.state.test_strip or self.state.test_strip_pending) and self.state.test_strip_kind == kind
target = (not showing) if force is None else bool(force)
if not target:
self._clear_test_strip()
return
if self.state.preview_raw is None:
return

# Reprinting an unchanged strip is 36 renders for pixels we already have.
cached = self._strip_memo.get(self.state.current_file_hash or "", self._strip_memo_key())
if kind == "colour":
overrides = ring_overrides()
grid = RING_GRID
toast = "Printing the colour ring-around…"
else:
overrides = strip_overrides()
grid = STRIP_GRID
toast = "Printing test strip…"

# Reprinting an unchanged proof is a pile of renders for pixels we already have.
cached = self._strip_memo.get(self.state.current_file_hash or "", self._strip_memo_key(kind))
if cached is not None:
self.state.test_strip_kind = kind
self.on_strip_finished(cached["mosaic"], cached["content_rect"], from_cache=True)
return

proofing = self.state.soft_proof_enabled
icc_input = self.effective_input_icc() if (proofing or self.state.config.process.narrowband_scan) else None
self.state.test_strip_kind = kind
self.state.test_strip_pending = True
self.test_strip_changed.emit(False)
# 36 renders take a few seconds; say so up front and tick the HUD progress bar
# per patch so it's clear the app is working rather than wedged.
self.status_message_requested.emit("Printing test strip…", 2500)
self.status_progress_requested.emit(0, len(strip_cells()))
# A few seconds of renders: tick the HUD so it doesn't read as wedged.
self.status_message_requested.emit(toast, 2500)
self.status_progress_requested.emit(0, len(overrides))
self.strip_requested.emit(
TestStripTask(
buffer=self.state.preview_raw,
config=self.state.config,
source_hash=self.state.current_file_hash or "preview",
# Always preview res, never HQ: 36 full-res renders would take minutes,
# and the patches are shown at a sixth of the frame's width anyway. The
# mosaic scales to the content rect, so a smaller one costs nothing here.
# Always preview res, never HQ: full-res renders per patch would take minutes,
# and each patch is shown at a fraction of the frame's width anyway.
preview_size=float(APP_CONFIG.preview_render_size),
overrides=tuple(overrides),
grid=grid,
icc_input_path=icc_input,
icc_output_path=self.effective_output_icc() if proofing else None,
color_space=self.state.workspace_color_space,
Expand Down Expand Up @@ -1272,26 +1302,32 @@ def on_strip_finished(self, mosaic: Any, content_rect: Any, from_cache: bool = F
# cancels the strip outright.
self._strip_memo.store(
self.state.current_file_hash or "",
self._strip_memo_key(),
self._strip_memo_key(self.state.test_strip_kind),
{"mosaic": mosaic, "content_rect": content_rect},
)
self.state.test_strip_pending = False
self.state.test_strip = True
self.state.test_strip_mosaic = mosaic
self.state.test_strip_content_rect = content_rect
self.test_strip_changed.emit(True)
self.status_message_requested.emit("Test strip ready — click a patch to keep it", 4000)
label = "Ring-around" if self.state.test_strip_kind == "colour" else "Test strip"
self.status_message_requested.emit(f"{label} ready — click a patch to keep it", 4000)

def apply_test_strip_pick(self, row: int, col: int) -> None:
"""Commit the clicked patch's density/grade, then drop the strip."""
"""Commit the clicked patch's settings, then drop the proof.

Cast Removal and the Auto toggles are left alone: the patches were rendered under
them, so flipping one would render something other than the patch that was clicked.
"""
if not self.state.test_strip:
return
density, grade = STRIP_DENSITIES[col], STRIP_GRADES[row]
exposure = self.state.config.exposure
if self.state.test_strip_kind == "colour":
_, _, magenta, yellow = ring_cells()[row * RING_GRID[1] + col]
new_exposure = replace(exposure, wb_magenta=magenta, wb_yellow=yellow)
else:
new_exposure = replace(exposure, density=STRIP_DENSITIES[col], grade=STRIP_GRADES[row])
self._clear_test_strip()
# Auto Density / Auto Grade are left exactly as they were: the patches were
# rendered under them, so changing them would render something other than the
# patch that was clicked.
new_exposure = replace(self.state.config.exposure, density=density, grade=grade)
self.session.update_config(replace(self.state.config, exposure=new_exposure), persist=True)
self.request_render()

Expand Down
3 changes: 3 additions & 0 deletions negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ class AppState:
test_strip_pending: bool = False
test_strip_mosaic: Optional[Any] = None
test_strip_content_rect: Optional[tuple] = None
# Which proof owns the canvas: "tone" (density × grade) or "colour" (M/Y ring-around).
# One slot, so every path that drops a proof drops both kinds.
test_strip_kind: str = "tone"

# Reverse scroll-wheel zoom direction on the image viewer (scroll up = zoom out).
invert_zoom_scroll: bool = False
Expand Down
113 changes: 76 additions & 37 deletions negpy/desktop/view/canvas/overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@
from negpy.desktop.view.canvas.crop_guides import CropGuide, guide_shapes
from negpy.desktop.view.styles.theme import THEME
from negpy.features.exposure.analysis import (
RING_GRID,
STRIP_DENSITIES,
STRIP_GRADES,
STRIP_GRID,
ring_cc,
ring_nearest_cell,
strip_cell_at,
strip_nearest_cell,
zone_grid,
Expand Down Expand Up @@ -768,18 +772,62 @@ def _strip_qimage(self) -> Optional[QImage]:
self._strip_cache = (key, img)
return img

def _strip_grid(self) -> Tuple[int, int]:
"""(rows, cols) of the proof currently on the canvas."""
return RING_GRID if self.state.test_strip_kind == "colour" else STRIP_GRID

def _strip_patch_rects(self, rect: QRectF) -> List[Tuple[int, int, QRectF]]:
"""(row, col, screen rect) per patch. Edges are computed from the same fractions
the mosaic was sliced on, so the drawn separators sit on the real seams."""
rows, cols = len(STRIP_GRADES), len(STRIP_DENSITIES)
rows, cols = self._strip_grid()
xs = [rect.x() + rect.width() * c / cols for c in range(cols + 1)]
ys = [rect.y() + rect.height() * r / rows for r in range(rows + 1)]
return [(r, c, QRectF(xs[c], ys[r], xs[c + 1] - xs[c], ys[r + 1] - ys[r])) for r in range(rows) for c in range(cols)]

def _draw_strip_labels(
self,
painter: QPainter,
patches: List[Tuple[int, int, QRectF]],
top_texts: List[str],
left_texts: List[str],
current: Tuple[int, int],
) -> None:
"""`top_texts` along the top edge, `left_texts` down the left, each axis labelled once.
The rung matching the settings in force is accented."""
painter.save()
painter.setFont(_overlay_label_font(painter))
inset = _STRIP_LABEL_INSET_PX
accent = QColor(THEME.accent_primary)
for row, col, cell in patches:
for text, flags, on_axis in (
(
top_texts[col] if row == 0 else "",
Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter,
col == current[1],
),
(
left_texts[row] if col == 0 else "",
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
row == current[0],
),
):
if not text:
continue
box = cell.adjusted(inset, inset, -inset, -inset)
painter.setPen(QColor(0, 0, 0, 190))
painter.drawText(box.translated(1.0, 1.0), flags, text)
painter.setPen(accent if on_axis else QColor(255, 255, 255, 235))
painter.drawText(box, flags, text)
painter.restore()

def _draw_test_strip(self, painter: QPainter) -> None:
"""Darkroom test strip: the frame sliced into a density × grade grid, each patch
printed at its own settings. Columns darken left to right, rows soften top to
bottom, so the diagonals read light/dark and hard/soft. Click a patch to keep it.
"""A darkroom proof mosaic: the frame sliced into a grid, each patch printed at its own
settings. Click a patch to keep it.

Tone strip — columns darken left to right, rows soften top to bottom, so the diagonals
read light/dark and hard/soft. Colour ring-around — the centre patch is the filtration
in force and the ring steps out to ±5cc on the magenta and yellow axes, so the
direction of a cast is visible instead of guessed.

The mosaic replaces the canvas frame over the content rect rather than tinting it —
these are real renders, and a wash over them would misreport the tone being judged.
Expand All @@ -793,7 +841,7 @@ def _draw_test_strip(self, painter: QPainter) -> None:
painter.drawImage(rect, img)

patches = self._strip_patch_rects(rect)
current = strip_nearest_cell(self.state.config.exposure.density, self.state.config.exposure.grade)
rows, cols = self._strip_grid()

# No grid: the patches are meant to be read as one print, the way a real test
# strip is. Only the patch under the cursor gets an outline, so it's obvious what
Expand All @@ -808,36 +856,27 @@ def _draw_test_strip(self, painter: QPainter) -> None:
painter.setPen(pen)
painter.drawRect(cell)

if min(rect.width() / len(STRIP_DENSITIES), rect.height() / len(STRIP_GRADES)) < _STRIP_LABEL_MIN_PX:
return
painter.save()
painter.setFont(_overlay_label_font(painter))
inset = _STRIP_LABEL_INSET_PX
accent = QColor(THEME.accent_primary)
for row, col, cell in patches:
# Density along the top edge, grade down the left — each axis labelled once.
# The rung matching the current settings is accented, standing in for the
# box this used to draw around that patch.
for text, flags, on_axis in (
(
f"D {STRIP_DENSITIES[col]:.1f}" if row == 0 else "",
Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter,
col == current[1],
),
(
f"R {STRIP_GRADES[row]:.0f}" if col == 0 else "",
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft,
row == current[0],
),
):
if not text:
continue
box = cell.adjusted(inset, inset, -inset, -inset)
painter.setPen(QColor(0, 0, 0, 190))
painter.drawText(box.translated(1.0, 1.0), flags, text)
painter.setPen(accent if on_axis else QColor(255, 255, 255, 235))
painter.drawText(box, flags, text)
painter.restore()
if min(rect.width() / cols, rect.height() / rows) < _STRIP_LABEL_MIN_PX:
return
if self.state.test_strip_kind == "colour":
exposure = self.state.config.exposure
self._draw_strip_labels(
painter,
patches,
# :+g so a fractional step prints as one rather than rounding away.
[f"Y {ring_cc(c):+g}" for c in range(cols)],
[f"M {ring_cc(r):+g}" for r in range(rows)],
ring_nearest_cell(exposure.wb_magenta, exposure.wb_yellow),
)
else:
exposure = self.state.config.exposure
self._draw_strip_labels(
painter,
patches,
[f"D {d:.1f}" for d in STRIP_DENSITIES],
[f"R {g:.0f}" for g in STRIP_GRADES],
strip_nearest_cell(exposure.density, exposure.grade),
)

def _draw_placed_heals(self, painter: QPainter) -> None:
"""Thin outlines of committed heals (strokes + legacy spots) while a retouch tool is active."""
Expand Down Expand Up @@ -1460,7 +1499,7 @@ def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton and self.state.test_strip:
coords = self._map_to_image_coords(event.position())
if coords is not None:
self.test_strip_picked.emit(*strip_cell_at(*coords))
self.test_strip_picked.emit(*strip_cell_at(*coords, self._strip_grid()))
event.accept()
return

Expand Down Expand Up @@ -1600,7 +1639,7 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None:
self.cursor_left.emit()

if self.state.test_strip:
hover = strip_cell_at(*coords) if coords is not None else None
hover = strip_cell_at(*coords, self._strip_grid()) if coords is not None else None
if hover != self._strip_hover:
self._strip_hover = hover
self.update()
Expand Down
Loading
Loading