Skip to main content

linear_srgb/
lib.rs

1//! Fast linear↔sRGB color space conversion.
2//!
3//! This crate provides efficient conversion between linear light values and
4//! sRGB gamma-encoded values following the IEC 61966-2-1:1999 standard.
5//!
6//! # Module Organization
7//!
8//! - [`default`] - **Recommended API** with optimal implementations for each use case
9//! - [`simd`] - SIMD-accelerated functions with full control over dispatch
10//! - [`scalar`] - Single-value conversion functions (f32/f64)
11//! - [`lut`] - Lookup table types for custom bit depths
12//!
13//! # Quick Start
14//!
15//! ```rust
16//! use linear_srgb::default::{srgb_to_linear, linear_to_srgb};
17//!
18//! // Convert sRGB 0.5 to linear
19//! let linear = srgb_to_linear(0.5);
20//! assert!((linear - 0.214).abs() < 0.001);
21//!
22//! // Convert back to sRGB
23//! let srgb = linear_to_srgb(linear);
24//! assert!((srgb - 0.5).abs() < 0.001);
25//! ```
26//!
27//! # Batch Processing (SIMD)
28//!
29//! For maximum throughput on slices:
30//!
31//! ```rust
32//! use linear_srgb::default::{srgb_to_linear_slice, linear_to_srgb_slice};
33//!
34//! let mut values = vec![0.5f32; 10000];
35//! srgb_to_linear_slice(&mut values);  // SIMD-accelerated
36//! linear_to_srgb_slice(&mut values);
37//! ```
38//!
39//! # Custom Gamma
40//!
41//! For non-sRGB gamma (pure power function without linear segment):
42//!
43//! ```rust
44//! use linear_srgb::default::{gamma_to_linear, linear_to_gamma};
45//!
46//! let linear = gamma_to_linear(0.5, 2.2);  // gamma 2.2
47//! let encoded = linear_to_gamma(linear, 2.2);
48//! ```
49//!
50//! # LUT-based Conversion
51//!
52//! For batch processing with pre-computed lookup tables:
53//!
54//! ```rust
55//! use linear_srgb::default::SrgbConverter;
56//!
57//! let conv = SrgbConverter::new();  // Zero-cost, const tables
58//!
59//! // Fast 8-bit conversions
60//! let linear = conv.srgb_u8_to_linear(128);
61//! let srgb = conv.linear_to_srgb_u8(linear);
62//! ```
63//!
64//! # Choosing the Right API
65//!
66//! | Use Case | Recommended Function |
67//! |----------|---------------------|
68//! | Single f32 value | [`default::srgb_to_linear`] |
69//! | Single u8 value | [`default::srgb_u8_to_linear`] |
70//! | f32 slice (in-place) | [`default::srgb_to_linear_slice`] |
71//! | u8 slice → f32 slice | [`default::srgb_u8_to_linear_slice`] |
72//! | Manual SIMD (8 values) | [`default::srgb_to_linear_x8`] |
73//! | Inside `#[magetypes]` | [`default::inline::srgb_to_linear_x8`] |
74//! | Inside `#[arcane]` (token) | [`rites::x8::srgb_to_linear_v3`] |
75//! | Custom bit depth LUT | [`lut::LinearTable16`] |
76//!
77//! # Clamping and Extended Range
78//!
79//! The f32↔f32 conversion functions come in two flavors: **clamped** (default)
80//! and **extended** (unclamped). Integer paths (u8, u16) always clamp since
81//! out-of-range values can't be represented in the output format.
82//!
83//! ## Clamped (default) — use for same-gamut pipelines
84//!
85//! All functions except the `_extended` variants clamp inputs to \[0, 1\]:
86//! negatives become 0, values above 1 become 1.
87//!
88//! This is correct whenever the source and destination share the same color
89//! space (gamut + transfer function). The typical pipeline:
90//!
91//! 1. Decode sRGB image (u8 → linear f32 via LUT, or f32 via TRC)
92//! 2. Process in linear light (resize, blur, blend, composite)
93//! 3. Re-encode to sRGB (linear f32 → sRGB f32 or u8)
94//!
95//! In this pipeline, out-of-range values only come from processing artifacts:
96//! resize filters with negative lobes (Lanczos, Mitchell, etc.) produce small
97//! negatives near dark edges and values slightly above 1.0 near bright edges.
98//! These are ringing artifacts, not real colors — clamping is correct.
99//!
100//! Float decoders like jpegli can also produce small out-of-range values from
101//! YCbCr quantization noise. When the image is sRGB, these are compression
102//! artifacts and clamping is correct — gives the same result as decoding to
103//! u8 first.
104//!
105//! ## Extended (unclamped) — use for cross-gamut pipelines
106//!
107//! [`scalar::srgb_to_linear_extended`] and [`scalar::linear_to_srgb_extended`]
108//! do not clamp. They follow the mathematical sRGB transfer function for all
109//! inputs: negatives pass through the linear segment, values above 1.0 pass
110//! through the power segment.
111//!
112//! Use these when the sRGB transfer function is applied to values from a
113//! **different, wider gamut**. A 3×3 matrix converting Rec. 2020 linear or
114//! Display P3 linear to sRGB linear can produce values well outside \[0, 1\]:
115//! a saturated Rec. 2020 green maps to deeply negative sRGB red and blue.
116//! These are real out-of-gamut colors, not artifacts — clamping destroys
117//! information that downstream gamut mapping or compositing may need.
118//!
119//! This matters in practice: JPEG and JPEG XL images can carry Rec. 2020 or
120//! Display P3 ICC profiles. Phones shoot Rec. 2020 HLG, cameras embed
121//! wide-gamut profiles. Decoding such an image and converting to sRGB for
122//! display produces out-of-gamut values that should survive until final
123//! output.
124//!
125//! If a float decoder (jpegli, libjxl) outputs wide-gamut data directly to
126//! f32, the output contains both small compression artifacts and real
127//! out-of-gamut values. The artifacts are tiny; the gamut excursions
128//! dominate. Using `_extended` preserves both — the artifacts are harmless
129//! noise that vanishes at quantization.
130//!
131//! The `_extended` variants also cover **scRGB** (float sRGB with values
132//! outside \[0, 1\] for HDR and wide color) and any pipeline where
133//! intermediate f32 values are not yet at the final output stage.
134//!
135//! ## Summary
136//!
137//! | Function | Range | Pipeline |
138//! |----------|-------|----------|
139//! | All `simd::*`, `mage::*`, `rites::*`, `lut::*` | \[0, 1\] | Same-gamut batch processing |
140//! | [`scalar::srgb_to_linear`] | \[0, 1\] | Same-gamut single values |
141//! | [`scalar::linear_to_srgb`] | \[0, 1\] | Same-gamut single values |
142//! | [`scalar::srgb_to_linear_extended`] | Unbounded | Cross-gamut, scRGB, HDR |
143//! | [`scalar::linear_to_srgb_extended`] | Unbounded | Cross-gamut, scRGB, HDR |
144//! | All u8/u16 paths | \[0, 1\] | Final quantization (clamp inherent) |
145//!
146//! **No SIMD extended-range variants exist yet.** The fast polynomial
147//! approximation is fitted to \[0, 1\] and produces garbage outside that
148//! domain. Extended-range SIMD would use `pow` instead of the polynomial
149//! (~3× slower, still faster than scalar for `linear_to_srgb`). For batch
150//! extended-range conversion today, loop over the scalar `_extended`
151//! functions.
152//!
153//! # Feature Flags
154//!
155//! - `std` (default): Enable std library support
156//! - `unsafe_simd`: Enable unsafe optimizations for maximum performance
157//!
158//! # `no_std` Support
159//!
160//! This crate is `no_std` compatible. Disable the `std` feature:
161//!
162//! ```toml
163//! linear-srgb = { version = "0.2", default-features = false }
164//! ```
165
166#![cfg_attr(not(feature = "std"), no_std)]
167#![cfg_attr(not(feature = "unsafe_simd"), deny(unsafe_code))]
168#![warn(missing_docs)]
169
170#[cfg(not(feature = "std"))]
171extern crate alloc;
172
173#[cfg(all(test, not(feature = "std")))]
174extern crate std;
175
176// ============================================================================
177// Public modules
178// ============================================================================
179
180/// Recommended API with optimal implementations for each use case.
181///
182/// See module documentation for details.
183pub mod default;
184
185/// Lookup table types for sRGB conversion.
186///
187/// Provides both build-time const tables ([`SrgbConverter`](lut::SrgbConverter))
188/// and runtime-generated tables for custom bit depths.
189pub mod lut;
190
191/// SIMD-accelerated conversion functions.
192///
193/// Provides full control over CPU dispatch with `_dispatch` and `_inline` variants.
194pub mod simd;
195
196/// Scalar (single-value) conversion functions.
197///
198/// Direct computation without SIMD. Best for individual value conversions.
199pub mod scalar;
200
201/// Inlineable `#[rite]` functions for embedding in your own `#[arcane]` code.
202///
203/// These carry `#[target_feature]` + `#[inline]` directly — no wrapper, no
204/// dispatch. When called from a matching `#[arcane]` context, LLVM inlines
205/// them fully. Organized by SIMD unit width; suffixed by required token tier.
206///
207/// Requires the `rites` feature.
208#[cfg(feature = "rites")]
209pub mod rites;
210
211/// Token-based API using archmage for zero dispatch overhead.
212///
213/// This module provides an alternative API using archmage tokens for users who
214/// want to avoid per-call dispatch overhead. Obtain a token once at startup,
215/// then pass it to all conversion functions.
216///
217/// Requires the `mage` feature.
218#[cfg(feature = "mage")]
219pub mod mage;
220
221// ============================================================================
222// Internal modules
223// ============================================================================
224
225mod mlaf;
226
227// Internal fast math for SIMD (not public API)
228pub(crate) mod fast_math;
229
230// Pre-computed const lookup tables (embedded in binary)
231mod const_luts;
232mod const_luts_u16;
233
234// Alternative/experimental implementations (for benchmarking)
235#[cfg(feature = "alt")]
236pub mod alt;
237
238// ============================================================================
239// Tests
240// ============================================================================
241
242#[cfg(test)]
243mod tests {
244    use crate::default::*;
245
246    #[cfg(not(feature = "std"))]
247    use alloc::vec::Vec;
248
249    #[test]
250    fn test_api_consistency() {
251        // Ensure direct and LUT-based conversions are consistent
252        let conv = SrgbConverter::new();
253
254        for i in 0..=255u8 {
255            let direct = srgb_u8_to_linear(i);
256            let lut = conv.srgb_u8_to_linear(i);
257            assert!(
258                (direct - lut).abs() < 1e-5,
259                "Mismatch at {}: direct={}, lut={}",
260                i,
261                direct,
262                lut
263            );
264        }
265    }
266
267    #[test]
268    fn test_slice_conversion() {
269        let mut values: Vec<f32> = (0..=10).map(|i| i as f32 / 10.0).collect();
270        let original = values.clone();
271
272        srgb_to_linear_slice(&mut values);
273        linear_to_srgb_slice(&mut values);
274
275        for (i, (orig, conv)) in original.iter().zip(values.iter()).enumerate() {
276            assert!(
277                (orig - conv).abs() < 1e-5,
278                "Slice roundtrip failed at {}: {} -> {}",
279                i,
280                orig,
281                conv
282            );
283        }
284    }
285}