Skip to main content

oar_ocr_core/utils/
transform.rs

1//! Image transformation utilities for OCR processing.
2//!
3//! This module provides functions for perspective transformation and image warping,
4//! which are essential for correcting skewed text regions in images.
5
6use crate::core::OCRError;
7use crate::processors::Point;
8use image::{Rgb, RgbImage, imageops};
9use nalgebra::{Matrix3, Vector3};
10use rayon::prelude::*;
11use tracing::debug;
12
13/// Calculates the Euclidean distance between two points.
14///
15/// # Arguments
16///
17/// * `p1` - First point
18/// * `p2` - Second point
19///
20/// # Returns
21///
22/// The distance between the two points.
23fn distance(p1: &Point, p2: &Point) -> f32 {
24    (p1.x - p2.x).hypot(p1.y - p2.y)
25}
26
27/// Extracts a rotated and cropped image from a source image based on bounding box points.
28///
29/// This function takes a source image and a set of four points that define a quadrilateral
30/// region in the image. It crops the image to the bounding box of these points, then applies
31/// a perspective transformation to produce a rectified image of the region. If the resulting
32/// image has an aspect ratio that suggests it's rotated, it will be automatically rotated.
33///
34/// # Arguments
35///
36/// * `src_image` - The source image to crop from
37/// * `box_points` - Array of exactly 4 points defining the quadrilateral region
38///
39/// # Returns
40///
41/// A Result containing the cropped and transformed image, or an OCRError if the operation fails.
42///
43/// # Errors
44///
45/// Returns an OCRError if:
46/// * The box_points array doesn't contain exactly 4 points
47/// * The calculated crop region is invalid
48/// * The calculated crop dimensions are zero
49/// * The perspective transformation fails
50pub fn get_rotate_crop_image(
51    src_image: &RgbImage,
52    box_points: &[Point],
53) -> Result<RgbImage, OCRError> {
54    // Validate input
55    if box_points.len() != 4 {
56        return Err(OCRError::InvalidInput {
57            message: "Box must contain exactly 4 points".to_string(),
58        });
59    }
60
61    // Find bounding box of the points
62    let mut min_x = f32::INFINITY;
63    let mut max_x = f32::NEG_INFINITY;
64    let mut min_y = f32::INFINITY;
65    let mut max_y = f32::NEG_INFINITY;
66
67    for p in box_points {
68        min_x = min_x.min(p.x);
69        max_x = max_x.max(p.x);
70        min_y = min_y.min(p.y);
71        max_y = max_y.max(p.y);
72    }
73
74    // Calculate crop boundaries, clamping to image dimensions
75    let left = min_x.max(0.0) as u32;
76    let top = min_y.max(0.0) as u32;
77    let right = max_x.min(src_image.width() as f32) as u32;
78    let bottom = max_y.min(src_image.height() as f32) as u32;
79
80    // Validate crop region
81    if right <= left || bottom <= top {
82        return Err(OCRError::InvalidInput {
83            message: "Invalid crop region".to_string(),
84        });
85    }
86
87    // Perform initial crop
88    let crop_width = right - left;
89    let crop_height = bottom - top;
90    let img_crop = imageops::crop_imm(src_image, left, top, crop_width, crop_height).to_image();
91
92    // Adjust points relative to the cropped image
93    let points: Vec<Point> = box_points
94        .iter()
95        .map(|p| Point::new(p.x - left as f32, p.y - top as f32))
96        .collect();
97
98    // Reorder points to (top-left, top-right, bottom-right, bottom-left)
99    // to keep width/height estimation stable when point order varies.
100    let mut sorted = points.clone();
101    sorted.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal));
102    let (mut index_a, mut index_d) = (0usize, 1usize);
103    if sorted[1].y < sorted[0].y {
104        index_a = 1;
105        index_d = 0;
106    }
107    let (mut index_b, mut index_c) = (2usize, 3usize);
108    if sorted[3].y < sorted[2].y {
109        index_b = 3;
110        index_c = 2;
111    }
112    let ordered = [
113        sorted[index_a],
114        sorted[index_b],
115        sorted[index_c],
116        sorted[index_d],
117    ];
118
119    // Calculate target image dimensions based on the max opposite-edge lengths
120    let width1 = distance(&ordered[0], &ordered[1]);
121    let width2 = distance(&ordered[2], &ordered[3]);
122    let img_crop_width = width1.max(width2).round() as u32;
123
124    let height1 = distance(&ordered[0], &ordered[3]);
125    let height2 = distance(&ordered[1], &ordered[2]);
126    let img_crop_height = height1.max(height2).round() as u32;
127
128    // Validate target dimensions
129    if img_crop_width == 0 || img_crop_height == 0 {
130        return Err(OCRError::InvalidInput {
131            message: "Invalid crop dimensions".to_string(),
132        });
133    }
134
135    // Define standard points for the target rectangle
136    let pts_std = [
137        Point::new(0.0, 0.0),
138        Point::new(img_crop_width as f32, 0.0),
139        Point::new(img_crop_width as f32, img_crop_height as f32),
140        Point::new(0.0, img_crop_height as f32),
141    ];
142
143    // Calculate perspective transformation matrix
144    let transform_matrix = get_perspective_transform(&ordered, &pts_std)?;
145
146    // Apply perspective transformation
147    let dst_img = warp_perspective(
148        &img_crop,
149        &transform_matrix,
150        img_crop_width,
151        img_crop_height,
152    )?;
153
154    // Automatically rotate if the aspect ratio suggests the text is vertical
155    if dst_img.height() as f32 >= dst_img.width() as f32 * 1.5 {
156        debug!(
157            "Rotating image due to aspect ratio: {}x{}",
158            dst_img.width(),
159            dst_img.height()
160        );
161
162        Ok(imageops::rotate270(&dst_img))
163    } else {
164        Ok(dst_img)
165    }
166}
167
168/// Calculates the perspective transformation matrix that maps source points to destination points.
169///
170/// This function solves the linear system of equations to find the perspective transformation
171/// matrix that maps four source points to four destination points.
172///
173/// # Arguments
174///
175/// * `src_points` - Array of exactly 4 source points
176/// * `dst_points` - Array of exactly 4 destination points
177///
178/// # Returns
179///
180/// A Result containing the 3x3 transformation matrix, or an OCRError if the operation fails.
181///
182/// # Errors
183///
184/// Returns an OCRError if:
185/// * Either array doesn't contain exactly 4 points
186/// * The linear system cannot be solved
187fn get_perspective_transform(
188    src_points: &[Point],
189    dst_points: &[Point],
190) -> Result<Matrix3<f32>, OCRError> {
191    // Validate input
192    if src_points.len() != 4 || dst_points.len() != 4 {
193        return Err(OCRError::InvalidInput {
194            message: "Need exactly 4 points for perspective transformation".to_string(),
195        });
196    }
197
198    // Set up the linear system of equations
199    let mut a = nalgebra::DMatrix::<f32>::zeros(8, 8);
200    let mut b = nalgebra::DVector::<f32>::zeros(8);
201
202    // Fill the matrix A and vector b with the equations for perspective transformation
203    for i in 0..4 {
204        let src = &src_points[i];
205        let dst = &dst_points[i];
206
207        // First equation for x coordinate transformation
208        a.set_row(
209            i * 2,
210            &nalgebra::RowDVector::from_row_slice(&[
211                src.x,
212                src.y,
213                1.0,
214                0.0,
215                0.0,
216                0.0,
217                -src.x * dst.x,
218                -src.y * dst.x,
219            ]),
220        );
221        b[i * 2] = dst.x;
222
223        // Second equation for y coordinate transformation
224        a.set_row(
225            i * 2 + 1,
226            &nalgebra::RowDVector::from_row_slice(&[
227                0.0,
228                0.0,
229                0.0,
230                src.x,
231                src.y,
232                1.0,
233                -src.x * dst.y,
234                -src.y * dst.y,
235            ]),
236        );
237        b[i * 2 + 1] = dst.y;
238    }
239
240    // Solve the linear system to find the transformation parameters
241    let decomp = a.lu();
242    let solution = decomp.solve(&b).ok_or_else(|| OCRError::InvalidInput {
243        message: "Cannot solve perspective transformation".to_string(),
244    })?;
245
246    // Construct the 3x3 transformation matrix
247    Ok(Matrix3::new(
248        solution[0],
249        solution[1],
250        solution[2],
251        solution[3],
252        solution[4],
253        solution[5],
254        solution[6],
255        solution[7],
256        1.0,
257    ))
258}
259
260/// Applies a perspective transformation to an image.
261///
262/// This function transforms an image using a given perspective transformation matrix.
263/// It uses inverse mapping with bicubic interpolation to produce the output image.
264///
265/// # Arguments
266///
267/// * `src_image` - The source image to transform
268/// * `transform_matrix` - The 3x3 perspective transformation matrix
269/// * `dst_width` - Width of the output image
270/// * `dst_height` - Height of the output image
271///
272/// # Returns
273///
274/// A Result containing the transformed image, or an OCRError if the operation fails.
275///
276/// # Errors
277///
278/// Returns an OCRError if:
279/// * The transformation matrix cannot be inverted
280fn warp_perspective(
281    src_image: &RgbImage,
282    transform_matrix: &Matrix3<f32>,
283    dst_width: u32,
284    dst_height: u32,
285) -> Result<RgbImage, OCRError> {
286    // Calculate the inverse transformation matrix for inverse mapping
287    let inv_matrix = transform_matrix
288        .try_inverse()
289        .ok_or_else(|| OCRError::InvalidInput {
290            message: "Cannot invert transformation matrix".to_string(),
291        })?;
292
293    // Create the destination image
294    let mut dst_image = RgbImage::new(dst_width, dst_height);
295    let buffer: &mut [u8] = dst_image.as_mut();
296
297    // Inverse-map each destination row via `warp_row`, which walks the source
298    // coordinate incrementally along the row instead of recomputing the 3x3
299    // homography mat-vec per pixel. Small-image fast path avoids rayon overhead.
300    // Bicubic with border replication (matches cv2.warpPerspective, INTER_CUBIC,
301    // BORDER_REPLICATE).
302    if dst_height <= 1 {
303        let row_buffer = &mut buffer[0..(dst_width * 3) as usize];
304        warp_row(&inv_matrix, src_image, 0, dst_width, row_buffer);
305    } else {
306        buffer
307            .par_chunks_mut((dst_width * 3) as usize)
308            .enumerate()
309            .for_each(|(dst_y, row_buffer)| {
310                warp_row(&inv_matrix, src_image, dst_y as u32, dst_width, row_buffer);
311            });
312    }
313
314    Ok(dst_image)
315}
316
317/// Inverse-maps a single destination row through the perspective `inv_matrix`,
318/// writing bicubic-sampled RGB pixels into `row_buffer`.
319///
320/// The source coordinate is recomputed per pixel with a full `inv_matrix *
321/// [dst_x, dst_y, 1]` mat-vec, bit-identical to `cv2.warpPerspective`. A
322/// per-row incremental variant (carry `src` and add the column-0 step each
323/// pixel) was benchmarked and gave no measurable speedup — the cost is
324/// dominated by the bicubic sampling and the two perspective divisions, not the
325/// mat-vec — so the exact form is kept. This helper exists to share one row
326/// loop between the sequential and rayon paths.
327#[inline]
328fn warp_row(
329    inv_matrix: &Matrix3<f32>,
330    src_image: &RgbImage,
331    dst_y: u32,
332    dst_width: u32,
333    row_buffer: &mut [u8],
334) {
335    for dst_x in 0..dst_width {
336        let src_point = inv_matrix * Vector3::new(dst_x as f32, dst_y as f32, 1.0);
337        let final_pixel = if src_point.z.abs() > f32::EPSILON {
338            let src_x = src_point.x / src_point.z;
339            let src_y = src_point.y / src_point.z;
340            // bicubic_interpolate handles out-of-bounds via border replication
341            bicubic_interpolate(src_image, src_x, src_y)
342        } else {
343            // Degenerate case: replicate top-left corner pixel
344            *src_image.get_pixel(0, 0)
345        };
346        let index = (dst_x * 3) as usize;
347        row_buffer[index..index + 3].copy_from_slice(&final_pixel.0);
348    }
349}
350
351/// Gets a pixel value with border replication for out-of-bounds coordinates.
352///
353/// This function implements OpenCV's BORDER_REPLICATE behavior:
354/// when coordinates are outside the image, the nearest edge pixel is used.
355///
356/// Now used only by the test-only bilinear reference; `bicubic_interpolate`
357/// inlines the equivalent clamping against the raw buffer.
358///
359/// # Arguments
360///
361/// * `image` - The source image
362/// * `x` - X coordinate (can be negative or >= width)
363/// * `y` - Y coordinate (can be negative or >= height)
364///
365/// # Returns
366///
367/// The pixel value at the clamped coordinates.
368#[cfg(test)]
369#[inline]
370fn get_pixel_replicate(image: &RgbImage, x: i32, y: i32) -> Rgb<u8> {
371    let clamped_x = x.clamp(0, image.width() as i32 - 1) as u32;
372    let clamped_y = y.clamp(0, image.height() as i32 - 1) as u32;
373    *image.get_pixel(clamped_x, clamped_y)
374}
375
376/// Cubic interpolation kernel function.
377///
378/// This implements the standard cubic convolution kernel used in bicubic interpolation.
379/// The kernel is defined as:
380/// - For |t| <= 1: (a+2)|t|³ - (a+3)|t|² + 1
381/// - For 1 < |t| < 2: a|t|³ - 5a|t|² + 8a|t| - 4a
382/// - Otherwise: 0
383///
384/// Where a = -0.5 (Catmull-Rom spline, same as OpenCV's default)
385#[inline]
386fn cubic_kernel(t: f32) -> f32 {
387    const A: f32 = -0.5; // Catmull-Rom spline coefficient (OpenCV default)
388    let t_abs = t.abs();
389
390    if t_abs <= 1.0 {
391        (A + 2.0) * t_abs * t_abs * t_abs - (A + 3.0) * t_abs * t_abs + 1.0
392    } else if t_abs < 2.0 {
393        A * t_abs * t_abs * t_abs - 5.0 * A * t_abs * t_abs + 8.0 * A * t_abs - 4.0 * A
394    } else {
395        0.0
396    }
397}
398
399/// Performs bicubic interpolation to get a pixel value at non-integer coordinates.
400///
401/// This function calculates the pixel value at a fractional (x, y) coordinate
402/// by interpolating using a 4x4 neighborhood of pixels with cubic convolution.
403/// Uses border replication for edge handling (same as OpenCV's BORDER_REPLICATE).
404///
405/// # Arguments
406///
407/// * `image` - The source image
408/// * `x` - X coordinate (can be fractional)
409/// * `y` - Y coordinate (can be fractional)
410///
411/// # Returns
412///
413/// The interpolated pixel value.
414fn bicubic_interpolate(image: &RgbImage, x: f32, y: f32) -> Rgb<u8> {
415    let x_int = x.floor() as i32;
416    let y_int = y.floor() as i32;
417    let dx = x - x_int as f32;
418    let dy = y - y_int as f32;
419
420    // Calculate x-direction weights
421    let wx = [
422        cubic_kernel(dx + 1.0),
423        cubic_kernel(dx),
424        cubic_kernel(dx - 1.0),
425        cubic_kernel(dx - 2.0),
426    ];
427
428    // Calculate y-direction weights
429    let wy = [
430        cubic_kernel(dy + 1.0),
431        cubic_kernel(dy),
432        cubic_kernel(dy - 1.0),
433        cubic_kernel(dy - 2.0),
434    ];
435
436    // Precompute the four border-replicated sample columns and rows once (8
437    // clamps total) instead of re-clamping inside the 4x4 loop (which did 16
438    // clamped `get_pixel` lookups with their bounds checks). Then index the raw
439    // interleaved buffer directly. This is bit-identical to the previous
440    // `get_pixel_replicate`-based version: same clamps, same accumulation order
441    // (row-major j,i with channel innermost), same round/clamp.
442    let w = image.width() as i32;
443    let h = image.height() as i32;
444    let raw = image.as_raw();
445    let stride = (w as usize) * 3;
446    let cx = [
447        (x_int - 1).clamp(0, w - 1) as usize * 3,
448        x_int.clamp(0, w - 1) as usize * 3,
449        (x_int + 1).clamp(0, w - 1) as usize * 3,
450        (x_int + 2).clamp(0, w - 1) as usize * 3,
451    ];
452    let cy = [
453        (y_int - 1).clamp(0, h - 1) as usize * stride,
454        y_int.clamp(0, h - 1) as usize * stride,
455        (y_int + 1).clamp(0, h - 1) as usize * stride,
456        (y_int + 2).clamp(0, h - 1) as usize * stride,
457    ];
458
459    let mut result = [0.0f32; 3];
460    for (j, &weight_y) in wy.iter().enumerate() {
461        let row = cy[j];
462        for (i, &weight_x) in wx.iter().enumerate() {
463            let weight = weight_x * weight_y;
464            let idx = row + cx[i];
465            result[0] += weight * raw[idx] as f32;
466            result[1] += weight * raw[idx + 1] as f32;
467            result[2] += weight * raw[idx + 2] as f32;
468        }
469    }
470
471    // Clamp and convert to u8
472    Rgb([
473        result[0].round().clamp(0.0, 255.0) as u8,
474        result[1].round().clamp(0.0, 255.0) as u8,
475        result[2].round().clamp(0.0, 255.0) as u8,
476    ])
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    /// Performs bilinear interpolation to get a pixel value at non-integer coordinates.
484    ///
485    /// This function calculates the pixel value at a fractional (x, y) coordinate
486    /// by interpolating between the four nearest pixels.
487    /// Uses border replication for edge handling (same as OpenCV's BORDER_REPLICATE).
488    fn bilinear_interpolate(image: &RgbImage, x: f32, y: f32) -> Rgb<u8> {
489        let x_int = x.floor() as i32;
490        let y_int = y.floor() as i32;
491
492        // Calculate the fractional parts
493        let dx = x - x_int as f32;
494        let dy = y - y_int as f32;
495
496        // Get the four neighboring pixels with border replication
497        let p11 = get_pixel_replicate(image, x_int, y_int);
498        let p12 = get_pixel_replicate(image, x_int, y_int + 1);
499        let p21 = get_pixel_replicate(image, x_int + 1, y_int);
500        let p22 = get_pixel_replicate(image, x_int + 1, y_int + 1);
501
502        // Interpolate each color channel
503        let mut result = [0u8; 3];
504        for (i, result_channel) in result.iter_mut().enumerate() {
505            let val = (1.0 - dx) * (1.0 - dy) * p11.0[i] as f32
506                + dx * (1.0 - dy) * p21.0[i] as f32
507                + (1.0 - dx) * dy * p12.0[i] as f32
508                + dx * dy * p22.0[i] as f32;
509            *result_channel = val.round().clamp(0.0, 255.0) as u8;
510        }
511
512        Rgb(result)
513    }
514
515    /// Independent reference for `bicubic_interpolate`, written exactly as the
516    /// previous `get_pixel_replicate`-based implementation. Used to prove the
517    /// raw-buffer rewrite is bit-identical.
518    fn bicubic_reference(image: &RgbImage, x: f32, y: f32) -> Rgb<u8> {
519        let x_int = x.floor() as i32;
520        let y_int = y.floor() as i32;
521        let dx = x - x_int as f32;
522        let dy = y - y_int as f32;
523        let wx = [
524            cubic_kernel(dx + 1.0),
525            cubic_kernel(dx),
526            cubic_kernel(dx - 1.0),
527            cubic_kernel(dx - 2.0),
528        ];
529        let wy = [
530            cubic_kernel(dy + 1.0),
531            cubic_kernel(dy),
532            cubic_kernel(dy - 1.0),
533            cubic_kernel(dy - 2.0),
534        ];
535        let mut result = [0.0f32; 3];
536        for (j, &weight_y) in wy.iter().enumerate() {
537            let sample_y = y_int - 1 + j as i32;
538            for (i, &weight_x) in wx.iter().enumerate() {
539                let sample_x = x_int - 1 + i as i32;
540                let weight = weight_x * weight_y;
541                let pixel = get_pixel_replicate(image, sample_x, sample_y);
542                for (c, result_c) in result.iter_mut().enumerate().take(3) {
543                    *result_c += weight * pixel.0[c] as f32;
544                }
545            }
546        }
547        Rgb([
548            result[0].round().clamp(0.0, 255.0) as u8,
549            result[1].round().clamp(0.0, 255.0) as u8,
550            result[2].round().clamp(0.0, 255.0) as u8,
551        ])
552    }
553
554    #[test]
555    fn bicubic_raw_buffer_matches_reference_bit_exact() {
556        // Deterministic pseudo-random image.
557        let (w, h) = (17u32, 11u32);
558        let img = RgbImage::from_fn(w, h, |x, y| {
559            let i = y * w + x;
560            Rgb([
561                (i.wrapping_mul(37).wrapping_add(11) % 256) as u8,
562                (i.wrapping_mul(59).wrapping_add(7) % 256) as u8,
563                (i.wrapping_mul(101).wrapping_add(3) % 256) as u8,
564            ])
565        });
566        // Sample fractional coords across the interior, edges, and out-of-bounds
567        // (negative and beyond width/height) to exercise border replication.
568        for yi in -3..(h as i32 + 3) {
569            for xi in -3..(w as i32 + 3) {
570                for &fx in &[0.0f32, 0.25, 0.5, 0.75] {
571                    for &fy in &[0.0f32, 0.33, 0.66] {
572                        let x = xi as f32 + fx;
573                        let y = yi as f32 + fy;
574                        assert_eq!(
575                            bicubic_interpolate(&img, x, y),
576                            bicubic_reference(&img, x, y),
577                            "mismatch at ({x}, {y})"
578                        );
579                    }
580                }
581            }
582        }
583    }
584
585    #[test]
586    fn test_distance() {
587        let p1 = Point::new(0.0, 0.0);
588        let p2 = Point::new(3.0, 4.0);
589        let dist = distance(&p1, &p2);
590        assert_eq!(dist, 5.0);
591    }
592
593    #[test]
594    fn test_get_perspective_transform() -> Result<(), OCRError> {
595        // Define a simple square in source and destination
596        let src_points = [
597            Point::new(0.0, 0.0),
598            Point::new(1.0, 0.0),
599            Point::new(1.0, 1.0),
600            Point::new(0.0, 1.0),
601        ];
602
603        let dst_points = [
604            Point::new(0.0, 0.0),
605            Point::new(2.0, 0.0),
606            Point::new(2.0, 2.0),
607            Point::new(0.0, 2.0),
608        ];
609
610        let transform = get_perspective_transform(&src_points, &dst_points)?;
611
612        // Check that the transformation matrix is valid (all elements are finite)
613        assert!(transform.iter().all(|&x| x.is_finite()));
614        Ok(())
615    }
616
617    #[test]
618    fn test_get_perspective_transform_invalid_input() {
619        // Test with wrong number of points
620        let src_points = [Point::new(0.0, 0.0), Point::new(1.0, 0.0)];
621
622        let dst_points = [
623            Point::new(0.0, 0.0),
624            Point::new(2.0, 0.0),
625            Point::new(2.0, 2.0),
626            Point::new(0.0, 2.0),
627        ];
628
629        let result = get_perspective_transform(&src_points, &dst_points);
630        assert!(result.is_err());
631    }
632
633    #[test]
634    fn test_get_rotate_crop_image_invalid_points() {
635        // Create a simple 4x4 image
636        let image = RgbImage::new(4, 4);
637
638        // Test with wrong number of points
639        let points = vec![Point::new(0.0, 0.0), Point::new(1.0, 0.0)];
640
641        let result = get_rotate_crop_image(&image, &points);
642        assert!(result.is_err());
643    }
644
645    #[test]
646    fn test_get_rotate_crop_image_success() -> Result<(), OCRError> {
647        // Create a simple 4x4 image with distinct colors
648        let mut image = RgbImage::new(4, 4);
649        for y in 0..4 {
650            for x in 0..4 {
651                // Create a gradient
652                let r = (x * 64) as u8;
653                let g = (y * 64) as u8;
654                let b = ((x + y) * 32) as u8;
655                image.put_pixel(x, y, Rgb([r, g, b]));
656            }
657        }
658
659        // Define a simple square region
660        let points = vec![
661            Point::new(1.0, 1.0),
662            Point::new(3.0, 1.0),
663            Point::new(3.0, 3.0),
664            Point::new(1.0, 3.0),
665        ];
666
667        let cropped_image = get_rotate_crop_image(&image, &points)?;
668        // Check that we got an image back
669        assert!(cropped_image.width() > 0);
670        assert!(cropped_image.height() > 0);
671        Ok(())
672    }
673
674    #[test]
675    fn test_warp_perspective_invalid_matrix() {
676        // Create a simple 2x2 image
677        let image = RgbImage::new(2, 2);
678
679        // Create a singular matrix (non-invertible)
680        let matrix = Matrix3::new(1.0, 1.0, 0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 1.0);
681
682        let result = warp_perspective(&image, &matrix, 2, 2);
683        assert!(result.is_err());
684    }
685
686    #[test]
687    fn test_bilinear_interpolate() {
688        // Create a simple 2x2 image with distinct colors
689        let mut image = RgbImage::new(2, 2);
690        image.put_pixel(0, 0, Rgb([255, 0, 0])); // Red
691        image.put_pixel(1, 0, Rgb([0, 255, 0])); // Green
692        image.put_pixel(0, 1, Rgb([0, 0, 255])); // Blue
693        image.put_pixel(1, 1, Rgb([255, 255, 0])); // Yellow
694
695        // Test interpolation at the center
696        let pixel = bilinear_interpolate(&image, 0.5, 0.5);
697        // Expected: average of all four colors
698        // Red + Green + Blue + Yellow = (255, 0, 0) + (0, 255, 0) + (0, 0, 255) + (255, 255, 0)
699        // = (510, 510, 255) / 4 = (127.5, 127.5, 63.75) ≈ (128, 128, 64)
700        assert_eq!(pixel.0[0], 128);
701        assert_eq!(pixel.0[1], 128);
702        assert_eq!(pixel.0[2], 64);
703    }
704}