//! Perfiles ICC: en qué espacio de color viene una imagen, y cómo llevarla al //! de trabajo (sRGB). //! //! ## El agujero que esto tapa //! //! Hasta 2026-08-05 el decodificador tiraba el perfil ICC del archivo y //! entregaba los bytes RGB crudos. Para una imagen sRGB eso está bien; para //! cualquier otra es **mostrar los números en el espacio equivocado**. Una foto //! Display P3 —lo que saca cualquier teléfono desde 2016— se ve **sobresaturada** //! porque sus rojos, que son más rojos que los de sRGB, se pintan como si fueran //! los de sRGB. Un escaneo con perfil de imprenta se ve lavado. Y no es un //! detalle estético: `tullpu` dice tener paridad Photoshop, y editar color sin //! saber en qué espacio estás no es paridad de nada. //! //! ## Qué cubre y qué no //! //! Los perfiles **matrix-shaper** (matriz de 3×3 + una curva por canal), que es //! lo que son sRGB, Display P3, Adobe RGB, Rec.2020 y la enorme mayoría de los //! perfiles de pantalla y de cámara. **No** cubre los perfiles basados en LUT //! (`A2B0`), que es como vienen los de imprenta CMYK: ahí devuelve `None` y la //! imagen queda como estaba — sin conversión es mejor que con una inventada. //! //! La conversión es **colorimétrica relativa** y recorta a gamut sRGB por //! saturación de canal. Un rojo P3 puro no existe en sRGB: se lo lleva al rojo //! sRGB más cercano. Eso es un recorte, no un error — pero conviene saberlo, y //! por eso está escrito acá y no oculto. /// Curva de transferencia de un canal (el «shaper» del matrix-shaper). #[derive(Debug, Clone, PartialEq)] pub enum Curva { /// Identidad — el perfil declara los valores ya lineales. Lineal, /// Una gamma sola: `lineal = v^γ`. Gamma(f32), /// Tabla de muestras equiespaciadas en 0..=1, con interpolación lineal /// entre ellas. Es como vienen casi todos los perfiles reales. Tabla(Vec), /// Curva paramétrica (`para`): los cinco tipos de la especificación, con /// sus parámetros `g, a, b, c, d, e, f`. Es la forma en que vienen la curva /// de sRGB y la de L*, y **no** se puede aproximar por su exponente: con /// ECI-RGBv2 (que usa L*) esa aproximación erraba un gris medio por unos /// 20 valores de 8 bits. Parametrica { tipo: u16, p: [f32; 7] }, } impl Curva { /// Lleva un valor codificado (0..=1) a lineal (0..=1). pub fn a_lineal(&self, v: f32) -> f32 { match self { Curva::Lineal => v, Curva::Gamma(g) => v.max(0.0).powf(*g), Curva::Tabla(t) if t.len() >= 2 => { let v = v.clamp(0.0, 1.0); let pos = v * (t.len() - 1) as f32; let i = pos.floor() as usize; let f = pos - i as f32; let a = t[i] as f32 / 65535.0; let b = t[(i + 1).min(t.len() - 1)] as f32 / 65535.0; a + (b - a) * f } // Una tabla de 0 ó 1 muestras no describe nada; identidad. Curva::Tabla(_) => v, // Los cinco tipos de la especificación, tal cual. El 3 es la // familia de sRGB y de L*: un tramo recto abajo y una potencia // arriba, que es justo lo que se pierde al mirar sólo `g`. Curva::Parametrica { tipo, p } => { let [g, a, b, c, d, e, f] = *p; let pot = |x: f32| (a * x + b).max(0.0).powf(g); match tipo { 0 => v.max(0.0).powf(g), 1 => { if a != 0.0 && v >= -b / a { pot(v) } else { 0.0 } } 2 => { if a != 0.0 && v >= -b / a { pot(v) + c } else { c } } 3 => { if v >= d { pot(v) } else { c * v } } 4 => { if v >= d { pot(v) + e } else { c * v + f } } // Un tipo que la especificación no define todavía: mejor // dejar el valor como está que inventar una curva. _ => v, } } } } /// Inversa de [`Curva::a_lineal`]: de un valor **lineal** (0..=1) al valor /// **codificado** que este perfil espera. /// /// Hace falta para *escribir* una imagen en el espacio del perfil, no sólo /// para leerla: sin esto, exponer el perfil de origen no serviría de nada /// porque no habría con qué volver a él. pub fn desde_lineal(&self, lin: f32) -> f32 { let lin = lin.clamp(0.0, 1.0); let salida = match self { Curva::Lineal => lin, Curva::Gamma(g) if *g > 0.0 => lin.powf(1.0 / *g), // Una gamma de 0 no describe una curva invertible; identidad. Curva::Gamma(_) => lin, Curva::Tabla(t) if t.len() >= 2 => { let objetivo = lin * 65535.0; // Las TRC reales son monótonas no decrecientes, así que se puede // buscar el tramo por bisección. Si alguna no lo fuera, esto // devuelve *un* valor válido, no basura. let i = t.partition_point(|m| (*m as f32) < objetivo); if i == 0 { 0.0 } else if i >= t.len() { 1.0 } else { let (a, b) = (t[i - 1] as f32, t[i] as f32); // Tramo plano: cualquier punto de él mapea al mismo lineal. let f = if b > a { (objetivo - a) / (b - a) } else { 0.0 }; (i as f32 - 1.0 + f) / (t.len() - 1) as f32 } } Curva::Tabla(_) => lin, Curva::Parametrica { tipo, p } => { let [g, a, b, c, d, e, f] = *p; // Despeje de `(a·x + b)^g = y`. let raiz = |y: f32| -> f32 { if g == 0.0 || a == 0.0 { 0.0 } else { (y.max(0.0).powf(1.0 / g) - b) / a } }; let pot = |x: f32| (a * x + b).max(0.0).powf(g); match tipo { 0 => { if g > 0.0 { lin.powf(1.0 / g) } else { lin } } 1 => raiz(lin), 2 => raiz(lin - c), // El corte va sobre el valor lineal que le toca a `d`, no // sobre `d` mismo: acá se entra por el otro lado de la curva. 3 => { if lin >= pot(d) { raiz(lin) } else if c != 0.0 { lin / c } else { 0.0 } } 4 => { if lin >= pot(d) + e { raiz(lin - e) } else if c != 0.0 { (lin - f) / c } else { 0.0 } } _ => lin, } } }; salida.clamp(0.0, 1.0) } } /// Un perfil matrix-shaper leído de un ICC. #[derive(Debug, Clone, PartialEq)] pub struct Perfil { /// Matriz RGB-del-perfil → XYZ del PCS, **por filas**. El PCS de un ICC de /// display siempre es XYZ adaptado a D50: eso lo fija la especificación, no /// una elección nuestra. pub a_xyz_d50: [f32; 9], pub curva: Curva, } // ── Constantes de colorimetría ─────────────────────────────────────────── // // Las tres son valores publicados, no derivados acá. El test // `un_perfil_srgb_no_cambia_nada` las cruza entre sí: si cualquiera estuviera // mal, la matriz de sRGB no daría la identidad. /// XYZ (D65) → sRGB lineal, por filas. const XYZ_D65_A_SRGB: [f32; 9] = [ 3.2404542, -1.5371385, -0.4985314, // -0.9692660, 1.8760108, 0.0415560, // 0.0556434, -0.2040259, 1.0572252, ]; /// Adaptación cromática Bradford D50 → D65, por filas. const BRADFORD_D50_A_D65: [f32; 9] = [ 0.9555766, -0.0230393, 0.0631636, // -0.0282895, 1.0099416, 0.0210077, // 0.0122982, -0.0204830, 1.3299098, ]; /// RGB sRGB → XYZ D50, por filas. Es la matriz que **trae adentro** cualquier /// perfil sRGB; sirve de patrón para el test de identidad. pub const SRGB_A_XYZ_D50: [f32; 9] = [ 0.4360747, 0.3850649, 0.1430804, // 0.2225045, 0.7168786, 0.0606169, // 0.0139322, 0.0971045, 0.7141733, ]; fn multiplicar(a: &[f32; 9], b: &[f32; 9]) -> [f32; 9] { let mut m = [0.0f32; 9]; for f in 0..3 { for c in 0..3 { m[f * 3 + c] = (0..3).map(|k| a[f * 3 + k] * b[k * 3 + c]).sum(); } } m } /// Matriz que lleva el RGB **lineal** del perfil al RGB **lineal** de sRGB. pub fn matriz_a_srgb(a_xyz_d50: &[f32; 9]) -> [f32; 9] { multiplicar(&XYZ_D65_A_SRGB, &multiplicar(&BRADFORD_D50_A_D65, a_xyz_d50)) } /// Inversa de una 3×3. `None` si es singular — no pasa con primarios de verdad /// (tres colores independientes), pero invertir a ciegas daría infinitos. fn invertir(m: &[f32; 9]) -> Option<[f32; 9]> { let det = m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6]) + m[2] * (m[3] * m[7] - m[4] * m[6]); if det.abs() < 1e-9 { return None; } let k = 1.0 / det; Some([ (m[4] * m[8] - m[5] * m[7]) * k, (m[2] * m[7] - m[1] * m[8]) * k, (m[1] * m[5] - m[2] * m[4]) * k, (m[5] * m[6] - m[3] * m[8]) * k, (m[0] * m[8] - m[2] * m[6]) * k, (m[2] * m[3] - m[0] * m[5]) * k, (m[3] * m[7] - m[4] * m[6]) * k, (m[1] * m[6] - m[0] * m[7]) * k, (m[0] * m[4] - m[1] * m[3]) * k, ]) } /// Matriz que lleva el RGB **lineal** de sRGB al RGB **lineal** del perfil: la /// inversa de [`matriz_a_srgb`]. Es el camino de **salida**, el que hace falta /// para re-exportar al espacio del que vino la imagen. pub fn matriz_desde_srgb(a_xyz_d50: &[f32; 9]) -> Option<[f32; 9]> { invertir(&matriz_a_srgb(a_xyz_d50)) } /// ¿Esta matriz es la identidad dentro de `tol`? Se usa para saltear la /// conversión cuando el perfil ya es sRGB: recorrer ocho millones de píxeles /// para multiplicarlos por 1 es la clase de trabajo que no se paga. pub fn es_identidad(m: &[f32; 9], tol: f32) -> bool { for f in 0..3 { for c in 0..3 { let esperado = if f == c { 1.0 } else { 0.0 }; if (m[f * 3 + c] - esperado).abs() > tol { return false; } } } true } // ── Lectura del ICC ────────────────────────────────────────────────────── fn u32_be(b: &[u8], o: usize) -> Option { Some(u32::from_be_bytes(b.get(o..o + 4)?.try_into().ok()?)) } /// s15Fixed16: entero con signo de 16 bits + 16 bits de fracción. fn s15f16(b: &[u8], o: usize) -> Option { Some(i32::from_be_bytes(b.get(o..o + 4)?.try_into().ok()?) as f32 / 65536.0) } /// Busca un tag por su firma de 4 letras y devuelve su rebanada. fn tag<'a>(icc: &'a [u8], firma: &[u8; 4]) -> Option<&'a [u8]> { let n = u32_be(icc, 128)? as usize; for i in 0..n { let e = 132 + i * 12; if icc.get(e..e + 4)? == firma { let off = u32_be(icc, e + 4)? as usize; let len = u32_be(icc, e + 8)? as usize; return icc.get(off..off.checked_add(len)?); } } None } /// Lee un tag de tipo `XYZ ` y devuelve su terna. fn xyz(icc: &[u8], firma: &[u8; 4]) -> Option<[f32; 3]> { let t = tag(icc, firma)?; if t.get(0..4)? != b"XYZ " { return None; } Some([s15f16(t, 8)?, s15f16(t, 12)?, s15f16(t, 16)?]) } /// Lee un tag de curva (`curv` o `para`). fn curva(icc: &[u8], firma: &[u8; 4]) -> Option { let t = tag(icc, firma)?; match t.get(0..4)? { b"curv" => { let n = u32_be(t, 8)? as usize; match n { 0 => Some(Curva::Lineal), // Una sola muestra es una gamma en u8Fixed8. 1 => { let g = u16::from_be_bytes(t.get(12..14)?.try_into().ok()?) as f32 / 256.0; Some(Curva::Gamma(g)) } _ => { let datos = t.get(12..12 + n * 2)?; Some(Curva::Tabla( datos .chunks_exact(2) .map(|c| u16::from_be_bytes([c[0], c[1]])) .collect(), )) } } } // Curva paramétrica: u16 de tipo en el offset 8, y de ahí los // parámetros en s15Fixed16, en el orden `g, a, b, c, d, e, f`. Cuántos // hay lo dice el tipo; los que falten quedan en 0. b"para" => { let tipo = u16::from_be_bytes(t.get(8..10)?.try_into().ok()?); let cuantos = match tipo { 0 => 1, 1 => 3, 2 => 4, 3 => 5, 4 => 7, _ => return None, }; let mut p = [0.0f32; 7]; for (i, v) in p.iter_mut().enumerate().take(cuantos) { *v = s15f16(t, 12 + i * 4)?; } Some(Curva::Parametrica { tipo, p }) } _ => None, } } /// Lee un perfil ICC matrix-shaper. Devuelve `None` si no lo es —un perfil de /// imprenta basado en LUT, un ICC truncado, cualquier cosa que no se entienda— /// y entonces la imagen se deja como está: sin conversión es mejor que con una /// inventada. pub fn leer_icc(icc: &[u8]) -> Option { if icc.len() < 132 { return None; } // El espacio de datos tiene que ser RGB: un CMYK no se arregla con una // matriz de 3×3. if icc.get(16..20)? != b"RGB " { return None; } let r = xyz(icc, b"rXYZ")?; let g = xyz(icc, b"gXYZ")?; let b = xyz(icc, b"bXYZ")?; // Los tags vienen por columna (cada uno es el XYZ de un primario); la // matriz se guarda por filas. let a_xyz_d50 = [ r[0], g[0], b[0], // r[1], g[1], b[1], // r[2], g[2], b[2], ]; // Se usa la curva del rojo para los tres. Un perfil con curvas distintas // por canal existe en el papel; en la práctica los tres tags apuntan al // mismo offset. Si algún día aparece uno que no, esto lo trata como gris. let curva = curva(icc, b"rTRC").unwrap_or(Curva::Gamma(2.2)); Some(Perfil { a_xyz_d50, curva }) } // ── Conversión de píxeles ──────────────────────────────────────────────── /// Codifica un valor lineal 0..=1 a sRGB 0..=255. fn a_srgb_u8(lin: f32) -> u8 { let v = lin.clamp(0.0, 1.0); let s = if v <= 0.0031308 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }; (s * 255.0 + 0.5) as u8 } /// Decodifica un valor sRGB 0..=1 a lineal 0..=1. La inversa exacta de /// [`a_srgb_u8`], con el mismo tramo recto abajo. fn srgb_a_lineal(v: f32) -> f32 { let v = v.clamp(0.0, 1.0); if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } } /// Muestras de la tabla lineal→sRGB. 16.384 alcanzan para que el error de /// cuantización quede debajo de 0,2 de un valor de 8 bits incluso en el tramo /// recto de abajo, que es el más empinado. const MUESTRAS_LUT: usize = 16_384; /// Convierte una imagen RGBA8 del espacio de `perfil` a sRGB, **in situ**. /// /// Devuelve `false` y no toca nada si el perfil ya es sRGB (o tan parecido que /// la diferencia no llega a un valor de 8 bits): recorrer ocho millones de /// píxeles para multiplicarlos por la identidad es trabajo que no se paga. /// /// El alfa no se toca: no es color. pub fn convertir_a_srgb(rgba: &mut [u8], perfil: &Perfil) -> bool { let m = matriz_a_srgb(&perfil.a_xyz_d50); // 1/512 en lineal es menos de medio valor de 8 bits en casi todo el rango. if es_identidad(&m, 1.0 / 512.0) { return false; } // Tabla de decodificación: 256 entradas, una por valor de entrada. let mut a_lineal = [0.0f32; 256]; for (i, v) in a_lineal.iter_mut().enumerate() { *v = perfil.curva.a_lineal(i as f32 / 255.0); } // Tabla de codificación: el `powf` de la curva sRGB por píxel y canal serían // veinticinco millones de llamadas en una foto 4K. let mut a_byte = vec![0u8; MUESTRAS_LUT]; for (i, v) in a_byte.iter_mut().enumerate() { *v = a_srgb_u8(i as f32 / (MUESTRAS_LUT - 1) as f32); } let esc = (MUESTRAS_LUT - 1) as f32; for px in rgba.chunks_exact_mut(4) { let (r, g, b) = ( a_lineal[px[0] as usize], a_lineal[px[1] as usize], a_lineal[px[2] as usize], ); for (c, salida) in px[..3].iter_mut().enumerate() { let lin = m[c * 3] * r + m[c * 3 + 1] * g + m[c * 3 + 2] * b; // El recorte a gamut es por saturación de canal: un rojo P3 puro no // existe en sRGB y termina en el rojo sRGB más cercano. let idx = (lin.clamp(0.0, 1.0) * esc + 0.5) as usize; *salida = a_byte[idx]; } } true } /// Convierte una imagen RGBA8 **de sRGB al espacio de `perfil`**, in situ. Es /// el camino de vuelta de [`convertir_a_srgb`]: lo que hace falta para /// **re-exportar** una imagen al espacio del archivo del que salió. /// /// Devuelve `false` y no toca nada si el perfil ya es sRGB, o si su matriz no /// se puede invertir. /// /// **No es una operación sin pérdida, y hay dos razones distintas.** La segunda /// vuelta de cuantización a 8 bits mete ±1 casi en cualquier valor. Y si la /// imagen ya pasó por [`convertir_a_srgb`] desde un espacio más ancho, lo que /// se recortó al entrar (un rojo P3 que en sRGB no existe) **no vuelve**: se /// perdió en el recorte, no acá. Editar sin pérdida un P3 pide trabajar en P3, /// no ir y venir — para eso está el [`Perfil`] expuesto junto a los píxeles. pub fn convertir_desde_srgb(rgba: &mut [u8], perfil: &Perfil) -> bool { let Some(m) = matriz_desde_srgb(&perfil.a_xyz_d50) else { return false; }; if es_identidad(&m, 1.0 / 512.0) { return false; } // Decodificación de sRGB: 256 entradas, una por valor de entrada. let mut a_lineal = [0.0f32; 256]; for (i, v) in a_lineal.iter_mut().enumerate() { *v = srgb_a_lineal(i as f32 / 255.0); } // Codificación con la curva del perfil, tabulada por la misma razón que en // el otro sentido: la inversa de una paramétrica también es un `powf`. let mut a_byte = vec![0u8; MUESTRAS_LUT]; for (i, v) in a_byte.iter_mut().enumerate() { let cod = perfil.curva.desde_lineal(i as f32 / (MUESTRAS_LUT - 1) as f32); *v = (cod * 255.0 + 0.5) as u8; } let esc = (MUESTRAS_LUT - 1) as f32; for px in rgba.chunks_exact_mut(4) { let (r, g, b) = ( a_lineal[px[0] as usize], a_lineal[px[1] as usize], a_lineal[px[2] as usize], ); for (c, salida) in px[..3].iter_mut().enumerate() { let lin = m[c * 3] * r + m[c * 3 + 1] * g + m[c * 3 + 2] * b; let idx = (lin.clamp(0.0, 1.0) * esc + 0.5) as usize; *salida = a_byte[idx]; } } true } /// Arma un ICC matrix-shaper mínimo pero **válido** con la matriz y la curva /// que se le pidan. /// /// Es un **fixture de test**, y está publicado a propósito: probar que una app /// no pierde el perfil de color de un archivo pide un archivo con perfil, y /// sin esto cada crate de afuera se copiaba las cuarenta líneas de armado de /// tags — que se desincronizan del lector de este mismo módulo sin que nada /// avise. Lo usan los tests de acá y los de `tullpu-module`. /// /// No sirve para escribir un perfil de verdad: sólo lleva `rXYZ`/`gXYZ`/`bXYZ` /// y una `rTRC` de gamma simple, nada de descripción ni punto blanco. pub fn icc_sintetico(a_xyz_d50: &[f32; 9], curva_gamma: f32) -> Vec { fn s15(v: f32) -> [u8; 4] { ((v * 65536.0).round() as i32).to_be_bytes() } let tags: [(&[u8; 4], usize); 4] = [(b"rXYZ", 0), (b"gXYZ", 1), (b"bXYZ", 2), (b"rTRC", 3)]; let mut cuerpo: Vec = Vec::new(); let mut entradas: Vec<(&[u8; 4], u32, u32)> = Vec::new(); let base = 128 + 4 + tags.len() * 12; for (firma, col) in tags { let off = base + cuerpo.len(); if *firma == *b"rTRC" { cuerpo.extend_from_slice(b"curv"); cuerpo.extend_from_slice(&[0; 4]); cuerpo.extend_from_slice(&1u32.to_be_bytes()); cuerpo.extend_from_slice(&(((curva_gamma * 256.0) as u16).to_be_bytes())); cuerpo.extend_from_slice(&[0, 0]); // relleno a 4 } else { cuerpo.extend_from_slice(b"XYZ "); cuerpo.extend_from_slice(&[0; 4]); for fila in 0..3 { cuerpo.extend_from_slice(&s15(a_xyz_d50[fila * 3 + col])); } } entradas.push((firma, off as u32, (base + cuerpo.len() - off) as u32)); } let mut icc = vec![0u8; 128]; icc[16..20].copy_from_slice(b"RGB "); icc[20..24].copy_from_slice(b"XYZ "); icc.extend_from_slice(&(tags.len() as u32).to_be_bytes()); for (firma, off, len) in &entradas { icc.extend_from_slice(*firma); icc.extend_from_slice(&off.to_be_bytes()); icc.extend_from_slice(&len.to_be_bytes()); } icc.extend_from_slice(&cuerpo); let n = icc.len() as u32; icc[0..4].copy_from_slice(&n.to_be_bytes()); icc } #[cfg(test)] mod tests { use super::*; #[test] fn un_perfil_srgb_no_cambia_nada() { // Éste es el test que **cruza las tres constantes publicadas**: la // matriz de sRGB en D50 (la que trae adentro cualquier perfil sRGB), // pasada por Bradford D50→D65 y por XYZ→sRGB, tiene que dar la // identidad. Si cualquiera de las tres estuviera mal transcrita, no da. let m = matriz_a_srgb(&SRGB_A_XYZ_D50); assert!( es_identidad(&m, 1e-3), "sRGB → sRGB tendría que ser la identidad, dio {m:?}" ); // Y de punta a punta: los píxeles quedan intactos y se dice que no se // hizo nada, para que el caller no pague el recorrido. let perfil = leer_icc(&icc_sintetico(&SRGB_A_XYZ_D50, 2.2)).expect("matrix-shaper"); let original = vec![10u8, 128, 240, 255, 0, 255, 7, 33]; let mut px = original.clone(); assert!(!convertir_a_srgb(&mut px, &perfil)); assert_eq!(px, original); } #[test] fn el_lector_saca_los_numeros_que_se_le_pusieron() { // El lector por separado de la colorimetría: que sepa recorrer la tabla // de tags y desarmar un s15Fixed16. let m = [ 0.5, 0.25, 0.125, // 0.75, 0.5, 0.25, // 0.0625, 0.125, 0.875, ]; let p = leer_icc(&icc_sintetico(&m, 1.8)).expect("matrix-shaper"); for (leido, puesto) in p.a_xyz_d50.iter().zip(m.iter()) { assert!((leido - puesto).abs() < 1e-4, "{:?} vs {m:?}", p.a_xyz_d50); } // La gamma de una sola muestra viaja en u8Fixed8, así que 1,8 no es // representable: vuelve 1,796875. No es pérdida nuestra, es el formato. let Curva::Gamma(g) = p.curva else { panic!("se esperaba una gamma, vino {:?}", p.curva) }; assert!((g - 1.8).abs() < 1.0 / 256.0, "gamma leída {g}"); } #[test] fn un_perfil_de_gamut_ancho_desatura_al_convertir() { // Display P3 en D50, tal como lo trae el perfil de Apple. Lo que se // afirma acá no es el valor exacto sino la **dirección**: el rojo puro // de P3 es más rojo que el de sRGB, así que al llevarlo a sRGB satura // el rojo y **tiene que meter verde y azul negativos** — que el recorte // deja en 0. Y un gris se mantiene gris, porque la conversión no toca // el punto blanco. let p3_d50 = [ 0.5151, 0.2920, 0.1571, // 0.2412, 0.6922, 0.0666, // -0.0011, 0.0419, 0.7841, ]; let m = matriz_a_srgb(&p3_d50); assert!(m[0] > 1.0, "el rojo P3 debería exceder el rojo sRGB: {m:?}"); assert!(m[1] < 0.0 && m[2] < 0.02, "y compensar con verde/azul: {m:?}"); let perfil = Perfil { a_xyz_d50: p3_d50, curva: Curva::Gamma(2.2) }; // Rojo puro, blanco, gris medio. let mut px = vec![255u8, 0, 0, 255, 255, 255, 255, 255, 128, 128, 128, 255]; assert!(convertir_a_srgb(&mut px, &perfil)); assert_eq!(&px[0..4], &[255, 0, 0, 255], "rojo P3 → rojo sRGB saturado"); for (i, c) in px[4..7].iter().enumerate() { assert!(*c >= 253, "el blanco tiene que seguir blanco (canal {i} = {c})"); } let gris = &px[8..11]; assert!( gris.iter().all(|c| (*c as i32 - 128).abs() <= 2), "un gris no debería teñirse: {gris:?}" ); } #[test] fn lo_que_no_se_entiende_se_deja_en_paz() { // Un perfil CMYK de imprenta no se arregla con una matriz de 3×3, y un // ICC truncado no se arregla con nada. En los dos casos la respuesta es // no tocar la imagen. let mut cmyk = icc_sintetico(&SRGB_A_XYZ_D50, 2.2); cmyk[16..20].copy_from_slice(b"CMYK"); assert!(leer_icc(&cmyk).is_none()); assert!(leer_icc(&[]).is_none()); assert!(leer_icc(&[0u8; 200]).is_none()); // Y un tag con offset fuera del archivo no puede hacer pánico. let mut roto = icc_sintetico(&SRGB_A_XYZ_D50, 2.2); let n = roto.len(); roto[136..140].copy_from_slice(&(n as u32 + 9999).to_be_bytes()); assert!(leer_icc(&roto).is_none()); } #[test] fn la_curva_parametrica_de_srgb_es_la_curva_de_srgb() { // Tipo 3 con los parámetros canónicos de sRGB. La prueba es contra la // fórmula publicada, no contra mi implementación: si el tramo recto de // abajo o el corte en `d` estuvieran mal, los valores chicos fallan. let srgb = Curva::Parametrica { tipo: 3, p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.0, 0.0], }; for v in [0.0f32, 0.01, 0.04, 0.05, 0.2, 0.5, 0.8, 1.0] { let esperado = if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; let dio = srgb.a_lineal(v); assert!((dio - esperado).abs() < 1e-5, "en {v}: dio {dio}, esperaba {esperado}"); } // Y la razón por la que esto se implementó: aproximar por el exponente // —lo que hacía antes— erra feo en el medio del rango. Con L* (tipo 3, // g=3) un gris medio se iba unos 20 valores de 8 bits. let l_estrella = Curva::Parametrica { tipo: 3, p: [3.0, 1.0 / 1.16, 0.16 / 1.16, 1.0 / 9.033, 0.08, 0.0, 0.0], }; let bien = l_estrella.a_lineal(128.0 / 255.0); let atajo = Curva::Gamma(3.0).a_lineal(128.0 / 255.0); assert!(bien > atajo * 1.4, "el atajo por el exponente daba {atajo}, la curva da {bien}"); } #[test] fn la_curva_de_tabla_interpola_entre_muestras() { let c = Curva::Tabla(vec![0, 16384, 65535]); assert!((c.a_lineal(0.0) - 0.0).abs() < 1e-4); assert!((c.a_lineal(0.5) - 0.25).abs() < 1e-3); assert!((c.a_lineal(1.0) - 1.0).abs() < 1e-4); // A mitad de camino entre la primera y la segunda muestra. assert!((c.a_lineal(0.25) - 0.125).abs() < 1e-3); // Una tabla que no describe nada no puede romper. assert_eq!(Curva::Tabla(vec![]).a_lineal(0.7), 0.7); } // ── El camino de vuelta ────────────────────────────────────────────── #[test] fn cada_curva_deshace_lo_que_hizo() { // `desde_lineal` es la inversa de `a_lineal`, y la única forma honesta // de probarlo es componerlas: no hay ninguna constante que creerle. // Van las cinco formas que el crate sabe leer, no una de muestra. let curvas = [ Curva::Lineal, Curva::Gamma(2.2), Curva::Gamma(1.8), // Una tabla no lineal, como la que trae casi cualquier perfil real. Curva::Tabla((0..=64).map(|i| ((i as f32 / 64.0).powf(2.2) * 65535.0) as u16).collect()), // sRGB (tipo 3) y L* (tipo 3 con g=3): las dos con tramo recto. Curva::Parametrica { tipo: 3, p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.0, 0.0], }, Curva::Parametrica { tipo: 3, p: [3.0, 1.0 / 1.16, 0.16 / 1.16, 1.0 / 9.033, 0.08, 0.0, 0.0], }, Curva::Parametrica { tipo: 0, p: [2.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] }, Curva::Parametrica { tipo: 1, p: [2.4, 0.9, 0.1, 0.0, 0.0, 0.0, 0.0] }, Curva::Parametrica { tipo: 2, p: [2.4, 0.9, 0.05, 0.02, 0.0, 0.0, 0.0] }, Curva::Parametrica { tipo: 4, p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.01, 0.005], }, ]; for c in &curvas { for i in 0..=32 { let v = i as f32 / 32.0; let ida = c.a_lineal(v); // Un tramo fuera de 0..=1 (la tipo 2 con `c` desplaza el cero) // no se puede exigir que vuelva: el clamp ya lo perdió. if !(0.0..=1.0).contains(&ida) { continue; } let vuelta = c.desde_lineal(ida); assert!( (vuelta - v).abs() < 0.01, "{c:?}: {v} → {ida} → {vuelta}, no volvió" ); } } } #[test] fn la_matriz_de_vuelta_deshace_la_de_ida() { // P3 en D50. Componer la matriz de ida con la de vuelta tiene que dar // la identidad — y ésta es la prueba de que `invertir` invierte, sin // depender de ningún valor publicado de P3. let p3_d50 = [ 0.5151, 0.2920, 0.1571, // 0.2412, 0.6922, 0.0666, // -0.0011, 0.0419, 0.7841, ]; let ida = matriz_a_srgb(&p3_d50); let vuelta = matriz_desde_srgb(&p3_d50).expect("P3 no es singular"); assert!(es_identidad(&multiplicar(&ida, &vuelta), 1e-4)); assert!(es_identidad(&multiplicar(&vuelta, &ida), 1e-4)); // Una matriz con dos primarios iguales no tiene inversa, y eso se dice // en vez de devolver infinitos. let degenerada = [1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0]; assert!(invertir(°enerada).is_none()); } #[test] fn una_imagen_srgb_va_a_p3_y_vuelve_igual() { // El lazo que justifica exponer el perfil: tullpu edita en sRGB y // re-exporta al espacio del archivo. sRGB **cabe entero** dentro de P3, // así que esta ida y vuelta no recorta nada y lo único que queda es la // cuantización a 8 bits. let p3 = Perfil { a_xyz_d50: [ 0.5151, 0.2920, 0.1571, // 0.2412, 0.6922, 0.0666, // -0.0011, 0.0419, 0.7841, ], curva: Curva::Gamma(2.2), }; let original: Vec = vec![ 255, 0, 0, 255, // rojo sRGB puro 0, 255, 0, 255, // verde 0, 0, 255, 255, // azul 128, 128, 128, 255, // gris medio 200, 120, 40, 255, // un naranja cualquiera 12, 34, 56, 255, // y una sombra ]; let mut px = original.clone(); assert!(convertir_desde_srgb(&mut px, &p3), "sRGB → P3 tiene que hacer algo"); assert_ne!(px, original, "si no cambió nada, no se convirtió"); assert!(convertir_a_srgb(&mut px, &p3), "y P3 → sRGB también"); for (i, (dio, esperaba)) in px.iter().zip(original.iter()).enumerate() { let d = (*dio as i32 - *esperaba as i32).abs(); assert!(d <= 3, "canal {i}: volvió {dio}, había salido de {esperaba}"); } } #[test] fn volver_a_srgb_desde_srgb_no_recorre_la_imagen() { // La misma guarda que en el otro sentido: multiplicar ocho millones de // píxeles por la identidad es trabajo que no se paga. let perfil = leer_icc(&icc_sintetico(&SRGB_A_XYZ_D50, 2.2)).expect("matrix-shaper"); let original = vec![10u8, 128, 240, 255, 0, 255, 7, 33]; let mut px = original.clone(); assert!(!convertir_desde_srgb(&mut px, &perfil)); assert_eq!(px, original); } }