ifc_lite_processing/appearance/
registration.rs1use super::registration_types::*;
6use nalgebra::{linalg::SVD, DMatrix, Matrix3, Vector3};
7use sha2::{Digest, Sha256};
8use std::collections::HashSet;
9
10const ALGORITHM: &str = "ifclite-rigid-correspondence-v1";
11const MAX_POINTS: usize = 256;
12const MIN_NON_COLLINEARITY: f64 = 1e-10;
13
14fn label(value: &str) -> bool {
15 !value.trim().is_empty() && value.len() <= 256 && !value.chars().any(char::is_control)
16}
17fn validate(r: &ScanRegistrationRequest) -> Result<(), String> {
18 for frame in [&r.source_frame, &r.target_frame] {
19 if !label(&frame.frame_key)
20 || frame.asset_sha256.len() != 64
21 || !frame
22 .asset_sha256
23 .bytes()
24 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
25 {
26 return Err(
27 "Registration requires exact frame keys and lowercase SHA-256 asset identities"
28 .into(),
29 );
30 }
31 }
32 if r.fit.len() < 3 || r.fit.len() > MAX_POINTS || r.held_out.len() > MAX_POINTS {
33 return Err("Registration needs 3–256 fit points and at most 256 held-out points".into());
34 }
35 let mut ids = HashSet::new();
36 let mut source_ids = HashSet::new();
37 let mut target_ids = HashSet::new();
38 let mut sources = HashSet::new();
39 let mut targets = HashSet::new();
40 let key = |p: [f64; 3]| p.map(|v| if v == 0. { 0 } else { v.to_bits() });
44 for p in r.fit.iter().chain(&r.held_out) {
45 if !label(&p.id)
46 || !label(&p.source_observation)
47 || !label(&p.target_feature)
48 || !p
49 .source
50 .iter()
51 .chain(&p.target)
52 .all(|v| v.is_finite() && v.abs() <= 1e12)
53 {
54 return Err("Registration needs bounded observation identities and finite coordinates within 1e12 metres".into());
55 }
56 if !ids.insert(&p.id)
57 || !source_ids.insert(&p.source_observation)
58 || !target_ids.insert(&p.target_feature)
59 || !sources.insert(key(p.source))
60 || !targets.insert(key(p.target))
61 {
62 return Err("Fit and held-out observations must be distinct: duplicate ID, feature or coordinate".into());
63 }
64 }
65 Ok(())
66}
67fn centroid(points: &[ScanCorrespondence], source: bool) -> Vector3<f64> {
68 let point = |p: &ScanCorrespondence| Vector3::from(if source { p.source } else { p.target });
69 let anchor = point(&points[0]);
70 anchor
71 + points
72 .iter()
73 .map(|p| point(p) - anchor)
74 .sum::<Vector3<f64>>()
75 / points.len() as f64
76}
77fn spread(points: &[Vector3<f64>]) -> Result<RegistrationSpread, String> {
78 let scatter = points
82 .iter()
83 .fold(Matrix3::zeros(), |sum, p| sum + p * p.transpose());
84 let values = SVD::try_new(
85 DMatrix::from_column_slice(3, 3, scatter.as_slice()),
86 false,
87 false,
88 f64::EPSILON,
89 128,
90 )
91 .ok_or("Registration scatter decomposition did not converge within its budget")?
92 .singular_values;
93 if !values.iter().all(|v| v.is_finite())
94 || values[0] <= 0.
95 || values[1] / values[0] < MIN_NON_COLLINEARITY
96 {
97 return Err(
98 "Degenerate registration: fitting points coincide or are effectively collinear".into(),
99 );
100 }
101 Ok(RegistrationSpread {
102 singular_values: std::array::from_fn(|i| values[i]),
103 non_collinearity_ratio: values[1] / values[0],
104 non_planarity_ratio: values[2] / values[0],
105 })
106}
107fn residuals(
108 points: &[ScanCorrespondence],
109 rotation: &Matrix3<f64>,
110 source: Vector3<f64>,
111 target: Vector3<f64>,
112) -> RegistrationResiduals {
113 let points: Vec<_> = points
114 .iter()
115 .map(|p| {
116 let error =
117 target + rotation * (Vector3::from(p.source) - source) - Vector3::from(p.target);
118 CorrespondenceResidual {
119 id: p.id.clone(),
120 vector_metres: error.into(),
121 distance_metres: error.norm(),
122 }
123 })
124 .collect();
125 let rms_metres = (!points.is_empty()).then(|| {
126 (points
127 .iter()
128 .map(|p| p.distance_metres.powi(2))
129 .sum::<f64>()
130 / points.len() as f64)
131 .sqrt()
132 });
133 let max_metres = points.iter().map(|p| p.distance_metres).reduce(f64::max);
134 RegistrationResiduals {
135 points,
136 rms_metres,
137 max_metres,
138 }
139}
140
141pub fn register_scan_correspondences(
145 r: &ScanRegistrationRequest,
146) -> Result<ScanRegistrationReport, String> {
147 validate(r)?;
148 let source_anchor = centroid(&r.fit, true);
149 let target_anchor = centroid(&r.fit, false);
150 let sources: Vec<_> = r
151 .fit
152 .iter()
153 .map(|p| Vector3::from(p.source) - source_anchor)
154 .collect();
155 let targets: Vec<_> = r
156 .fit
157 .iter()
158 .map(|p| Vector3::from(p.target) - target_anchor)
159 .collect();
160 let source_spread = spread(&sources)?;
161 let target_spread = spread(&targets)?;
162 let covariance = sources
163 .iter()
164 .zip(&targets)
165 .fold(Matrix3::zeros(), |sum, (s, t)| sum + s * t.transpose());
166 let svd = SVD::try_new(
167 DMatrix::from_column_slice(3, 3, covariance.as_slice()),
168 true,
169 true,
170 f64::EPSILON,
171 128,
172 )
173 .ok_or("Registration rotation decomposition did not converge within its budget")?;
174 if svd.singular_values[0] <= 0.
175 || svd.singular_values[1] / svd.singular_values[0] < MIN_NON_COLLINEARITY
176 {
177 return Err(
178 "Degenerate registration: correspondence covariance cannot constrain a unique rotation"
179 .into(),
180 );
181 }
182 let dynamic_u = svd.u.ok_or("Registration SVD omitted source basis")?;
183 let u = Matrix3::from_column_slice(dynamic_u.as_slice());
184 let dynamic_vt = svd
185 .v_t
186 .ok_or("Registration SVD omitted destination basis")?;
187 let v = Matrix3::from_column_slice(dynamic_vt.as_slice()).transpose();
188 let mut correction = Matrix3::identity();
189 correction[(2, 2)] = (v * u.transpose()).determinant().signum();
190 let rotation = v * correction * u.transpose();
191 if !rotation.iter().all(|v| v.is_finite()) || (rotation.determinant() - 1.).abs() > 1e-10 {
192 return Err("Registration could not produce a finite proper rotation".into());
193 }
194 let mut digest = Sha256::new();
195 digest.update(ALGORITHM.as_bytes());
196 digest.update([0]);
197 digest.update(
198 serde_json::to_vec(r).map_err(|e| format!("Cannot bind registration request: {e}"))?,
199 );
200 let mut diagnostics =
201 vec!["Mathematical rigid fit only; no automatic registration or accuracy approval".into()];
202 if r.held_out.is_empty() {
203 diagnostics.push("No held-out evidence supplied".into());
204 }
205 if r.fit.len() < 4 || r.held_out.len() < 4 {
206 diagnostics.push("F4 acceptance requires at least four fitting and four independently selected, spatially distributed checks".into());
207 }
208 if source_spread.non_planarity_ratio < 1e-10 || target_spread.non_planarity_ratio < 1e-10 {
209 diagnostics.push("Planar non-collinear fitting points are valid; check coverage at different heights separately".into());
210 }
211 if correction[(2, 2)] < 0. && svd.singular_values[2] / svd.singular_values[0] > 1e-10 {
212 diagnostics.push("Unconstrained solution would reflect; proper rotation retained and mismatch remains in residuals".into());
213 }
214 Ok(ScanRegistrationReport {
215 request_sha256: format!("{:x}", digest.finalize()),
216 algorithm: ALGORITHM.into(),
217 source_frame: r.source_frame.clone(),
218 target_frame: r.target_frame.clone(),
219 rotation: std::array::from_fn(|i| std::array::from_fn(|j| rotation[(i, j)])),
220 source_anchor: source_anchor.into(),
221 target_anchor: target_anchor.into(),
222 fit: residuals(&r.fit, &rotation, source_anchor, target_anchor),
223 held_out: residuals(&r.held_out, &rotation, source_anchor, target_anchor),
224 source_spread,
225 target_spread,
226 diagnostics,
227 })
228}
229
230#[cfg(test)]
231#[path = "registration_tests.rs"]
232mod tests;