1use crate::NegMode;
29use crate::PixelValue;
30use crate::colorbar::ColorbarTicks;
31use crate::healpix::is_seen;
32use std::cmp::Ordering;
33
34#[inline]
37pub fn unsafe_float_cmp(a: &f64, b: &f64) -> Ordering {
38 if a < b {
40 Ordering::Less
41 } else if a > b {
42 Ordering::Greater
43 } else {
44 Ordering::Equal
45 }
46}
47
48pub 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#[derive(Clone, Debug)]
83pub struct ScaleCache {
84 pub scale_type: Scale,
86 pub log_min: f64,
88 pub log_range: f64, pub asinh_min: f64,
92 pub asinh_range: f64, }
95
96impl ScaleCache {
97 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 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>, pub cdf: Vec<f64>, 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 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 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 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 for d in dvdt.iter_mut() {
297 *d = (1.0 + *d).ln();
298 }
299
300 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 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 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 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 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 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 0.5 + 0.5 * (v / total)
517 } else {
518 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 scale_position(
529 value,
530 min,
531 max,
532 &Scale::Symlog {
533 linthresh: *linthresh,
534 },
535 )
536 }
537
538 Scale::Histogram => todo!(), }
540}
541
542fn linear_ticks(min: f64, max: f64) -> ColorbarTicks {
543 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 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 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 if min == max {
696 return if is_seen(value) {
697 PixelValue::Color(0.5) } else {
699 PixelValue::Bad
700 };
701 }
702
703 if !is_seen(value) {
705 return PixelValue::Bad;
706 }
707
708 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 return match neg_mode {
727 NegMode::Zero => PixelValue::Color(0.0),
728 NegMode::Unseen => PixelValue::Bad,
729 };
730 } else if value < min {
731 return PixelValue::Color(0.0);
733 } else if value >= max {
734 1.0
735 } else {
736 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 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 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 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 match hist
792 .values
793 .binary_search_by(|v| v.partial_cmp(&value).unwrap())
794 {
795 Ok(i) => {
796 hist.cdf[i]
798 }
799 Err(i) => {
800 if i == 0 {
803 0.0
804 } else if i >= hist.values.len() {
805 1.0
806 } else {
807 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 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}