From a49b954ce1998288abeaa26d9da40cd5ab1ebdda Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:10:43 +0200 Subject: [PATCH 1/4] Create bug_report.yml --- .github/ISSUE_TEMPLATE/bug_report.yml | 113 ++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..4f7d56a6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,113 @@ +name: Bug Report +description: Report unexpected color, artifact, or crash behavior in NegPy +title: "[Bug]: " +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug. Color and artifact issues in particular are + near-impossible to diagnose from a rendered screenshot alone — please fill in as much of + this as you can, and attach a source file if possible. + + - type: input + id: negpy-version + attributes: + label: NegPy version / build + description: Version number, or commit hash / branch if built from source + placeholder: "e.g. 0.48, or main @ a1b2c3d" + validations: + required: true + + - type: input + id: os + attributes: + label: OS + placeholder: "e.g. macOS 14 Sonoma, Windows 11, Ubuntu 24.04" + validations: + required: true + + - type: dropdown + id: scanner + attributes: + label: Scanner + description: Select your scanner, or choose "Other" and specify below + options: + - Pakon (F135/F235) + - Nikon Coolscan (specify model below) + - Noritsu + - Minolta Dimage Scan Elite + - Digital camera (macro/copy setup) + - Other / not sure + - N/A (not a scan-related issue) + validations: + required: true + + - type: input + id: scanner-detail + attributes: + label: Scanner model / details + description: If you picked Coolscan or "Other" above, specify exact model here. If a digital camera setup, list camera + lens. + placeholder: "e.g. Coolscan LS-9000, or Sony A7RIV + 90mm macro" + + - type: input + id: light-source + attributes: + label: Light source + description: What's illuminating the film — scanner's built-in lamp/LED, or an external light source for camera-scanning setups + placeholder: "e.g. stock Pakon halogen, Kaiser slimlite plano, narrowband LED (specify wavelengths if known)" + validations: + required: true + + - type: dropdown + id: correction-mode + attributes: + label: Correction mode + description: What was the Narrowband Trichrome Correction toggle set to when the issue occurred? + options: + - "On (narrowband trichrome correction enabled)" + - "Off" + - Not sure / didn't touch this setting + - N/A + validations: + required: true + + - type: input + id: input-profile + attributes: + label: Input ICC profile (if any) + description: Which profile was selected under Input ICC, if you set one explicitly + placeholder: "e.g. RGBScan.icc, none/default" + + - type: textarea + id: settings + attributes: + label: Relevant process-tab settings + description: Any non-default values for Dye Separation, Separation Damping, Dye Mute, ICE, or other sliders you changed + placeholder: "e.g. Dye Separation 1.50, Separation Damping 1.00" + + - type: textarea + id: description + attributes: + label: What happened + description: What did you expect, and what did you get instead? + validations: + required: true + + - type: textarea + id: source-file + attributes: + label: Source file + description: | + Please attach (or link to) the original scan file, not just the rendered/exported image — + the raw file is usually required to actually reproduce and diagnose color/artifact bugs. + If you can't share the full file, a small cropped region of the raw scan still helps enormously. + placeholder: "Link to file, or drag-and-drop attachment here" + validations: + required: true + + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: Before/after, or comparison images, are still useful alongside the source file From f4962928c410ea6ed882f6acbee64c51b2dd6cf6 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:14:18 +0200 Subject: [PATCH 2/4] Create config.yml --- .github/ISSUE_TEMPLATE/.github/ISSUE_TEMPLATE/config.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/.github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..e41f303b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Questions / general discussion + url: https://github.com/marcinz606/NegPy/discussions + about: For questions, setup help, or general discussion, please use Discussions instead of an issue. From 38b3f00a7318e62babe25a6edf262e1d97b645b1 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:06:06 +0200 Subject: [PATCH 3/4] fix: move issue-template config.yml out of nested .github path --- .github/ISSUE_TEMPLATE/{.github/ISSUE_TEMPLATE => }/config.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/ISSUE_TEMPLATE/{.github/ISSUE_TEMPLATE => }/config.yml (100%) diff --git a/.github/ISSUE_TEMPLATE/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/.github/ISSUE_TEMPLATE/config.yml rename to .github/ISSUE_TEMPLATE/config.yml From fddc382ddd49f2bcedfb7f4862eb997522bd31de Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:06:37 +0200 Subject: [PATCH 4/4] feat(analysis): split the density histogram into R, G, B and luminance The Analysis chart's density histogram only ever showed one merged luma trace, so a channel that clipped or sat far from the others (a strong color cast) was invisible until it showed up in the print. It now bins per channel on both engines, matching how the output histogram already works. The R/G/B/L split only earns its place where there is no print histogram doing that job already: Peek Negative. Everywhere else the density histogram stays a single merged luma trace, as before. Co-Authored-By: Claude Sonnet 5 --- docs/USER_GUIDE.md | 2 +- negpy/desktop/view/sidebar/right_panel.py | 5 +- negpy/desktop/view/widgets/charts.py | 76 ++++++++++++++----- negpy/features/exposure/analysis.py | 12 +-- .../exposure/shaders/density_hist.wgsl | 17 ++++- negpy/services/rendering/gpu_engine.py | 17 +++-- .../test_density_histogram_channel_wiring.py | 42 ++++++++++ tests/test_exposure_analysis.py | 45 +++++++---- tests/test_negative_peek_analysis_chart.py | 9 ++- tests/test_readout_and_curve_feedback.py | 22 ++++++ 10 files changed, 195 insertions(+), 52 deletions(-) create mode 100644 tests/test_density_histogram_channel_wiring.py diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 849c645c..65a3d4c9 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -250,7 +250,7 @@ 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. +Peek Negative shows the scan before any curve ran, so the chart drops the curve, the output histogram and the zone strip, and the density histogram itself splits into R, G, B and luminance, since there is no print histogram left to carry color information. Each channel is scaled to its own peak, so a spike pinned to the left or right edge is that channel clipping, and a trace sitting well apart from the others is a strong color cast. #### LIN / LOG toggle diff --git a/negpy/desktop/view/sidebar/right_panel.py b/negpy/desktop/view/sidebar/right_panel.py index 4c45143e..32b22a6a 100644 --- a/negpy/desktop/view/sidebar/right_panel.py +++ b/negpy/desktop/view/sidebar/right_panel.py @@ -349,14 +349,17 @@ def _update_histograms(self, metrics: Dict[str, Any]) -> None: # 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. + # the scan itself, before the curve, and splits into channels since there is no + # print histogram here to carry color information. if self.controller.state.negative_peek: self.curve_widget.set_output_histogram(None) self.curve_widget.set_show_print(False) + self.curve_widget.set_channel_density(True) self._clip_fracs = (None, None) self.zone_strip.setVisible(False) return self.curve_widget.set_show_print(True) + self.curve_widget.set_channel_density(False) source = metrics.get("histogram_raw") if source is None: diff --git a/negpy/desktop/view/widgets/charts.py b/negpy/desktop/view/widgets/charts.py index c0c78d46..1e9ae8bd 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] + # Per-channel density traces only while Peek Negative is up: the print's own output + # histogram already carries color information the rest of the time. + self._channel_density: bool = False # 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 @@ -83,6 +86,15 @@ def set_log_scale(self, enabled: bool) -> None: self._log_scale = enabled self.update() + def set_channel_density(self, enabled: bool) -> None: + """Draw the density histogram as separate R/G/B/L traces instead of one merged + luma trace; True only while Peek Negative is up.""" + enabled = bool(enabled) + if enabled == self._channel_density: + return + self._channel_density = 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) @@ -130,10 +142,11 @@ def set_tracking_point(self, val: float | None) -> None: self.update() def set_density_histogram(self, bins: Any) -> None: - """Negative-density occupancy along the curve's exposure axis; None clears.""" + """(4, DENSITY_HIST_BINS) [R, G, B, L] negative-density occupancy along the curve's + exposure axis; None clears.""" if bins is not None: bins = np.asarray(bins, dtype=float) - if bins.size < 2 or float(bins.max()) <= 0.0: + if bins.ndim != 2 or bins.shape[0] != 4 or bins.shape[1] < 2 or float(bins.max()) <= 0.0: bins = None if bins is None and self._density_bins is None: return @@ -621,32 +634,59 @@ def mouseMoveEvent(self, event) -> None: super().mouseMoveEvent(event) def _draw_density_histogram(self, painter: QPainter, w: int, h: int) -> None: + """Luma-only merged trace normally; while Peek Negative is up (`_channel_density`), + R, G, B and luminance traces instead, each normalized to its own peak like the + output histogram, so a channel that clips (edge-bin spike) or sits off from the + others (a dominant mask color) reads at a glance.""" bins = self._density_bins if bins is None: return from negpy.features.exposure.analysis import DENSITY_HIST_RANGE lo, hi = DENSITY_HIST_RANGE - n = bins.size - vals = np.log1p(bins) if self._log_scale else bins - peak = float(vals.max()) + n = bins.shape[-1] bot = self._wy(self._Y_MIN, h) scale = self._DENSITY_HIST_FRAC * h - - path = QPainterPath() xs = [self._wx(1.0 - (lo + (i + 0.5) * (hi - lo) / n), w) for i in range(n)] - path.moveTo(xs[0], bot) - for x, count in zip(xs, vals.tolist()): - path.lineTo(x, bot - count / peak * scale) - path.lineTo(xs[-1], bot) - path.closeSubpath() - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QBrush(QColor(200, 200, 200, 30))) - painter.drawPath(path) - painter.setBrush(Qt.BrushStyle.NoBrush) - painter.setPen(QPen(QColor(200, 200, 200, 80), 1)) - painter.drawPath(path) + if self._channel_density: + specs = ( + (3, "#D4D4D4", 26, 120), + (0, THEME.channel_red, 55, 160), + (1, THEME.channel_green, 55, 160), + (2, THEME.channel_blue, 55, 160), + ) + else: + specs = ((3, "#D4D4D4", 26, 120),) + for row, color_hex, alpha_fill, alpha_line in specs: + counts = np.log1p(bins[row]) if self._log_scale else bins[row] + peak = float(counts.max()) + if peak <= 0: + continue + vals = (counts / peak).tolist() + + path = QPainterPath() + path.moveTo(xs[0], bot) + for x, count in zip(xs, vals): + path.lineTo(x, bot - count * scale) + path.lineTo(xs[-1], bot) + path.closeSubpath() + + c_fill = QColor(color_hex) + c_fill.setAlpha(alpha_fill) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QBrush(c_fill)) + painter.drawPath(path) + + path_line = QPainterPath() + path_line.moveTo(xs[0], bot - vals[0] * scale) + for x, count in zip(xs, vals): + path_line.lineTo(x, bot - count * scale) + c_line = QColor(color_hex) + c_line.setAlpha(alpha_line) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(QPen(c_line, 1.0)) + painter.drawPath(path_line) def _draw_zone_shading( self, diff --git a/negpy/features/exposure/analysis.py b/negpy/features/exposure/analysis.py index d830b631..6b46dff5 100644 --- a/negpy/features/exposure/analysis.py +++ b/negpy/features/exposure/analysis.py @@ -321,8 +321,8 @@ def loupe_acutance(patch: Any) -> float: def density_histogram(normalized_log: np.ndarray, roi: Optional[Tuple[int, int, int, int]] = None) -> np.ndarray: - """Luma occupancy of the val domain; out-of-range mass lands in the edge bins. - `roi` is the crop rect (y1, y2, x1, x2) in the same frame as `normalized_log`.""" + """(4, DENSITY_HIST_BINS) [R, G, B, L] val-domain occupancy; out-of-range mass lands in + the edge bins. `roi` is the crop rect (y1, y2, x1, x2) in the same frame as `normalized_log`.""" img = normalized_log if roi is not None: y1, y2, x1, x2 = roi @@ -330,7 +330,9 @@ def density_histogram(normalized_log: np.ndarray, roi: Optional[Tuple[int, int, step = max(1, round(np.sqrt(img.shape[0] * img.shape[1] / _MAX_HIST_SAMPLES))) if step > 1: img = img[::step, ::step] - val = get_luminance(np.ascontiguousarray(img)) + img = np.ascontiguousarray(img) + lum = get_luminance(img) lo, hi = DENSITY_HIST_RANGE - hist, _ = np.histogram(np.clip(val, lo, hi), bins=DENSITY_HIST_BINS, range=DENSITY_HIST_RANGE) - return hist.astype(np.float64) + rows = [np.histogram(np.clip(img[..., c], lo, hi), bins=DENSITY_HIST_BINS, range=DENSITY_HIST_RANGE)[0] for c in range(3)] + rows.append(np.histogram(np.clip(lum, lo, hi), bins=DENSITY_HIST_BINS, range=DENSITY_HIST_RANGE)[0]) + return np.stack(rows).astype(np.float64) diff --git a/negpy/features/exposure/shaders/density_hist.wgsl b/negpy/features/exposure/shaders/density_hist.wgsl index 4db138fc..d03e6591 100644 --- a/negpy/features/exposure/shaders/density_hist.wgsl +++ b/negpy/features/exposure/shaders/density_hist.wgsl @@ -6,7 +6,7 @@ struct DensityHistUniforms { }; @group(0) @binding(0) var input_tex: texture_2d; -@group(0) @binding(1) var bins: array, 120>; +@group(0) @binding(1) var bins: array, 480>; // 120 * 4 (R, G, B, Luma) @group(0) @binding(2) var u: DensityHistUniforms; const LUMA_COEFFS = vec3(0.2126, 0.7152, 0.0722); @@ -22,7 +22,16 @@ fn main(@builtin(global_invocation_id) gid: vec3) { return; } let coords = vec2(i32(gid.x + u.roi_offset.x), i32(gid.y + u.roi_offset.y)); - let val = dot(textureLoad(input_tex, coords, 0).rgb, LUMA_COEFFS); - let bin = u32(clamp((val - VAL_MIN) / VAL_SPAN * BIN_COUNT, 0.0, BIN_COUNT - 1.0)); - atomicAdd(&bins[bin], 1u); + let color = textureLoad(input_tex, coords, 0).rgb; + let luma = dot(color, LUMA_COEFFS); + + let bin_r = u32(clamp((color.r - VAL_MIN) / VAL_SPAN * BIN_COUNT, 0.0, BIN_COUNT - 1.0)); + let bin_g = u32(clamp((color.g - VAL_MIN) / VAL_SPAN * BIN_COUNT, 0.0, BIN_COUNT - 1.0)); + let bin_b = u32(clamp((color.b - VAL_MIN) / VAL_SPAN * BIN_COUNT, 0.0, BIN_COUNT - 1.0)); + let bin_l = u32(clamp((luma - VAL_MIN) / VAL_SPAN * BIN_COUNT, 0.0, BIN_COUNT - 1.0)); + + atomicAdd(&bins[bin_r], 1u); + atomicAdd(&bins[120u + bin_g], 1u); + atomicAdd(&bins[240u + bin_b], 1u); + atomicAdd(&bins[360u + bin_l], 1u); } diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index 2e3d71b6..94d85206 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -80,12 +80,13 @@ TILE_HALO = 32 TILING_THRESHOLD_PX = 12_000_000 HISTOGRAM_BINS = 256 -# Metrics buffer layout in u32 words: RGBL histogram (metrics.wgsl), then the density -# histogram (density_hist.wgsl). 256 B-aligned offsets, mirrored as WGSL array lengths. -# Append-only. +# Metrics buffer layout in u32 words: RGBL output histogram (metrics.wgsl), then the RGBL +# density histogram (density_hist.wgsl). 256 B-aligned offsets, mirrored as WGSL array +# lengths. Append-only. _METRICS_HIST_WORDS = HISTOGRAM_BINS * 4 _METRICS_DENSITY_BASE = 1024 -METRICS_BUFFER_SIZE = 1152 * 4 +_METRICS_DENSITY_WORDS = DENSITY_HIST_BINS * 4 # R, G, B, Luma +METRICS_BUFFER_SIZE = (_METRICS_DENSITY_BASE + _METRICS_DENSITY_WORDS) * 4 # Per-frame metrics clear; write_buffer copies at call time, so sharing is safe. _METRICS_ZEROS = np.zeros(METRICS_BUFFER_SIZE // 4, dtype=np.uint32) @@ -1105,7 +1106,7 @@ def _analyze_bounds() -> LogNegativeBounds: { "buffer": self._buffers["metrics"].buffer, "offset": _METRICS_DENSITY_BASE * 4, - "size": DENSITY_HIST_BINS * 4, + "size": _METRICS_DENSITY_WORDS * 4, }, ), (2, self._get_uniform_binding("density_hist")), @@ -1153,7 +1154,11 @@ def _analyze_bounds() -> LogNegativeBounds: if not tiling_mode and readback_metrics: raw_metrics = self._readback_metrics() metrics["histogram_raw"] = raw_metrics[:_METRICS_HIST_WORDS].reshape((4, HISTOGRAM_BINS)) - metrics["histogram_density"] = raw_metrics[_METRICS_DENSITY_BASE : _METRICS_DENSITY_BASE + DENSITY_HIST_BINS].astype(np.float64) + metrics["histogram_density"] = ( + raw_metrics[_METRICS_DENSITY_BASE : _METRICS_DENSITY_BASE + _METRICS_DENSITY_WORDS] + .reshape((4, DENSITY_HIST_BINS)) + .astype(np.float64) + ) try: uv_key = ( h, diff --git a/tests/test_density_histogram_channel_wiring.py b/tests/test_density_histogram_channel_wiring.py new file mode 100644 index 00000000..43d5b254 --- /dev/null +++ b/tests/test_density_histogram_channel_wiring.py @@ -0,0 +1,42 @@ +"""The per-channel density histogram is a Peek Negative feature: everywhere else the print's +own output histogram already carries color information, so the merged luma trace stays.""" + +from unittest.mock import MagicMock + +import numpy as np + +from negpy.desktop.view.sidebar.right_panel import RightPanel +from negpy.features.exposure.analysis import DENSITY_HIST_BINS + + +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_channel_density_follows_negative_peek() -> None: + panel = _panel_stub(negative_peek=True) + metrics = { + "histogram_density": np.zeros((4, DENSITY_HIST_BINS)), + "histogram_raw": np.full((4, 4, 3), 0.5, dtype=np.float32), + } + + RightPanel._update_histograms(panel, metrics) + + panel.curve_widget.set_channel_density.assert_called_once_with(True) + + +def test_channel_density_off_for_a_normal_render() -> None: + panel = _panel_stub(negative_peek=False) + metrics = { + "histogram_density": np.zeros((4, DENSITY_HIST_BINS)), + "histogram_raw": np.full((4, 4, 3), 0.5, dtype=np.float32), + } + + RightPanel._update_histograms(panel, metrics) + + panel.curve_widget.set_channel_density.assert_called_once_with(False) diff --git a/tests/test_exposure_analysis.py b/tests/test_exposure_analysis.py index 32cc7877..25d3eb45 100644 --- a/tests/test_exposure_analysis.py +++ b/tests/test_exposure_analysis.py @@ -20,28 +20,40 @@ class TestDensityHistogram(unittest.TestCase): def test_bin_placement(self): # Bin-center value: exact bin edges are float32-fragile by construction. hist = density_histogram(_gray([0.505])) - self.assertEqual(hist.sum(), 1) - self.assertEqual(int(np.argmax(hist)), _bin_of(0.505)) + self.assertEqual(hist.shape, (4, DENSITY_HIST_BINS)) + self.assertEqual(hist[0].sum(), 1) # gray input: every row agrees + self.assertEqual(int(np.argmax(hist[0])), _bin_of(0.505)) def test_out_of_range_mass_lands_in_edge_bins(self): hist = density_histogram(_gray([-0.5, -0.2, 2.0])) - self.assertEqual(hist[0], 2) - self.assertEqual(hist[-1], 1) - self.assertEqual(hist.sum(), 3) + self.assertEqual(hist[0, 0], 2) + self.assertEqual(hist[0, -1], 1) + self.assertEqual(hist[0].sum(), 3) def test_roi_slicing(self): img = np.zeros((4, 4, 3), dtype=np.float32) img[1:3, 1:3] = 0.905 hist = density_histogram(img, roi=(1, 3, 1, 3)) - self.assertEqual(hist.sum(), 4) - self.assertEqual(hist[_bin_of(0.905)], 4) - self.assertEqual(hist[_bin_of(0.005)], 0) + self.assertEqual(hist[0].sum(), 4) + self.assertEqual(hist[0, _bin_of(0.905)], 4) + self.assertEqual(hist[0, _bin_of(0.005)], 0) def test_luma_weighting(self): img = np.zeros((1, 1, 3), dtype=np.float32) img[..., 1] = 1.0 hist = density_histogram(img) - self.assertEqual(int(np.argmax(hist)), _bin_of(0.7152)) + self.assertEqual(int(np.argmax(hist[3])), _bin_of(0.7152)) + + def test_channels_bin_independently(self): + # A red-dominant pixel: the R row sits on its own density, G/B stay off at theirs. + img = np.full((1, 1, 3), 0.005, dtype=np.float32) + img[..., 0] = 0.905 + hist = density_histogram(img) + self.assertEqual(int(np.argmax(hist[0])), _bin_of(0.905)) + self.assertEqual(int(np.argmax(hist[1])), _bin_of(0.005)) + self.assertEqual(int(np.argmax(hist[2])), _bin_of(0.005)) + luma = 0.2126 * 0.905 + (0.7152 + 0.0722) * 0.005 + self.assertEqual(int(np.argmax(hist[3])), _bin_of(luma)) class TestOutputHistogram(unittest.TestCase): @@ -140,13 +152,14 @@ def test_cpu_gpu_distributions_agree(self): hc = np.asarray(m_cpu["histogram_density"], dtype=np.float64) hg = np.asarray(m_gpu["histogram_density"], dtype=np.float64) - self.assertEqual(hc.shape, (DENSITY_HIST_BINS,)) - self.assertEqual(hg.shape, (DENSITY_HIST_BINS,)) - self.assertGreater(hc.sum(), 0) - self.assertGreater(hg.sum(), 0) - # Distribution-level agreement: engines sample/round differently. - l1 = float(np.abs(hc / hc.sum() - hg / hg.sum()).sum()) - self.assertLess(l1, 0.05, f"normalized L1 distance {l1:.4f}") + self.assertEqual(hc.shape, (4, DENSITY_HIST_BINS)) + self.assertEqual(hg.shape, (4, DENSITY_HIST_BINS)) + for row in range(4): + self.assertGreater(hc[row].sum(), 0) + self.assertGreater(hg[row].sum(), 0) + # Distribution-level agreement: engines sample/round differently. + l1 = float(np.abs(hc[row] / hc[row].sum() - hg[row] / hg[row].sum()).sum()) + self.assertLess(l1, 0.05, f"row {row} normalized L1 distance {l1:.4f}") if __name__ == "__main__": diff --git a/tests/test_negative_peek_analysis_chart.py b/tests/test_negative_peek_analysis_chart.py index af3009bd..9435f46e 100644 --- a/tests/test_negative_peek_analysis_chart.py +++ b/tests/test_negative_peek_analysis_chart.py @@ -46,11 +46,18 @@ def test_a_normal_render_still_shows_the_print(monkeypatch) -> 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.""" + import numpy as np + + from negpy.features.exposure.analysis import DENSITY_HIST_BINS + 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]) + bins = np.zeros((4, DENSITY_HIST_BINS)) + bins[:, 10] = 1.0 + widget.set_density_histogram(bins) widget.set_show_print(False) + widget.set_channel_density(True) assert widget._show_print is False # paintEvent must not raise with print traces suppressed but density data present. diff --git a/tests/test_readout_and_curve_feedback.py b/tests/test_readout_and_curve_feedback.py index fd13a85e..f74aa011 100644 --- a/tests/test_readout_and_curve_feedback.py +++ b/tests/test_readout_and_curve_feedback.py @@ -25,6 +25,28 @@ def test_histogram_marker_set_clear_and_paint() -> None: widget.grab() +def test_channel_density_toggle_selects_the_row_count() -> None: + from negpy.features.exposure.analysis import DENSITY_HIST_BINS + + widget = PhotometricCurveWidget() + widget.resize(200, 120) + widget.update_curve(ExposureConfig()) + bins = np.zeros((4, DENSITY_HIST_BINS)) + bins[0, 10] = 5.0 # R only, so a merged luma-only view and a per-channel one differ + bins[3, 60] = 3.0 + widget.set_density_histogram(bins) + + assert widget._channel_density is False + widget.grab() # luma-only trace; R must not raise or get drawn + + widget.set_channel_density(True) + assert widget._channel_density is True + widget.grab() # R/G/B/L traces + + widget.set_channel_density(False) + assert widget._channel_density is False + + def test_curve_ghost_frozen_across_updates_and_cleared() -> None: widget = PhotometricCurveWidget() widget.resize(200, 120)