1use spatialrust_image::{Image, ImageView};
4use spatialrust_math::Vec2;
5
6use crate::{PixelComponent, VisionError, VisionResult};
7
8#[derive(Clone, Copy, Debug, PartialEq)]
10pub struct LucasKanadeOptions {
11 pub window_radius: usize,
13 pub pyramid_levels: usize,
15 pub max_iterations: usize,
17 pub epsilon: f64,
19 pub min_eigenvalue: f64,
21}
22
23impl Default for LucasKanadeOptions {
24 fn default() -> Self {
25 Self {
26 window_radius: 5,
27 pyramid_levels: 3,
28 max_iterations: 30,
29 epsilon: 1e-3,
30 min_eigenvalue: 1e-4,
31 }
32 }
33}
34
35impl LucasKanadeOptions {
36 pub fn validate(self) -> VisionResult<Self> {
38 if self.window_radius == 0 || self.window_radius > isize::MAX as usize {
39 return Err(VisionError::InvalidParameter(
40 "Lucas–Kanade window_radius must be positive and fit isize".into(),
41 ));
42 }
43 if self.pyramid_levels == 0 || self.pyramid_levels > u32::BITS as usize {
44 return Err(VisionError::InvalidParameter(
45 "Lucas–Kanade pyramid_levels must be in 1..=32".into(),
46 ));
47 }
48 if self.max_iterations == 0 {
49 return Err(VisionError::InvalidParameter(
50 "Lucas–Kanade max_iterations must be positive".into(),
51 ));
52 }
53 if !self.epsilon.is_finite() || self.epsilon <= 0.0 {
54 return Err(VisionError::InvalidParameter(
55 "Lucas–Kanade epsilon must be finite and positive".into(),
56 ));
57 }
58 if !self.min_eigenvalue.is_finite() || self.min_eigenvalue <= 0.0 {
59 return Err(VisionError::InvalidParameter(
60 "Lucas–Kanade min_eigenvalue must be finite and positive".into(),
61 ));
62 }
63 Ok(self)
64 }
65}
66
67#[derive(Clone, Debug, PartialEq)]
69pub struct TrackedPoints {
70 next_points: Vec<Vec2<f64>>,
71 status: Vec<bool>,
72}
73
74impl TrackedPoints {
75 pub fn next_points(&self) -> &[Vec2<f64>] {
77 &self.next_points
78 }
79
80 pub fn status(&self) -> &[bool] {
82 &self.status
83 }
84}
85
86pub fn track_points_lucas_kanade<T: PixelComponent>(
88 previous: ImageView<'_, T, 1>,
89 next: ImageView<'_, T, 1>,
90 points: &[Vec2<f64>],
91 options: LucasKanadeOptions,
92) -> VisionResult<TrackedPoints> {
93 let pair = LucasKanadePair::try_new(previous, next, options)?;
94 Ok(pair.track(points, false))
95}
96
97pub(crate) struct LucasKanadePair {
99 previous_pyramid: Vec<Image<f32, 1>>,
100 next_pyramid: Vec<Image<f32, 1>>,
101 options: LucasKanadeOptions,
102}
103
104impl LucasKanadePair {
105 pub(crate) fn try_new<T: PixelComponent>(
106 previous: ImageView<'_, T, 1>,
107 next: ImageView<'_, T, 1>,
108 options: LucasKanadeOptions,
109 ) -> VisionResult<Self> {
110 let options = options.validate()?;
111 if previous.width() != next.width() || previous.height() != next.height() {
112 return Err(VisionError::ShapeMismatch(
113 "Lucas–Kanade frames must share width and height".into(),
114 ));
115 }
116 if previous.width() < 3 || previous.height() < 3 {
117 return Err(VisionError::InvalidDimensions(
118 "Lucas–Kanade requires at least 3x3 images".into(),
119 ));
120 }
121 let previous_pyramid = build_pyramid(previous, options.pyramid_levels)?;
122 let next_pyramid = build_pyramid(next, options.pyramid_levels)?;
123 Ok(Self { previous_pyramid, next_pyramid, options })
124 }
125
126 pub(crate) fn track(&self, points: &[Vec2<f64>], reverse: bool) -> TrackedPoints {
127 let options = self.options;
128 let (previous_pyramid, next_pyramid) = if reverse {
129 (&self.next_pyramid, &self.previous_pyramid)
130 } else {
131 (&self.previous_pyramid, &self.next_pyramid)
132 };
133 let mut next_points = points.to_vec();
134 let mut status = vec![true; points.len()];
135 let scale = 1.0 / f64::from(1u32 << (options.pyramid_levels.saturating_sub(1) as u32));
136 for point in &mut next_points {
137 point.x *= scale;
138 point.y *= scale;
139 }
140 let mut previous_guess = next_points.clone();
141 for level in (0..options.pyramid_levels).rev() {
142 let level_scale = 1.0 / f64::from(1u32 << (level as u32));
143 for (index, previous_point) in points.iter().enumerate() {
144 if !status[index] {
145 continue;
146 }
147 let mut guess = previous_guess[index];
148 let target =
149 Vec2 { x: previous_point.x * level_scale, y: previous_point.y * level_scale };
150 match track_one_level(
151 previous_pyramid[level].view(),
152 next_pyramid[level].view(),
153 target,
154 guess,
155 options,
156 ) {
157 Ok(updated) => {
158 guess = updated;
159 status[index] = true;
160 }
161 Err(_) => status[index] = false,
162 }
163 previous_guess[index] = guess;
164 if level > 0 {
165 previous_guess[index].x *= 2.0;
166 previous_guess[index].y *= 2.0;
167 }
168 }
169 }
170 TrackedPoints { next_points: previous_guess, status }
171 }
172}
173
174fn track_one_level(
175 previous: ImageView<'_, f32, 1>,
176 next: ImageView<'_, f32, 1>,
177 previous_point: Vec2<f64>,
178 mut next_point: Vec2<f64>,
179 options: LucasKanadeOptions,
180) -> VisionResult<Vec2<f64>> {
181 if options.window_radius >= previous.width().min(previous.height()) {
182 return Err(VisionError::InvalidParameter("Lucas–Kanade patch exceeds the image".into()));
183 }
184 let radius = options.window_radius as f64;
185 let mut patch_cache: LkTemplateCache =
186 if options.window_radius <= 5 && options.max_iterations > 1 {
187 Some([None; 121])
188 } else {
189 None
190 };
191 for _ in 0..options.max_iterations {
192 let mut gxx = 0.0;
193 let mut gxy = 0.0;
194 let mut gyy = 0.0;
195 let mut bx = 0.0;
196 let mut by = 0.0;
197 let mut samples = 0usize;
198 let start = -(options.window_radius as isize);
199 let end = options.window_radius as isize;
200 for dy in start..=end {
201 for dx in start..=end {
202 let px = previous_point.x + dx as f64;
203 let py = previous_point.y + dy as f64;
204 let qx = next_point.x + dx as f64;
205 let qy = next_point.y + dy as f64;
206 if !(in_bounds(previous, px, py, radius) && in_bounds(next, qx, qy, radius)) {
207 continue;
208 }
209 let (ix, iy, it) = if let Some(cache) = patch_cache.as_mut() {
210 let slot = (dy - start) as usize * (2 * options.window_radius + 1)
213 + (dx - start) as usize;
214 if let Some(saved) = cache[slot] {
215 (saved.ix, saved.iy, sample(next, qx, qy)? - saved.value)
216 } else {
217 let ix = 0.5
218 * (sample(previous, px + 1.0, py)? - sample(previous, px - 1.0, py)?);
219 let iy = 0.5
220 * (sample(previous, px, py + 1.0)? - sample(previous, px, py - 1.0)?);
221 let current = sample(next, qx, qy)?;
222 let value = sample(previous, px, py)?;
223 cache[slot] = Some(LkTemplateSample { ix, iy, value });
224 (ix, iy, current - value)
225 }
226 } else {
227 let ix =
228 0.5 * (sample(previous, px + 1.0, py)? - sample(previous, px - 1.0, py)?);
229 let iy =
230 0.5 * (sample(previous, px, py + 1.0)? - sample(previous, px, py - 1.0)?);
231 let it = sample(next, qx, qy)? - sample(previous, px, py)?;
232 (ix, iy, it)
233 };
234 gxx += ix * ix;
235 gxy += ix * iy;
236 gyy += iy * iy;
237 bx += ix * it;
238 by += iy * it;
239 samples += 1;
240 }
241 }
242 if samples < 4 {
243 return Err(VisionError::InvalidParameter("Lucas–Kanade patch left the image".into()));
244 }
245 let det = gxx * gyy - gxy * gxy;
246 if det.abs() < options.min_eigenvalue {
247 return Err(VisionError::InvalidParameter(
248 "Lucas–Kanade structure tensor is singular".into(),
249 ));
250 }
251 let dx = (-gyy * bx + gxy * by) / det;
252 let dy = (gxy * bx - gxx * by) / det;
253 next_point.x += dx;
254 next_point.y += dy;
255 if dx.hypot(dy) < options.epsilon {
256 break;
257 }
258 }
259 if !in_bounds(next, next_point.x, next_point.y, 0.0) {
260 return Err(VisionError::InvalidParameter(
261 "Lucas–Kanade tracked point left the image".into(),
262 ));
263 }
264 Ok(next_point)
265}
266
267#[derive(Clone, Copy)]
268struct LkTemplateSample {
269 ix: f64,
270 iy: f64,
271 value: f64,
272}
273
274type LkTemplateCache = Option<[Option<LkTemplateSample>; 121]>;
276const _: () = assert!(std::mem::size_of::<LkTemplateCache>() <= 4096);
277
278fn build_pyramid<T: PixelComponent>(
279 image: ImageView<'_, T, 1>,
280 levels: usize,
281) -> VisionResult<Vec<Image<f32, 1>>> {
282 let mut pyramid = Vec::with_capacity(levels);
283 pyramid.push(to_f32_image(image)?);
284 for _ in 1..levels {
285 let previous = pyramid.last().expect("pyramid starts non-empty");
286 pyramid.push(downsample(previous.view())?);
287 }
288 Ok(pyramid)
289}
290
291fn to_f32_image<T: PixelComponent>(image: ImageView<'_, T, 1>) -> VisionResult<Image<f32, 1>> {
292 let mut data = Vec::with_capacity(image.width() * image.height());
293 for y in 0..image.height() {
294 for x in 0..image.width() {
295 data.push(image.get(x, y).expect("in-bounds")[0].to_f64() as f32);
296 }
297 }
298 Ok(Image::try_new(image.width(), image.height(), data)?)
299}
300
301fn downsample(image: ImageView<'_, f32, 1>) -> VisionResult<Image<f32, 1>> {
302 let width = (image.width() / 2).max(1);
303 let height = (image.height() / 2).max(1);
304 let mut data = Vec::with_capacity(width * height);
305 for y in 0..height {
306 for x in 0..width {
307 let sx = x * 2;
308 let sy = y * 2;
309 let mut sum = 0.0_f64;
310 let mut count = 0.0_f64;
311 for dy in 0..2 {
312 for dx in 0..2 {
313 if let Some(pixel) = image.get(sx + dx, sy + dy) {
314 sum += f64::from(pixel[0]);
315 count += 1.0;
316 }
317 }
318 }
319 data.push((sum / count.max(1.0)) as f32);
320 }
321 }
322 Ok(Image::try_new(width, height, data)?)
323}
324
325fn sample(image: ImageView<'_, f32, 1>, x: f64, y: f64) -> VisionResult<f64> {
326 if !x.is_finite() || !y.is_finite() {
327 return Err(VisionError::InvalidParameter(
328 "Lucas–Kanade sample coordinates must be finite".into(),
329 ));
330 }
331 let x0 = x.floor() as isize;
332 let y0 = y.floor() as isize;
333 let x1 = x0 + 1;
334 let y1 = y0 + 1;
335 if x0 < 0 || y0 < 0 || x1 >= image.width() as isize || y1 >= image.height() as isize {
336 return Err(VisionError::InvalidParameter("Lucas–Kanade sample is out of bounds".into()));
337 }
338 let ax = x - x0 as f64;
339 let ay = y - y0 as f64;
340 let i00 = f64::from(image.get(x0 as usize, y0 as usize).expect("in-bounds")[0]);
341 let i10 = f64::from(image.get(x1 as usize, y0 as usize).expect("in-bounds")[0]);
342 let i01 = f64::from(image.get(x0 as usize, y1 as usize).expect("in-bounds")[0]);
343 let i11 = f64::from(image.get(x1 as usize, y1 as usize).expect("in-bounds")[0]);
344 Ok((1.0 - ax) * (1.0 - ay) * i00
345 + ax * (1.0 - ay) * i10
346 + (1.0 - ax) * ay * i01
347 + ax * ay * i11)
348}
349
350fn in_bounds(image: ImageView<'_, f32, 1>, x: f64, y: f64, margin: f64) -> bool {
351 x >= margin
352 && y >= margin
353 && x < image.width() as f64 - 1.0 - margin
354 && y < image.height() as f64 - 1.0 - margin
355}
356
357#[cfg(test)]
358#[path = "optical_flow_template_tests.rs"]
359mod template_tests;
360
361#[cfg(test)]
362mod tests {
363 use super::{track_points_lucas_kanade, LucasKanadeOptions};
364 use spatialrust_image::Image;
365 use spatialrust_math::Vec2;
366
367 #[test]
368 fn rejects_unrepresentable_scales_and_windows_before_pyramid_allocation() {
369 for levels in [0, 33, usize::MAX] {
370 let options = LucasKanadeOptions { pyramid_levels: levels, ..Default::default() };
371 assert!(options.validate().is_err());
372 }
373 for radius in [0, isize::MAX as usize + 1, usize::MAX] {
374 let options = LucasKanadeOptions { window_radius: radius, ..Default::default() };
375 assert!(options.validate().is_err());
376 }
377 assert!(LucasKanadeOptions { pyramid_levels: 32, ..Default::default() }.validate().is_ok());
378 }
379
380 #[test]
381 fn oversized_representable_patch_fails_without_iterating_the_window() {
382 let image = Image::<u8, 1>::try_new(8, 8, vec![0; 64]).unwrap();
383 let result = track_points_lucas_kanade(
384 image.view(),
385 image.view(),
386 &[Vec2 { x: 4.0, y: 4.0 }],
387 LucasKanadeOptions {
388 window_radius: isize::MAX as usize,
389 pyramid_levels: 1,
390 ..Default::default()
391 },
392 )
393 .unwrap();
394 assert_eq!(result.status(), &[false]);
395 }
396
397 #[test]
398 fn tracks_integer_translation() {
399 let width = 64;
400 let height = 48;
401 let mut previous = vec![0u8; width * height];
402 for y in 0..height {
403 for x in 0..width {
404 previous[y * width + x] = ((x * 13 + y * 7) % 200 + 20) as u8;
405 }
406 }
407 let shift = 3isize;
408 let mut next = vec![0u8; width * height];
409 for y in 0..height {
410 for x in 0..width {
411 let sx = x as isize - shift;
412 let sy = y as isize;
413 if (0..width as isize).contains(&sx) && (0..height as isize).contains(&sy) {
414 next[y * width + x] = previous[sy as usize * width + sx as usize];
415 }
416 }
417 }
418 let previous = Image::<u8, 1>::try_new(width, height, previous).unwrap();
419 let next = Image::<u8, 1>::try_new(width, height, next).unwrap();
420 let points = [Vec2 { x: 24.0, y: 20.0 }];
421 let tracked = track_points_lucas_kanade(
422 previous.view(),
423 next.view(),
424 &points,
425 LucasKanadeOptions {
426 window_radius: 4,
427 pyramid_levels: 1,
428 max_iterations: 50,
429 epsilon: 1e-4,
430 min_eigenvalue: 1e-8,
431 },
432 )
433 .unwrap();
434 assert!(tracked.status().iter().all(|&ok| ok), "status={:?}", tracked.status());
435 let actual = tracked.next_points()[0];
436 assert!(
437 (actual.x - (points[0].x + shift as f64)).abs() < 1.5,
438 "x actual={} expected={}",
439 actual.x,
440 points[0].x + shift as f64
441 );
442 assert!(
443 (actual.y - points[0].y).abs() < 1.5,
444 "y actual={} expected={}",
445 actual.y,
446 points[0].y
447 );
448 }
449}