diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 16d21076..849c645c 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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. diff --git a/negpy/desktop/view/sidebar/right_panel.py b/negpy/desktop/view/sidebar/right_panel.py index 4b7b6db6..4c45143e 100644 --- a/negpy/desktop/view/sidebar/right_panel.py +++ b/negpy/desktop/view/sidebar/right_panel.py @@ -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") @@ -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. @@ -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. diff --git a/negpy/desktop/view/widgets/charts.py b/negpy/desktop/view/widgets/charts.py index 957da0ee..c0c78d46 100644 --- a/negpy/desktop/view/widgets/charts.py +++ b/negpy/desktop/view/widgets/charts.py @@ -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] = {} @@ -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: @@ -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: diff --git a/tests/test_negative_peek_analysis_chart.py b/tests/test_negative_peek_analysis_chart.py new file mode 100644 index 00000000..af3009bd --- /dev/null +++ b/tests/test_negative_peek_analysis_chart.py @@ -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() diff --git a/tests/test_step_wedge_widget.py b/tests/test_step_wedge_widget.py index 6617d355..8a0afc3f 100644 --- a/tests/test_step_wedge_widget.py +++ b/tests/test_step_wedge_widget.py @@ -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) @@ -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."""