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
2 changes: 2 additions & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ Two different histograms share the chart. Behind the curve, rising from the bott

Read them against each other. The density histogram tells you which part of the horizontal axis your negative occupies, and the curve tells you what happens to it. If the negative's data sits entirely on the flat toe, no amount of contrast pulls those shadows apart. Move the exposure so the data lands on the steep middle instead.

Peek Negative shows the scan before any curve ran, so the chart drops the curve, the output histogram and the zone strip and leaves only the density histogram.

#### LIN / LOG toggle

Bottom-right of the chart. It switches the histogram's *height* axis (how many pixels), not the tone axis. **LIN** is literal, so a big flat sky dwarfs everything else. **LOG** compresses the tall peaks so the thin tails become visible, which is where the few hundred pixels of deep shadow or specular highlight live. Use LOG to hunt for clipping, LIN to judge where the bulk of the frame sits. NegPy remembers the choice between sessions.
Expand Down
18 changes: 17 additions & 1 deletion negpy/desktop/view/sidebar/right_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,19 @@ def _update_histograms(self, metrics: Dict[str, Any]) -> None:
zone_warnings,
)

self.curve_widget.set_density_histogram(metrics.get("histogram_density"))

# Peek Negative applied no curve, so the print histogram, curve and zone strip
# would describe a print that was never made. Density is unaffected: it reads
# the scan itself, before the curve.
if self.controller.state.negative_peek:
self.curve_widget.set_output_histogram(None)
self.curve_widget.set_show_print(False)
self._clip_fracs = (None, None)
self.zone_strip.setVisible(False)
return
self.curve_widget.set_show_print(True)

source = metrics.get("histogram_raw")
if source is None:
source = metrics.get("analysis_buffer")
Expand All @@ -358,7 +371,6 @@ def _update_histograms(self, metrics: Dict[str, Any]) -> None:
bins = output_histogram(source)

self.curve_widget.set_output_histogram(bins)
self.curve_widget.set_density_histogram(metrics.get("histogram_density"))
self._clip_fracs = output_clip_fractions(bins) if bins is not None else (None, None)

# A flat log master has no print zones, so hide them rather than mislead.
Expand Down Expand Up @@ -399,6 +411,10 @@ def _update_analysis(self) -> None:
self.curve_widget.update_curve(flat_cfg, slope=gain, pivot=lift, flat=True)
# A flat master has no print curve, so there is no wedge to print through it.
self.step_wedge.setVisible(False)
elif self.controller.state.negative_peek:
# No curve ran, so there is nothing to plot or to print the wedge through;
# the chart itself already suppressed the curve in _update_histograms.
self.step_wedge.setVisible(False)
else:
# Mirror PhotometricProcessor, so the plotted curve matches the render under the Auto
# Grade, Auto Density and Cast Removal toggles.
Expand Down
220 changes: 117 additions & 103 deletions negpy/desktop/view/widgets/charts.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ def __init__(self, parent=None):
# plt_x = 1 - val_center(i).
self._density_bins: np.ndarray | None = None
self._output_counts: np.ndarray | None = None # (4, 256) [R, G, B, L]
# False during Peek Negative: no curve was applied, so the curve, print histogram
# and zone traces would describe a print that was never made.
self._show_print: bool = True
self._log_scale: bool = False
self._clip_low: dict[int, bool] = {}
self._clip_high: dict[int, bool] = {}
Expand Down Expand Up @@ -80,6 +83,14 @@ def set_log_scale(self, enabled: bool) -> None:
self._log_scale = enabled
self.update()

def set_show_print(self, visible: bool) -> None:
"""Hide the curve, print histogram, zone traces and clip marks; density stays."""
visible = bool(visible)
if visible == self._show_print:
return
self._show_print = visible
self.update()

def set_marker(self, rgb: tuple | None) -> None:
"""Marks the hovered pixel's R/G/B values (0-255) with vertical lines; None clears."""
if rgb == self._marker:
Expand Down Expand Up @@ -351,111 +362,114 @@ def paintEvent(self, event) -> None:

# Histograms under everything else so the curve/zone tints stay readable.
self._draw_density_histogram(painter, w, h)
self._draw_output_histogram(painter, w, h)

# Build the main curve path (reused for fill and line)
curve_path = QPainterPath()
curve_path.moveTo(self._wx(self._curve_pts[0][0], w), self._wy(self._curve_pts[0][1], h))
for px, py in self._curve_pts[1:]:
curve_path.lineTo(self._wx(px, w), self._wy(py, h))

# P4: toe zone shading. Warm amber, right side, dense silver = shadows
self._draw_zone_shading(painter, w, h, self._toe_mask, self._toe_strength, QColor(255, 140, 50))

