fovea 0.2.0

A high-precision, type-safe computer vision library guaranteeing absolute image correctness at compile time
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
//! The [`Histogram`] type — a typed container for per-channel bin counts.
//!
//! `Histogram<S, V>` pairs a [`BinningStrategy<V>`] instance with the
//! counts it produced. Storing the strategy is deliberate: for data-
//! carrying strategies (`LinearBins`, `CustomBins`) the strategy *is*
//! the histogram's interpretive contract, and queries such as
//! `count_for(value)` or `bin_range(i)` are only meaningful relative
//! to it.
//!
//! The second type parameter `V` is a phantom witness for the channel
//! value type the histogram was built from. It is never stored: it
//! exists only so that the type system can distinguish, for example,
//! `Histogram<NaturalBins, u8>` (built from `Indexed8`) from
//! `Histogram<NaturalBins, Saturating<u8>>` (built from `Mono8` /
//! `Rgb8` / …). Without `V`, the two would be the same type and
//! `bin_range(i)` would be ambiguous when channel primitives and
//! pixel types share the same underlying storage.
//!

use core::marker::PhantomData;

use super::strategy::{BinIndex, BinningStrategy};

// ═══════════════════════════════════════════════════════════════════════════════
// Histogram<S, V>
// ═══════════════════════════════════════════════════════════════════════════════

/// A histogram of channel-value counts, parameterised by its binning
/// strategy `S` and the channel value type `V` it was built from.
///
/// # Type parameters
///
/// - `S` — the [`BinningStrategy`] used to classify values into bins.
///   The strategy instance is stored by value (queries such as
///   `bin_range(i)` are only meaningful against the strategy that
///   produced the histogram).
/// - `V` — the channel value type. A phantom witness; never stored.
///   Distinguishes histograms built from `u8` (e.g. `Indexed8`) from
///   those built from `Saturating<u8>` (e.g. `Mono8`, `Rgb8`).
///
/// # Counters
///
/// Bin counts are `u64`. A 64 Mpx image with every pixel in one bin
/// produces 6.4 × 10⁷ — well within `u64` and an overflow of `u32`.
///
/// `nan_count`, `underflow_count`, and `overflow_count` are surfaced as
/// public fields rather than hidden by `count_for`. Philosophy §8
/// ("surface information, don't decide"): the library reports each
/// out-of-bin category and lets the caller decide what to do with them.
///
/// # Construction
///
/// `Histogram` is constructed only by the histogram engine. The
/// constructor `Histogram::new` is `pub(crate)` and lives in this
/// module; user code obtains a `Histogram` via the top-level
/// `histogram()` function (added in M4).
///
/// # Example
///
/// ```ignore
/// // (Engine entry point lands in M4; this is the M3 surface.)
/// use fovea::analyze::histogram::engine::Histogram;
/// use fovea::analyze::histogram::strategy::NaturalBins;
///
/// let h: &Histogram<NaturalBins, u8> = /* … */;
/// assert_eq!(h.bins().len(), 256);
/// ```
#[derive(Debug, Clone)]
pub struct Histogram<S, V> {
    /// The strategy used to classify values into bins.
    strategy: S,

    /// In-range bin counts. `bins.len() == strategy.bin_count()` after
    /// construction.
    bins: Vec<u64>,

    /// Pixels whose value was `NaN`. Always 0 for integer strategies.
    pub nan_count: u64,

    /// Pixels strictly below the strategy's configured minimum. Always
    /// 0 for `NaturalBins`.
    pub underflow_count: u64,

    /// Pixels strictly above the strategy's configured maximum. Always
    /// 0 for `NaturalBins`.
    pub overflow_count: u64,

    /// Total pixels processed:
    /// `bins.iter().sum::<u64>() + nan_count + underflow_count + overflow_count`.
    pub total_count: u64,

    /// Phantom witness for the channel value type. Never stored —
    /// only inhabits the type system. The `fn() -> V` form keeps
    /// `Histogram<S, V>` covariant in `V` without imposing
    /// `Send` / `Sync` constraints derived from a stored `V`.
    _value: PhantomData<fn() -> V>,
}

