Skip to main content

fovea_display/
debug_histogram.rs

1//! Debug histogram window for quick inspection of [`Histogram`] data.
2//!
3//! This module provides an `imshow`-style entry point for visualising
4//! histograms during development. It is gated behind the `debug-window`
5//! feature flag and is **not intended for production use**.
6//!
7//! Histograms produced by [`fovea::analyze::histogram::histogram()`]
8//! carry a `bins()` slice that is strategy-independent — that is the
9//! only datum the renderer needs. One or more histograms are
10//! rasterised into a small [`Srgba8`](fovea::pixel::Srgba8) bar-chart
11//! image and handed off to the same [`Framebuffer`] / event-loop
12//! pipeline as [`crate::show`].
13//!
14//! # Single histogram
15//!
16//! ```no_run
17//! use fovea::analyze::histogram::{histogram, Histogram, NaturalBins};
18//! use fovea::image::Image;
19//! use fovea::pixel::Mono8;
20//! use fovea_display::debug_histogram;
21//!
22//! let img = Image::<Mono8>::zero(64, 64);
23//! let h: Histogram<NaturalBins, _> = histogram(&img, &NaturalBins).unwrap();
24//! debug_histogram("Sample histogram", &h);
25//! ```
26//!
27//! # Multiple translucent layers in one window
28//!
29//! Each layer carries its own colour (with alpha) and its own `bins`
30//! slice. All layers in a call share the chart frame, padding, axis,
31//! background, and y-scale — so an RGB image's three channels can be
32//! overlaid in one plot, or two transforms of the same data (e.g.
33//! linear vs. log) can be compared side-on-side.
34//!
35//! ```no_run
36//! use fovea::analyze::histogram::{histogram, Histogram, NaturalBins};
37//! use fovea::image::Image;
38//! use fovea::pixel::{Rgb8, Srgba8};
39//! use fovea_display::{debug_histogram_layers, HistogramLayer, HistogramPlotOptions};
40//!
41//! let img = Image::<Rgb8>::zero(64, 64);
42//! let chans: [Histogram<NaturalBins, _>; 3] =
43//!     histogram(&img, &NaturalBins).unwrap();
44//!
45//! let layers = [
46//!     HistogramLayer::new(chans[0].bins(), Srgba8::new(220,  60,  60, 160)),
47//!     HistogramLayer::new(chans[1].bins(), Srgba8::new( 60, 200,  80, 160)),
48//!     HistogramLayer::new(chans[2].bins(), Srgba8::new( 80, 120, 230, 160)),
49//! ];
50//! debug_histogram_layers("RGB histogram", &layers, &HistogramPlotOptions::default());
51//! ```
52
53use fovea::analyze::histogram::Histogram;
54use fovea::image::{Image, ImageViewMut};
55use fovea::pixel::Srgba8;
56
57use crate::DisplayContext;
58use crate::strategy::{Framebuffer, Identity};
59
60// ═══════════════════════════════════════════════════════════════════════════════
61// Plot-level configuration (chart frame, shared by all layers)
62// ═══════════════════════════════════════════════════════════════════════════════
63
64/// Frame-level configuration for [`render_histogram_layers`].
65///
66/// Holds everything that is *shared* between all layers in a single
67/// chart: the canvas size, padding, background colour, axis colour,
68/// and whether the y-axis is log-scaled.
69///
70/// Per-layer properties (bin counts and bar colour) live on
71/// [`HistogramLayer`].
72#[derive(Debug, Clone, Copy)]
73pub struct HistogramPlotOptions {
74    /// Width of the rendered chart in pixels.
75    pub width: u32,
76    /// Height of the rendered chart in pixels.
77    pub height: u32,
78    /// Padding around the plot area, in pixels.
79    pub padding: u32,
80    /// Background fill colour. Use a translucent value if you intend
81    /// to composite the chart over something else later — the
82    /// renderer will honour the alpha channel verbatim.
83    pub background: Srgba8,
84    /// Axis / baseline colour.
85    pub axis: Srgba8,
86    /// If `true`, every layer's bar heights are scaled by
87    /// `log10(1 + count)`. Useful for natural images where a few
88    /// bins dominate.
89    pub log_scale: bool,
90}
91
92impl Default for HistogramPlotOptions {
93    fn default() -> Self {
94        Self {
95            width: 512,
96            height: 256,
97            padding: 8,
98            background: Srgba8::new(24, 24, 28, 255),
99            axis: Srgba8::new(96, 96, 104, 255),
100            log_scale: false,
101        }
102    }
103}
104
105// ═══════════════════════════════════════════════════════════════════════════════
106// Layer
107// ═══════════════════════════════════════════════════════════════════════════════
108
109/// One translucent histogram layer in a multi-layer plot.
110///
111/// `bins` is borrowed (it's just a slice into a [`Histogram`]'s bin
112/// counts) so layers are cheap to construct and keep no ownership of
113/// the histogram itself.
114///
115/// The renderer treats `color.a` as straight (non-premultiplied)
116/// alpha. Lower alpha values let underlying layers show through —
117/// pick `~120..180` for typical 2–3 layer overlays.
118#[derive(Debug, Clone, Copy)]
119pub struct HistogramLayer<'a> {
120    /// Bin counts to draw, one entry per bar.
121    pub bins: &'a [u64],
122    /// Bar fill colour (RGBA, straight alpha).
123    pub color: Srgba8,
124}
125
126impl<'a> HistogramLayer<'a> {
127    /// Build a layer from raw bins and a colour.
128    #[inline]
129    pub fn new(bins: &'a [u64], color: Srgba8) -> Self {
130        Self { bins, color }
131    }
132
133    /// Build a layer from a [`Histogram`] and a colour.
134    ///
135    /// Equivalent to `HistogramLayer::new(h.bins(), color)`. Generic
136    /// over both the strategy `S` and the channel value type `V`,
137    /// because only the strategy-independent `bins()` slice is read.
138    #[inline]
139    pub fn from_histogram<S, V>(h: &'a Histogram<S, V>, color: Srgba8) -> Self {
140        Self::new(h.bins(), color)
141    }
142}
143
144// ═══════════════════════════════════════════════════════════════════════════════
145// Single-histogram convenience: HistogramRenderOptions
146// ═══════════════════════════════════════════════════════════════════════════════
147
148/// Visual configuration for the single-histogram entry points.
149///
150/// Combines [`HistogramPlotOptions`] with one `bar` colour for the
151/// classic "render exactly one histogram" use case. Multi-histogram
152/// plotting goes through [`HistogramPlotOptions`] +
153/// [`HistogramLayer`] directly.
154#[derive(Debug, Clone, Copy)]
155pub struct HistogramRenderOptions {
156    /// Width of the rendered chart in pixels.
157    pub width: u32,
158    /// Height of the rendered chart in pixels.
159    pub height: u32,
160    /// Padding around the plot area, in pixels.
161    pub padding: u32,
162    /// Background fill colour.
163    pub background: Srgba8,
164    /// Bar fill colour. Alpha is honoured but with one layer it
165    /// simply blends against the background.
166    pub bar: Srgba8,
167    /// Axis / baseline colour.
168    pub axis: Srgba8,
169    /// If `true`, scale bar heights logarithmically (`log10(1 + count)`).
170    pub log_scale: bool,
171}
172
173impl Default for HistogramRenderOptions {
174    fn default() -> Self {
175        Self {
176            width: 512,
177            height: 256,
178            padding: 8,
179            background: Srgba8::new(24, 24, 28, 255),
180            bar: Srgba8::new(180, 200, 230, 255),
181            axis: Srgba8::new(96, 96, 104, 255),
182            log_scale: false,
183        }
184    }
185}
186
187impl HistogramRenderOptions {
188    /// Split into the frame-level [`HistogramPlotOptions`] and the
189    /// single bar colour. Used internally to delegate to the layered
190    /// renderer.
191    #[inline]
192    fn split(&self) -> (HistogramPlotOptions, Srgba8) {
193        (
194            HistogramPlotOptions {
195                width: self.width,
196                height: self.height,
197                padding: self.padding,
198                background: self.background,
199                axis: self.axis,
200                log_scale: self.log_scale,
201            },
202            self.bar,
203        )
204    }
205}
206
207// ═══════════════════════════════════════════════════════════════════════════════
208// Renderer
209// ═══════════════════════════════════════════════════════════════════════════════
210
211/// Render any number of histogram layers into one [`Image<Srgba8>`].
212///
213/// All layers share the chart frame and a single y-scale: the maximum
214/// (transformed) bin value is taken across *every* layer so relative
215/// heights stay comparable. Per-layer bars are alpha-blended into the
216/// canvas in the order the slice provides them — the last layer is
217/// drawn on top.
218///
219/// This is the workhorse renderer; [`render_histogram`] delegates here
220/// with a single layer.
221///
222/// # Notes
223///
224/// - Bin → column mapping uses max-pooling: when columns are narrower
225///   than the bin count, several adjacent bins collapse onto one
226///   column and the column adopts their max so spikes are preserved.
227/// - NaN / underflow / overflow counters are not drawn (they have no
228///   in-range bin); callers that care can read those fields off the
229///   [`Histogram`].
230/// - Blending uses sRGB straight-alpha. This is a debug visualiser,
231///   not a colour-managed pipeline — the goal is "translucent layers
232///   look translucent", not photometric accuracy.
233pub fn render_histogram_layers(
234    layers: &[HistogramLayer<'_>],
235    opts: &HistogramPlotOptions,
236) -> Image<Srgba8> {
237    let w = opts.width.max(1) as usize;
238    let height_px = opts.height.max(1) as usize;
239    let pad = (opts.padding as usize).min(w / 4).min(height_px / 4);
240
241    let mut img = Image::fill(w, height_px, opts.background);
242
243    // Plot rectangle in image coordinates (top-left origin).
244    let plot_x0 = pad;
245    let plot_y0 = pad;
246    let plot_x1 = w.saturating_sub(pad).max(plot_x0 + 1);
247    let plot_y1 = height_px.saturating_sub(pad).max(plot_y0 + 1);
248    let plot_w = plot_x1 - plot_x0;
249    let plot_h = plot_y1 - plot_y0;
250
251    // Baseline (axis).
252    let baseline_y = plot_y1.saturating_sub(1);
253    for x in plot_x0..plot_x1 {
254        *img.pixel_at_mut(x, baseline_y) = opts.axis;
255    }
256
257    if layers.is_empty() || plot_w == 0 || plot_h == 0 {
258        return img;
259    }
260
261    let transform = |c: u64| -> f64 {
262        if opts.log_scale {
263            (1.0 + c as f64).ln()
264        } else {
265            c as f64
266        }
267    };
268
269    // Shared y-scale: max across all layers and all bins. Empty layers
270    // contribute 0 and do not affect the scale.
271    let mut max_val = 0.0_f64;
272    for layer in layers {
273        for &c in layer.bins {
274            let v = transform(c);
275            if v > max_val {
276                max_val = v;
277            }
278        }
279    }
280    if max_val <= 0.0 {
281        return img;
282    }
283
284    let usable_h = plot_h.saturating_sub(1); // reserve baseline row
285    if usable_h == 0 {
286        return img;
287    }
288
289    // Draw each layer in order. Later layers blend on top.
290    for layer in layers {
291        let n = layer.bins.len();
292        if n == 0 {
293            continue;
294        }
295
296        for col in 0..plot_w {
297            let lo = (col * n) / plot_w;
298            let hi_excl = (((col + 1) * n) / plot_w).max(lo + 1).min(n);
299
300            let mut local_max = 0.0_f64;
301            for b in &layer.bins[lo..hi_excl] {
302                let v = transform(*b);
303                if v > local_max {
304                    local_max = v;
305                }
306            }
307            if local_max <= 0.0 {
308                continue;
309            }
310
311            let bar_h = ((local_max / max_val) * usable_h as f64).round() as usize;
312            let bar_h = bar_h.min(usable_h);
313            if bar_h == 0 {
314                continue;
315            }
316
317            let x = plot_x0 + col;
318            let top = baseline_y.saturating_sub(bar_h);
319            for y in top..baseline_y {
320                let dst = img.pixel_at_mut(x, y);
321                *dst = blend_over(layer.color, *dst);
322            }
323        }
324    }
325
326    img
327}
328
329/// Render a single histogram's bin counts to an [`Image<Srgba8>`].
330///
331/// Convenience wrapper around [`render_histogram_layers`] for the
332/// classic single-bar-colour case. See that function for the rendering
333/// model.
334pub fn render_histogram<S, V>(h: &Histogram<S, V>, opts: &HistogramRenderOptions) -> Image<Srgba8> {
335    let (plot, bar) = opts.split();
336    let layers = [HistogramLayer::new(h.bins(), bar)];
337    render_histogram_layers(&layers, &plot)
338}
339
340// ── Compositing ─────────────────────────────────────────────────────────────
341
342/// Straight-alpha "source over destination" blend in 8-bit sRGB space.
343///
344/// This is intentionally **not** colour-managed: gamma-correct blending
345/// would require linearising both operands. For a debug visualiser the
346/// approximation is good enough and keeps everything in the same byte
347/// space as the [`Framebuffer`].
348#[inline]
349fn blend_over(src: Srgba8, dst: Srgba8) -> Srgba8 {
350    let sa = src.a.0 as u32;
351    if sa == 0 {
352        return dst;
353    }
354    if sa == 255 {
355        return src;
356    }
357    let inv = 255 - sa;
358
359    // Round-to-nearest division by 255 via the classic
360    // `(x * 0x8081) >> 23` trick; the simpler `(x + 127) / 255` is
361    // plenty fast for the debug path and easier to audit.
362    let mix = |s: u8, d: u8| -> u8 {
363        let v = (s as u32) * sa + (d as u32) * inv;
364        ((v + 127) / 255) as u8
365    };
366
367    let da = dst.a.0 as u32;
368    let out_a = sa + (da * inv + 127) / 255;
369    let out_a = out_a.min(255) as u8;
370
371    Srgba8::new(
372        mix(src.r.0, dst.r.0),
373        mix(src.g.0, dst.g.0),
374        mix(src.b.0, dst.b.0),
375        out_a,
376    )
377}
378
379// ═══════════════════════════════════════════════════════════════════════════════
380// Public entry points — single histogram
381// ═══════════════════════════════════════════════════════════════════════════════
382
383/// Display a histogram in a debug window using default render options.
384///
385/// This is the histogram counterpart of [`crate::show`]: it blocks
386/// until the user presses a key or closes the window. See
387/// [`debug_histogram_with`] for control over the rendered appearance,
388/// and [`debug_histogram_layers`] for multi-layer plots.
389///
390/// # Platform notes
391///
392/// On macOS, this function **must** be called from the main thread.
393/// Internally it goes through the same one-thread-per-process winit
394/// event loop used by [`crate::show`], so do not mix this with
395/// [`crate::DebugDisplay::run`] in the same process.
396pub fn debug_histogram<S, V>(title: &str, h: &Histogram<S, V>) {
397    debug_histogram_with(title, h, &HistogramRenderOptions::default());
398}
399
400/// Display a histogram in a debug window with custom render options.
401pub fn debug_histogram_with<S, V>(title: &str, h: &Histogram<S, V>, opts: &HistogramRenderOptions) {
402    let img = render_histogram(h, opts);
403    crate::show(title, &img, Identity);
404}
405
406// ═══════════════════════════════════════════════════════════════════════════════
407// Public entry points — layered histograms
408// ═══════════════════════════════════════════════════════════════════════════════
409
410/// Display any number of histogram layers in a single debug window.
411///
412/// All layers share one chart frame and one y-scale; per-layer colour
413/// (with alpha) controls overlay appearance. See
414/// [`render_histogram_layers`] for the rendering model and
415/// [`crate::show`] for the windowing semantics.
416pub fn debug_histogram_layers(
417    title: &str,
418    layers: &[HistogramLayer<'_>],
419    opts: &HistogramPlotOptions,
420) {
421    let img = render_histogram_layers(layers, opts);
422    crate::show(title, &img, Identity);
423}
424
425// ═══════════════════════════════════════════════════════════════════════════════
426// DisplayContext extension
427// ═══════════════════════════════════════════════════════════════════════════════
428
429impl DisplayContext {
430    /// Display a histogram inside a [`DebugDisplay::run`](crate::DebugDisplay::run)
431    /// session, using default render options.
432    ///
433    /// Non-blocking. Uses the same window-update semantics as
434    /// [`DisplayContext::show`]: passing the same `title` again
435    /// updates the existing window in place.
436    pub fn show_histogram<S, V>(&self, title: &str, h: &Histogram<S, V>) {
437        self.show_histogram_with(title, h, &HistogramRenderOptions::default());
438    }
439
440    /// Display a histogram inside a [`DebugDisplay::run`](crate::DebugDisplay::run)
441    /// session with custom render options.
442    pub fn show_histogram_with<S, V>(
443        &self,
444        title: &str,
445        h: &Histogram<S, V>,
446        opts: &HistogramRenderOptions,
447    ) {
448        let img = render_histogram(h, opts);
449        let fb = Framebuffer::from_image(&img, Identity);
450        self.show_framebuffer(title, fb);
451    }
452
453    /// Display a multi-layer histogram inside a
454    /// [`DebugDisplay::run`](crate::DebugDisplay::run) session.
455    ///
456    /// Non-blocking. See [`render_histogram_layers`] for the
457    /// rendering model.
458    pub fn show_histogram_layers(
459        &self,
460        title: &str,
461        layers: &[HistogramLayer<'_>],
462        opts: &HistogramPlotOptions,
463    ) {
464        let img = render_histogram_layers(layers, opts);
465        let fb = Framebuffer::from_image(&img, Identity);
466        self.show_framebuffer(title, fb);
467    }
468}
469
470// ═══════════════════════════════════════════════════════════════════════════════
471// Tests
472// ═══════════════════════════════════════════════════════════════════════════════
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use fovea::analyze::histogram::{NaturalBins, histogram};
478    use fovea::image::ImageView;
479    use fovea::pixel::Mono8;
480
481    fn build_hist() -> Histogram<NaturalBins, std::num::Saturating<u8>> {
482        // 4×4 image with one pixel at value 0 and the rest at 255.
483        let mut img: Image<Mono8> = Image::fill(4, 4, Mono8::new(255));
484        *img.pixel_at_mut(0, 0) = Mono8::new(0);
485        histogram(&img, &NaturalBins).unwrap()
486    }
487
488    // ── Single-histogram renderer ────────────────────────────────────────
489
490    #[test]
491    fn render_default_size_matches_options() {
492        let h = build_hist();
493        let opts = HistogramRenderOptions::default();
494        let img = render_histogram(&h, &opts);
495        assert_eq!(img.width(), opts.width as usize);
496        assert_eq!(img.height(), opts.height as usize);
497    }
498
499    #[test]
500    fn render_zero_size_padded_does_not_panic() {
501        let h = build_hist();
502        let opts = HistogramRenderOptions {
503            width: 1,
504            height: 1,
505            padding: 0,
506            ..HistogramRenderOptions::default()
507        };
508        let img = render_histogram(&h, &opts);
509        assert_eq!(img.width(), 1);
510        assert_eq!(img.height(), 1);
511    }
512
513    #[test]
514    fn render_paints_background_outside_bars() {
515        let h = build_hist();
516        let opts = HistogramRenderOptions::default();
517        let img = render_histogram(&h, &opts);
518        // Top-left corner is well inside the padding region, which is
519        // never overwritten by bars.
520        assert_eq!(img.pixel_at(0, 0), opts.background);
521    }
522
523    #[test]
524    fn render_log_scale_runs() {
525        let h = build_hist();
526        let opts = HistogramRenderOptions {
527            log_scale: true,
528            ..HistogramRenderOptions::default()
529        };
530        let _ = render_histogram(&h, &opts);
531    }
532
533    #[test]
534    fn render_empty_bins_is_safe() {
535        let h = build_hist();
536        let opts = HistogramRenderOptions {
537            width: 4,
538            height: 4,
539            padding: 2,
540            ..HistogramRenderOptions::default()
541        };
542        let img = render_histogram(&h, &opts);
543        assert_eq!(img.width(), 4);
544        assert_eq!(img.height(), 4);
545    }
546
547    // ── Layered renderer ─────────────────────────────────────────────────
548
549    #[test]
550    fn layered_no_layers_yields_background_and_axis_only() {
551        let opts = HistogramPlotOptions::default();
552        let img = render_histogram_layers(&[], &opts);
553        assert_eq!(img.width(), opts.width as usize);
554        assert_eq!(img.height(), opts.height as usize);
555        // Top-left padding pixel is background.
556        assert_eq!(img.pixel_at(0, 0), opts.background);
557    }
558
559    #[test]
560    fn layered_two_translucent_layers_blend() {
561        let h = build_hist();
562        let opts = HistogramPlotOptions::default();
563        let layers = [
564            HistogramLayer::from_histogram(&h, Srgba8::new(255, 0, 0, 128)),
565            HistogramLayer::from_histogram(&h, Srgba8::new(0, 0, 255, 128)),
566        ];
567        let img = render_histogram_layers(&layers, &opts);
568        assert_eq!(img.width(), opts.width as usize);
569        // No panics, dimensions match.
570        assert_eq!(img.height(), opts.height as usize);
571    }
572
573    // ── Compositing ──────────────────────────────────────────────────────
574
575    #[test]
576    fn blend_over_zero_alpha_is_passthrough() {
577        let dst = Srgba8::new(10, 20, 30, 200);
578        let src = Srgba8::new(255, 0, 0, 0);
579        assert_eq!(blend_over(src, dst), dst);
580    }
581
582    #[test]
583    fn blend_over_full_alpha_replaces_destination() {
584        let dst = Srgba8::new(10, 20, 30, 200);
585        let src = Srgba8::new(255, 0, 0, 255);
586        assert_eq!(blend_over(src, dst), src);
587    }
588
589    #[test]
590    fn blend_over_half_alpha_mixes_components() {
591        // 50% red over solid black → mid red, fully opaque (since dst
592        // alpha is also 255).
593        let dst = Srgba8::new(0, 0, 0, 255);
594        let src = Srgba8::new(255, 0, 0, 128);
595        let out = blend_over(src, dst);
596        // 255*128/255 ≈ 128 (with rounding).
597        assert!(out.r.0 >= 127 && out.r.0 <= 129, "r = {}", out.r.0);
598        assert_eq!(out.g.0, 0);
599        assert_eq!(out.b.0, 0);
600        assert_eq!(out.a.0, 255);
601    }
602}