# P4: shoulder zone shading. Cool blue, left side, thin silver = highlights
self._draw_zone_shading(painter, w, h, self._shoulder_mask, self._shoulder_strength, QColor(60, 130, 255))

# Glow the dragged slider's zone at fixed strength, so it reads at zero and negative values
if self._active_param in ("toe", "toe_width"):
self._draw_zone_shading(painter, w, h, self._toe_mask, 0.5, QColor(255, 140, 50))
elif self._active_param in ("shoulder", "shoulder_width"):
self._draw_zone_shading(painter, w, h, self._shoulder_mask, 0.5, QColor(60, 130, 255))

# P2: Gradient luminance fill under the curve
fill_path = QPainterPath(curve_path)
bot = self._wy(self._Y_MIN, h)
fill_path.lineTo(self._wx(self._curve_pts[-1][0], w), bot)
fill_path.lineTo(self._wx(self._curve_pts[0][0], w), bot)
fill_path.closeSubpath()

gradient = QLinearGradient(0.0, 0.0, float(w), 0.0)
gradient.setColorAt(0.0, QColor(0, 0, 0, 55))
gradient.setColorAt(1.0, QColor(255, 255, 255, 55))
painter.setBrush(QBrush(gradient))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawPath(fill_path)

# P5: zone tick marks along the bottom (Adams Zone I to IX)
painter.setPen(QPen(QColor("#3A3A3A"), 1))
for i in range(1, 10):
zx = int(self._wx(i * 0.1, w))
painter.drawLine(zx, h - 5, zx, h - 1)

self._draw_mask_band(painter, w, h)

# Pre-drag ghost curve
if self._ghost_pts:
ghost_path = QPainterPath()
ghost_path.moveTo(self._wx(self._ghost_pts[0][0], w), self._wy(self._ghost_pts[0][1], h))
for px, py in self._ghost_pts[1:]:
ghost_path.lineTo(self._wx(px, w), self._wy(py, h))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor(200, 200, 200, 90), 1, Qt.PenStyle.DashLine))
painter.drawPath(ghost_path)
if self._ghost_pivot:
painter.setBrush(QBrush(QColor(200, 200, 200, 90)))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(QPointF(self._wx(self._ghost_pivot[0], w), self._wy(self._ghost_pivot[1], h)), 2.5, 2.5)

# Curve line on top; per-channel traces replace the white line when present.
painter.setBrush(Qt.BrushStyle.NoBrush)
if self._channel_curves:
for color, pts in self._channel_curves:
ch_path = QPainterPath()
ch_path.moveTo(self._wx(pts[0][0], w), self._wy(pts[0][1], h))
for px, py in pts[1:]:
ch_path.lineTo(self._wx(px, w), self._wy(py, h))
painter.setPen(QPen(color, 1.5))
painter.drawPath(ch_path)
else:
painter.setPen(QPen(QColor("#FFFFFF"), 1.5))
painter.drawPath(curve_path)

# P3: Pivot crosshairs + dot
if self._pivot_pt:
wpx = self._wx(self._pivot_pt[0], w)
wpy = self._wy(self._pivot_pt[1], h)

# Grade and Density act about the pivot, so brighten its crosshair while dragged.
cross_alpha = 110 if self._active_param in ("grade", "density") else 45
painter.setPen(QPen(QColor(200, 200, 200, cross_alpha), 1, Qt.PenStyle.DotLine))
painter.drawLine(int(wpx), 0, int(wpx), h)
painter.drawLine(0, int(wpy), w, int(wpy))

painter.setBrush(QBrush(QColor("#FFFFFF")))
painter.setPen(QPen(QColor("#050505"), 1))
painter.drawEllipse(QPointF(wpx, wpy), 3.5, 3.5)

# Spot-densitometer tracking dot
if self._tracking_val is not None:
plt_x = float(np.clip(1.0 - self._tracking_val, self._X_MIN, self._X_MAX))
idx = round((plt_x - self._X_MIN) / (self._X_MAX - self._X_MIN) * (len(self._curve_pts) - 1))
idx = max(0, min(len(self._curve_pts) - 1, idx))
tx, ty = self._curve_pts[idx]
if self._show_print:
self._draw_output_histogram(painter, w, h)

# Build the main curve path (reused for fill and line)
curve_path = QPainterPath()
curve_path.moveTo(self._wx(self._curve_pts[0][0], w), self._wy(self._curve_pts[0][1], h))
for px, py in self._curve_pts[1:]:
curve_path.lineTo(self._wx(px, w), self._wy(py, h))