impl<S, V> Histogram<S, V> {
    /// Constructs a histogram from a strategy and pre-tallied counters.
    ///
    /// `total_count` is computed from the supplied counters; callers do
    /// not pass it.
    ///
    /// This constructor is `pub(crate)` because the only valid producers
    /// are the histogram engine and the test code in this module. User
    /// code goes through the top-level `histogram()` function.
    #[allow(dead_code)] // M3: consumed by the engine in M4.
    pub(crate) fn new(
        strategy: S,
        bins: Vec<u64>,
        nan_count: u64,
        underflow_count: u64,
        overflow_count: u64,
    ) -> Self {
        let total_count =
            bins.iter().copied().sum::<u64>() + nan_count + underflow_count + overflow_count;

        Self {
            strategy,
            bins,
            nan_count,
            underflow_count,
            overflow_count,
            total_count,
            _value: PhantomData,
        }
    }
}

// ── Strategy-independent queries ────────────────────────────────────────────
//
// These methods only inspect `bins` and the stored strategy's identity;
// they do not require `S: BinningStrategy<V>`. Splitting them out keeps
// the trait bounds minimal: e.g. `bins()` and `cumulative()` are usable
// on a `Histogram<UserStrategy, V>` even before the user supplies the
// matching `BinningStrategy<V>` impl.

impl<S, V> Histogram<S, V> {
    /// Raw bin counts, one entry per in-range bin.
    #[inline]
    pub fn bins(&self) -> &[u64] {
        &self.bins
    }

    /// The count for the bin at position `index`.
    ///
    /// # Panics
    ///
    /// Panics if `index >= self.bins().len()` (Tier 3 — programmer
    /// bug).
    #[inline]
    pub fn count_at_bin(&self, index: usize) -> u64 {
        assert!(
            index < self.bins.len(),
            "Histogram::count_at_bin: index {} out of range (bin_count = {})",
            index,
            self.bins.len()
        );
        self.bins[index]
    }

    /// The binning strategy used to construct this histogram.
    #[inline]
    pub fn strategy(&self) -> &S {
        &self.strategy
    }

    /// Computes the cumulative histogram.
    ///
    /// `result[i]` is the number of pixels whose bin index is `≤ i`.
    /// NaN, underflow, and overflow counts are *not* included — they
    /// are not in-range bins. Callers that need a true total reach for
    /// `total_count` directly.
    pub fn cumulative(&self) -> Vec<u64> {
        let mut out = self.bins.clone();
        for i in 1..out.len() {
            out[i] += out[i - 1];
        }
        out
    }
}

// ── Strategy-aware queries ──────────────────────────────────────────────────
//
// These methods delegate to `BinningStrategy<V>` and therefore require
// the impl to exist. They are the user-facing way to round-trip a
// channel value through the histogram or to recover a bin's edges.

impl<S, V> Histogram<S, V>
where
    V: Copy,
    S: BinningStrategy<V>,
{
    /// Count for the bin that `value` maps to.
    ///
    /// Returns `Some(count)` only when the value lies in an in-range
    /// bin (Tier 1 absence). Returns `None` for `NaN`,
    /// underflow, and overflow — callers that need the precise
    /// category read [`nan_count`](Self::nan_count),
    /// [`underflow_count`](Self::underflow_count), or
    /// [`overflow_count`](Self::overflow_count) directly.
    #[inline]
    pub fn count_for(&self, value: V) -> Option<u64> {
        match self.strategy.bin_index(value) {
            BinIndex::In(i) => Some(self.bins[i]),
            BinIndex::Underflow | BinIndex::Overflow | BinIndex::Nan => None,
        }
    }

    /// Value range `[lower, upper]` covered by bin `index`.
    ///
    /// Delegates to [`BinningStrategy::bin_range`]. The return type is
    /// the strategy's [`Range`](BinningStrategy::Range), which may
    /// differ from `V` (e.g. `LinearBins` reports `f64` edges even for
    /// `Saturating<u16>` channels).
    ///
    /// # Panics
    ///
    /// Panics if `index >= self.bins().len()` (Tier 3 — programmer
    /// bug).
    #[inline]
    pub fn bin_range(&self, index: usize) -> (S::Range, S::Range) {
        self.strategy.bin_range(index)
    }
}

