Skip to main content

map2fig/
scale.rs

1//! Data scaling and value normalization for visualization.
2//!
3//! This module provides methods for mapping raw data values to normalized [0.0, 1.0] range
4//! for colormap sampling. It supports multiple scaling strategies:
5//!
6//! - **Linear**: Direct linear mapping `(val - min) / (max - min)`
7//! - **Logarithmic**: `log(val / min) / log(max / min)` for positive data with wide dynamic range
8//! - **SymLog**: Symmetric logarithm for data containing both positive and negative values
9//! - **Asinh**: Inverse hyperbolic sine for data with wide dynamic range
10//! - **Histogram Equalization**: Perceptual stretching using histogram distribution
11//!
12//! # Handling Invalid Data
13//!
14//! The [NegMode] enum controls treatment of masked/invalid pixels:
15//! - [NegMode::Zero]: Render as minimum value
16//! - [NegMode::Unseen]: Render as bad color (typically white)
17//!
18//! # Examples
19//!
20//! ```ignore
21//! use map2fig::scale::{scale_value, Scale};
22//! use map2fig::NegMode;
23//!
24//! let scaled = scale_value(5.0, 0.0, 10.0, Scale::Linear, NegMode::Zero, None, None);
25//! // Result: PixelValue::Color(0.5)
26//! ```
27
28use crate::NegMode;
29use crate::PixelValue;
30use crate::colorbar::ColorbarTicks;
31use crate::healpix::is_seen;
32use std::cmp::Ordering;
33
34/// Direct float comparison using std::cmp for faster sorting.
35/// This avoids the NaN check overhead since data is pre-validated.
36#[inline]
37pub fn unsafe_float_cmp(a: &f64, b: &f64) -> Ordering {
38    // Direct comparison - Rust will optimize this better than partial_cmp
39    if a < b {
40        Ordering::Less
41    } else if a > b {
42        Ordering::Greater
43    } else {
44        Ordering::Equal
45    }
46}
47
48/// Validate scaling configuration parameters.
49///
50/// Ensures that scale parameters are compatible (e.g., log scale requires positive min value).
51///
52/// # Arguments
53///
54/// * `scale` - Data scaling method
55/// * `min` - Optional minimum value for scaling range
56/// * `max` - Optional maximum value for scaling range
57///
58/// # Panics
59///
60/// Panics with a helpful error message if configuration is invalid:
61/// - Log scale without explicit --min specified
62/// - Log scale with min ≤ 0 (logarithm undefined for non-positive values)
63pub fn validate_scale_config(scale: &Scale, min: Option<f64>, max: Option<f64>) {
64    if scale == &Scale::Log {
65        let min = min.expect("log scale requires --min to be specified");
66        if min <= 0.0 {
67            panic!("Invalid --min value for log scale: {} (must be > 0)", min);
68        }
69    }
70
71    if let (Some(min), Some(max)) = (min, max)
72        && min >= max
73    {
74        panic!("Invalid scale range: min ({}) must be < max ({})", min, max);
75    }
76}
77
78/// Pre-computed scale transformation constants to avoid per-pixel recomputation.
79///
80/// For log and asinh scales, we compute expensive constants once at the start
81/// and reuse them for every pixel, avoiding redundant log() and asinh() calls.
82#[derive(Clone, Debug)]
83pub struct ScaleCache {
84    /// Scale type this cache is for
85    pub scale_type: Scale,
86    /// Cached log(min) for Log scale
87    pub log_min: f64,
88    /// Cached log(max) for Log scale  
89    pub log_range: f64, // log_max - log_min
90    /// Cached asinh(min) for Asinh scale
91    pub asinh_min: f64,
92    /// Cached range for Asinh
93    pub asinh_range: f64, // asinh_max - asinh_min
94}
95
96impl ScaleCache {
97    /// Create a new scale cache, pre-computing expensive transformations.
98    pub fn new(min: f64, max: f64, scale: Scale) -> Self {
99        match scale {
100            Scale::Log => {
101                let log_min = min.ln();
102                let log_max = max.ln();
103                Self {
104                    scale_type: scale,
105                    log_min,
106                    log_range: log_max - log_min,
107                    asinh_min: 0.0,
108                    asinh_range: 0.0,
109                }
110            }
111            Scale::Asinh { scale: s } => {
112                let asinh_min = (min / s).asinh();
113                let asinh_max = (max / s).asinh();
114                Self {
115                    scale_type: scale,
116                    log_min: 0.0,
117                    log_range: 0.0,
118                    asinh_min,
119                    asinh_range: asinh_max - asinh_min,
120                }
121            }
122            _ => Self {
123                scale_type: scale,
124                log_min: 0.0,
125                log_range: 0.0,
126                asinh_min: 0.0,
127                asinh_range: 0.0,
128            },
129        }
130    }
131}
132
133#[allow(dead_code)]
134fn scale_t_to_value(t: f64, min: f64, max: f64, scale: Scale) -> f64 {
135    match scale {
136        Scale::Linear => min + t * (max - min),
137        Scale::Log => {
138            let lmin = min.ln();
139            let lmax = max.ln();
140            (lmin + t * (lmax - lmin)).exp()
141        }
142        Scale::Asinh { scale } => {
143            let amin = (min / scale).asinh();
144            let amax = (max / scale).asinh();
145            scale * (amin + t * (amax - amin)).sinh()
146        }
147        _ => unimplemented!(),
148    }
149}
150
151#[allow(dead_code)]
152fn value_to_t(value: f64, min: f64, max: f64, scale: Scale) -> Option<f64> {
153    match scale {
154        Scale::Linear => Some((value - min) / (max - min)),
155
156        Scale::Log => {
157            if value <= 0.0 || min <= 0.0 {
158                None
159            } else {
160                Some((value.ln() - min.ln()) / (max.ln() - min.ln()))
161            }
162        }
163
164        Scale::Asinh { scale: s } => Some((value / s).asinh() / (max / s).asinh()),
165
166        Scale::Symlog { linthresh } => {
167            let f = |x: f64| {
168                if x.abs() < linthresh {
169                    x / linthresh
170                } else {
171                    x.signum() * (x.abs() / linthresh).ln()
172                }
173            };
174            Some((f(value) - f(min)) / (f(max) - f(min)))
175        }
176
177        Scale::PlanckLog { linthresh } => {
178            // use same mapping you already trust elsewhere
179            let f = |x: f64| {
180                if x.abs() < linthresh {
181                    x / linthresh
182                } else {
183                    x.signum() * (1.0 + (x.abs() / linthresh).ln())
184                }
185            };
186            Some((f(value) - f(min)) / (f(max) - f(min)))
187        }
188
189        Scale::Histogram => todo!(),
190    }
191}
192
193pub struct HistogramScale {
194    pub values: Vec<f64>, // sorted unique values or bin centers
195    pub cdf: Vec<f64>,    // monotonically increasing [0,1]
196
197    pub minv: f64,
198    pub maxv: f64,
199}
200
201impl HistogramScale {
202    pub fn lookup_cdf(&self, value: f64) -> Option<f64> {
203        if self.values.is_empty() {
204            return None;
205        }
206
207        match self
208            .values
209            .binary_search_by(|v| v.partial_cmp(&value).unwrap())
210        {
211            Ok(i) => Some(self.cdf[i]),
212            Err(i) => {
213                if i == 0 {
214                    Some(0.0)
215                } else if i >= self.cdf.len() {
216                    Some(1.0)
217                } else {
218                    Some(self.cdf[i])
219                }
220            }
221        }
222    }
223    pub fn inverse_cdf(&self, q: f64) -> Option<f64> {
224        if self.cdf.is_empty() {
225            return None;
226        }
227
228        match self.cdf.binary_search_by(|p| p.partial_cmp(&q).unwrap()) {
229            Ok(i) => Some(self.values[i]),
230            Err(i) => {
231                if i == 0 {
232                    Some(self.values[0])
233                } else if i >= self.values.len() {
234                    Some(*self.values.last().unwrap())
235                } else {
236                    Some(self.values[i])
237                }
238            }
239        }
240    }
241
242    fn value_at_quantile(&self, t: f64) -> Option<f64> {
243        if self.values.is_empty() {
244            return Some(0.0);
245        }
246
247        if t <= 0.0 {
248            return Some(self.minv);
249        }
250        if t >= 1.0 {
251            return Some(self.maxv);
252        }
253
254        match self.cdf.binary_search_by(|p| p.partial_cmp(&t).unwrap()) {
255            Ok(i) => Some(self.values[i]),
256            Err(i) => {
257                if i == 0 {
258                    Some(self.values[0])
259                } else if i >= self.values.len() {
260                    Some(*self.values.last().unwrap())
261                } else {
262                    // Linear interpolation between neighbors
263                    let t0 = self.cdf[i - 1];
264                    let t1 = self.cdf[i];
265                    let v0 = self.values[i - 1];
266                    let v1 = self.values[i];
267
268                    let w = (t - t0) / (t1 - t0);
269                    Some(v0 + w * (v1 - v0))
270                }
271            }
272        }
273    }
274    pub fn distortion_profile(&self, n: usize) -> Vec<f64> {
275        assert!(n >= 2, "distortion_profile requires n >= 2");
276
277        // 1. Sample inverse CDF
278        let mut v = Vec::with_capacity(n);
279        for i in 0..n {
280            let t = i as f64 / (n - 1) as f64;
281            v.push(self.value_at_quantile(t).unwrap_or(0.0));
282        }
283
284        // 2. Finite-difference dv/dt
285        let dt = 1.0 / (n - 1) as f64;
286        let mut dvdt = vec![0.0; n];
287
288        for i in 1..n - 1 {
289            dvdt[i] = (v[i + 1] - v[i - 1]).abs() / (2.0 * dt);
290        }
291
292        dvdt[0] = (v[1] - v[0]).abs() / dt;
293        dvdt[n - 1] = (v[n - 1] - v[n - 2]).abs() / dt;
294
295        // 3. Compress dynamic range
296        for d in dvdt.iter_mut() {
297            *d = (1.0 + *d).ln();
298        }
299
300        // 4. Normalize to [0,1]
301        let max_d = dvdt.iter().cloned().fold(0.0_f64, f64::max);
302
303        if max_d > 0.0 {
304            for d in dvdt.iter_mut() {
305                *d /= max_d;
306            }
307        }
308
309        dvdt
310    }
311}
312
313#[derive(Clone, Copy, Debug)]
314pub enum HistogramRange {
315    Percentile { low: f64, high: f64 },
316    Explicit { min: f64, max: f64 },
317    Full,
318}
319
320pub fn build_histogram_scale(map: &[f64], range: HistogramRange, bins: usize) -> HistogramScale {
321    // 1. Filter valid values only
322    let mut vals: Vec<f64> = map.iter().copied().filter(|v| is_seen(*v)).collect();
323    if vals.is_empty() {
324        return HistogramScale {
325            values: vec![],
326            cdf: vec![],
327            minv: 0.0,
328            maxv: 1.0,
329        };
330    }
331
332    vals.sort_unstable_by(unsafe_float_cmp);
333    let n = vals.len();
334
335    // 2. Compute minv / maxv based on HistogramRange
336    let (minv, maxv) = match range {
337        HistogramRange::Explicit { min, max } => (min, max),
338        HistogramRange::Full => (vals[0], vals[n - 1]),
339        HistogramRange::Percentile { low, high } => {
340            let ilo = ((n - 1) as f64 * low).round() as usize;
341            let ihi = ((n - 1) as f64 * high).round() as usize;
342            (vals[ilo], vals[ihi])
343        }
344    };
345
346    // 3. Filter to minv/maxv range for histogram LUT
347    let vals: Vec<f64> = vals
348        .into_iter()
349        .filter(|v| *v >= minv && *v <= maxv)
350        .collect();
351    if vals.is_empty() {
352        return HistogramScale {
353            values: vec![],
354            cdf: vec![],
355            minv,
356            maxv,
357        };
358    }
359
360    // 4. Downsample to bins
361    let n = vals.len();
362    let step = (n as f64 / bins as f64).ceil() as usize;
363    let mut values = Vec::new();
364    let mut cdf = Vec::new();
365
366    for (i, chunk) in vals.chunks(step).enumerate() {
367        let v = chunk[chunk.len() / 2];
368        let t = (i * step) as f64 / (n - 1) as f64;
369        values.push(v);
370        cdf.push(t.min(1.0));
371    }
372
373    HistogramScale {
374        values,
375        cdf,
376        minv,
377        maxv,
378    }
379}
380
381const TARGET_MAJOR_TICKS: usize = 7;
382
383fn uniform_quantiles(n: usize) -> Vec<f64> {
384    (0..n).map(|i| i as f64 / (n - 1) as f64).collect()
385}
386
387#[derive(Clone, Copy, PartialEq, Debug)]
388pub enum Scale {
389    Linear,
390    Log,
391    Asinh { scale: f64 },
392    Symlog { linthresh: f64 },
393    PlanckLog { linthresh: f64 },
394    Histogram,
395}
396
397pub fn generate_colorbar_ticks(
398    min: f64,
399    max: f64,
400    scale: &Scale,
401    hist: Option<&HistogramScale>,
402) -> ColorbarTicks {
403    if scale == &Scale::Histogram {
404        let ticks = histogram_ticks(hist.expect("Histogram ticks require histogram scale"));
405        return ticks;
406    }
407
408    let mut ticks = match scale {
409        Scale::Linear => linear_ticks(min, max),
410        Scale::Log => log_ticks(min, max),
411        Scale::Symlog { linthresh } => symlog_ticks(min, max, *linthresh),
412        Scale::Asinh { scale } => asinh_ticks(min, max, *scale),
413        Scale::PlanckLog { linthresh } => symlog_ticks(min, max, *linthresh),
414        Scale::Histogram => unreachable!(),
415    };
416
417    ticks.major_positions = ticks
418        .major_values
419        .iter()
420        .filter_map(|&v| scale_position(v, min, max, scale))
421        .collect();
422
423    ticks.minor_positions = ticks
424        .minor_values
425        .iter()
426        .filter_map(|&v| scale_position(v, min, max, scale))
427        .collect();
428
429    ticks
430}
431
432fn histogram_major_ticks(hist: &HistogramScale) -> (Vec<f64>, Vec<f64>) {
433    let mut values = Vec::new();
434    let mut positions = Vec::new();
435
436    for q in uniform_quantiles(TARGET_MAJOR_TICKS) {
437        if let Some(v) = hist.value_at_quantile(q) {
438            values.push(v);
439            positions.push(q);
440        }
441    }
442
443    (values, positions)
444}
445
446fn histogram_minor_ticks(hist: &HistogramScale, _major_q: &[f64]) -> (Vec<f64>, Vec<f64>) {
447    let mut values = Vec::new();
448    let mut positions = Vec::new();
449
450    for q in uniform_quantiles(50) {
451        // Always allow near edges
452        if !(0.05..=0.95).contains(&q) {
453            if let Some(v) = hist.value_at_quantile(q) {
454                values.push(v);
455                positions.push(q);
456            }
457            continue;
458        }
459
460        if let Some(v) = hist.value_at_quantile(q) {
461            values.push(v);
462            positions.push(q);
463        }
464    }
465
466    (values, positions)
467}
468
469fn histogram_ticks(hist: &HistogramScale) -> ColorbarTicks {
470    let (major_values, major_positions) = histogram_major_ticks(hist);
471    let (minor_values, minor_positions) = histogram_minor_ticks(hist, &major_positions);
472
473    ColorbarTicks {
474        major_values,
475        major_positions,
476        minor_values,
477        minor_positions,
478    }
479}
480
481fn scale_position(value: f64, min: f64, max: f64, scale: &Scale) -> Option<f64> {
482    match scale {
483        Scale::Linear => Some(((value - min) / (max - min)).clamp(0.0, 1.0)),
484
485        Scale::Log => {
486            if value <= 0.0 || min <= 0.0 {
487                None
488            } else {
489                Some(((value.ln() - min.ln()) / (max.ln() - min.ln())).clamp(0.0, 1.0))
490            }
491        }
492
493        Scale::Asinh { scale } => {
494            let v = (value / scale).asinh();
495            let vmin = (min / scale).asinh();
496            let vmax = (max / scale).asinh();
497            Some(((v - vmin) / (vmax - vmin)).clamp(0.0, 1.0))
498        }
499
500        Scale::Symlog { linthresh } => {
501            let v = value;
502            let sign = v.signum();
503            let abs = v.abs();
504
505            let max_abs = max.abs().max(min.abs());
506            if max_abs <= *linthresh {
507                return Some(0.5);
508            }
509
510            let log_max = (max_abs / linthresh).ln();
511            let linear_width = *linthresh;
512            let total = linear_width + log_max;
513
514            let mapped = if abs <= *linthresh {
515                // Linear core
516                0.5 + 0.5 * (v / total)
517            } else {
518                // Log wings
519                let log_part = (abs / linthresh).ln();
520                0.5 + 0.5 * sign * (linear_width + log_part) / total
521            };
522
523            Some(mapped.clamp(0.0, 1.0))
524        }
525
526        Scale::PlanckLog { linthresh } => {
527            // identical behavior for ticks
528            scale_position(
529                value,
530                min,
531                max,
532                &Scale::Symlog {
533                    linthresh: *linthresh,
534                },
535            )
536        }
537
538        Scale::Histogram => todo!(), // intentionally unsupported here
539    }
540}
541
542fn linear_ticks(min: f64, max: f64) -> ColorbarTicks {
543    // Handle the case where all values are the same
544    if min >= max {
545        return ColorbarTicks {
546            major_values: vec![min],
547            major_positions: vec![0.5],
548            minor_values: vec![],
549            minor_positions: vec![],
550        };
551    }
552
553    let span = max - min;
554    let raw_step = span / 5.0;
555
556    let pow10 = 10f64.powf(raw_step.log10().floor());
557    let step = [1.0, 2.0, 5.0, 10.0]
558        .iter()
559        .map(|m| m * pow10)
560        .find(|s| span / s <= 7.0)
561        .unwrap();
562
563    let start = (min / step).floor() * step;
564
565    let mut major_values = Vec::new();
566    let mut minor_values = Vec::new();
567
568    let mut v = start;
569    while v <= max + 1e-12 {
570        if v >= min {
571            major_values.push(v);
572        }
573
574        let minor_step = step / 5.0;
575        for i in 1..5 {
576            let mv = v + i as f64 * minor_step;
577            if mv > min && mv < max {
578                minor_values.push(mv);
579            }
580        }
581
582        v += step;
583    }
584
585    ColorbarTicks {
586        major_positions: vec![],
587        minor_positions: vec![],
588        major_values,
589        minor_values,
590    }
591}
592
593fn log_ticks(min: f64, max: f64) -> ColorbarTicks {
594    let dmin = min.log10().floor() as i32;
595    let dmax = max.log10().ceil() as i32;
596
597    let mut major_values = Vec::new();
598    let mut minor_values = Vec::new();
599
600    for d in dmin..=dmax {
601        let base = 10f64.powi(d);
602
603        if base >= min && base <= max {
604            major_values.push(base);
605        }
606
607        for m in 2..10 {
608            let v = base * m as f64;
609            if v >= min && v <= max {
610                minor_values.push(v);
611            }
612        }
613    }
614
615    ColorbarTicks {
616        major_positions: vec![],
617        minor_positions: vec![],
618        major_values,
619        minor_values,
620    }
621}
622
623fn asinh_ticks(min: f64, max: f64, scale: f64) -> ColorbarTicks {
624    symlog_ticks(min, max, scale)
625}
626
627fn symlog_ticks(min: f64, max: f64, linthresh: f64) -> ColorbarTicks {
628    let mut major_values = vec![0.0, linthresh, -linthresh];
629    let mut minor_values = Vec::new();
630
631    // linear core
632    let n = 4;
633    let step = linthresh / n as f64;
634
635    for i in (-n + 1)..=(n - 1) {
636        let v = i as f64 * step;
637        if v != 0.0 {
638            minor_values.push(v);
639        }
640    }
641
642    // log wings
643    let log_max = max.abs().log10().ceil() as i32;
644
645    for d in 1..=log_max {
646        let base = linthresh * 10f64.powi(d);
647
648        for &sign in &[-1.0, 1.0] {
649            let v = sign * base;
650            if v >= min && v <= max {
651                major_values.push(v);
652            }
653
654            for m in 2..10 {
655                let mv = sign * base * m as f64;
656                if mv.abs() > linthresh && mv >= min && mv <= max {
657                    minor_values.push(mv);
658                }
659            }
660        }
661    }
662
663    major_values.sort_unstable_by(unsafe_float_cmp);
664    minor_values.sort_unstable_by(unsafe_float_cmp);
665
666    ColorbarTicks {
667        major_positions: vec![],
668        minor_positions: vec![],
669        major_values,
670        minor_values,
671    }
672}
673
674#[inline]
675pub fn scale_value(
676    value: f64,
677    mut min: f64,
678    mut max: f64,
679    scale: Scale,
680    neg_mode: NegMode,
681    hist: Option<&HistogramScale>,
682    cache: Option<&ScaleCache>,
683) -> PixelValue {
684    if min > max {
685        if std::env::var("FUZZ_SILENT").is_err() {
686            eprintln!(
687                "WARNING: scale_value called with min > max ({} > {}), swapping automatically",
688                min, max
689            );
690        }
691        std::mem::swap(&mut min, &mut max);
692    }
693
694    // Handle the case where all valid values are the same
695    if min == max {
696        return if is_seen(value) {
697            PixelValue::Color(0.5) // Map to middle of color scale
698        } else {
699            PixelValue::Bad
700        };
701    }
702
703    // Unseen / NaN handling
704    if !is_seen(value) {
705        return PixelValue::Bad;
706    }
707
708    // Fast path for linear scale (most common case)
709    if matches!(scale, Scale::Linear) {
710        let t = if value <= min {
711            0.0
712        } else if value >= max {
713            1.0
714        } else {
715            (value - min) / (max - min)
716        };
717        return PixelValue::Color(t);
718    }
719
720    let t = match scale {
721        Scale::Linear => unreachable!(),
722
723        Scale::Log => {
724            if value <= 0.0 {
725                // Negative values: apply neg_mode setting
726                return match neg_mode {
727                    NegMode::Zero => PixelValue::Color(0.0),
728                    NegMode::Unseen => PixelValue::Bad,
729                };
730            } else if value < min {
731                // Positive but under-range: always use minimum color
732                return PixelValue::Color(0.0);
733            } else if value >= max {
734                1.0
735            } else {
736                // Use pre-computed log constants if available, avoiding 2× ln() calls per pixel
737                if let Some(c) = cache {
738                    (value.ln() - c.log_min) / c.log_range
739                } else {
740                    (value.ln() - min.ln()) / (max.ln() - min.ln())
741                }
742            }
743        }
744
745        Scale::Asinh { scale } => {
746            let val = (value / scale).asinh();
747            // Use pre-computed asinh constants if available, avoiding 2× asinh() calls per pixel
748            if let Some(c) = cache {
749                (val - c.asinh_min) / c.asinh_range
750            } else {
751                let min_val = (min / scale).asinh();
752                let max_val = (max / scale).asinh();
753                (val - min_val) / (max_val - min_val)
754            }
755        }
756
757        // ✅ Symlog explicitly supports negative values
758        Scale::Symlog { linthresh } => {
759            let abs_val = value.abs();
760            let max_abs = max.abs();
761
762            if abs_val < linthresh {
763                0.5 + 0.5 * (value / linthresh)
764            } else {
765                0.5 + 0.5 * value.signum() * (linthresh + (abs_val - linthresh).ln())
766                    / (linthresh + (max_abs - linthresh).ln())
767            }
768        }
769
770        // ✅ PlanckLog also symmetric
771        Scale::PlanckLog { linthresh } => {
772            if value.abs() < linthresh {
773                0.5 + 0.5 * (value / linthresh)
774            } else {
775                0.5 + 0.5 * value.signum() * (linthresh + (value.abs() - linthresh).ln())
776                    / (linthresh + (max - linthresh).ln())
777            }
778        }
779
780        Scale::Histogram => {
781            let hist = hist.expect("Histogram scale requires histogram");
782
783            if value <= hist.minv {
784                return PixelValue::Color(0.0);
785            }
786            if value >= hist.maxv {
787                return PixelValue::Color(1.0);
788            }
789
790            // Binary search with linear interpolation for smooth CDF lookup
791            match hist
792                .values
793                .binary_search_by(|v| v.partial_cmp(&value).unwrap())
794            {
795                Ok(i) => {
796                    // Exact match: return CDF value directly
797                    hist.cdf[i]
798                }
799                Err(i) => {
800                    // Value falls between hist.values[i-1] and hist.values[i]
801                    // Linear interpolation in CDF space
802                    if i == 0 {
803                        0.0
804                    } else if i >= hist.values.len() {
805                        1.0
806                    } else {
807                        // Interpolate between CDF[i-1] and CDF[i] based on value position
808                        let v0 = hist.values[i - 1];
809                        let v1 = hist.values[i];
810                        let cdf0 = hist.cdf[i - 1];
811                        let cdf1 = hist.cdf[i];
812
813                        // Weight: where does value fall between v0 and v1?
814                        let w = (value - v0) / (v1 - v0);
815                        cdf0 + w * (cdf1 - cdf0)
816                    }
817                }
818            }
819        }
820    };
821
822    PixelValue::Color(t.clamp(0.0, 1.0))
823}
824
825#[test]
826fn linear_underflow_always_saturates() {
827    let t = scale_value(-5.0, 0.0, 10.0, Scale::Linear, NegMode::Unseen, None, None);
828    match t {
829        PixelValue::Color(c) => assert_eq!(c, 0.0),
830        _ => panic!("Linear underflow should saturate, not go Bad"),
831    }
832}