# P4: toe zone shading. Warm amber, right side, dense silver = shadows
self._draw_zone_shading(painter, w, h, self._toe_mask, self._toe_strength, QColor(255, 140, 50))

# P4: shoulder zone shading. Cool blue, left side, thin silver = highlights
self._draw_zone_shading(painter, w, h, self._shoulder_mask, self._shoulder_strength, QColor(60, 130, 255))

# Glow the dragged slider's zone at fixed strength, so it reads at zero and negative values
if self._active_param in ("toe", "toe_width"):
self._draw_zone_shading(painter, w, h, self._toe_mask, 0.5, QColor(255, 140, 50))
elif self._active_param in ("shoulder", "shoulder_width"):
self._draw_zone_shading(painter, w, h, self._shoulder_mask, 0.5, QColor(60, 130, 255))

# P2: Gradient luminance fill under the curve
fill_path = QPainterPath(curve_path)
bot = self._wy(self._Y_MIN, h)
fill_path.lineTo(self._wx(self._curve_pts[-1][0], w), bot)
fill_path.lineTo(self._wx(self._curve_pts[0][0], w), bot)
fill_path.closeSubpath()

gradient = QLinearGradient(0.0, 0.0, float(w), 0.0)
gradient.setColorAt(0.0, QColor(0, 0, 0, 55))
gradient.setColorAt(1.0, QColor(255, 255, 255, 55))
painter.setBrush(QBrush(gradient))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawPath(fill_path)

# P5: zone tick marks along the bottom (Adams Zone I to IX)
painter.setPen(QPen(QColor("#3A3A3A"), 1))
for i in range(1, 10):
zx = int(self._wx(i * 0.1, w))
painter.drawLine(zx, h - 5, zx, h - 1)

self._draw_mask_band(painter, w, h)

# Pre-drag ghost curve
if self._ghost_pts:
ghost_path = QPainterPath()
ghost_path.moveTo(self._wx(self._ghost_pts[0][0], w), self._wy(self._ghost_pts[0][1], h))
for px, py in self._ghost_pts[1:]:
ghost_path.lineTo(self._wx(px, w), self._wy(py, h))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor(200, 200, 200, 90), 1, Qt.PenStyle.DashLine))
painter.drawPath(ghost_path)
if self._ghost_pivot:
painter.setBrush(QBrush(QColor(200, 200, 200, 90)))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(QPointF(self._wx(self._ghost_pivot[0], w), self._wy(self._ghost_pivot[1], h)), 2.5, 2.5)

# Curve line on top; per-channel traces replace the white line when present.
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor(255, 255, 255, 220), 1.5))
painter.drawEllipse(QPointF(self._wx(tx, w), self._wy(ty, h)), 4.0, 4.0)

# Hovered-pixel marker lines
if self._marker is not None:
for value, color_hex in zip(self._marker, (THEME.channel_red, THEME.channel_green, THEME.channel_blue)):
c = QColor(color_hex)
c.setAlpha(220)
painter.setPen(QPen(c, 1, Qt.PenStyle.DashLine))
mx = int(value / 255 * (w - 1))
painter.drawLine(mx, 0, mx, h)

self._draw_clip_indicators(painter, w, h)
if self._channel_curves:
for color, pts in self._channel_curves:
ch_path = QPainterPath()
ch_path.moveTo(self._wx(pts[0][0], w), self._wy(pts[0][1], h))
for px, py in pts[1:]:
ch_path.lineTo(self._wx(px, w), self._wy(py, h))
painter.setPen(QPen(color, 1.5))
painter.drawPath(ch_path)
else:
painter.setPen(QPen(QColor("#FFFFFF"), 1.5))
painter.drawPath(curve_path)

# P3: Pivot crosshairs + dot
if self._pivot_pt:
wpx = self._wx(self._pivot_pt[0], w)
wpy = self._wy(self._pivot_pt[1], h)

# Grade and Density act about the pivot, so brighten its crosshair while dragged.
cross_alpha = 110 if self._active_param in ("grade", "density") else 45
painter.setPen(QPen(QColor(200, 200, 200, cross_alpha), 1, Qt.PenStyle.DotLine))
painter.drawLine(int(wpx), 0, int(wpx), h)
painter.drawLine(0, int(wpy), w, int(wpy))

painter.setBrush(QBrush(QColor("#FFFFFF")))
painter.setPen(QPen(QColor("#050505"), 1))
painter.drawEllipse(QPointF(wpx, wpy), 3.5, 3.5)

