tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1
llimphi-image/src/perfil.rs
Ñawpa willay
Pi imata llamkarqan
ch'uya
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! Perfiles ICC: en qué espacio de color viene una imagen, y cómo llevarla al
2//! de trabajo (sRGB).
3//!
4//! ## El agujero que esto tapa
5//!
6//! Hasta 2026-08-05 el decodificador tiraba el perfil ICC del archivo y
7//! entregaba los bytes RGB crudos. Para una imagen sRGB eso está bien; para
8//! cualquier otra es **mostrar los números en el espacio equivocado**. Una foto
9//! Display P3 —lo que saca cualquier teléfono desde 2016— se ve **sobresaturada**
10//! porque sus rojos, que son más rojos que los de sRGB, se pintan como si fueran
11//! los de sRGB. Un escaneo con perfil de imprenta se ve lavado. Y no es un
12//! detalle estético: `tullpu` dice tener paridad Photoshop, y editar color sin
13//! saber en qué espacio estás no es paridad de nada.
14//!
15//! ## Qué cubre y qué no
16//!
17//! Los perfiles **matrix-shaper** (matriz de 3×3 + una curva por canal), que es
18//! lo que son sRGB, Display P3, Adobe RGB, Rec.2020 y la enorme mayoría de los
19//! perfiles de pantalla y de cámara. **No** cubre los perfiles basados en LUT
20//! (`A2B0`), que es como vienen los de imprenta CMYK: ahí devuelve `None` y la
21//! imagen queda como estaba — sin conversión es mejor que con una inventada.
22//!
23//! La conversión es **colorimétrica relativa** y recorta a gamut sRGB por
24//! saturación de canal. Un rojo P3 puro no existe en sRGB: se lo lleva al rojo
25//! sRGB más cercano. Eso es un recorte, no un error — pero conviene saberlo, y
26//! por eso está escrito acá y no oculto.
27
28/// Curva de transferencia de un canal (el «shaper» del matrix-shaper).
29#[derive(Debug, Clone, PartialEq)]
30pub enum Curva {
31 /// Identidad — el perfil declara los valores ya lineales.
32 Lineal,
33 /// Una gamma sola: `lineal = v^γ`.
34 Gamma(f32),
35 /// Tabla de muestras equiespaciadas en 0..=1, con interpolación lineal
36 /// entre ellas. Es como vienen casi todos los perfiles reales.
37 Tabla(Vec<u16>),
38 /// Curva paramétrica (`para`): los cinco tipos de la especificación, con
39 /// sus parámetros `g, a, b, c, d, e, f`. Es la forma en que vienen la curva
40 /// de sRGB y la de L*, y **no** se puede aproximar por su exponente: con
41 /// ECI-RGBv2 (que usa L*) esa aproximación erraba un gris medio por unos
42 /// 20 valores de 8 bits.
43 Parametrica { tipo: u16, p: [f32; 7] },
44}
45
46impl Curva {
47 /// Lleva un valor codificado (0..=1) a lineal (0..=1).
48 pub fn a_lineal(&self, v: f32) -> f32 {
49 match self {
50 Curva::Lineal => v,
51 Curva::Gamma(g) => v.max(0.0).powf(*g),
52 Curva::Tabla(t) if t.len() >= 2 => {
53 let v = v.clamp(0.0, 1.0);
54 let pos = v * (t.len() - 1) as f32;
55 let i = pos.floor() as usize;
56 let f = pos - i as f32;
57 let a = t[i] as f32 / 65535.0;
58 let b = t[(i + 1).min(t.len() - 1)] as f32 / 65535.0;
59 a + (b - a) * f
60 }
61 // Una tabla de 0 ó 1 muestras no describe nada; identidad.
62 Curva::Tabla(_) => v,
63 // Los cinco tipos de la especificación, tal cual. El 3 es la
64 // familia de sRGB y de L*: un tramo recto abajo y una potencia
65 // arriba, que es justo lo que se pierde al mirar sólo `g`.
66 Curva::Parametrica { tipo, p } => {
67 let [g, a, b, c, d, e, f] = *p;
68 let pot = |x: f32| (a * x + b).max(0.0).powf(g);
69 match tipo {
70 0 => v.max(0.0).powf(g),
71 1 => {
72 if a != 0.0 && v >= -b / a {
73 pot(v)
74 } else {
75 0.0
76 }
77 }
78 2 => {
79 if a != 0.0 && v >= -b / a {
80 pot(v) + c
81 } else {
82 c
83 }
84 }
85 3 => {
86 if v >= d {
87 pot(v)
88 } else {
89 c * v
90 }
91 }
92 4 => {
93 if v >= d {
94 pot(v) + e
95 } else {
96 c * v + f
97 }
98 }
99 // Un tipo que la especificación no define todavía: mejor
100 // dejar el valor como está que inventar una curva.
101 _ => v,
102 }
103 }
104 }
105 }
106
107 /// Inversa de [`Curva::a_lineal`]: de un valor **lineal** (0..=1) al valor
108 /// **codificado** que este perfil espera.
109 ///
110 /// Hace falta para *escribir* una imagen en el espacio del perfil, no sólo
111 /// para leerla: sin esto, exponer el perfil de origen no serviría de nada
112 /// porque no habría con qué volver a él.
113 pub fn desde_lineal(&self, lin: f32) -> f32 {
114 let lin = lin.clamp(0.0, 1.0);
115 let salida = match self {
116 Curva::Lineal => lin,
117 Curva::Gamma(g) if *g > 0.0 => lin.powf(1.0 / *g),
118 // Una gamma de 0 no describe una curva invertible; identidad.
119 Curva::Gamma(_) => lin,
120 Curva::Tabla(t) if t.len() >= 2 => {
121 let objetivo = lin * 65535.0;
122 // Las TRC reales son monótonas no decrecientes, así que se puede
123 // buscar el tramo por bisección. Si alguna no lo fuera, esto
124 // devuelve *un* valor válido, no basura.
125 let i = t.partition_point(|m| (*m as f32) < objetivo);
126 if i == 0 {
127 0.0
128 } else if i >= t.len() {
129 1.0
130 } else {
131 let (a, b) = (t[i - 1] as f32, t[i] as f32);
132 // Tramo plano: cualquier punto de él mapea al mismo lineal.
133 let f = if b > a { (objetivo - a) / (b - a) } else { 0.0 };
134 (i as f32 - 1.0 + f) / (t.len() - 1) as f32
135 }
136 }
137 Curva::Tabla(_) => lin,
138 Curva::Parametrica { tipo, p } => {
139 let [g, a, b, c, d, e, f] = *p;
140 // Despeje de `(a·x + b)^g = y`.
141 let raiz = |y: f32| -> f32 {
142 if g == 0.0 || a == 0.0 {
143 0.0
144 } else {
145 (y.max(0.0).powf(1.0 / g) - b) / a
146 }
147 };
148 let pot = |x: f32| (a * x + b).max(0.0).powf(g);
149 match tipo {
150 0 => {
151 if g > 0.0 {
152 lin.powf(1.0 / g)
153 } else {
154 lin
155 }
156 }
157 1 => raiz(lin),
158 2 => raiz(lin - c),
159 // El corte va sobre el valor lineal que le toca a `d`, no
160 // sobre `d` mismo: acá se entra por el otro lado de la curva.
161 3 => {
162 if lin >= pot(d) {
163 raiz(lin)
164 } else if c != 0.0 {
165 lin / c
166 } else {
167 0.0
168 }
169 }
170 4 => {
171 if lin >= pot(d) + e {
172 raiz(lin - e)
173 } else if c != 0.0 {
174 (lin - f) / c
175 } else {
176 0.0
177 }
178 }
179 _ => lin,
180 }
181 }
182 };
183 salida.clamp(0.0, 1.0)
184 }
185}
186
187/// Un perfil matrix-shaper leído de un ICC.
188#[derive(Debug, Clone, PartialEq)]
189pub struct Perfil {
190 /// Matriz RGB-del-perfil → XYZ del PCS, **por filas**. El PCS de un ICC de
191 /// display siempre es XYZ adaptado a D50: eso lo fija la especificación, no
192 /// una elección nuestra.
193 pub a_xyz_d50: [f32; 9],
194 pub curva: Curva,
195}
196
197// ── Constantes de colorimetría ───────────────────────────────────────────
198//
199// Las tres son valores publicados, no derivados acá. El test
200// `un_perfil_srgb_no_cambia_nada` las cruza entre sí: si cualquiera estuviera
201// mal, la matriz de sRGB no daría la identidad.
202
203/// XYZ (D65) → sRGB lineal, por filas.
204const XYZ_D65_A_SRGB: [f32; 9] = [
205 3.2404542, -1.5371385, -0.4985314, //
206 -0.9692660, 1.8760108, 0.0415560, //
207 0.0556434, -0.2040259, 1.0572252,
208];
209
210/// Adaptación cromática Bradford D50 → D65, por filas.
211const BRADFORD_D50_A_D65: [f32; 9] = [
212 0.9555766, -0.0230393, 0.0631636, //
213 -0.0282895, 1.0099416, 0.0210077, //
214 0.0122982, -0.0204830, 1.3299098,
215];
216
217/// RGB sRGB → XYZ D50, por filas. Es la matriz que **trae adentro** cualquier
218/// perfil sRGB; sirve de patrón para el test de identidad.
219pub const SRGB_A_XYZ_D50: [f32; 9] = [
220 0.4360747, 0.3850649, 0.1430804, //
221 0.2225045, 0.7168786, 0.0606169, //
222 0.0139322, 0.0971045, 0.7141733,
223];
224
225fn multiplicar(a: &[f32; 9], b: &[f32; 9]) -> [f32; 9] {
226 let mut m = [0.0f32; 9];
227 for f in 0..3 {
228 for c in 0..3 {
229 m[f * 3 + c] = (0..3).map(|k| a[f * 3 + k] * b[k * 3 + c]).sum();
230 }
231 }
232 m
233}
234
235/// Matriz que lleva el RGB **lineal** del perfil al RGB **lineal** de sRGB.
236pub fn matriz_a_srgb(a_xyz_d50: &[f32; 9]) -> [f32; 9] {
237 multiplicar(&XYZ_D65_A_SRGB, &multiplicar(&BRADFORD_D50_A_D65, a_xyz_d50))
238}
239
240/// Inversa de una 3×3. `None` si es singular — no pasa con primarios de verdad
241/// (tres colores independientes), pero invertir a ciegas daría infinitos.
242fn invertir(m: &[f32; 9]) -> Option<[f32; 9]> {
243 let det = m[0] * (m[4] * m[8] - m[5] * m[7]) - m[1] * (m[3] * m[8] - m[5] * m[6])
244 + m[2] * (m[3] * m[7] - m[4] * m[6]);
245 if det.abs() < 1e-9 {
246 return None;
247 }
248 let k = 1.0 / det;
249 Some([
250 (m[4] * m[8] - m[5] * m[7]) * k,
251 (m[2] * m[7] - m[1] * m[8]) * k,
252 (m[1] * m[5] - m[2] * m[4]) * k,
253 (m[5] * m[6] - m[3] * m[8]) * k,
254 (m[0] * m[8] - m[2] * m[6]) * k,
255 (m[2] * m[3] - m[0] * m[5]) * k,
256 (m[3] * m[7] - m[4] * m[6]) * k,
257 (m[1] * m[6] - m[0] * m[7]) * k,
258 (m[0] * m[4] - m[1] * m[3]) * k,
259 ])
260}
261
262/// Matriz que lleva el RGB **lineal** de sRGB al RGB **lineal** del perfil: la
263/// inversa de [`matriz_a_srgb`]. Es el camino de **salida**, el que hace falta
264/// para re-exportar al espacio del que vino la imagen.
265pub fn matriz_desde_srgb(a_xyz_d50: &[f32; 9]) -> Option<[f32; 9]> {
266 invertir(&matriz_a_srgb(a_xyz_d50))
267}
268
269/// ¿Esta matriz es la identidad dentro de `tol`? Se usa para saltear la
270/// conversión cuando el perfil ya es sRGB: recorrer ocho millones de píxeles
271/// para multiplicarlos por 1 es la clase de trabajo que no se paga.
272pub fn es_identidad(m: &[f32; 9], tol: f32) -> bool {
273 for f in 0..3 {
274 for c in 0..3 {
275 let esperado = if f == c { 1.0 } else { 0.0 };
276 if (m[f * 3 + c] - esperado).abs() > tol {
277 return false;
278 }
279 }
280 }
281 true
282}
283
284// ── Lectura del ICC ──────────────────────────────────────────────────────
285
286fn u32_be(b: &[u8], o: usize) -> Option<u32> {
287 Some(u32::from_be_bytes(b.get(o..o + 4)?.try_into().ok()?))
288}
289
290/// s15Fixed16: entero con signo de 16 bits + 16 bits de fracción.
291fn s15f16(b: &[u8], o: usize) -> Option<f32> {
292 Some(i32::from_be_bytes(b.get(o..o + 4)?.try_into().ok()?) as f32 / 65536.0)
293}
294
295/// Busca un tag por su firma de 4 letras y devuelve su rebanada.
296fn tag<'a>(icc: &'a [u8], firma: &[u8; 4]) -> Option<&'a [u8]> {
297 let n = u32_be(icc, 128)? as usize;
298 for i in 0..n {
299 let e = 132 + i * 12;
300 if icc.get(e..e + 4)? == firma {
301 let off = u32_be(icc, e + 4)? as usize;
302 let len = u32_be(icc, e + 8)? as usize;
303 return icc.get(off..off.checked_add(len)?);
304 }
305 }
306 None
307}
308
309/// Lee un tag de tipo `XYZ ` y devuelve su terna.
310fn xyz(icc: &[u8], firma: &[u8; 4]) -> Option<[f32; 3]> {
311 let t = tag(icc, firma)?;
312 if t.get(0..4)? != b"XYZ " {
313 return None;
314 }
315 Some([s15f16(t, 8)?, s15f16(t, 12)?, s15f16(t, 16)?])
316}
317
318/// Lee un tag de curva (`curv` o `para`).
319fn curva(icc: &[u8], firma: &[u8; 4]) -> Option<Curva> {
320 let t = tag(icc, firma)?;
321 match t.get(0..4)? {
322 b"curv" => {
323 let n = u32_be(t, 8)? as usize;
324 match n {
325 0 => Some(Curva::Lineal),
326 // Una sola muestra es una gamma en u8Fixed8.
327 1 => {
328 let g = u16::from_be_bytes(t.get(12..14)?.try_into().ok()?) as f32 / 256.0;
329 Some(Curva::Gamma(g))
330 }
331 _ => {
332 let datos = t.get(12..12 + n * 2)?;
333 Some(Curva::Tabla(
334 datos
335 .chunks_exact(2)
336 .map(|c| u16::from_be_bytes([c[0], c[1]]))
337 .collect(),
338 ))
339 }
340 }
341 }
342 // Curva paramétrica: u16 de tipo en el offset 8, y de ahí los
343 // parámetros en s15Fixed16, en el orden `g, a, b, c, d, e, f`. Cuántos
344 // hay lo dice el tipo; los que falten quedan en 0.
345 b"para" => {
346 let tipo = u16::from_be_bytes(t.get(8..10)?.try_into().ok()?);
347 let cuantos = match tipo {
348 0 => 1,
349 1 => 3,
350 2 => 4,
351 3 => 5,
352 4 => 7,
353 _ => return None,
354 };
355 let mut p = [0.0f32; 7];
356 for (i, v) in p.iter_mut().enumerate().take(cuantos) {
357 *v = s15f16(t, 12 + i * 4)?;
358 }
359 Some(Curva::Parametrica { tipo, p })
360 }
361 _ => None,
362 }
363}
364
365/// Lee un perfil ICC matrix-shaper. Devuelve `None` si no lo es —un perfil de
366/// imprenta basado en LUT, un ICC truncado, cualquier cosa que no se entienda—
367/// y entonces la imagen se deja como está: sin conversión es mejor que con una
368/// inventada.
369pub fn leer_icc(icc: &[u8]) -> Option<Perfil> {
370 if icc.len() < 132 {
371 return None;
372 }
373 // El espacio de datos tiene que ser RGB: un CMYK no se arregla con una
374 // matriz de 3×3.
375 if icc.get(16..20)? != b"RGB " {
376 return None;
377 }
378 let r = xyz(icc, b"rXYZ")?;
379 let g = xyz(icc, b"gXYZ")?;
380 let b = xyz(icc, b"bXYZ")?;
381 // Los tags vienen por columna (cada uno es el XYZ de un primario); la
382 // matriz se guarda por filas.
383 let a_xyz_d50 = [
384 r[0], g[0], b[0], //
385 r[1], g[1], b[1], //
386 r[2], g[2], b[2],
387 ];
388 // Se usa la curva del rojo para los tres. Un perfil con curvas distintas
389 // por canal existe en el papel; en la práctica los tres tags apuntan al
390 // mismo offset. Si algún día aparece uno que no, esto lo trata como gris.
391 let curva = curva(icc, b"rTRC").unwrap_or(Curva::Gamma(2.2));
392 Some(Perfil { a_xyz_d50, curva })
393}
394
395// ── Conversión de píxeles ────────────────────────────────────────────────
396
397/// Codifica un valor lineal 0..=1 a sRGB 0..=255.
398fn a_srgb_u8(lin: f32) -> u8 {
399 let v = lin.clamp(0.0, 1.0);
400 let s = if v <= 0.0031308 {
401 v * 12.92
402 } else {
403 1.055 * v.powf(1.0 / 2.4) - 0.055
404 };
405 (s * 255.0 + 0.5) as u8
406}
407
408/// Decodifica un valor sRGB 0..=1 a lineal 0..=1. La inversa exacta de
409/// [`a_srgb_u8`], con el mismo tramo recto abajo.
410fn srgb_a_lineal(v: f32) -> f32 {
411 let v = v.clamp(0.0, 1.0);
412 if v <= 0.04045 {
413 v / 12.92
414 } else {
415 ((v + 0.055) / 1.055).powf(2.4)
416 }
417}
418
419/// Muestras de la tabla lineal→sRGB. 16.384 alcanzan para que el error de
420/// cuantización quede debajo de 0,2 de un valor de 8 bits incluso en el tramo
421/// recto de abajo, que es el más empinado.
422const MUESTRAS_LUT: usize = 16_384;
423
424/// Convierte una imagen RGBA8 del espacio de `perfil` a sRGB, **in situ**.
425///
426/// Devuelve `false` y no toca nada si el perfil ya es sRGB (o tan parecido que
427/// la diferencia no llega a un valor de 8 bits): recorrer ocho millones de
428/// píxeles para multiplicarlos por la identidad es trabajo que no se paga.
429///
430/// El alfa no se toca: no es color.
431pub fn convertir_a_srgb(rgba: &mut [u8], perfil: &Perfil) -> bool {
432 let m = matriz_a_srgb(&perfil.a_xyz_d50);
433 // 1/512 en lineal es menos de medio valor de 8 bits en casi todo el rango.
434 if es_identidad(&m, 1.0 / 512.0) {
435 return false;
436 }
437
438 // Tabla de decodificación: 256 entradas, una por valor de entrada.
439 let mut a_lineal = [0.0f32; 256];
440 for (i, v) in a_lineal.iter_mut().enumerate() {
441 *v = perfil.curva.a_lineal(i as f32 / 255.0);
442 }
443 // Tabla de codificación: el `powf` de la curva sRGB por píxel y canal serían
444 // veinticinco millones de llamadas en una foto 4K.
445 let mut a_byte = vec![0u8; MUESTRAS_LUT];
446 for (i, v) in a_byte.iter_mut().enumerate() {
447 *v = a_srgb_u8(i as f32 / (MUESTRAS_LUT - 1) as f32);
448 }
449 let esc = (MUESTRAS_LUT - 1) as f32;
450
451 for px in rgba.chunks_exact_mut(4) {
452 let (r, g, b) = (
453 a_lineal[px[0] as usize],
454 a_lineal[px[1] as usize],
455 a_lineal[px[2] as usize],
456 );
457 for (c, salida) in px[..3].iter_mut().enumerate() {
458 let lin = m[c * 3] * r + m[c * 3 + 1] * g + m[c * 3 + 2] * b;
459 // El recorte a gamut es por saturación de canal: un rojo P3 puro no
460 // existe en sRGB y termina en el rojo sRGB más cercano.
461 let idx = (lin.clamp(0.0, 1.0) * esc + 0.5) as usize;
462 *salida = a_byte[idx];
463 }
464 }
465 true
466}
467
468/// Convierte una imagen RGBA8 **de sRGB al espacio de `perfil`**, in situ. Es
469/// el camino de vuelta de [`convertir_a_srgb`]: lo que hace falta para
470/// **re-exportar** una imagen al espacio del archivo del que salió.
471///
472/// Devuelve `false` y no toca nada si el perfil ya es sRGB, o si su matriz no
473/// se puede invertir.
474///
475/// **No es una operación sin pérdida, y hay dos razones distintas.** La segunda
476/// vuelta de cuantización a 8 bits mete ±1 casi en cualquier valor. Y si la
477/// imagen ya pasó por [`convertir_a_srgb`] desde un espacio más ancho, lo que
478/// se recortó al entrar (un rojo P3 que en sRGB no existe) **no vuelve**: se
479/// perdió en el recorte, no acá. Editar sin pérdida un P3 pide trabajar en P3,
480/// no ir y venir — para eso está el [`Perfil`] expuesto junto a los píxeles.
481pub fn convertir_desde_srgb(rgba: &mut [u8], perfil: &Perfil) -> bool {
482 let Some(m) = matriz_desde_srgb(&perfil.a_xyz_d50) else {
483 return false;
484 };
485 if es_identidad(&m, 1.0 / 512.0) {
486 return false;
487 }
488
489 // Decodificación de sRGB: 256 entradas, una por valor de entrada.
490 let mut a_lineal = [0.0f32; 256];
491 for (i, v) in a_lineal.iter_mut().enumerate() {
492 *v = srgb_a_lineal(i as f32 / 255.0);
493 }
494 // Codificación con la curva del perfil, tabulada por la misma razón que en
495 // el otro sentido: la inversa de una paramétrica también es un `powf`.
496 let mut a_byte = vec![0u8; MUESTRAS_LUT];
497 for (i, v) in a_byte.iter_mut().enumerate() {
498 let cod = perfil.curva.desde_lineal(i as f32 / (MUESTRAS_LUT - 1) as f32);
499 *v = (cod * 255.0 + 0.5) as u8;
500 }
501 let esc = (MUESTRAS_LUT - 1) as f32;
502
503 for px in rgba.chunks_exact_mut(4) {
504 let (r, g, b) = (
505 a_lineal[px[0] as usize],
506 a_lineal[px[1] as usize],
507 a_lineal[px[2] as usize],
508 );
509 for (c, salida) in px[..3].iter_mut().enumerate() {
510 let lin = m[c * 3] * r + m[c * 3 + 1] * g + m[c * 3 + 2] * b;
511 let idx = (lin.clamp(0.0, 1.0) * esc + 0.5) as usize;
512 *salida = a_byte[idx];
513 }
514 }
515 true
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 /// Arma un ICC matrix-shaper mínimo pero **válido** con la matriz y la
523 /// curva que se le pidan. Sirve para probar el lector sin depender de
524 /// archivos del sistema.
525 fn icc_sintetico(a_xyz_d50: &[f32; 9], curva_gamma: f32) -> Vec<u8> {
526 fn s15(v: f32) -> [u8; 4] {
527 ((v * 65536.0).round() as i32).to_be_bytes()
528 }
529 let tags: [(&[u8; 4], usize); 4] = [(b"rXYZ", 0), (b"gXYZ", 1), (b"bXYZ", 2), (b"rTRC", 3)];
530 let mut cuerpo: Vec<u8> = Vec::new();
531 let mut entradas: Vec<(&[u8; 4], u32, u32)> = Vec::new();
532 let base = 128 + 4 + tags.len() * 12;
533 for (firma, col) in tags {
534 let off = base + cuerpo.len();
535 if *firma == *b"rTRC" {
536 cuerpo.extend_from_slice(b"curv");
537 cuerpo.extend_from_slice(&[0; 4]);
538 cuerpo.extend_from_slice(&1u32.to_be_bytes());
539 cuerpo.extend_from_slice(&(((curva_gamma * 256.0) as u16).to_be_bytes()));
540 cuerpo.extend_from_slice(&[0, 0]); // relleno a 4
541 } else {
542 cuerpo.extend_from_slice(b"XYZ ");
543 cuerpo.extend_from_slice(&[0; 4]);
544 for fila in 0..3 {
545 cuerpo.extend_from_slice(&s15(a_xyz_d50[fila * 3 + col]));
546 }
547 }
548 entradas.push((firma, off as u32, (base + cuerpo.len() - off) as u32));
549 }
550 let mut icc = vec![0u8; 128];
551 icc[16..20].copy_from_slice(b"RGB ");
552 icc[20..24].copy_from_slice(b"XYZ ");
553 icc.extend_from_slice(&(tags.len() as u32).to_be_bytes());
554 for (firma, off, len) in &entradas {
555 icc.extend_from_slice(*firma);
556 icc.extend_from_slice(&off.to_be_bytes());
557 icc.extend_from_slice(&len.to_be_bytes());
558 }
559 icc.extend_from_slice(&cuerpo);
560 let n = icc.len() as u32;
561 icc[0..4].copy_from_slice(&n.to_be_bytes());
562 icc
563 }
564
565 #[test]
566 fn un_perfil_srgb_no_cambia_nada() {
567 // Éste es el test que **cruza las tres constantes publicadas**: la
568 // matriz de sRGB en D50 (la que trae adentro cualquier perfil sRGB),
569 // pasada por Bradford D50→D65 y por XYZ→sRGB, tiene que dar la
570 // identidad. Si cualquiera de las tres estuviera mal transcrita, no da.
571 let m = matriz_a_srgb(&SRGB_A_XYZ_D50);
572 assert!(
573 es_identidad(&m, 1e-3),
574 "sRGB → sRGB tendría que ser la identidad, dio {m:?}"
575 );
576
577 // Y de punta a punta: los píxeles quedan intactos y se dice que no se
578 // hizo nada, para que el caller no pague el recorrido.
579 let perfil = leer_icc(&icc_sintetico(&SRGB_A_XYZ_D50, 2.2)).expect("matrix-shaper");
580 let original = vec![10u8, 128, 240, 255, 0, 255, 7, 33];
581 let mut px = original.clone();
582 assert!(!convertir_a_srgb(&mut px, &perfil));
583 assert_eq!(px, original);
584 }
585
586 #[test]
587 fn el_lector_saca_los_numeros_que_se_le_pusieron() {
588 // El lector por separado de la colorimetría: que sepa recorrer la tabla
589 // de tags y desarmar un s15Fixed16.
590 let m = [
591 0.5, 0.25, 0.125, //
592 0.75, 0.5, 0.25, //
593 0.0625, 0.125, 0.875,
594 ];
595 let p = leer_icc(&icc_sintetico(&m, 1.8)).expect("matrix-shaper");
596 for (leido, puesto) in p.a_xyz_d50.iter().zip(m.iter()) {
597 assert!((leido - puesto).abs() < 1e-4, "{:?} vs {m:?}", p.a_xyz_d50);
598 }
599 // La gamma de una sola muestra viaja en u8Fixed8, así que 1,8 no es
600 // representable: vuelve 1,796875. No es pérdida nuestra, es el formato.
601 let Curva::Gamma(g) = p.curva else { panic!("se esperaba una gamma, vino {:?}", p.curva) };
602 assert!((g - 1.8).abs() < 1.0 / 256.0, "gamma leída {g}");
603 }
604
605 #[test]
606 fn un_perfil_de_gamut_ancho_desatura_al_convertir() {
607 // Display P3 en D50, tal como lo trae el perfil de Apple. Lo que se
608 // afirma acá no es el valor exacto sino la **dirección**: el rojo puro
609 // de P3 es más rojo que el de sRGB, así que al llevarlo a sRGB satura
610 // el rojo y **tiene que meter verde y azul negativos** — que el recorte
611 // deja en 0. Y un gris se mantiene gris, porque la conversión no toca
612 // el punto blanco.
613 let p3_d50 = [
614 0.5151, 0.2920, 0.1571, //
615 0.2412, 0.6922, 0.0666, //
616 -0.0011, 0.0419, 0.7841,
617 ];
618 let m = matriz_a_srgb(&p3_d50);
619 assert!(m[0] > 1.0, "el rojo P3 debería exceder el rojo sRGB: {m:?}");
620 assert!(m[1] < 0.0 && m[2] < 0.02, "y compensar con verde/azul: {m:?}");
621
622 let perfil = Perfil { a_xyz_d50: p3_d50, curva: Curva::Gamma(2.2) };
623 // Rojo puro, blanco, gris medio.
624 let mut px = vec![255u8, 0, 0, 255, 255, 255, 255, 255, 128, 128, 128, 255];
625 assert!(convertir_a_srgb(&mut px, &perfil));
626 assert_eq!(&px[0..4], &[255, 0, 0, 255], "rojo P3 → rojo sRGB saturado");
627 for (i, c) in px[4..7].iter().enumerate() {
628 assert!(*c >= 253, "el blanco tiene que seguir blanco (canal {i} = {c})");
629 }
630 let gris = &px[8..11];
631 assert!(
632 gris.iter().all(|c| (*c as i32 - 128).abs() <= 2),
633 "un gris no debería teñirse: {gris:?}"
634 );
635 }
636
637 #[test]
638 fn lo_que_no_se_entiende_se_deja_en_paz() {
639 // Un perfil CMYK de imprenta no se arregla con una matriz de 3×3, y un
640 // ICC truncado no se arregla con nada. En los dos casos la respuesta es
641 // no tocar la imagen.
642 let mut cmyk = icc_sintetico(&SRGB_A_XYZ_D50, 2.2);
643 cmyk[16..20].copy_from_slice(b"CMYK");
644 assert!(leer_icc(&cmyk).is_none());
645 assert!(leer_icc(&[]).is_none());
646 assert!(leer_icc(&[0u8; 200]).is_none());
647
648 // Y un tag con offset fuera del archivo no puede hacer pánico.
649 let mut roto = icc_sintetico(&SRGB_A_XYZ_D50, 2.2);
650 let n = roto.len();
651 roto[136..140].copy_from_slice(&(n as u32 + 9999).to_be_bytes());
652 assert!(leer_icc(&roto).is_none());
653 }
654
655 #[test]
656 fn la_curva_parametrica_de_srgb_es_la_curva_de_srgb() {
657 // Tipo 3 con los parámetros canónicos de sRGB. La prueba es contra la
658 // fórmula publicada, no contra mi implementación: si el tramo recto de
659 // abajo o el corte en `d` estuvieran mal, los valores chicos fallan.
660 let srgb = Curva::Parametrica {
661 tipo: 3,
662 p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.0, 0.0],
663 };
664 for v in [0.0f32, 0.01, 0.04, 0.05, 0.2, 0.5, 0.8, 1.0] {
665 let esperado = if v <= 0.04045 {
666 v / 12.92
667 } else {
668 ((v + 0.055) / 1.055).powf(2.4)
669 };
670 let dio = srgb.a_lineal(v);
671 assert!((dio - esperado).abs() < 1e-5, "en {v}: dio {dio}, esperaba {esperado}");
672 }
673
674 // Y la razón por la que esto se implementó: aproximar por el exponente
675 // —lo que hacía antes— erra feo en el medio del rango. Con L* (tipo 3,
676 // g=3) un gris medio se iba unos 20 valores de 8 bits.
677 let l_estrella = Curva::Parametrica {
678 tipo: 3,
679 p: [3.0, 1.0 / 1.16, 0.16 / 1.16, 1.0 / 9.033, 0.08, 0.0, 0.0],
680 };
681 let bien = l_estrella.a_lineal(128.0 / 255.0);
682 let atajo = Curva::Gamma(3.0).a_lineal(128.0 / 255.0);
683 assert!(bien > atajo * 1.4, "el atajo por el exponente daba {atajo}, la curva da {bien}");
684 }
685
686 #[test]
687 fn la_curva_de_tabla_interpola_entre_muestras() {
688 let c = Curva::Tabla(vec![0, 16384, 65535]);
689 assert!((c.a_lineal(0.0) - 0.0).abs() < 1e-4);
690 assert!((c.a_lineal(0.5) - 0.25).abs() < 1e-3);
691 assert!((c.a_lineal(1.0) - 1.0).abs() < 1e-4);
692 // A mitad de camino entre la primera y la segunda muestra.
693 assert!((c.a_lineal(0.25) - 0.125).abs() < 1e-3);
694 // Una tabla que no describe nada no puede romper.
695 assert_eq!(Curva::Tabla(vec![]).a_lineal(0.7), 0.7);
696 }
697
698 // ── El camino de vuelta ──────────────────────────────────────────────
699
700 #[test]
701 fn cada_curva_deshace_lo_que_hizo() {
702 // `desde_lineal` es la inversa de `a_lineal`, y la única forma honesta
703 // de probarlo es componerlas: no hay ninguna constante que creerle.
704 // Van las cinco formas que el crate sabe leer, no una de muestra.
705 let curvas = [
706 Curva::Lineal,
707 Curva::Gamma(2.2),
708 Curva::Gamma(1.8),
709 // Una tabla no lineal, como la que trae casi cualquier perfil real.
710 Curva::Tabla((0..=64).map(|i| ((i as f32 / 64.0).powf(2.2) * 65535.0) as u16).collect()),
711 // sRGB (tipo 3) y L* (tipo 3 con g=3): las dos con tramo recto.
712 Curva::Parametrica {
713 tipo: 3,
714 p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.0, 0.0],
715 },
716 Curva::Parametrica {
717 tipo: 3,
718 p: [3.0, 1.0 / 1.16, 0.16 / 1.16, 1.0 / 9.033, 0.08, 0.0, 0.0],
719 },
720 Curva::Parametrica { tipo: 0, p: [2.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0] },
721 Curva::Parametrica { tipo: 1, p: [2.4, 0.9, 0.1, 0.0, 0.0, 0.0, 0.0] },
722 Curva::Parametrica { tipo: 2, p: [2.4, 0.9, 0.05, 0.02, 0.0, 0.0, 0.0] },
723 Curva::Parametrica {
724 tipo: 4,
725 p: [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.01, 0.005],
726 },
727 ];
728 for c in &curvas {
729 for i in 0..=32 {
730 let v = i as f32 / 32.0;
731 let ida = c.a_lineal(v);
732 // Un tramo fuera de 0..=1 (la tipo 2 con `c` desplaza el cero)
733 // no se puede exigir que vuelva: el clamp ya lo perdió.
734 if !(0.0..=1.0).contains(&ida) {
735 continue;
736 }
737 let vuelta = c.desde_lineal(ida);
738 assert!(
739 (vuelta - v).abs() < 0.01,
740 "{c:?}: {v} → {ida} → {vuelta}, no volvió"
741 );
742 }
743 }
744 }
745
746 #[test]
747 fn la_matriz_de_vuelta_deshace_la_de_ida() {
748 // P3 en D50. Componer la matriz de ida con la de vuelta tiene que dar
749 // la identidad — y ésta es la prueba de que `invertir` invierte, sin
750 // depender de ningún valor publicado de P3.
751 let p3_d50 = [
752 0.5151, 0.2920, 0.1571, //
753 0.2412, 0.6922, 0.0666, //
754 -0.0011, 0.0419, 0.7841,
755 ];
756 let ida = matriz_a_srgb(&p3_d50);
757 let vuelta = matriz_desde_srgb(&p3_d50).expect("P3 no es singular");
758 assert!(es_identidad(&multiplicar(&ida, &vuelta), 1e-4));
759 assert!(es_identidad(&multiplicar(&vuelta, &ida), 1e-4));
760
761 // Una matriz con dos primarios iguales no tiene inversa, y eso se dice
762 // en vez de devolver infinitos.
763 let degenerada = [1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0];
764 assert!(invertir(°enerada).is_none());
765 }
766
767 #[test]
768 fn una_imagen_srgb_va_a_p3_y_vuelve_igual() {
769 // El lazo que justifica exponer el perfil: tullpu edita en sRGB y
770 // re-exporta al espacio del archivo. sRGB **cabe entero** dentro de P3,
771 // así que esta ida y vuelta no recorta nada y lo único que queda es la
772 // cuantización a 8 bits.
773 let p3 = Perfil {
774 a_xyz_d50: [
775 0.5151, 0.2920, 0.1571, //
776 0.2412, 0.6922, 0.0666, //
777 -0.0011, 0.0419, 0.7841,
778 ],
779 curva: Curva::Gamma(2.2),
780 };
781 let original: Vec<u8> = vec![
782 255, 0, 0, 255, // rojo sRGB puro
783 0, 255, 0, 255, // verde
784 0, 0, 255, 255, // azul
785 128, 128, 128, 255, // gris medio
786 200, 120, 40, 255, // un naranja cualquiera
787 12, 34, 56, 255, // y una sombra
788 ];
789 let mut px = original.clone();
790 assert!(convertir_desde_srgb(&mut px, &p3), "sRGB → P3 tiene que hacer algo");
791 assert_ne!(px, original, "si no cambió nada, no se convirtió");
792
793 assert!(convertir_a_srgb(&mut px, &p3), "y P3 → sRGB también");
794 for (i, (dio, esperaba)) in px.iter().zip(original.iter()).enumerate() {
795 let d = (*dio as i32 - *esperaba as i32).abs();
796 assert!(d <= 3, "canal {i}: volvió {dio}, había salido de {esperaba}");
797 }
798 }
799
800 #[test]
801 fn volver_a_srgb_desde_srgb_no_recorre_la_imagen() {
802 // La misma guarda que en el otro sentido: multiplicar ocho millones de
803 // píxeles por la identidad es trabajo que no se paga.
804 let perfil = leer_icc(&icc_sintetico(&SRGB_A_XYZ_D50, 2.2)).expect("matrix-shaper");
805 let original = vec![10u8, 128, 240, 255, 0, 255, 7, 33];
806 let mut px = original.clone();
807 assert!(!convertir_desde_srgb(&mut px, &perfil));
808 assert_eq!(px, original);
809 }
810}