// ═══════════════════════════════════════════════════════════════════════════════
// Tests
// ═══════════════════════════════════════════════════════════════════════════════

#[cfg(test)]
mod tests {
    use super::*;
    use crate::analyze::histogram::strategy::{CustomBins, LinearBins, NaturalBins};
    use std::num::Saturating;

    // ── Construction ────────────────────────────────────────────────────────

    #[test]
    fn new_computes_total_count_from_counters() {
        let h: Histogram<NaturalBins, u8> = Histogram::new(NaturalBins, vec![1, 2, 3, 4], 5, 6, 7);
        assert_eq!(h.total_count, 1 + 2 + 3 + 4 + 5 + 6 + 7);
    }

    #[test]
    fn new_stores_supplied_counters_verbatim() {
        let h: Histogram<NaturalBins, u8> = Histogram::new(NaturalBins, vec![10, 20], 1, 2, 3);
        assert_eq!(h.nan_count, 1);
        assert_eq!(h.underflow_count, 2);
        assert_eq!(h.overflow_count, 3);
    }

    #[test]
    fn new_with_empty_bins_has_only_outlier_total() {
        let h: Histogram<NaturalBins, u8> = Histogram::new(NaturalBins, vec![], 4, 5, 6);
        assert_eq!(h.total_count, 15);
        assert!(h.bins().is_empty());
    }

    // ── bins() / count_at_bin() ─────────────────────────────────────────────

    #[test]
    fn bins_returns_supplied_slice() {
        let h: Histogram<NaturalBins, u8> = Histogram::new(NaturalBins, vec![7, 8, 9], 0, 0, 0);
        assert_eq!(h.bins(), &[7, 8, 9]);
    }

    #[test]
    fn count_at_bin_returns_value_at_index() {
        let h: Histogram<NaturalBins, u8> = Histogram::new(NaturalBins, vec![7, 8, 9], 0, 0, 0);
        assert_eq!(h.count_at_bin(0), 7);
        assert_eq!(h.count_at_bin(1), 8);
        assert_eq!(h.count_at_bin(2), 9);
    }

    #[test]
    #[should_panic(expected = "out of range")]
    fn count_at_bin_panics_when_out_of_range() {
        let h: Histogram<NaturalBins, u8> = Histogram::new(NaturalBins, vec![7, 8, 9], 0, 0, 0);
        let _ = h.count_at_bin(3);
    }

    // ── strategy() ──────────────────────────────────────────────────────────

    #[test]
    fn strategy_returns_supplied_strategy() {
        let s = LinearBins {
            min: 0.0,
            max: 1.0,
            bin_count: 4,
        };
        let h: Histogram<LinearBins, f32> = Histogram::new(s, vec![0; 4], 0, 0, 0);
        assert_eq!(
            *h.strategy(),
            LinearBins {
                min: 0.0,
                max: 1.0,
                bin_count: 4
            }
        );
    }

    // ── cumulative() ────────────────────────────────────────────────────────

    #[test]
    fn cumulative_on_empty_bins_is_empty() {
        let h: Histogram<NaturalBins, u8> = Histogram::new(NaturalBins, vec![], 0, 0, 0);
        assert!(h.cumulative().is_empty());
    }

    #[test]
    fn cumulative_on_single_bin_is_identity() {
        let h: Histogram<NaturalBins, u8> = Histogram::new(NaturalBins, vec![42], 0, 0, 0);
        assert_eq!(h.cumulative(), vec![42]);
    }

    #[test]
    fn cumulative_on_multiple_bins_is_running_sum() {
        let h: Histogram<NaturalBins, u8> = Histogram::new(NaturalBins, vec![1, 2, 3, 4], 0, 0, 0);
        assert_eq!(h.cumulative(), vec![1, 3, 6, 10]);
    }

    #[test]
    fn cumulative_excludes_outlier_counters() {
        // 99 NaN / underflow / overflow pixels do not appear in
        // `cumulative()` because they are not in-range bins.
        let h: Histogram<NaturalBins, u8> = Histogram::new(NaturalBins, vec![1, 2, 3], 99, 99, 99);
        assert_eq!(h.cumulative(), vec![1, 3, 6]);
    }