# Spot-densitometer tracking dot
if self._tracking_val is not None:
plt_x = float(np.clip(1.0 - self._tracking_val, self._X_MIN, self._X_MAX))
idx = round((plt_x - self._X_MIN) / (self._X_MAX - self._X_MIN) * (len(self._curve_pts) - 1))
idx = max(0, min(len(self._curve_pts) - 1, idx))
tx, ty = self._curve_pts[idx]
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor(255, 255, 255, 220), 1.5))
painter.drawEllipse(QPointF(self._wx(tx, w), self._wy(ty, h)), 4.0, 4.0)

# Hovered-pixel marker lines
if self._marker is not None:
for value, color_hex in zip(self._marker, (THEME.channel_red, THEME.channel_green, THEME.channel_blue)):
c = QColor(color_hex)
c.setAlpha(220)
painter.setPen(QPen(c, 1, Qt.PenStyle.DashLine))
mx = int(value / 255 * (w - 1))
painter.drawLine(mx, 0, mx, h)

self._draw_clip_indicators(painter, w, h)

self._draw_scale_toggle(painter, w, h)

def _draw_mask_band(self, painter: QPainter, w: int, h: int) -> None:
Expand Down
57 changes: 57 additions & 0 deletions tests/test_negative_peek_analysis_chart.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Peek Negative applies no curve, so the Analysis chart's print histogram, curve and zone
strip would describe a print that was never made; only the pre-curve density histogram
still applies."""

from unittest.mock import MagicMock

from negpy.desktop.view.sidebar.right_panel import RightPanel
from negpy.desktop.view.widgets.charts import PhotometricCurveWidget


def _panel_stub(negative_peek: bool) -> MagicMock:
panel = MagicMock()
panel.controller.state.negative_peek = negative_peek
panel.controller.state.flat_peek = False
panel.curve_widget = MagicMock()
panel.zone_strip = MagicMock()
return panel


def test_a_negative_peek_shows_only_the_density_histogram() -> None:
panel = _panel_stub(negative_peek=True)
metrics = {"histogram_density": [1.0, 2.0], "histogram_raw": object()}

RightPanel._update_histograms(panel, metrics)

panel.curve_widget.set_density_histogram.assert_called_once_with(metrics["histogram_density"])
panel.curve_widget.set_output_histogram.assert_called_once_with(None)
panel.curve_widget.set_show_print.assert_called_once_with(False)
panel.zone_strip.setVisible.assert_called_once_with(False)
assert panel._clip_fracs == (None, None)


def test_a_normal_render_still_shows_the_print(monkeypatch) -> None:
import numpy as np

panel = _panel_stub(negative_peek=False)
metrics = {"histogram_density": None, "histogram_raw": np.zeros((4, 4, 3), dtype=np.float32)}

RightPanel._update_histograms(panel, metrics)

panel.curve_widget.set_show_print.assert_called_once_with(True)
panel.curve_widget.set_output_histogram.assert_called_once()
assert panel.curve_widget.set_output_histogram.call_args[0][0] is not None


def test_show_print_false_leaves_the_density_histogram_paintable() -> None:
"""set_show_print only gates the print-derived traces; the widget must not blank
entirely just because there is no curve to draw."""
widget = PhotometricCurveWidget()
widget.resize(200, 120)
widget._curve_pts = [(0.0, 0.0), (1.0, 1.0)]
widget.set_density_histogram([1.0, 2.0, 3.0])
widget.set_show_print(False)

assert widget._show_print is False
# paintEvent must not raise with print traces suppressed but density data present.
widget.grab()
9 changes: 9 additions & 0 deletions tests/test_step_wedge_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def _panel_stub(flat_peek: bool) -> MagicMock:
panel = MagicMock()
panel._clip_fracs = (None, None)
panel.controller.state.flat_peek = flat_peek
panel.controller.state.negative_peek = False
panel.controller.session.state.config = WorkspaceConfig()
panel.controller.session.state.last_metrics = {}
panel.controller.display_transform_params.return_value = ("sRGB", None, None)
Expand All @@ -132,6 +133,14 @@ def test_a_flat_peek_hides_the_wedge() -> None:
assert not panel.step_wedge.isVisibleTo(panel.step_wedge)


def test_a_negative_peek_hides_the_wedge() -> None:
"""No curve ran, so there is nothing to print the wedge through."""
panel = _panel_stub(flat_peek=False)
panel.controller.state.negative_peek = True
RightPanel._update_analysis(panel)
assert not panel.step_wedge.isVisibleTo(panel.step_wedge)


def test_the_wedge_is_fed_the_curve_the_chart_just_plotted() -> None:
"""One solve, two consumers: the chart and the wedge must not resolve slope and pivot
separately, or Auto Grade could move one and not the other."""
Expand Down
Loading