gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ main llimphi-image/tests/icc_reales.rs Rust · 249 siq'ikuna · 11.1 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! El lector de ICC contra **perfiles reales**, no sintéticos.
2//!
3//! Los tests unitarios de `perfil.rs` arman sus propios ICC, así que prueban la
4//! aritmética y el recorrido de tags pero no que un archivo escrito por otra
5//! gente entre. Esto lo cubre con los perfiles que trae `colord`, que están en
6//! casi cualquier Linux de escritorio.
7//!
8//! **Se saltea solo** si la máquina no los tiene: el test no puede exigir que
9//! haya un paquete instalado, pero tampoco tiene sentido tirar la única
10//! verificación contra archivos ajenos que hay.
11//!
12//! Certificado por texto (§8): son valores de píxel, no una pantalla.
13
14use llimphi_image::perfil::{
15    convertir_a_srgb, convertir_desde_srgb, es_identidad, leer_icc, matriz_a_srgb,
16};
17
18const DIR: &str = "/usr/share/color/icc/colord";
19
20fn cargar(nombre: &str) -> Option<Vec<u8>> {
21    std::fs::read(std::path::Path::new(DIR).join(nombre)).ok()
22}
23
24/// Convierte rojo puro, verde puro y un gris medio.
25fn convertir_muestras(icc: &[u8]) -> Option<(bool, [u8; 3], [u8; 3], [u8; 3])> {
26    let p = leer_icc(icc)?;
27    let mut px = vec![255u8, 0, 0, 255, 0, 255, 0, 255, 128, 128, 128, 255];
28    let toco = convertir_a_srgb(&mut px, &p);
29    Some((
30        toco,
31        [px[0], px[1], px[2]],
32        [px[4], px[5], px[6]],
33        [px[8], px[9], px[10]],
34    ))
35}
36
37#[test]
38fn un_perfil_srgb_de_verdad_no_toca_la_imagen() {
39    let Some(icc) = cargar("sRGB.icc") else { return };
40    let p = leer_icc(&icc).expect("sRGB.icc es matrix-shaper");
41    assert!(
42        es_identidad(&matriz_a_srgb(&p.a_xyz_d50), 1.0 / 512.0),
43        "la matriz de un sRGB real tendría que dar la identidad"
44    );
45    let (toco, r, g, gris) = convertir_muestras(&icc).unwrap();
46    assert!(!toco, "no hay nada que convertir y no se debería recorrer la imagen");
47    assert_eq!((r, g, gris), ([255, 0, 0], [0, 255, 0], [128, 128, 128]));
48
49    // Rec.709 comparte primarios con sRGB: también identidad. Si diera otra
50    // cosa, el que estaría mal sería el lector.
51    if let Some(icc) = cargar("Rec709.icc") {
52        let p = leer_icc(&icc).expect("Rec709.icc es matrix-shaper");
53        assert!(es_identidad(&matriz_a_srgb(&p.a_xyz_d50), 1.0 / 512.0));
54    }
55}
56
57#[test]
58fn el_perfil_que_intercambia_rojo_y_verde_intercambia_rojo_y_verde() {
59    // El mejor caso de prueba que se puede pedir: la respuesta correcta no
60    // depende de creerle a ninguna constante de colorimetría.
61    let Some(icc) = cargar("SwappedRedAndGreen.icc") else { return };
62    let (toco, r, g, gris) = convertir_muestras(&icc).unwrap();
63    assert!(toco);
64    assert!(r[1] > 250 && r[0] < 8, "el rojo tendría que salir verde, salió {r:?}");
65    assert!(g[0] > 250 && g[1] < 8, "el verde tendría que salir rojo, salió {g:?}");
66    assert_eq!(gris, [128, 128, 128], "un gris no se altera al permutar canales");
67}
68
69/// Mete un perfil ICC en un JPEG ya codificado, como marcador APP2.
70///
71/// JPEG guarda el ICC **sin comprimir** (a diferencia del `iCCP` de PNG, que
72/// va en deflate), así que inyectarlo es concatenar bytes y no hace falta
73/// ninguna dependencia nueva para armar el caso de prueba.
74fn jpeg_con_icc(jpeg: &[u8], icc: &[u8]) -> Vec<u8> {
75    let mut out = Vec::with_capacity(jpeg.len() + icc.len() + 32);
76    out.extend_from_slice(&jpeg[..2]); // SOI
77    let largo = 2 + 12 + 2 + icc.len();
78    out.extend_from_slice(&[0xFF, 0xE2]);
79    out.extend_from_slice(&(largo as u16).to_be_bytes());
80    out.extend_from_slice(b"ICC_PROFILE\0");
81    out.extend_from_slice(&[1, 1]); // secuencia 1 de 1
82    out.extend_from_slice(icc);
83    out.extend_from_slice(&jpeg[2..]);
84    out
85}
86
87#[test]
88fn decodificar_un_archivo_con_perfil_lo_lleva_a_srgb() {
89    // El lazo entero: bytes de archivo → `decode_bytes` → píxeles convertidos.
90    // Los otros tests prueban el lector y la aritmética por separado; éste es
91    // el único que verifica que el decodificador **le pregunta** el perfil al
92    // decoder en vez de tirarlo, que es lo que pasaba hasta 2026-08-05.
93    let Some(icc) = cargar("SwappedRedAndGreen.icc") else { return };
94    assert!(icc.len() < 65_000, "el perfil no entra en un solo APP2");
95
96    // Un JPEG chico de rojo puro. Se codifica con calidad máxima para que el
97    // subsampling de croma no se coma lo que se está midiendo.
98    let rojo = image::RgbImage::from_pixel(16, 16, image::Rgb([255, 0, 0]));
99    let mut jpeg = Vec::new();
100    image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100)
101        .encode_image(&image::DynamicImage::ImageRgb8(rojo))
102        .expect("encode");
103
104    let sin_perfil = llimphi_image::decode_bytes(&jpeg).expect("decode");
105    let con_perfil =
106        llimphi_image::decode_bytes(&jpeg_con_icc(&jpeg, &icc)).expect("decode con icc");
107
108    let centro = |img: &llimphi_image::Image| -> [u8; 3] {
109        let d: &[u8] = img.image.data.as_ref();
110        let o = ((8 * 16 + 8) * 4) as usize;
111        [d[o], d[o + 1], d[o + 2]]
112    };
113    let a = centro(&sin_perfil);
114    let b = centro(&con_perfil);
115    assert!(a[0] > 240 && a[1] < 20, "sin perfil el rojo queda rojo, dio {a:?}");
116    assert!(
117        b[1] > 240 && b[0] < 20,
118        "con el perfil que permuta canales el rojo tiene que salir verde, dio {b:?}"
119    );
120}
121
122#[test]
123fn ningun_perfil_del_sistema_tiñe_un_gris() {
124    // La invariante que vale para **toda** conversión colorimétrica relativa
125    // entre dos espacios RGB: el blanco va al blanco, así que los neutros
126    // quedan neutros. El valor puede cambiar —un perfil de gamma 1,8 aclara el
127    // gris medio— pero los tres canales tienen que seguir siendo iguales.
128    //
129    // Es la prueba que agarró el atajo de las curvas paramétricas: aproximarlas
130    // por su exponente dejaba el gris de `SwappedRedAndGreen` en 121 y el de
131    // `ECI-RGBv2` en 100, cuando la curva bien evaluada da 128 y 119.
132    let Ok(dir) = std::fs::read_dir(DIR) else { return };
133    let (mut leidos, mut salteados) = (0, 0);
134    for e in dir.flatten() {
135        let ruta = e.path();
136        if ruta.extension().and_then(|s| s.to_str()) != Some("icc") {
137            continue;
138        }
139        let Ok(bytes) = std::fs::read(&ruta) else { continue };
140        let nombre = ruta.file_name().unwrap().to_string_lossy().into_owned();
141        match convertir_muestras(&bytes) {
142            Some((_, _, _, gris)) => {
143                leidos += 1;
144                assert!(
145                    gris[0] == gris[1] && gris[1] == gris[2],
146                    "{nombre} tiñó un gris: {gris:?}"
147                );
148            }
149            // Los de imprenta (FOGRA, SWOP, GRACoL…) son CMYK con LUT: no se
150            // arreglan con una matriz de 3×3 y se dejan pasar sin tocar.
151            None => salteados += 1,
152        }
153    }
154    // Guarda del arnés: si el directorio existiera vacío, el bucle no probaría
155    // nada y el test pasaría igual.
156    assert!(leidos >= 10, "sólo {leidos} perfiles leídos; el arnés no está probando");
157    assert!(salteados >= 1, "ninguno salteado: los CMYK deberían caer afuera");
158}
159
160// ── El camino de vuelta, contra los mismos archivos ajenos ───────────────
161
162#[test]
163fn abrir_y_volver_a_guardar_devuelve_los_bytes_que_habia() {
164    // El lazo completo de un editor: `tullpu` abre un archivo con perfil, los
165    // píxeles le llegan en sRGB, y al guardar los devuelve al espacio de origen
166    // para no degradar el archivo del usuario por el solo hecho de haberlo
167    // abierto.
168    //
169    // Con `SwappedRedAndGreen` el ida y vuelta es verificable a ojo cerrado y
170    // sin creerle a ninguna constante: el rojo del archivo se abre como verde y
171    // al guardar tiene que volver a ser rojo.
172    let Some(icc) = cargar("SwappedRedAndGreen.icc") else { return };
173
174    let rojo = image::RgbImage::from_pixel(8, 8, image::Rgb([230, 40, 60]));
175    let mut jpeg = Vec::new();
176    image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, 100)
177        .encode_image(&image::DynamicImage::ImageRgb8(rojo))
178        .expect("encode");
179
180    let d = llimphi_image::decode_bytes_con_origen(&jpeg_con_icc(&jpeg, &icc))
181        .expect("decode con icc");
182    assert!(d.origen.is_some(), "el perfil del archivo tiene que llegar al caller");
183    assert!(d.convertida, "y con este perfil hay conversión de verdad");
184
185    let mut px: Vec<u8> = d.imagen.image.data.as_ref().to_vec();
186    let abierto = [px[0], px[1], px[2]];
187    assert!(
188        abierto[1] > abierto[0],
189        "abierto tendría que venir con los canales permutados, dio {abierto:?}"
190    );
191
192    assert!(d.devolver_al_origen(&mut px), "hay a dónde volver");
193    let guardado = [px[0], px[1], px[2]];
194    // El JPEG a calidad 100 no es exacto: la tolerancia cubre eso más las dos
195    // cuantizaciones a 8 bits, no un error de la conversión.
196    for (i, (dio, habia)) in guardado.iter().zip([230u8, 40, 60].iter()).enumerate() {
197        let delta = (*dio as i32 - *habia as i32).abs();
198        assert!(delta <= 4, "canal {i}: se guardó {dio} donde el archivo tenía {habia}");
199    }
200}
201
202#[test]
203fn ningun_perfil_del_sistema_corre_un_gris_al_ir_y_volver() {
204    // La invariante del párrafo anterior, pero **sobre los 38 archivos**, no
205    // sobre uno elegido. Se usa un gris porque es el único color que ningún
206    // espacio RGB puede recortar: el blanco va al blanco en toda conversión
207    // colorimétrica relativa, así que la ida y vuelta no pierde por gamut y lo
208    // que quede es error de la aritmética.
209    //
210    // Es la prueba que le pone dientes a `desde_lineal`: una inversa de curva
211    // mal despejada corre el gris decenas de valores, y ninguna cuenta de
212    // matrices lo taparía.
213    let Ok(dir) = std::fs::read_dir(DIR) else { return };
214    let mut probados = 0;
215    for e in dir.flatten() {
216        let ruta = e.path();
217        if ruta.extension().and_then(|s| s.to_str()) != Some("icc") {
218            continue;
219        }
220        let Ok(bytes) = std::fs::read(&ruta) else { continue };
221        let Some(p) = leer_icc(&bytes) else { continue };
222        let nombre = ruta.file_name().unwrap().to_string_lossy().into_owned();
223
224        // Los dos primeros son **sombras**, y están puestos a propósito: el
225        // tramo recto que las paramétricas de tipo 3 tienen abajo sólo se pisa
226        // ahí. Con grises de 32 para arriba, una inversa que ignorara ese tramo
227        // —el atajo obvio— pasaría el test sin que nadie se enterara.
228        let original: Vec<u8> = vec![
229            4, 4, 4, 255, //
230            9, 9, 9, 255, //
231            32, 32, 32, 255, //
232            128, 128, 128, 255, //
233            200, 200, 200, 255,
234        ];
235        let mut px = original.clone();
236        // Un perfil que es sRGB no toca nada en ninguno de los dos sentidos, y
237        // entonces no hay ida y vuelta que probar.
238        if !convertir_desde_srgb(&mut px, &p) {
239            continue;
240        }
241        assert!(convertir_a_srgb(&mut px, &p), "{nombre}: fue pero no volvió");
242        probados += 1;
243        for (i, (dio, habia)) in px.iter().zip(original.iter()).enumerate() {
244            let delta = (*dio as i32 - *habia as i32).abs();
245            assert!(delta <= 3, "{nombre} corrió el canal {i}: {habia} → {dio}");
246        }
247    }
248    assert!(probados >= 5, "sólo {probados} perfiles con ida y vuelta; el arnés no prueba nada");
249}