From e2ddd8911a967907f2bee0d8e189fe3b75ee4cc8 Mon Sep 17 00:00:00 2001 From: Robert Grimm Date: Tue, 3 Sep 2024 03:07:25 -0400 Subject: [PATCH] replace macro-generated Illuminant and Observer with more bespoke handwritten versions; notably, illuminant now forwards to a dyn trait, which enables use with implementations based on lookup table as well as constant value --- prettypretty/color/spectrum/__init__.pyi | 14 +- prettypretty/viz3d.py | 20 +- src/lib.rs | 59 +-- src/spectrum.rs | 445 +++++++++++++++++------ 4 files changed, 392 insertions(+), 146 deletions(-) diff --git a/prettypretty/color/spectrum/__init__.pyi b/prettypretty/color/spectrum/__init__.pyi index f539579..b233472 100644 --- a/prettypretty/color/spectrum/__init__.pyi +++ b/prettypretty/color/spectrum/__init__.pyi @@ -8,11 +8,15 @@ from . import std_observer as std_observer class Illuminant: - """A standard illuminant.""" + """ + A standard illuminant. Note that `at` accepts a nanometer wavelength whereas + `__getitem__` accepts a zero-based index. + """ def label(self) -> str: ... def is_empty(self) -> bool: ... def start(self) -> int: ... def end(self) -> int: ... + def len(self) -> int: ... def at(self, wavelength: int) -> float: ... def __len__(self) -> int: ... def __getitem__(self, index: int) -> float: ... @@ -20,11 +24,16 @@ class Illuminant: class Observer: - """A standard observer, that is, a color matching function.""" + """ + A standard observer, that is, a color matching function. Note that `at` + accepts a nanometer wavelength whereas `__getitem__` accepts a zero-based + index. + """ def label(self) -> str: ... def is_empty(self) -> bool: ... def start(self) -> int: ... def end(self) -> int: ... + def len(self) -> int: ... def at(self, wavelength: int) -> list[float]: ... def __len__(self) -> int: ... def __getitem__(self, index: int) -> list[float]: ... @@ -44,5 +53,6 @@ class SpectrumTraversal: CIE_ILLUMINANT_D65: Illuminant +CIE_ILLUMINANT_E: Illuminant CIE_OBSERVER_2DEG_1931: Observer CIE_OBSERVER_2DEG_2015: Observer diff --git a/prettypretty/viz3d.py b/prettypretty/viz3d.py index 609826c..665dccf 100644 --- a/prettypretty/viz3d.py +++ b/prettypretty/viz3d.py @@ -10,7 +10,8 @@ GamutTraversalStep ) from prettypretty.color.spectrum import ( # pyright: ignore [reportMissingModuleSource] - CIE_ILLUMINANT_D65, CIE_OBSERVER_2DEG_1931, SpectrumTraversal + CIE_ILLUMINANT_D65, CIE_ILLUMINANT_E, CIE_OBSERVER_2DEG_1931, Illuminant, + SpectrumTraversal ) @@ -20,6 +21,11 @@ def create_parser() -> argparse.ArgumentParser: "as well as Oklrab and store the data in 'visual-gamut-xyz.ply' and " "'visual-gamut-ok.ply' files" ) + parser.add_argument( + "--illuminant-e", + action="store_true", + help="use CIE standard illuminant E instead of D65" + ) parser.add_argument( "--gamut", "-g", help="plot boundary of gamut for 'sRGB', 'P3', or 'Rec2020' color space " @@ -445,8 +451,9 @@ def generate( mesh: bool = False, alpha: float = 1.0, sampler: None | Sampler = None, + illuminant: Illuminant = CIE_ILLUMINANT_D65, ) -> None: - traversal = SpectrumTraversal(CIE_ILLUMINANT_D65, CIE_OBSERVER_2DEG_1931) + traversal = SpectrumTraversal(illuminant, CIE_OBSERVER_2DEG_1931) traversal.set_step_sizes(step_size) log(f"Traversing visual gamut in {space} with step size {step_size}:") @@ -560,28 +567,31 @@ def render() -> None: log(f"step size {step_size} is not between 1 and 10.") sampler = Sampler() + illuminant = CIE_ILLUMINANT_E if options.illuminant_e else CIE_ILLUMINANT_D65 generate( ColorSpace.Xyz, + step_size=step_size, gamut=gamut, planar_gamut=options.planar_gamut, mesh=options.mesh, - step_size=step_size, darken=options.darken, - alpha=float(options.alpha) + alpha=float(options.alpha), + illuminant=illuminant, ) log() generate( ColorSpace.Oklrab, + step_size=step_size, gamut=gamut, planar_gamut=options.planar_gamut, mesh=options.mesh, sampler=sampler, - step_size=step_size, darken=options.darken, alpha=float(options.alpha), + illuminant=illuminant, ) log("\nShape of visible gamut (sampled on chroma/hue plane):\n") diff --git a/src/lib.rs b/src/lib.rs index 671119f..99698b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -210,8 +210,8 @@ pub fn color(m: &Bound<'_, PyModule>) -> PyResult<()> { // --------------------------------------------------------------- color.gamut let modgamut = PyModule::new_bound(m.py(), "gamut")?; modgamut.add("__package__", modcolor_name)?; - modgamut.add_class::()?; - modgamut.add_class::()?; + modgamut.add_class::()?; + modgamut.add_class::()?; m.add_submodule(&modgamut)?; // Only change __name__ attribute after submodule has been added. @@ -220,18 +220,21 @@ pub fn color(m: &Bound<'_, PyModule>) -> PyResult<()> { // --------------------------------------------------------------- color.spectrum let modspectrum = PyModule::new_bound(m.py(), "spectrum")?; modspectrum.add("__package__", modcolor_name)?; - modspectrum.add("CIE_ILLUMINANT_D65", crate::spectrum::CIE_ILLUMINANT_D65)?; modspectrum.add( - "CIE_OBSERVER_2DEG_1931", - crate::spectrum::CIE_OBSERVER_2DEG_1931, + "CIE_ILLUMINANT_D65", + spectrum::Illuminant::new(Box::new(spectrum::CIE_ILLUMINANT_D65) + as Box + Send>), )?; modspectrum.add( - "CIE_OBSERVER_2DEG_2015", - crate::spectrum::CIE_OBSERVER_2DEG_2015, + "CIE_ILLUMINANT_E", + spectrum::Illuminant::new(Box::new(spectrum::CIE_ILLUMINANT_E) + as Box + Send>), )?; - modspectrum.add_class::()?; - modspectrum.add_class::()?; - modspectrum.add_class::()?; + modspectrum.add("CIE_OBSERVER_2DEG_1931", spectrum::CIE_OBSERVER_2DEG_1931)?; + modspectrum.add("CIE_OBSERVER_2DEG_2015", spectrum::CIE_OBSERVER_2DEG_2015)?; + modspectrum.add_class::()?; + modspectrum.add_class::()?; + modspectrum.add_class::()?; m.add_submodule(&modspectrum)?; // Only change __name__ attribute after submodule has been added. @@ -248,16 +251,16 @@ pub fn color(m: &Bound<'_, PyModule>) -> PyResult<()> { // --------------------------------------------------------------- color.style let modstyle = PyModule::new_bound(m.py(), "style")?; modstyle.add("__package__", modcolor_name)?; - modstyle.add_class::()?; - modstyle.add_class::()?; - modstyle.add_class::()?; - modstyle.add_class::()?; - modstyle.add_class::()?; - modstyle.add_class::()?; - modstyle.add_function(wrap_pyfunction!(crate::style::stylist, m)?)?; - modstyle.add_class::()?; - modstyle.add_class::()?; - modstyle.add_class::()?; + modstyle.add_class::()?; + modstyle.add_class::()?; + modstyle.add_class::()?; + modstyle.add_class::()?; + modstyle.add_class::()?; + modstyle.add_class::()?; + modstyle.add_function(wrap_pyfunction!(style::stylist, m)?)?; + modstyle.add_class::()?; + modstyle.add_class::()?; + modstyle.add_class::()?; m.add_submodule(&modstyle)?; // Only change __name__ attribute after submodule has been added. @@ -266,10 +269,10 @@ pub fn color(m: &Bound<'_, PyModule>) -> PyResult<()> { // -------------------------------------------------------- color.style.format let modformat = PyModule::new_bound(m.py(), "format")?; modformat.add("__package__", &modstyle_name)?; - modformat.add_class::()?; - modformat.add_class::()?; - modformat.add_class::()?; - modformat.add_class::()?; + modformat.add_class::()?; + modformat.add_class::()?; + modformat.add_class::()?; + modformat.add_class::()?; modstyle.add_submodule(&modformat)?; modformat.setattr("__name__", &modformat_name)?; @@ -277,10 +280,10 @@ pub fn color(m: &Bound<'_, PyModule>) -> PyResult<()> { // --------------------------------------------------------------- color.trans let modtrans = PyModule::new_bound(m.py(), "trans")?; modtrans.add("__package__", modcolor_name)?; - modtrans.add_class::()?; - modtrans.add_class::()?; - modtrans.add_class::()?; - modtrans.add("VGA_COLORS", crate::trans::VGA_COLORS)?; + modtrans.add_class::()?; + modtrans.add_class::()?; + modtrans.add_class::()?; + modtrans.add("VGA_COLORS", trans::VGA_COLORS)?; m.add_submodule(&modtrans)?; // Only change __name__ attribute after submodule has been added. diff --git a/src/spectrum.rs b/src/spectrum.rs index 59b94ca..ea19069 100644 --- a/src/spectrum.rs +++ b/src/spectrum.rs @@ -20,119 +20,337 @@ use crate::{ Color, ColorSpace, Float, }; -// PyO3 doesn't bridge generic types including individual instances. It does, -// however, bridge types generated by macros, which is largely equivalent. - -macro_rules! declare_spectrum_distribution { - ($name:ident<$item_type:ty>, $dist_type:ty) => { - /// A spectral distribution with one-nanometer resolution. - #[cfg_attr(feature = "pyffi", pyclass(module = "prettypretty.color.spectrum"))] - #[derive(Debug)] - pub struct $name { - label: &'static str, - start: usize, - data: &'static [$item_type], +/// A spectral distribution at nanometer resolution. +/// +/// A concrete implementation must provide methods that return a descriptive +/// label, a start wavelength, a length, and the spectral distribution's values. +/// +/// This trait requires implementation of `start()` and `len()` instead of just +/// `range()` because the former two methods allow for more performant default +/// implementations. +pub trait SpectralDistribution { + /// The spectral distribution's value type. + type Value; + + /// Get a descriptive label for this spectral distribution. + fn label(&self) -> String; + + /// Get the starting wavelength for this spectral distribution. + fn start(&self) -> usize; + + /// Get the ending wavelength for this spectral distribution. + fn end(&self) -> usize { + self.start() + self.len() + } + + /// Get the range of this spectral distribution. + fn range(&self) -> std::ops::Range { + self.start()..self.end() + } + + /// Determine whether this distribution is empty. + fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Get the length of this spectral distribution. + fn len(&self) -> usize; + + /// Get this spectral distribution's value for the given wavelength. + /// + /// If the wavelength is within this spectral distribution's range, this + /// method returns some value. Otherwise, it returns none. + fn at(&self, wavelength: usize) -> Option; +} + +// -------------------------------------------------------------------------------------------------------------------- + +/// An illuminant at nanometer resolution. +/// +/// This struct exposes all functionality as methods for Python +/// interoperability. But it doesn't actually implement the functionality of a +/// spectral distribution and instead forwards all method invocations to a `dyn +/// SpectralDistribution`. +#[cfg_attr( + feature = "pyffi", + pyclass(frozen, module = "prettypretty.color.trans") +)] +pub struct Illuminant { + distribution: Box + Send>, +} + +impl Illuminant { + /// Create a new illuminant. + pub fn new(distribution: Box + Send>) -> Self { + Self { distribution } + } +} + +#[cfg_attr(feature = "pyffi", pymethods)] +impl Illuminant { + /// Get a descriptive label for this spectral distribution. + pub fn label(&self) -> String { + self.distribution.label() + } + + /// Get this spectral distribution's starting wavelength. + pub fn start(&self) -> usize { + self.distribution.start() + } + + /// Get this spectral distribution's ending wavelength. + pub fn end(&self) -> usize { + self.distribution.end() + } + + /// Determine whether this distribution is empty. + pub fn is_empty(&self) -> bool { + self.distribution.is_empty() + } + + /// Determine the number of entries in this distribution. + pub fn len(&self) -> usize { + self.distribution.len() + } + + /// Get this spectral distribution's value for the given wavelength. + pub fn at(&self, wavelength: usize) -> Option { + self.distribution.at(wavelength) + } + + /// Get the number of entries. Python only! + #[cfg(feature = "pyffi")] + pub fn __len__(&self) -> usize { + self.distribution.len() + } + + /// Get the entry at the given index. Python + /// only! + #[cfg(feature = "pyffi")] + pub fn __getitem__(&self, index: usize) -> PyResult { + self.distribution + .at(self.distribution.start() + index) + .ok_or_else(|| { + pyo3::exceptions::PyIndexError::new_err(format!( + "{} <= index for {}", + self.distribution.len(), + self.distribution.label() + )) + }) + } + + /// Get a debug representation. Python only! + #[cfg(feature = "pyffi")] + pub fn __repr__(&self) -> String { + format!("Illuminant({})", self.label()) + } +} + +impl SpectralDistribution for Illuminant { + type Value = Float; + + fn label(&self) -> String { + self.distribution.label() + } + + fn start(&self) -> usize { + self.distribution.start() + } + + fn len(&self) -> usize { + self.distribution.len() + } + + fn at(&self, wavelength: usize) -> Option { + self.distribution.at(wavelength) + } +} + +// -------------------------------------------------------------------------------------------------------------------- + +/// A table-driven spectral distribution. +#[derive(Debug)] +pub struct TabularDistribution { + label: &'static str, + start: usize, + data: &'static [Float], +} + +impl TabularDistribution { + /// Create a new tabular distribution. + pub const fn new(label: &'static str, start: usize, data: &'static [Float]) -> Self { + Self { label, 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) -> Option { + if self.start <= wavelength && wavelength < self.start + self.data.len() { + Some(self.data[wavelength - self.start]) + } else { + None } + } +} - #[cfg_attr(feature = "pyffi", pymethods)] - impl $name { - /// Get the label. - pub fn label(&self) -> &'static str { - self.label - } - - /// Determine whether this spectral distribution is empty. - pub fn is_empty(&self) -> bool { - self.data.len() == 0 - } - - /// Get the smallest wavelength in nanometers of this spectral - /// distribution. - pub fn start(&self) -> usize { - self.start - } - - /// Get the one after the largest wavelength in nanometers of this - /// spectral distribution. - pub fn end(&self) -> usize { - self.start + self.data.len() - } - - /// Get the number of entries in this spectral distribution. - pub fn len(&self) -> usize { - self.data.len() - } - - /// Determine the wavelength overlap between the two distributions. - pub fn overlap(&self, other: &$dist_type) -> (usize, usize) { - let start = self.start().max(other.start()); - let end = self.end().min(other.end()); - (start, end) - } - - /// Look up the value of this spectral distribution for the given - /// wavelength. - /// - /// This method returns `None` if the wavelength is out of range for - /// this spectral distribution. When combining data from more than - /// one spectral distribution, use of this method ensures that all - /// distributions actually have values at given wavelengths. - pub fn at(&self, wavelength: usize) -> Option<$item_type> { - if self.start <= wavelength && wavelength < self.start + self.data.len() { - Some(self.data[wavelength - self.start]) - } else { - None - } - } - - /// Get the number of entries. Python only! - #[cfg(feature = "pyffi")] - pub fn __len__(&self) -> usize { - self.data.len() - } - - /// Get the entry at the given index. Python - /// only! - #[cfg(feature = "pyffi")] - pub fn __getitem__(&self, index: usize) -> PyResult<$item_type> { - if index < self.data.len() { - Ok(self.data[index]) - } else { - Err(pyo3::exceptions::PyIndexError::new_err(format!( - "len {} <= index {} into {}", - self.data.len(), - index, - self.label, - ))) - } - } - - /// Get a debug representation. Python - /// only! - #[cfg(feature = "pyffi")] - pub fn __repr__(&self) -> String { - format!( - "{}(\"{}\", {}:{})", - stringify!($name), - self.label, - self.start, - self.end() - ) - } +// -------------------------------------------------------------------------------------------------------------------- + +/// A spectral distribution with a fixed value. +pub struct FixedDistribution { + label: &'static str, + start: usize, + len: usize, + value: Float, +} + +impl FixedDistribution { + /// Create a new spectral distribution with a fixed value. + pub const fn new(label: &'static str, start: usize, len: usize, value: Float) -> Self { + Self { + label, + start, + len, + value, } + } +} + +impl SpectralDistribution for FixedDistribution { + type Value = Float; + + fn label(&self) -> String { + self.label.to_string() + } - impl std::ops::Index for $name { - type Output = $item_type; + fn start(&self) -> usize { + self.start + } - fn index(&self, index: usize) -> &Self::Output { - &self.data[index] - } + fn len(&self) -> usize { + self.len + } + + fn at(&self, wavelength: usize) -> Option { + if self.start <= wavelength && wavelength < self.start + self.len { + Some(self.value) + } else { + None } - }; + } +} + +// -------------------------------------------------------------------------------------------------------------------- + +/// A standard observer at nanometer resolution. +#[cfg_attr( + feature = "pyffi", + pyclass(frozen, module = "prettypretty.color.spectrum") +)] +#[derive(Debug)] +pub struct Observer { + label: &'static str, + start: usize, + data: &'static [[Float; 3]], } -declare_spectrum_distribution!(Observer<[Float; 3]>, Illuminant); -declare_spectrum_distribution!(Illuminant, Observer); +#[cfg_attr(feature = "pyffi", pymethods)] +impl Observer { + /// Get a descriptive label for this observer. + pub fn label(&self) -> String { + self.label.to_string() + } + + /// Get this observer's starting wavelength. + pub fn start(&self) -> usize { + self.start + } + + /// Get this observer's ending wavelength. + pub fn end(&self) -> usize { + self.start + self.data.len() + } + + /// Determine whether this observer is empty. + pub fn is_empty(&self) -> bool { + self.data.len() == 0 + } + + /// Determine the number of entries for this observer. + pub fn len(&self) -> usize { + self.data.len() + } + + /// Get this observer's value for the given wavelength. + pub fn at(&self, wavelength: usize) -> Option<[Float; 3]> { + if self.start <= wavelength && wavelength < self.start + self.data.len() { + Some(self.data[wavelength - self.start]) + } else { + None + } + } + /// Get the number of entries. Python only! + #[cfg(feature = "pyffi")] + pub fn __len__(&self) -> usize { + self.data.len() + } + + /// Get the entry at the given index. Python + /// only! + #[cfg(feature = "pyffi")] + pub fn __getitem__(&self, index: usize) -> PyResult<[Float; 3]> { + self.at(self.start + index).ok_or_else(|| { + pyo3::exceptions::PyIndexError::new_err(format!( + "{} <= index for {}", + self.data.len(), + self.label + )) + }) + } + + /// Get a debug representation. Python only! + #[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) -> Option { + if self.start <= wavelength && wavelength < self.start + self.data.len() { + Some(self.data[wavelength - self.start]) + } else { + None + } + } +} // -------------------------------------------------------------------------------------------------------------------- @@ -195,7 +413,8 @@ struct SpectrumTraversalData { impl SpectrumTraversalData { /// Create the spectrum traversal data for the observer and illuminant. fn new(illuminant: &Illuminant, observer: &Observer) -> Self { - let (start, end) = illuminant.overlap(observer); + let start = illuminant.start().max(observer.start()); + let end = illuminant.end().min(observer.end()); let mut premultiplied: Vec<[Float; 3]> = Vec::with_capacity(end - start); for index in start..end { @@ -1349,6 +1568,10 @@ pub const CIE_OBSERVER_2DEG_2015: Observer = Observer { ], }; +/// The CIE E standard illuminant at one-nanometer resolution. +pub const CIE_ILLUMINANT_E: FixedDistribution = + FixedDistribution::new("Illuminant E", 300, 530, 100.0); + // D65: 6504K, 2ยบ: x = 0.31272, y = 0.32903; X = 95.047, Y = 100, Z = 108.883 /// The CIE D65 standard illuminant at one-nanometer resolution. @@ -1356,10 +1579,10 @@ pub const CIE_OBSERVER_2DEG_2015: Observer = Observer { /// D65 represents daylight around noon. The data for this spectral power /// distribution was sourced from the /// [CIE](https://cie.co.at/datatable/cie-standard-illuminant-d65). -pub const CIE_ILLUMINANT_D65: Illuminant = Illuminant { - label: "Illuminant D65", - start: 300, // ..=830 - data: &[ +pub const CIE_ILLUMINANT_D65: TabularDistribution = TabularDistribution::new( + "Illuminant D65", + 300, //..=830 + &[ 0.0341, 0.36014, 0.68618, 1.01222, 1.33826, 1.6643, 1.99034, 2.31638, 2.64242, 2.96846, 3.2945, 4.98865, 6.6828, 8.37695, 10.0711, 11.7652, 13.4594, 15.1535, 16.8477, 18.5418, 20.236, 21.9177, 23.5995, 25.2812, 26.963, 28.6447, 30.3265, 32.0082, 33.69, 35.3717, @@ -1414,4 +1637,4 @@ pub const CIE_ILLUMINANT_D65: Illuminant = Illuminant { 52.5072, 53.0553, 53.6035, 54.1516, 54.6998, 55.248, 55.7961, 56.3443, 56.8924, 57.4406, 57.7278, 58.015, 58.3022, 58.5894, 58.8765, 59.1637, 59.4509, 59.7381, 60.0253, 60.3125, ], -}; +);