    // ── count_for() (strategy-aware) ────────────────────────────────────────

    #[test]
    fn count_for_natural_u8_returns_some_for_in_range() {
        // bins[42] = 7 means seven pixels had value 42.
        let mut bins = vec![0u64; 256];
        bins[42] = 7;
        let h: Histogram<NaturalBins, u8> = Histogram::new(NaturalBins, bins, 0, 0, 0);
        assert_eq!(h.count_for(42_u8), Some(7));
        assert_eq!(h.count_for(0_u8), Some(0));
    }

    #[test]
    fn count_for_natural_satu8_dispatches_through_wrapper_impl() {
        let mut bins = vec![0u64; 256];
        bins[200] = 11;
        let h: Histogram<NaturalBins, Saturating<u8>> = Histogram::new(NaturalBins, bins, 0, 0, 0);
        assert_eq!(h.count_for(Saturating(200_u8)), Some(11));
    }

    #[test]
    fn count_for_returns_none_for_underflow_overflow_nan() {
        let s = LinearBins {
            min: 0.0,
            max: 1.0,
            bin_count: 4,
        };
        let h: Histogram<LinearBins, f32> = Histogram::new(s, vec![1, 1, 1, 1], 0, 0, 0);
        assert_eq!(h.count_for(-0.5_f32), None); // underflow
        assert_eq!(h.count_for(1.5_f32), None); // overflow
        assert_eq!(h.count_for(f32::NAN), None); // NaN
    }

    // ── bin_range() (strategy-aware) ────────────────────────────────────────

    #[test]
    fn bin_range_delegates_to_strategy_natural() {
        let h: Histogram<NaturalBins, u8> = Histogram::new(NaturalBins, vec![0; 256], 0, 0, 0);
        assert_eq!(h.bin_range(17), (17_u8, 17_u8));
    }

    #[test]
    fn bin_range_delegates_to_strategy_linear() {
        let s = LinearBins {
            min: 0.0,
            max: 1.0,
            bin_count: 4,
        };
        let h: Histogram<LinearBins, f32> = Histogram::new(s, vec![0; 4], 0, 0, 0);
        let (lo, hi) = h.bin_range(0);
        assert_eq!(lo, 0.0);
        assert_eq!(hi, 0.25);
        let (_, hi_last) = h.bin_range(3);
        assert_eq!(hi_last, 1.0);
    }

    #[test]
    fn bin_range_delegates_to_strategy_custom() {
        let s = CustomBins {
            edges: vec![0.0, 0.5, 1.0, 4.0],
        };
        let h: Histogram<CustomBins, f64> = Histogram::new(s, vec![0; 3], 0, 0, 0);
        assert_eq!(h.bin_range(0), (0.0, 0.5));
        assert_eq!(h.bin_range(2), (1.0, 4.0));
    }

    #[test]
    #[should_panic(expected = "out of range")]
    fn bin_range_panics_when_strategy_panics() {
        let s = LinearBins {
            min: 0.0,
            max: 1.0,
            bin_count: 4,
        };
        let h: Histogram<LinearBins, f32> = Histogram::new(s, vec![0; 4], 0, 0, 0);
        let _ = h.bin_range(4);
    }

    // ── Type-level: V distinguishes histograms with the same S ─────────────

    #[test]
    fn v_phantom_distinguishes_u8_and_satu8_histograms() {
        // This test exists to *document* a compile-time invariant: the
        // two histograms below have different types even though they
        // share the same strategy `S = NaturalBins`. If that ever
        // stops being true the strategy-vs-value phantom-witness
        // design needs revisiting.
        let h_idx: Histogram<NaturalBins, u8> = Histogram::new(NaturalBins, vec![0; 256], 0, 0, 0);
        let h_mono: Histogram<NaturalBins, Saturating<u8>> =
            Histogram::new(NaturalBins, vec![0; 256], 0, 0, 0);

        // We can call the strategy-aware query on each with its own
        // value type, but we *cannot* mix them — that's the point.
        assert_eq!(h_idx.count_for(0_u8), Some(0));
        assert_eq!(h_mono.count_for(Saturating(0_u8)), Some(0));
    }
}