#![allow(dead_code)]
#![allow(clippy::cast_precision_loss)]
use crate::error::{CalibrationError, CalibrationResult};
use crate::Lab;
pub type Cmyk = [f64; 4];
#[must_use]
pub fn enforce_ink_limit(cmyk: Cmyk, max_tac: f64) -> Cmyk {
let total = cmyk[0] + cmyk[1] + cmyk[2] + cmyk[3];
if total <= max_tac || total < f64::EPSILON {
return cmyk;
}
let scale = max_tac / total;
[
cmyk[0] * scale,
cmyk[1] * scale,
cmyk[2] * scale,
cmyk[3] * scale,
]
}
#[derive(Debug, Clone, PartialEq)]
pub struct PrinterPatch {
pub cmyk: Cmyk,
pub lab: Lab,
}
impl PrinterPatch {
#[must_use]
pub fn new(cmyk: Cmyk, lab: Lab) -> Self {
Self { cmyk, lab }
}
}
#[derive(Debug, Clone)]
pub struct LinearForwardModel {
coefficients: [[f64; 5]; 3],
}
impl LinearForwardModel {
pub fn fit(patches: &[PrinterPatch]) -> CalibrationResult<Self> {
if patches.len() < 5 {
return Err(CalibrationError::InvalidMeasurement(
"At least 5 measurement patches required for linear CMYK→Lab model".to_string(),
));
}
let n = patches.len();
let mut x_mat = vec![[0_f64; 5]; n];
for (i, p) in patches.iter().enumerate() {
x_mat[i] = [
p.cmyk[0] / 100.0,
p.cmyk[1] / 100.0,
p.cmyk[2] / 100.0,
p.cmyk[3] / 100.0,
1.0,
];
}
let xt_x = mat_transpose_mul(&x_mat, n);
let xt_x_inv = invert_5x5(&xt_x).ok_or_else(|| {
CalibrationError::InvalidMeasurement(
"CMYK→Lab normal matrix is singular; patches may be collinear".to_string(),
)
})?;
let mut coefficients = [[0_f64; 5]; 3];
for ch in 0..3 {
let y: Vec<f64> = patches.iter().map(|p| p.lab[ch]).collect();
let xt_y = mat_t_times_vec(&x_mat, &y, n);
let beta = mat_times_vec_5(&xt_x_inv, &xt_y);
coefficients[ch] = beta;
}
Ok(Self { coefficients })
}
#[must_use]
pub fn predict(&self, cmyk: Cmyk) -> Lab {
let x = [
cmyk[0] / 100.0,
cmyk[1] / 100.0,
cmyk[2] / 100.0,
cmyk[3] / 100.0,
1.0,
];
let mut lab = [0_f64; 3];
for ch in 0..3 {
lab[ch] = self.coefficients[ch]
.iter()
.zip(x.iter())
.map(|(a, b)| a * b)
.sum();
}
lab
}
#[must_use]
pub fn coefficients(&self) -> &[[f64; 5]; 3] {
&self.coefficients
}
}
#[derive(Debug, Clone)]
pub struct InversePrinterModel {
forward: LinearForwardModel,
max_tac: f64,
}
impl InversePrinterModel {
#[must_use]
pub fn new(forward: LinearForwardModel, max_tac: f64) -> Self {
Self { forward, max_tac }
}
pub fn invert(
&self,
target_lab: Lab,
initial_cmyk: Option<Cmyk>,
max_iter: u32,
) -> CalibrationResult<(Cmyk, f64)> {
let mut cmyk = initial_cmyk.unwrap_or([25.0, 25.0, 25.0, 0.0]);
let j = build_jacobian_4x3(&self.forward.coefficients);
let mut jt_j = mat4x3_jtj(&j);
let lambda = 1e-6;
for i in 0..4 {
jt_j[i][i] += lambda;
}
let jt_j_inv = invert_4x4(&jt_j).ok_or_else(|| {
CalibrationError::NumericalInstability(
"Printer model Jacobian is rank-deficient".to_string(),
)
})?;
let j_pinv = mat4x4_times_mat4x3t(&jt_j_inv, &j);
for _ in 0..max_iter {
let predicted = self.forward.predict(cmyk);
let residual = [
target_lab[0] - predicted[0],
target_lab[1] - predicted[1],
target_lab[2] - predicted[2],
];
let delta = mat4x3_times_vec3(&j_pinv, &residual);
for i in 0..4 {
cmyk[i] = (cmyk[i] + delta[i] * 100.0).clamp(0.0, 100.0);
}
cmyk = enforce_ink_limit(cmyk, self.max_tac);
}
let final_pred = self.forward.predict(cmyk);
let residual_de = delta_e_lab(target_lab, final_pred);
Ok((cmyk, residual_de))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderIntent {
Perceptual,
RelativeColorimetric,
AbsoluteColorimetric,
}
pub fn soft_proof(
rgb_pixels: &[[f64; 3]],
inverse_model: &InversePrinterModel,
intent: RenderIntent,
) -> CalibrationResult<Vec<Lab>> {
let mut result = Vec::with_capacity(rgb_pixels.len());
for &rgb in rgb_pixels {
let lab = srgb_to_lab(rgb);
let adjusted_lab = match intent {
RenderIntent::Perceptual => gamut_compress_perceptual(lab),
RenderIntent::RelativeColorimetric => lab,
RenderIntent::AbsoluteColorimetric => lab,
};
let (cmyk, _residual) = inverse_model.invert(adjusted_lab, None, 8)?;
let simulated = inverse_model.forward.predict(cmyk);
result.push(simulated);
}
Ok(result)
}
#[derive(Debug, Clone)]
pub struct PrinterCalibratorConfig {
pub max_tac: f64,
pub max_iter: u32,
pub acceptable_de: f64,
pub render_intent: RenderIntent,
}
impl Default for PrinterCalibratorConfig {
fn default() -> Self {
Self {
max_tac: 300.0,
max_iter: 12,
acceptable_de: 3.0,
render_intent: RenderIntent::Perceptual,
}
}
}
pub struct PrinterCalibrator {
config: PrinterCalibratorConfig,
forward: Option<LinearForwardModel>,
inverse: Option<InversePrinterModel>,
}
impl PrinterCalibrator {
#[must_use]
pub fn new(config: PrinterCalibratorConfig) -> Self {
Self {
config,
forward: None,
inverse: None,
}
}
pub fn fit(&mut self, patches: &[PrinterPatch]) -> CalibrationResult<()> {
let forward = LinearForwardModel::fit(patches)?;
let inverse = InversePrinterModel::new(forward.clone(), self.config.max_tac);
self.forward = Some(forward);
self.inverse = Some(inverse);
Ok(())
}
pub fn predict_lab(&self, cmyk: Cmyk) -> CalibrationResult<Lab> {
self.forward
.as_ref()
.map(|m| m.predict(cmyk))
.ok_or_else(|| {
CalibrationError::InvalidMeasurement(
"Printer calibrator not fitted; call fit() first".to_string(),
)
})
}
pub fn lab_to_cmyk(&self, lab: Lab) -> CalibrationResult<Cmyk> {
let inv = self.inverse.as_ref().ok_or_else(|| {
CalibrationError::InvalidMeasurement(
"Printer calibrator not fitted; call fit() first".to_string(),
)
})?;
let (cmyk, _de) = inv.invert(lab, None, self.config.max_iter)?;
Ok(cmyk)
}
pub fn soft_proof(&self, rgb_pixels: &[[f64; 3]]) -> CalibrationResult<Vec<Lab>> {
let inv = self.inverse.as_ref().ok_or_else(|| {
CalibrationError::InvalidMeasurement(
"Printer calibrator not fitted; call fit() first".to_string(),
)
})?;
soft_proof(rgb_pixels, inv, self.config.render_intent)
}
#[must_use]
pub fn forward_model(&self) -> Option<&LinearForwardModel> {
self.forward.as_ref()
}
#[must_use]
pub fn config(&self) -> &PrinterCalibratorConfig {
&self.config
}
}
fn mat_transpose_mul(x: &[[f64; 5]], n: usize) -> [[f64; 5]; 5] {
let mut result = [[0_f64; 5]; 5];
for i in 0..5 {
for j in 0..5 {
let mut s = 0_f64;
for k in 0..n {
s += x[k][i] * x[k][j];
}
result[i][j] = s;
}
}
result
}
fn mat_t_times_vec(x: &[[f64; 5]], y: &[f64], n: usize) -> [f64; 5] {
let mut result = [0_f64; 5];
for i in 0..5 {
let mut s = 0_f64;
for k in 0..n {
s += x[k][i] * y[k];
}
result[i] = s;
}
result
}
fn mat_times_vec_5(m: &[[f64; 5]; 5], v: &[f64; 5]) -> [f64; 5] {
let mut result = [0_f64; 5];
for i in 0..5 {
let mut s = 0_f64;
for j in 0..5 {
s += m[i][j] * v[j];
}
result[i] = s;
}
result
}
fn invert_5x5(m: &[[f64; 5]; 5]) -> Option<[[f64; 5]; 5]> {
let mut aug = [[0_f64; 10]; 5];
for i in 0..5 {
for j in 0..5 {
aug[i][j] = m[i][j];
}
aug[i][5 + i] = 1.0;
}
for col in 0..5 {
let mut max_row = col;
let mut max_val = aug[col][col].abs();
for row in (col + 1)..5 {
if aug[row][col].abs() > max_val {
max_val = aug[row][col].abs();
max_row = row;
}
}
if max_val < 1e-12 {
return None;
}
aug.swap(col, max_row);
let pivot = aug[col][col];
for j in 0..10 {
aug[col][j] /= pivot;
}
for row in 0..5 {
if row != col {
let factor = aug[row][col];
for j in 0..10 {
aug[row][j] -= factor * aug[col][j];
}
}
}
}
let mut inv = [[0_f64; 5]; 5];
for i in 0..5 {
for j in 0..5 {
inv[i][j] = aug[i][5 + j];
}
}
Some(inv)
}
fn build_jacobian_4x3(coefs: &[[f64; 5]; 3]) -> [[f64; 3]; 4] {
let mut j = [[0_f64; 3]; 4];
for lab_ch in 0..3 {
for ink in 0..4 {
j[ink][lab_ch] = coefs[lab_ch][ink] / 100.0;
}
}
j
}
fn mat4x3_jtj(j: &[[f64; 3]; 4]) -> [[f64; 4]; 4] {
let mut result = [[0_f64; 4]; 4];
for i in 0..4 {
for k in 0..4 {
let mut s = 0_f64;
for m in 0..3 {
s += j[i][m] * j[k][m];
}
result[i][k] = s;
}
}
result
}
fn invert_4x4(m: &[[f64; 4]; 4]) -> Option<[[f64; 4]; 4]> {
let mut aug = [[0_f64; 8]; 4];
for i in 0..4 {
for j in 0..4 {
aug[i][j] = m[i][j];
}
aug[i][4 + i] = 1.0;
}
for col in 0..4 {
let mut max_row = col;
let mut max_val = aug[col][col].abs();
for row in (col + 1)..4 {
if aug[row][col].abs() > max_val {
max_val = aug[row][col].abs();
max_row = row;
}
}
if max_val < 1e-12 {
return None;
}
aug.swap(col, max_row);
let pivot = aug[col][col];
for j in 0..8 {
aug[col][j] /= pivot;
}
for row in 0..4 {
if row != col {
let factor = aug[row][col];
for j in 0..8 {
aug[row][j] -= factor * aug[col][j];
}
}
}
}
let mut inv = [[0_f64; 4]; 4];
for i in 0..4 {
for j in 0..4 {
inv[i][j] = aug[i][4 + j];
}
}
Some(inv)
}
fn mat4x4_times_mat4x3t(m44: &[[f64; 4]; 4], j43: &[[f64; 3]; 4]) -> [[f64; 3]; 4] {
let mut result = [[0_f64; 3]; 4];
for i in 0..4 {
for lab_ch in 0..3 {
let mut s = 0_f64;
for k in 0..4 {
s += m44[i][k] * j43[k][lab_ch];
}
result[i][lab_ch] = s;
}
}
result
}
fn mat4x3_times_vec3(m: &[[f64; 3]; 4], v: &[f64; 3]) -> [f64; 4] {
let mut result = [0_f64; 4];
for i in 0..4 {
let mut s = 0_f64;
for j in 0..3 {
s += m[i][j] * v[j];
}
result[i] = s;
}
result
}
fn delta_e_lab(a: Lab, b: Lab) -> f64 {
let dl = a[0] - b[0];
let da = a[1] - b[1];
let db = a[2] - b[2];
(dl * dl + da * da + db * db).sqrt()
}
fn srgb_to_lab(rgb: [f64; 3]) -> Lab {
let lin: [f64; 3] = std::array::from_fn(|i| {
let v = rgb[i].clamp(0.0, 1.0);
if v <= 0.04045 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
});
let x = 0.4124564 * lin[0] + 0.3575761 * lin[1] + 0.1804375 * lin[2];
let y = 0.2126729 * lin[0] + 0.7151522 * lin[1] + 0.0721750 * lin[2];
let z = 0.0193339 * lin[0] + 0.1191920 * lin[1] + 0.9503041 * lin[2];
let xn = 0.9505;
let yn = 1.0;
let zn = 1.0890;
let fx = xyz_f(x / xn);
let fy = xyz_f(y / yn);
let fz = xyz_f(z / zn);
let l = 116.0 * fy - 16.0;
let a = 500.0 * (fx - fy);
let b = 200.0 * (fy - fz);
[l, a, b]
}
fn xyz_f(t: f64) -> f64 {
const DELTA: f64 = 6.0 / 29.0;
if t > DELTA * DELTA * DELTA {
t.cbrt()
} else {
t / (3.0 * DELTA * DELTA) + 4.0 / 29.0
}
}
fn gamut_compress_perceptual(lab: Lab) -> Lab {
let chroma = (lab[1] * lab[1] + lab[2] * lab[2]).sqrt();
let max_chroma = 60.0_f64;
if chroma <= max_chroma {
return lab;
}
let scale = max_chroma / chroma;
[lab[0], lab[1] * scale, lab[2] * scale]
}
#[cfg(test)]
mod tests {
use super::*;
fn make_patches() -> Vec<PrinterPatch> {
vec![
PrinterPatch::new([0.0, 0.0, 0.0, 0.0], [100.0, 0.0, 0.0]),
PrinterPatch::new([100.0, 0.0, 0.0, 0.0], [50.0, -35.0, -15.0]),
PrinterPatch::new([0.0, 100.0, 0.0, 0.0], [55.0, 60.0, -30.0]),
PrinterPatch::new([0.0, 0.0, 100.0, 0.0], [70.0, -5.0, 70.0]),
PrinterPatch::new([0.0, 0.0, 0.0, 100.0], [20.0, 5.0, 5.0]),
PrinterPatch::new([50.0, 50.0, 50.0, 0.0], [60.0, 10.0, 10.0]),
PrinterPatch::new([0.0, 0.0, 0.0, 50.0], [60.0, 2.0, 2.0]),
]
}
#[test]
fn test_enforce_ink_limit_below_max() {
let cmyk = [50.0, 50.0, 50.0, 50.0]; let result = enforce_ink_limit(cmyk, 300.0);
for i in 0..4 {
assert!((result[i] - cmyk[i]).abs() < 1e-9);
}
}
#[test]
fn test_enforce_ink_limit_above_max() {
let cmyk = [100.0, 100.0, 100.0, 100.0]; let result = enforce_ink_limit(cmyk, 300.0);
let total: f64 = result.iter().sum();
assert!((total - 300.0).abs() < 1e-6, "total={total}");
}
#[test]
fn test_enforce_ink_limit_zero() {
let cmyk = [0.0, 0.0, 0.0, 0.0];
let result = enforce_ink_limit(cmyk, 300.0);
assert_eq!(result, [0.0, 0.0, 0.0, 0.0]);
}
#[test]
fn test_linear_forward_model_fit_too_few_patches() {
let patches = vec![
PrinterPatch::new([0.0, 0.0, 0.0, 0.0], [100.0, 0.0, 0.0]),
PrinterPatch::new([100.0, 0.0, 0.0, 0.0], [50.0, -35.0, -15.0]),
];
assert!(LinearForwardModel::fit(&patches).is_err());
}
#[test]
fn test_linear_forward_model_fit_and_predict() {
let patches = make_patches();
let model = LinearForwardModel::fit(&patches).expect("fit should succeed");
let paper_white = model.predict([0.0, 0.0, 0.0, 0.0]);
assert!(
(paper_white[0] - 100.0).abs() < 10.0,
"L={}",
paper_white[0]
);
}
#[test]
fn test_linear_forward_model_coefficients_shape() {
let patches = make_patches();
let model = LinearForwardModel::fit(&patches).expect("fit ok");
assert_eq!(model.coefficients().len(), 3);
assert_eq!(model.coefficients()[0].len(), 5);
}
#[test]
fn test_printer_calibrator_workflow() {
let patches = make_patches();
let mut calibrator = PrinterCalibrator::new(PrinterCalibratorConfig::default());
calibrator.fit(&patches).expect("fit ok");
let lab = calibrator
.predict_lab([0.0, 0.0, 0.0, 0.0])
.expect("predict ok");
assert!((lab[0] - 100.0).abs() < 10.0, "L={}", lab[0]);
}
#[test]
fn test_printer_calibrator_not_fitted_error() {
let calibrator = PrinterCalibrator::new(PrinterCalibratorConfig::default());
assert!(calibrator.predict_lab([0.0, 0.0, 0.0, 0.0]).is_err());
assert!(calibrator.lab_to_cmyk([50.0, 0.0, 0.0]).is_err());
}
#[test]
fn test_soft_proof_basic() {
let patches = make_patches();
let mut calibrator = PrinterCalibrator::new(PrinterCalibratorConfig::default());
calibrator.fit(&patches).expect("fit ok");
let result = calibrator
.soft_proof(&[[1.0, 1.0, 1.0], [0.5, 0.5, 0.5]])
.expect("soft proof ok");
assert_eq!(result.len(), 2);
for lab in &result {
assert!(lab[0] >= 0.0 && lab[0] <= 110.0, "L={}", lab[0]);
}
}
#[test]
fn test_srgb_to_lab_white() {
let lab = srgb_to_lab([1.0, 1.0, 1.0]);
assert!((lab[0] - 100.0).abs() < 2.0, "L={}", lab[0]);
assert!(lab[1].abs() < 2.0, "a={}", lab[1]);
assert!(lab[2].abs() < 2.0, "b={}", lab[2]);
}
#[test]
fn test_srgb_to_lab_black() {
let lab = srgb_to_lab([0.0, 0.0, 0.0]);
assert!(lab[0].abs() < 1.0, "L={}", lab[0]);
}
#[test]
fn test_render_intent_variants() {
assert_ne!(RenderIntent::Perceptual, RenderIntent::RelativeColorimetric);
assert_ne!(RenderIntent::Perceptual, RenderIntent::AbsoluteColorimetric);
}
}