#![cfg_attr(
feature = "pyffi",
doc = "This module reflects a somewhat unsatisfactory compromise between what's
possible in Rust and what's necessary for PyO3. In particular, PyO3 requires
monomorphic enums/structs with monomorphic method implementations and does not
support generic anything. The PyO3 guide justifies this restriction with the
fact that the Rust compiler produces monomorphic code only. While correct, PyO3
complicates matters significantly by not supporting trait methods, its
`#[pymethods]` macro disallowing item-level macros, and its error checking
ignoring `#[cfg]` attributes. The impact is noticeable: Whereas Rust code
might get by with four different implementations of [`SpectralDistribution`]'s
methods, Python integration necessitates another three."
)]
#![cfg_attr(
feature = "pyffi",
doc = " * [`Illuminant`] is an implementation of
`SpectralDistribution<Value=Float>` that wraps a `Box<dyn
SpectralDistribution<Value=Float> + Send + Sync>`."
)]
#![cfg_attr(
feature = "pyffi",
doc = "To play nice with PyO3 and Python, `Observer` and `IlluminatedObserver`
reimplement all but one trait method in their `impl` blocks. `Illuminant`
does the same, but is only defined if the `pyffi` feature is enabled. It
makes two different trait implementations appear as the same type type in
Python and allows instances to be passed back to Rust."
)]
#![cfg_attr(
feature = "pyffi",
doc = "In Python, the constructor accepts `&Illuminant` and `&Observer`
arguments only, which makes it monomorphic. While that is insufficient
for generally overcoming PyO3's prohibition against polymorphic anything,
in this particular case it suffices because spectrum traversal's
constructors do not retain their inputs and instead keep the result of
premultiplying the two spectral distributions. In other words, the rest
of [`SpectrumTraversal`]'s implementation is strictly monomophic itself."
)]
extern crate alloc;
use alloc::sync::Arc;
use core::num::NonZeroUsize;
#[cfg(feature = "pyffi")]
use pyo3::prelude::*;
use crate::{
core::{GamutTraversalStep, ThreeSum},
Color, ColorSpace, Float,
};
pub trait SpectralDistribution {
type Value;
fn label(&self) -> String;
fn start(&self) -> usize;
#[inline]
fn end(&self) -> usize {
self.start() + self.len()
}
#[inline]
fn range(&self) -> core::ops::Range<usize> {
self.start()..self.end()
}
#[inline]
fn is_empty(&self) -> bool {
self.len() == 0
}
fn len(&self) -> usize;
fn at(&self, wavelength: usize) -> &Self::Value;
fn get(&self, wavelength: usize) -> Option<&Self::Value> {
if self.range().contains(&wavelength) {
Some(self.at(wavelength))
} else {
None
}
}
fn checksum(&self) -> Self::Value;
}
#[cfg(feature = "pyffi")]
#[pyclass(frozen, module = "prettypretty.color.trans")]
pub struct Illuminant {
distribution: Box<dyn SpectralDistribution<Value = Float> + Send + Sync>,
}
#[cfg(feature = "pyffi")]
impl Illuminant {
pub fn new(distribution: Box<dyn SpectralDistribution<Value = Float> + Send + Sync>) -> Self {
Self { distribution }
}
}
#[cfg(feature = "pyffi")]
#[pymethods]
impl Illuminant {
pub fn label(&self) -> String {
self.distribution.label()
}
pub fn start(&self) -> usize {
self.distribution.start()
}
pub fn end(&self) -> usize {
self.distribution.end()
}
pub fn is_empty(&self) -> bool {
self.distribution.is_empty()
}
pub fn len(&self) -> usize {
self.distribution.len()
}
pub fn at(&self, wavelength: usize) -> &Float {
self.distribution.at(wavelength)
}
pub fn checksum(&self) -> Float {
self.distribution.checksum()
}
pub fn __len__(&self) -> usize {
self.distribution.len()
}
pub fn __getitem__(&self, index: usize) -> PyResult<Float> {
self.distribution.get(index).copied().ok_or_else(|| {
pyo3::exceptions::PyIndexError::new_err(format!(
"{} <= {} < {} doesn't hold for {}",
self.distribution.start(),
index,
self.distribution.end(),
self.distribution.label()
))
})
}
pub fn __repr__(&self) -> String {
format!("Illuminant({})", self.label())
}
}
#[cfg(feature = "pyffi")]
impl SpectralDistribution for Illuminant {
type Value = Float;
fn label(&self) -> String {
self.distribution.label()
}
fn start(&self) -> usize {
self.distribution.start()
}
fn end(&self) -> usize {
self.distribution.end()
}
fn range(&self) -> core::ops::Range<usize> {
self.distribution.range()
}
fn is_empty(&self) -> bool {
self.distribution.is_empty()
}
fn len(&self) -> usize {
self.distribution.len()
}
fn at(&self, wavelength: usize) -> &Self::Value {
self.distribution.at(wavelength)
}
fn checksum(&self) -> Self::Value {
self.distribution.checksum()
}
}
#[cfg(feature = "pyffi")]
impl core::ops::Index<usize> for Illuminant {
type Output = <Self as SpectralDistribution>::Value;
#[inline]
fn index(&self, index: usize) -> &Self::Output {
self.at(index)
}
}
#[derive(Clone, Debug)]
pub struct TabularDistribution {
label: &'static str,
start: usize,
checksum: Float,
data: &'static [Float],
}
impl TabularDistribution {
pub const fn new(
label: &'static str,
start: usize,
checksum: Float,
data: &'static [Float],
) -> Self {
Self {
label,
checksum,
start,
data,
}
}
}
impl SpectralDistribution for TabularDistribution {
type Value = Float;
fn label(&self) -> String {
self.label.to_string()
}
fn start(&self) -> usize {
self.start
}
fn len(&self) -> usize {
self.data.len()
}
fn at(&self, wavelength: usize) -> &Self::Value {
&self.data[wavelength.saturating_sub(self.start)]
}
fn checksum(&self) -> Self::Value {
self.checksum
}
}
impl core::ops::Index<usize> for TabularDistribution {
type Output = <Self as SpectralDistribution>::Value;
#[inline]
fn index(&self, index: usize) -> &Self::Output {
self.at(index)
}
}
#[derive(Clone, Debug)]
pub struct FixedDistribution {
label: &'static str,
start: usize,
len: usize,
checksum: Float,
value: Float,
}
impl FixedDistribution {
pub const fn new(
label: &'static str,
start: usize,
len: usize,
checksum: Float,
value: Float,
) -> Self {
Self {
label,
start,
len,
checksum,
value,
}
}
}
impl SpectralDistribution for FixedDistribution {
type Value = Float;
fn label(&self) -> String {
self.label.to_string()
}
fn start(&self) -> usize {
self.start
}
fn len(&self) -> usize {
self.len
}
fn at(&self, _wavelength: usize) -> &Self::Value {
&self.value
}
fn checksum(&self) -> Self::Value {
self.checksum
}
}
impl core::ops::Index<usize> for FixedDistribution {
type Output = <Self as SpectralDistribution>::Value;
#[inline]
fn index(&self, index: usize) -> &Self::Output {
self.at(index)
}
}
#[cfg_attr(
feature = "pyffi",
pyclass(frozen, module = "prettypretty.color.spectrum")
)]
#[derive(Clone, Debug)]
pub struct Observer {
label: &'static str,
start: usize,
checksum: [Float; 3],
data: &'static [[Float; 3]],
}
impl Observer {
pub const fn new(
label: &'static str,
start: usize,
checksum: [Float; 3],
data: &'static [[Float; 3]],
) -> Self {
Self {
label,
start,
checksum,
data,
}
}
}
#[cfg_attr(feature = "pyffi", pymethods)]
impl Observer {
#[inline]
pub fn label(&self) -> String {
self.label.to_string()
}
#[inline]
pub fn start(&self) -> usize {
self.start
}
#[inline]
pub fn end(&self) -> usize {
self.start + self.data.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.data.len() == 0
}
#[inline]
pub fn len(&self) -> usize {
self.data.len()
}
#[inline]
pub fn at(&self, wavelength: usize) -> &[Float; 3] {
&self.data[wavelength.saturating_sub(self.start)]
}
#[inline]
pub fn checksum(&self) -> [Float; 3] {
self.checksum
}
#[cfg(feature = "pyffi")]
pub fn __len__(&self) -> usize {
self.data.len()
}
#[cfg(feature = "pyffi")]
pub fn __getitem__(&self, index: usize) -> PyResult<[Float; 3]> {
self.get(index).copied().ok_or_else(|| {
pyo3::exceptions::PyIndexError::new_err(format!(
"{} <= {} < {} doesn't hold for {}",
self.start(),
index,
self.end(),
self.label()
))
})
}
#[cfg(feature = "pyffi")]
pub fn __repr__(&self) -> String {
format!("{:?}", self)
}
}
impl SpectralDistribution for Observer {
type Value = [Float; 3];
fn label(&self) -> String {
self.label.to_string()
}
fn start(&self) -> usize {
self.start
}
fn len(&self) -> usize {
self.data.len()
}
fn at(&self, wavelength: usize) -> &Self::Value {
&self.data[wavelength.saturating_sub(self.start)]
}
fn checksum(&self) -> Self::Value {
self.checksum
}
}
impl core::ops::Index<usize> for Observer {
type Output = <Self as SpectralDistribution>::Value;
#[inline]
fn index(&self, index: usize) -> &Self::Output {
self.at(index)
}
}
#[cfg_attr(
feature = "pyffi",
pyclass(frozen, module = "prettypretty.color.spectrum")
)]
#[derive(Debug, Default)]
pub struct IlluminatedObserver {
label: String,
start: usize,
minimum: [Float; 3],
maximum: [Float; 3],
checksum: [Float; 3],
data: Arc<[[Float; 3]]>,
}
impl IlluminatedObserver {
#[allow(clippy::missing_panics_doc)]
pub fn new<Illuminant, Observer>(illuminant: &Illuminant, observer: &Observer) -> Self
where
Illuminant: SpectralDistribution<Value = Float>,
Observer: SpectralDistribution<Value = [Float; 3]>,
{
let start = illuminant.start().max(observer.start());
let end = illuminant.end().min(observer.end());
let mut data = Arc::<[[Float; 3]]>::new_uninit_slice(end - start);
let values = Arc::get_mut(&mut data).expect("value was just created");
let mut checksum = ThreeSum::new();
let mut minimum = [Float::INFINITY; 3];
let mut maximum = [Float::NEG_INFINITY; 3];
for index in start..end {
let [x, y, z] = *observer.at(index);
let s = *illuminant.at(index) / 100.0;
let value = [s * x, s * y, s * z];
values[index - start].write(value);
checksum += value;
for c in 0..=2 {
minimum[c] = minimum[c].min(value[c]);
maximum[c] = maximum[c].max(value[c]);
}
}
Self {
label: format!("{} / {}", illuminant.label(), observer.label()),
start,
minimum,
maximum,
checksum: checksum.value(),
data: unsafe { data.assume_init() },
}
}
}
#[cfg_attr(feature = "pyffi", pymethods)]
impl IlluminatedObserver {
#[cfg(feature = "pyffi")]
#[new]
pub fn py_new(illuminant: &Illuminant, observer: &Observer) -> Self {
Self::new(illuminant, observer)
}
#[inline]
pub fn label(&self) -> String {
self.label.clone()
}
#[inline]
pub fn start(&self) -> usize {
self.start
}
#[inline]
pub fn end(&self) -> usize {
self.start + self.data.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.data.len() == 0
}
#[inline]
pub fn len(&self) -> usize {
self.data.len()
}
pub fn at(&self, wavelength: usize) -> &[Float; 3] {
&self.data[wavelength.saturating_sub(self.start)]
}
#[inline]
pub fn minimum(&self) -> [Float; 3] {
self.minimum
}
#[inline]
pub fn maximum(&self) -> [Float; 3] {
self.maximum
}
#[inline]
pub fn checksum(&self) -> [Float; 3] {
self.checksum
}
#[inline]
pub fn luminosity(&self) -> Float {
self.checksum[1]
}
pub fn white_point(&self) -> Color {
let [x, y, z] = self.checksum;
#[allow(clippy::eq_op)]
Color::new(ColorSpace::Xyz, [x / y, y / y, z / y])
}
pub fn visual_gamut(&self, stride: NonZeroUsize) -> SpectrumTraversal {
SpectrumTraversal::new(stride, self.luminosity(), self.data.clone())
}
#[cfg(feature = "pyffi")]
pub fn __len__(&self) -> usize {
self.data.len()
}
#[cfg(feature = "pyffi")]
pub fn __getitem__(&self, index: usize) -> PyResult<[Float; 3]> {
self.get(index).copied().ok_or_else(|| {
pyo3::exceptions::PyIndexError::new_err(format!(
"{} <= {} < {} doesn't hold for {}",
self.start(),
index,
self.end(),
self.label()
))
})
}
#[cfg(feature = "pyffi")]
pub fn __repr__(&self) -> String {
format!("{:?}", self)
}
}
impl SpectralDistribution for IlluminatedObserver {
type Value = [Float; 3];
fn label(&self) -> String {
self.label.clone()
}
fn start(&self) -> usize {
self.start
}
fn len(&self) -> usize {
self.data.len()
}
fn at(&self, wavelength: usize) -> &Self::Value {
&self.data[wavelength.saturating_sub(self.start)]
}
fn checksum(&self) -> Self::Value {
self.checksum
}
}
impl core::ops::Index<usize> for IlluminatedObserver {
type Output = <Self as SpectralDistribution>::Value;
#[inline]
fn index(&self, index: usize) -> &Self::Output {
self.at(index)
}
}
pub mod std_observer {
#[cfg(feature = "pyffi")]
use pyo3::prelude::pyfunction;
use crate::Float;
#[cfg_attr(feature = "pyffi", pyfunction)]
pub fn x(wavelength: Float) -> Float {
let p1 = (wavelength - 442.0) * (if wavelength < 442.0 { 0.0624 } else { 0.0374 });
let p2 = (wavelength - 599.8) * (if wavelength < 599.8 { 0.0264 } else { 0.0323 });
let p3 = (wavelength - 501.1) * (if wavelength < 501.1 { 0.0490 } else { 0.0382 });
const C0_362: Float = 0.362;
const C1_056: Float = 1.056;
C0_362.mul_add(
(-0.5 * p1 * p1).exp(),
C1_056.mul_add((-0.5 * p2 * p2).exp(), -0.065 * (-0.5 * p3 * p3).exp()),
)
}
#[cfg_attr(feature = "pyffi", pyfunction)]
pub fn y(wavelength: Float) -> Float {
let p1 = (wavelength - 568.8) * (if wavelength < 568.8 { 0.0213 } else { 0.0247 });
let p2 = (wavelength - 530.9) * (if wavelength < 530.9 { 0.0613 } else { 0.0322 });
const C0_821: Float = 0.821;
C0_821.mul_add((-0.5 * p1 * p1).exp(), 0.286 * (-0.5 * p2 * p2).exp())
}
#[cfg_attr(feature = "pyffi", pyfunction)]
pub fn z(wavelength: Float) -> Float {
let p1 = (wavelength - 437.0) * (if wavelength < 437.0 { 0.0845 } else { 0.0278 });
let p2 = (wavelength - 459.0) * (if wavelength < 459.0 { 0.0385 } else { 0.0725 });
const C1_217: Float = 1.217;
C1_217.mul_add((-0.5 * p1 * p1).exp(), 0.681 * (-0.5 * p2 * p2).exp())
}
}
pub const ONE_NANOMETER: NonZeroUsize = NonZeroUsize::new(1).expect("one is non-zero");
#[cfg_attr(feature = "pyffi", pyclass(module = "prettypretty.color.spectrum"))]
#[derive(Debug)]
pub struct SpectrumTraversal {
data: Arc<[[Float; 3]]>,
luminosity: Float,
stride: usize,
position: usize,
width: usize,
remaining: usize,
}
impl SpectrumTraversal {
fn new(stride: NonZeroUsize, luminosity: Float, data: Arc<[[Float; 3]]>) -> Self {
let stride = stride.get();
let remaining = Self::derive_total_count(data.len(), stride);
Self {
data,
luminosity,
stride,
position: 0,
width: 0,
remaining,
}
}
fn derive_total_count(len: usize, stride: usize) -> usize {
Self::derive_line_count(len, stride) * Self::derive_line_length(len, stride)
}
#[inline]
fn derive_line_count(len: usize, stride: usize) -> usize {
let mut count = (len - 1) / stride;
if (len - 1) % stride > 0 {
count += 1;
}
count
}
#[inline]
fn derive_line_length(len: usize, stride: usize) -> usize {
1 + (len - 1) / stride
}
}
#[cfg_attr(feature = "pyffi", pymethods)]
impl SpectrumTraversal {
#[inline]
pub fn stride(&self) -> usize {
self.stride
}
#[inline]
pub fn line_count(&self) -> usize {
Self::derive_line_count(self.data.len(), self.stride)
}
#[inline]
pub fn line_length(&self) -> usize {
Self::derive_line_length(self.data.len(), self.stride)
}
pub fn pulse(&self, start: usize, width: usize) -> [Float; 3] {
let mut sum = ThreeSum::new();
for index in start..start + width {
let index = index % self.data.len();
sum += self.data[index];
}
sum.value()
}
pub fn pulse_color(&self, start: usize, width: usize) -> Color {
let [x, y, z] = self.pulse(start, width);
Color::new(
ColorSpace::Xyz,
[
x / self.luminosity,
y / self.luminosity,
z / self.luminosity,
],
)
}
#[cfg(feature = "pyffi")]
pub fn __len__(&self) -> usize {
self.len()
}
#[cfg(feature = "pyffi")]
pub fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> {
slf
}
#[cfg(feature = "pyffi")]
pub fn __next__(mut slf: PyRefMut<'_, Self>) -> Option<GamutTraversalStep> {
slf.next()
}
#[cfg(feature = "pyffi")]
pub fn __repr__(&self) -> String {
format!(
"SpectrumTraversal(stride={}, position={}, width={}, remaining={}, samples={})",
self.stride,
self.position,
self.width,
self.remaining,
self.data.len(),
)
}
}
impl Iterator for SpectrumTraversal {
type Item = GamutTraversalStep;
fn next(&mut self) -> Option<Self::Item> {
if self.data.len() <= self.width {
return None;
} else if self.width == 0 {
self.width = 1;
}
self.remaining -= 1;
let color = self.pulse_color(self.position, self.width);
let result = if self.position == 0 {
GamutTraversalStep::MoveTo(color)
} else {
GamutTraversalStep::LineTo(color)
};
self.position += self.stride;
if self.data.len() <= self.position {
self.width += self.stride;
self.position = 0;
}
Some(result)
}
fn size_hint(&self) -> (usize, Option<usize>) {
(self.remaining, Some(self.remaining))
}
}
impl ExactSizeIterator for SpectrumTraversal {
fn len(&self) -> usize {
self.remaining
}
}
impl core::iter::FusedIterator for SpectrumTraversal {}
pub use crate::cie::CIE_ILLUMINANT_D50;
pub use crate::cie::CIE_ILLUMINANT_D65;
pub use crate::cie::CIE_OBSERVER_10DEG_1964;
pub use crate::cie::CIE_OBSERVER_2DEG_1931;
pub static CIE_ILLUMINANT_E: FixedDistribution =
FixedDistribution::new("Illuminant E", 300, 531, 53_100.0, 100.0);
#[cfg(test)]
mod test {
use super::*;
use crate::core::Sum;
use std::num::NonZeroUsize;
#[test]
fn test_checksum() {
for illuminant in [&CIE_ILLUMINANT_D50, &CIE_ILLUMINANT_D65] {
let mut sum = Sum::new();
for wavelength in illuminant.range() {
sum += *illuminant.at(wavelength);
}
assert_eq!(sum.value(), illuminant.checksum());
}
for observer in [&CIE_OBSERVER_2DEG_1931, &CIE_OBSERVER_10DEG_1964] {
let mut x_sum = Sum::new();
let mut y_sum = Sum::new();
let mut z_sum = Sum::new();
for wavelength in observer.range() {
let [x, y, z] = *observer.at(wavelength);
x_sum += x;
y_sum += y;
z_sum += z;
}
assert_eq!(
[x_sum.value(), y_sum.value(), z_sum.value()],
observer.checksum()
);
}
}
#[test]
fn test_spectrum_traversal() {
for (stride, line_count, line_length) in [(9, 53, 53), (10, 47, 48)] {
let total = line_count * line_length;
let mut traversal =
IlluminatedObserver::new(&CIE_ILLUMINANT_D65, &CIE_OBSERVER_2DEG_1931)
.visual_gamut(NonZeroUsize::new(stride).unwrap());
assert_eq!(traversal.line_count(), line_count);
assert_eq!(traversal.line_length(), line_length);
assert_eq!(traversal.len(), total);
for index in 0..(line_count * line_length) {
let step = traversal.next();
assert_eq!(traversal.len(), total - index - 1);
if index % line_length == 0 {
assert!(matches!(step, Some(GamutTraversalStep::MoveTo(_))));
} else {
assert!(matches!(step, Some(GamutTraversalStep::LineTo(_))));
}
}
assert!(matches!(traversal.next(), None));
}
}
}