long_screen/kernels/track/reacquire.rs
1//! Anchor re-acquisition and drift correction — split out of the former monolithic `track.rs`.
2//! `track.ts::reacquire`/`driftCorrection`, each split into a
3//! hypothesis/audit half with no native luma and a native-patch refinement half, so the ABI layer can decide
4//! whether the resident native luma plane needs filling at all before calling the refinement half.
5
6use crate::kernels::features::{match_features, Feature};
7use crate::kernels::geometry::{js_hypot, Rect};
8use crate::kernels::motion::{
9 refine_patches, refine_spatial_patches, translation_hypotheses, Gray, MatchPoints, Motion,
10 Patch, Point,
11};
12
13/// `track.ts::reacquire`'s hypothesis-generation half: `matchFeatures` + `translationHypotheses`
14/// filtered by `support >= 6`, with NO native luma involved. Split out from the native-refinement half
15/// (`reacquire_refine`) so the ABI layer can decide whether the resident native luma plane needs filling at
16/// all this call — the original TS `native()` thunk was only ever evaluated once `models.slice(0, 4)` actually
17/// had a candidate (`reacquire`'s own doc comment); an empty result here means the caller must return "no
18/// reacquisition" without touching native luma, exactly like the original's `models.length === 0` path.
19pub fn reacquire_hypotheses(anchor_features: &[Feature], own_features: &[Feature]) -> Vec<Motion> {
20 let raw_matches = match_features(anchor_features, own_features, true);
21 let match_points: Vec<MatchPoints> = raw_matches
22 .iter()
23 .map(|m| {
24 let a = &anchor_features[m.a as usize];
25 let b = &own_features[m.b as usize];
26 MatchPoints {
27 ax: a.x as f64,
28 ay: a.y as f64,
29 bx: b.x as f64,
30 by: b.y as f64,
31 unique: m.unique,
32 }
33 })
34 .collect();
35 translation_hypotheses(&match_points, 8)
36 .into_iter()
37 .filter(|m| m.support >= 6)
38 .collect()
39}
40
41/// `track.ts::reacquire`'s output; `confidence` is the fully computed
42/// `top.m.confidence.max(0.05) * exp(-error / 20) * (ambiguous ? 0.6 : 1)`, using the unweighted native residual
43/// and `libm::exp` (the same code on every engine — see `odometry.rs`'s `OdometryEstimate.confidence` doc comment for why this
44/// is no longer finished by the TS caller with `Math.exp`).
45pub struct ReacquireEstimate {
46 pub x: i32,
47 pub y: i32,
48 pub ambiguous: bool,
49 pub confidence: f64,
50 /// Ordinary native residual at the selected offset; spatial weighting only selects the motion.
51 pub error: f64,
52}
53
54/// `track.ts::reacquire`'s native-refinement half, given the (non-empty) `models` `reacquire_hypotheses`
55/// already found and an ALREADY-FILLED native luma plane (the ABI layer's job, once it knows `models` is
56/// non-empty — see this module's doc comment). `None` covers both the original's "no candidate refined below
57/// the error threshold" and "a rival candidate exists" branches (the original returns `undefined` for both,
58/// not just marks the result ambiguous — unlike `odometry`'s rival check).
59pub fn reacquire_refine(
60 models: &[Motion],
61 patches: &[Patch<'_>],
62 native: Gray<'_>,
63 rect: Rect,
64 f: f64,
65 radius: i32,
66) -> Option<ReacquireEstimate> {
67 let mut options: Vec<_> = models
68 .iter()
69 .take(4)
70 .map(|m| {
71 let n = refine_spatial_patches(
72 patches,
73 native,
74 rect,
75 Point {
76 x: m.x * f,
77 y: m.y * f,
78 },
79 radius,
80 );
81 (m, n)
82 })
83 .filter(|(_, n)| n.error < 12.0)
84 .collect();
85 options.sort_by(|a, b| {
86 a.1.error
87 .partial_cmp(&b.1.error)
88 .unwrap_or(std::cmp::Ordering::Equal)
89 });
90 let (top_m, top_n) = options.first()?;
91 let has_rival = options.iter().skip(1).any(|(_, n)| {
92 js_hypot(n.x as f64 - top_n.x as f64, n.y as f64 - top_n.y as f64) > 2.0
93 && n.error < top_n.error + 2.0
94 });
95 if has_rival {
96 return None;
97 }
98 let ambiguous = top_m.ambiguous;
99 // Area weights choose the motion, not the source quality. Keep the ordinary texture residual for
100 // confidence, or adding unsampled space would change which frame the compositor prefers.
101 let error = refine_patches(
102 patches,
103 native,
104 rect,
105 Point {
106 x: top_n.x as f64,
107 y: top_n.y as f64,
108 },
109 0,
110 )
111 .error;
112 // `f64::max(0.05)` returns `0.05` for a NaN `confidence` where `Math.max(.05, NaN)` returns `NaN` (see
113 // odometry.rs's `OdometryEstimate.confidence` doc comment for the same difference and why it is kept) —
114 // never observed in practice, since `top_m.confidence` comes from `translation_hypotheses`, which never
115 // produces NaN.
116 let confidence =
117 top_m.confidence.max(0.05) * libm::exp(-error / 20.0) * if ambiguous { 0.6 } else { 1.0 };
118 Some(ReacquireEstimate {
119 x: top_n.x,
120 y: top_n.y,
121 ambiguous,
122 confidence,
123 error,
124 })
125}
126
127/// `track.ts::driftCorrection`'s output. `confidence_floor` is `0.96 * exp(-error / 20)` (`libm::exp`, identical
128/// on every engine); the TS caller takes `Math.max(carriedInConfidence, confidenceFloor)`, a plain comparison
129/// with no transcendental function left to vary between engines.
130pub struct DriftEstimate {
131 pub x: f64,
132 pub y: f64,
133 pub error: f64,
134 pub confidence_floor: f64,
135}
136
137/// `track.ts::driftCorrection`. Unlike `reacquire`, there is no gate: the original always evaluates `native()`
138/// here, so the ABI layer always fills the native luma plane (if not already filled this frame) before calling
139/// this — there is no "skip the fill" branch to preserve.
140pub fn drift_correction(
141 patches: &[Patch<'_>],
142 native: Gray<'_>,
143 rect: Rect,
144 anchor: (f64, f64),
145 pose: (f64, f64),
146 radius: i32,
147) -> Option<DriftEstimate> {
148 let expected = Point {
149 x: pose.0 - anchor.0,
150 y: pose.1 - anchor.1,
151 };
152 let n = refine_patches(patches, native, rect, expected, radius);
153 if n.error < 12.0 && n.runner_up > n.error + 1.5 {
154 Some(DriftEstimate {
155 x: anchor.0 + n.x as f64,
156 y: anchor.1 + n.y as f64,
157 error: n.error,
158 confidence_floor: 0.96 * libm::exp(-n.error / 20.0),
159 })
160 } else {
161 None
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 // Dense fixed controls must not outvote sparse page texture merely by supplying more patches.
170 // The same observations with no page movement must still re-acquire the stationary anchor.
171 #[test]
172 fn reacquisition_balances_clustered_controls_against_the_page() {
173 let (w, h, step, size) = (256, 960, 96, 16);
174 let page = |x: usize, y: usize| 180 + ((x * 37 + y * 71 + x * y * 13) % 64) as u8;
175 let frame = |dy: usize| {
176 let mut data = vec![0; w * h];
177 for y in 0..h {
178 for x in 0..w {
179 data[y * w + x] = if !(64..h - 64).contains(&y) {
180 64 + ((x * 43 + y * 29 + x * y * 17) % 128) as u8
181 } else {
182 page(x, y + dy)
183 };
184 }
185 }
186 data
187 };
188 let anchor = frame(0);
189 let current = frame(step);
190 let mut origins: Vec<_> = [16, h - 32]
191 .into_iter()
192 .flat_map(|y| (0..8).map(move |i| (8 + i * 30, y)))
193 .collect();
194 origins.extend([200, 400, 600, 800].map(|y| (120, y)));
195 let data: Vec<Vec<u8>> = origins
196 .iter()
197 .map(|&(x, y)| {
198 (0..size)
199 .flat_map(|py| {
200 anchor[(y + py) * w + x..(y + py) * w + x + size]
201 .iter()
202 .copied()
203 })
204 .collect()
205 })
206 .collect();
207 let patches: Vec<_> = origins
208 .iter()
209 .zip(&data)
210 .map(|(&(x, y), data)| Patch {
211 x: x as i32,
212 y: y as i32,
213 size,
214 data,
215 })
216 .collect();
217 let rect = Rect {
218 x: 0.0,
219 y: 0.0,
220 width: w as f64,
221 height: h as f64,
222 };
223 let models: Vec<_> = [step, 0]
224 .map(|dy| Motion {
225 x: 0.0,
226 y: dy as f64,
227 support: 30,
228 unique: 30,
229 confidence: 1.0,
230 error: 0.0,
231 ambiguous: false,
232 })
233 .into();
234 for (pixels, expected) in [(¤t, step), (&anchor, 0)] {
235 let found = reacquire_refine(
236 &models,
237 &patches,
238 Gray {
239 width: w,
240 height: h,
241 data: pixels,
242 },
243 rect,
244 1.0,
245 3,
246 )
247 .expect("one globally supported anchor");
248 assert_eq!((found.x, found.y), (0, expected as i32));
249 }
250 }
251 // Extending the pane beyond the sampled patches must not change their source quality.
252 // Area weights establish motion support; the same native residual still means the same quality.
253 #[test]
254 fn anchor_quality_does_not_depend_on_unsampled_pane_extent() {
255 let (w, h, size) = (160, 320, 32);
256 let reference: Vec<u8> = (0..w * h)
257 .map(|i| {
258 let (x, y) = (i % w, i / w);
259 32 + ((x * 13 + y * 31 + x * y * 17) % 128) as u8
260 })
261 .collect();
262 let origins = [(16, 16), (96, 112)];
263 let data: Vec<Vec<u8>> = origins
264 .iter()
265 .map(|&(x, y)| {
266 (0..size)
267 .flat_map(|dy| {
268 reference[(y + dy) * w + x..(y + dy) * w + x + size]
269 .iter()
270 .copied()
271 })
272 .collect()
273 })
274 .collect();
275 let patches: Vec<_> = origins
276 .iter()
277 .zip(&data)
278 .map(|(&(x, y), data)| Patch {
279 x: x as i32,
280 y: y as i32,
281 size,
282 data,
283 })
284 .collect();
285 let mut current = reference.clone();
286 for y in 112..112 + size {
287 for x in 96..96 + size {
288 current[y * w + x] += 8;
289 }
290 }
291 let models = [Motion {
292 x: 0.0,
293 y: 0.0,
294 support: 30,
295 unique: 30,
296 confidence: 1.0,
297 error: 0.0,
298 ambiguous: false,
299 }];
300 let estimates: Vec<_> = [160, 320]
301 .into_iter()
302 .map(|height| {
303 reacquire_refine(
304 &models,
305 &patches,
306 Gray {
307 width: w,
308 height: h,
309 data: ¤t,
310 },
311 Rect {
312 x: 0.0,
313 y: 0.0,
314 width: w as f64,
315 height: height as f64,
316 },
317 1.0,
318 3,
319 )
320 .unwrap()
321 })
322 .collect();
323 for estimate in &estimates {
324 assert_eq!((estimate.x, estimate.y), (0, 0));
325 }
326 assert_eq!(estimates[0].confidence, estimates[1].confidence);
327 assert_eq!(estimates[0].error, 4.0); // One equally sized patch differs by eight, the other is exact.
328 }
329}