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}