use std::path::Path;
use serde::{Deserialize, Serialize};
pub const RAW_MIN: i32 = 0;
pub const RAW_MAX: i32 = 4095;
pub const CALIB_FILE_VERSION: i64 = 1;
const SINGULAR_EPS: f64 = 1e-12;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FitError {
NotEnoughPairs,
LengthMismatch,
Singular,
}
impl std::fmt::Display for FitError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FitError::NotEnoughPairs => f.write_str("need at least 5 sample/target pairs"),
FitError::LengthMismatch => f.write_str("samples and targets must be the same length"),
FitError::Singular => f.write_str("affine fit is singular (samples colinear)"),
}
}
}
impl std::error::Error for FitError {}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Affine {
pub a: f64,
pub b: f64,
pub c: f64,
pub d: f64,
pub e: f64,
pub f: f64,
}
impl Affine {
pub fn apply(&self, x_raw: i32, y_raw: i32) -> (i32, i32) {
let xr = x_raw as f64;
let yr = y_raw as f64;
let x = self.a * xr + self.b * yr + self.c;
let y = self.d * xr + self.e * yr + self.f;
(round_half_even(x) as i32, round_half_even(y) as i32)
}
pub fn to_list(&self) -> [f64; 6] {
[self.a, self.b, self.c, self.d, self.e, self.f]
}
pub fn from_list(values: &[f64]) -> Option<Affine> {
if values.len() != 6 {
return None;
}
Some(Affine {
a: values[0],
b: values[1],
c: values[2],
d: values[3],
e: values[4],
f: values[5],
})
}
}
fn round_half_even(x: f64) -> f64 {
let floor = x.floor();
let diff = x - floor;
if diff < 0.5 {
floor
} else if diff > 0.5 {
floor + 1.0
} else {
if (floor as i64) % 2 == 0 {
floor
} else {
floor + 1.0
}
}
}
pub fn identity_for(rotation: i32, lcd_size: (i32, i32)) -> Affine {
let mut rotation = rotation.rem_euclid(360);
if !matches!(rotation, 0 | 90 | 180 | 270) {
rotation = 0;
}
let (lcd_w, lcd_h) = lcd_size;
let lcd_w = lcd_w as f64;
let lcd_h = lcd_h as f64;
let mut span = (RAW_MAX - RAW_MIN) as f64;
if span <= 0.0 {
span = 1.0;
}
let sx = lcd_w / span;
let sy = lcd_h / span;
match rotation {
0 => Affine {
a: sx,
b: 0.0,
c: 0.0,
d: 0.0,
e: sy,
f: 0.0,
},
90 => Affine {
a: 0.0,
b: lcd_w / span,
c: 0.0,
d: -lcd_h / span,
e: 0.0,
f: lcd_h,
},
180 => Affine {
a: -sx,
b: 0.0,
c: lcd_w,
d: 0.0,
e: -sy,
f: lcd_h,
},
_ => Affine {
a: 0.0,
b: -lcd_w / span,
c: lcd_w,
d: lcd_h / span,
e: 0.0,
f: 0.0,
},
}
}
pub fn compute_from_samples(
samples: &[(i32, i32)],
targets: &[(i32, i32)],
) -> Result<(Affine, f64), FitError> {
if samples.len() < 5 || targets.len() < 5 {
return Err(FitError::NotEnoughPairs);
}
if samples.len() != targets.len() {
return Err(FitError::LengthMismatch);
}
let mut sxx = 0.0;
let mut sxy = 0.0;
let mut sx = 0.0;
let mut syy = 0.0;
let mut sy = 0.0;
let mut n = 0.0;
let mut txx = 0.0; let mut txy = 0.0;
let mut tx_ = 0.0;
let mut tyx = 0.0; let mut tyy = 0.0;
let mut ty_ = 0.0;
for (&(x_raw, y_raw), &(x_t, y_t)) in samples.iter().zip(targets.iter()) {
let x_raw_f = x_raw as f64;
let y_raw_f = y_raw as f64;
let x_t = x_t as f64;
let y_t = y_t as f64;
sxx += x_raw_f * x_raw_f;
sxy += x_raw_f * y_raw_f;
syy += y_raw_f * y_raw_f;
sx += x_raw_f;
sy += y_raw_f;
n += 1.0;
txx += x_raw_f * x_t;
txy += y_raw_f * x_t;
tx_ += x_t;
tyx += x_raw_f * y_t;
tyy += y_raw_f * y_t;
ty_ += y_t;
}
let m = [[sxx, sxy, sx], [sxy, syy, sy], [sx, sy, n]];
let abc = solve3(&m, [txx, txy, tx_])?;
let def = solve3(&m, [tyx, tyy, ty_])?;
let affine = Affine {
a: abc[0],
b: abc[1],
c: abc[2],
d: def[0],
e: def[1],
f: def[2],
};
let mut sqsum = 0.0;
for (&(x_raw, y_raw), &(x_t, y_t)) in samples.iter().zip(targets.iter()) {
let (x_p, y_p) = affine.apply(x_raw, y_raw);
let dx = (x_p - x_t) as f64;
let dy = (y_p - y_t) as f64;
sqsum += dx * dx + dy * dy;
}
let rms = (sqsum / (samples.len().max(1) as f64)).sqrt();
Ok((affine, rms))
}
#[allow(clippy::needless_range_loop)]
fn solve3(m: &[[f64; 3]; 3], rhs: [f64; 3]) -> Result<[f64; 3], FitError> {
let mut a = [
[m[0][0], m[0][1], m[0][2], rhs[0]],
[m[1][0], m[1][1], m[1][2], rhs[1]],
[m[2][0], m[2][1], m[2][2], rhs[2]],
];
let n = 3usize;
for i in 0..n {
let mut pivot = i;
let mut max_abs = a[i][i].abs();
for k in (i + 1)..n {
if a[k][i].abs() > max_abs {
max_abs = a[k][i].abs();
pivot = k;
}
}
if max_abs < SINGULAR_EPS {
return Err(FitError::Singular);
}
if pivot != i {
a.swap(i, pivot);
}
for k in (i + 1)..n {
let factor = a[k][i] / a[i][i];
for j in i..=n {
a[k][j] -= factor * a[i][j];
}
}
}
let mut x = [0.0f64; 3];
for i in (0..n).rev() {
let mut s = a[i][n];
for j in (i + 1)..n {
s -= a[i][j] * x[j];
}
x[i] = s / a[i][i];
}
Ok(x)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CalibBlob {
version: i64,
calibrated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
device: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
raw_range: Option<[i32; 2]>,
#[serde(skip_serializing_if = "Option::is_none")]
lcd_size: Option<[i32; 2]>,
#[serde(skip_serializing_if = "Option::is_none")]
rotation_applied_at_save: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
matrix: Option<Vec<f64>>,
#[serde(skip_serializing_if = "Option::is_none")]
rms_residual_px: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
skipped: Option<bool>,
saved_at: i64,
}
#[derive(Debug, Clone)]
pub struct SaveParams {
pub rotation: i32,
pub rms: f64,
pub device: String,
pub raw_range: (i32, i32),
pub lcd_size: (i32, i32),
}
impl Default for SaveParams {
fn default() -> Self {
SaveParams {
rotation: 0,
rms: 0.0,
device: "ads7846".to_string(),
raw_range: (RAW_MIN, RAW_MAX),
lcd_size: (480, 320),
}
}
}
fn read_valid_blob(path: &Path) -> Option<CalibBlob> {
let text = std::fs::read_to_string(path).ok()?;
let blob: CalibBlob = serde_json::from_str(&text).ok()?;
if blob.version != CALIB_FILE_VERSION || !blob.calibrated {
return None;
}
Some(blob)
}
fn blob_affine(blob: &CalibBlob) -> Option<Affine> {
let matrix = blob.matrix.as_ref()?;
if matrix.len() != 6 {
return None;
}
Affine::from_list(matrix)
}
pub fn load(path: &Path) -> Option<Affine> {
blob_affine(&read_valid_blob(path)?)
}
pub fn load_for(path: &Path, expected_lcd: (i32, i32), expected_rotation: i32) -> Option<Affine> {
let blob = read_valid_blob(path)?;
if blob.lcd_size != Some([expected_lcd.0, expected_lcd.1]) {
return None;
}
if blob.rotation_applied_at_save != Some(expected_rotation.rem_euclid(360)) {
return None;
}
blob_affine(&blob)
}
pub fn save(affine: &Affine, path: &Path, params: &SaveParams) -> std::io::Result<()> {
let blob = CalibBlob {
version: CALIB_FILE_VERSION,
calibrated: true,
device: Some(params.device.clone()),
raw_range: Some([params.raw_range.0, params.raw_range.1]),
lcd_size: Some([params.lcd_size.0, params.lcd_size.1]),
rotation_applied_at_save: Some(params.rotation.rem_euclid(360)),
matrix: Some(affine.to_list().to_vec()),
rms_residual_px: Some(params.rms),
skipped: None,
saved_at: now_unix_secs(),
};
atomic_write_json(path, &blob)
}
pub fn save_skip_marker(path: &Path) -> std::io::Result<()> {
let blob = CalibBlob {
version: CALIB_FILE_VERSION,
calibrated: false,
device: None,
raw_range: None,
lcd_size: None,
rotation_applied_at_save: None,
matrix: None,
rms_residual_px: None,
skipped: Some(true),
saved_at: now_unix_secs(),
};
atomic_write_json(path, &blob)
}
fn now_unix_secs() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn atomic_write_json(path: &Path, blob: &CalibBlob) -> std::io::Result<()> {
use std::io::Write;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let body = serde_json::to_vec(blob).map_err(std::io::Error::other)?;
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("touch.calib");
let tmp = parent.join(format!("{}.{}.tmp", file_name, std::process::id()));
let write_result = (|| -> std::io::Result<()> {
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp)?;
f.write_all(&body)?;
f.flush()?;
f.sync_all()?;
Ok(())
})();
if write_result.is_err() {
let _ = std::fs::remove_file(&tmp);
return write_result;
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_rounds_to_nearest_pixel() {
let m = Affine {
a: 2.0,
b: 0.0,
c: 0.4,
d: 0.0,
e: 3.0,
f: 0.6,
};
let (x, y) = m.apply(10, 5);
assert_eq!(x, 20); assert_eq!(y, 16); }
#[test]
fn identity_rotation_0() {
let m = identity_for(0, (480, 320));
let span = (RAW_MAX - RAW_MIN) as f64;
assert_eq!(m.apply(0, 0), (0, 0));
assert_eq!(m.apply(RAW_MAX, RAW_MAX), (480, 320));
assert!((m.a - 480.0 / span).abs() < 1e-12);
assert!((m.e - 320.0 / span).abs() < 1e-12);
assert_eq!(m.b, 0.0);
assert_eq!(m.d, 0.0);
}
#[test]
fn identity_rotation_90() {
let m = identity_for(90, (480, 320));
assert_eq!(m.a, 0.0);
assert_eq!(m.e, 0.0);
assert_eq!(m.f, 320.0);
assert_eq!(m.apply(0, 0), (0, 320));
assert_eq!(m.apply(RAW_MAX, RAW_MAX), (480, 0));
}
#[test]
fn identity_rotation_180() {
let m = identity_for(180, (480, 320));
assert_eq!(m.c, 480.0);
assert_eq!(m.f, 320.0);
assert_eq!(m.apply(0, 0), (480, 320));
assert_eq!(m.apply(RAW_MAX, RAW_MAX), (0, 0));
}
#[test]
fn identity_rotation_270() {
let m = identity_for(270, (480, 320));
assert_eq!(m.c, 480.0);
assert_eq!(m.f, 0.0);
assert_eq!(m.apply(0, 0), (480, 0));
assert_eq!(m.apply(RAW_MAX, RAW_MAX), (0, 320));
}
#[test]
fn identity_out_of_range_rotation_falls_back_to_zero() {
let m45 = identity_for(45, (480, 320));
let m0 = identity_for(0, (480, 320));
assert_eq!(m45, m0);
let m_neg = identity_for(-90, (480, 320));
assert_eq!(m_neg, identity_for(270, (480, 320)));
}
#[test]
fn least_squares_recovers_a_known_transform_with_zero_rms() {
let truth = Affine {
a: 0.1,
b: 0.0,
c: 5.0,
d: 0.0,
e: 0.07,
f: 3.0,
};
let samples = [
(100, 200),
(3900, 250),
(2000, 3800),
(500, 3000),
(3500, 1500),
(1800, 800),
];
let targets: Vec<(i32, i32)> = samples.iter().map(|&(x, y)| truth.apply(x, y)).collect();
let (fit, rms) = compute_from_samples(&samples, &targets).unwrap();
assert!((fit.a - truth.a).abs() < 1e-3, "a={}", fit.a);
assert!((fit.e - truth.e).abs() < 1e-3, "e={}", fit.e);
assert!((fit.c - truth.c).abs() < 1.0, "c={}", fit.c);
for (&(x, y), &t) in samples.iter().zip(targets.iter()) {
assert_eq!(fit.apply(x, y), t);
}
assert!(rms < 1.0, "rms={rms}");
}
#[test]
fn least_squares_needs_five_pairs() {
let s = [(0, 0), (1, 1), (2, 2), (3, 3)];
let t = [(0, 0), (1, 1), (2, 2), (3, 3)];
assert_eq!(
compute_from_samples(&s, &t).unwrap_err(),
FitError::NotEnoughPairs
);
}
#[test]
fn least_squares_rejects_length_mismatch() {
let s = [(0, 0), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5)];
let t = [(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)];
assert_eq!(
compute_from_samples(&s, &t).unwrap_err(),
FitError::LengthMismatch
);
}
#[test]
fn singular_guard_rejects_colinear_samples() {
let s = [(0, 0), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5)];
let t = [(0, 0), (10, 10), (20, 20), (30, 30), (40, 40), (50, 50)];
assert_eq!(
compute_from_samples(&s, &t).unwrap_err(),
FitError::Singular
);
}
#[test]
fn save_load_round_trip() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("touch.calib");
let m = Affine {
a: 0.117,
b: 0.001,
c: -3.2,
d: -0.002,
e: 0.078,
f: 4.5,
};
let params = SaveParams {
rotation: 90,
rms: 2.5,
..SaveParams::default()
};
save(&m, &path, ¶ms).unwrap();
let loaded = load(&path).unwrap();
assert_eq!(loaded, m);
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
for k in [
"version",
"calibrated",
"device",
"raw_range",
"lcd_size",
"rotation_applied_at_save",
"matrix",
"rms_residual_px",
"saved_at",
] {
assert!(v.get(k).is_some(), "missing key {k}");
}
assert_eq!(v["version"], CALIB_FILE_VERSION);
assert_eq!(v["calibrated"], true);
assert_eq!(v["device"], "ads7846");
assert_eq!(v["raw_range"], serde_json::json!([0, 4095]));
assert_eq!(v["lcd_size"], serde_json::json!([480, 320]));
assert_eq!(v["rotation_applied_at_save"], 90);
assert_eq!(v["matrix"].as_array().unwrap().len(), 6);
assert_eq!(v["rms_residual_px"], 2.5);
let text = std::fs::read_to_string(&path).unwrap();
assert!(!text.contains(", "));
assert!(!text.contains(": "));
let stray = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.any(|e| e.file_name().to_string_lossy().ends_with(".tmp"));
assert!(!stray, "stray .tmp file left behind");
}
#[test]
fn load_for_rejects_a_mismatched_panel_or_rotation() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("touch.calib");
let m = Affine {
a: 0.1,
b: 0.0,
c: -3.0,
d: 0.0,
e: 0.1,
f: 4.0,
};
let params = SaveParams {
rotation: 90,
lcd_size: (480, 320),
..SaveParams::default()
};
save(&m, &path, ¶ms).unwrap();
assert_eq!(load_for(&path, (480, 320), 90), Some(m));
assert!(load_for(&path, (320, 240), 90).is_none());
assert!(load_for(&path, (480, 320), 0).is_none());
assert_eq!(load(&path), Some(m));
}
#[test]
fn load_for_rejects_a_blob_without_geometry() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("touch.calib");
std::fs::write(
&path,
format!(
r#"{{"version":{CALIB_FILE_VERSION},"calibrated":true,"matrix":[0.1,0.0,-3.0,0.0,0.1,4.0],"saved_at":0}}"#
),
)
.unwrap();
assert!(
load(&path).is_some(),
"the geometry-blind load still accepts it"
);
assert!(
load_for(&path, (480, 320), 0).is_none(),
"an untagged blob's geometry cannot be proven, so load_for rejects it"
);
}
#[test]
fn skip_marker_loads_as_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("touch.calib");
save_skip_marker(&path).unwrap();
assert!(load(&path).is_none());
let v: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(v["calibrated"], false);
assert_eq!(v["skipped"], true);
assert!(v.get("matrix").is_none());
}
#[test]
fn load_missing_file_is_none() {
assert!(load(Path::new("/nonexistent/touch.calib")).is_none());
}
#[test]
fn load_version_mismatch_is_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("touch.calib");
let blob = serde_json::json!({
"version": 99,
"calibrated": true,
"matrix": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0],
"saved_at": 0,
});
std::fs::write(&path, serde_json::to_vec(&blob).unwrap()).unwrap();
assert!(load(&path).is_none());
}
#[test]
fn load_wrong_matrix_length_is_none() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("touch.calib");
let blob = serde_json::json!({
"version": CALIB_FILE_VERSION,
"calibrated": true,
"matrix": [1.0, 0.0, 0.0],
"saved_at": 0,
});
std::fs::write(&path, serde_json::to_vec(&blob).unwrap()).unwrap();
assert!(load(&path).is_none());
}
}