fix: wrap contour detection and pipeline in catch_unwind

imageproc::find_contours panics on some edge images (called
Option::unwrap() on None). Wrap with catch_unwind and provide
a safe fallback (use full image bounds).

Also add catch_unwind around the entire pipeline so no panic
can crash the worker thread.

Co-Authored-By: Kilo <kilo@kilo.ai>
This commit is contained in:
asepharyana
2026-07-24 19:01:28 +07:00
co-authored by Kilo
parent d2bfcb83a0
commit 5a64048ba2
2 changed files with 66 additions and 23 deletions
+44 -23
View File
@@ -1,3 +1,5 @@
use std::panic::catch_unwind;
use image::GrayImage; use image::GrayImage;
use imageproc::contours::find_contours; use imageproc::contours::find_contours;
@@ -6,16 +8,18 @@ use tools_common::error::PipelineError;
/// Represents a detected corner point. /// Represents a detected corner point.
pub type CornerPoint = (f64, f64); pub type CornerPoint = (f64, f64);
/// The fallback reason if corner detection fails.
pub enum FallbackReason {
NoContours,
NoRectangularContour,
TooSmall,
}
/// Find the 4 corners of the document from an edge image. /// Find the 4 corners of the document from an edge image.
/// Wrapped in catch_unwind because imageproc's find_contours can panic.
pub fn detect_corners(edges: &GrayImage) -> Result<[CornerPoint; 4], FallbackReason> { pub fn detect_corners(edges: &GrayImage) -> Result<[CornerPoint; 4], FallbackReason> {
let contours = find_contours::<u8>(edges); let result = catch_unwind(std::panic::AssertUnwindSafe(|| {
find_contours::<u8>(edges)
}));
let contours = match result {
Ok(c) => c,
Err(_) => return Err(FallbackReason::FindContoursPanic),
};
if contours.is_empty() { if contours.is_empty() {
return Err(FallbackReason::NoContours); return Err(FallbackReason::NoContours);
} }
@@ -35,7 +39,10 @@ pub fn detect_corners(edges: &GrayImage) -> Result<[CornerPoint; 4], FallbackRea
}); });
for points in sorted.iter().take(5) { for points in sorted.iter().take(5) {
if let Some(corners) = approx_quadrilateral(points) { let approx = catch_unwind(std::panic::AssertUnwindSafe(|| {
approx_quadrilateral(points)
}));
if let Ok(Some(corners)) = approx {
let ordered = order_corners(&corners); let ordered = order_corners(&corners);
return Ok(ordered); return Ok(ordered);
} }
@@ -56,6 +63,15 @@ pub fn detect_corners(edges: &GrayImage) -> Result<[CornerPoint; 4], FallbackRea
Err(FallbackReason::NoContours) Err(FallbackReason::NoContours)
} }
/// The fallback reason if corner detection fails.
#[derive(Debug)]
pub enum FallbackReason {
FindContoursPanic,
NoContours,
NoRectangularContour,
TooSmall,
}
/// Compute the area of a contour using the Shoelace formula. /// Compute the area of a contour using the Shoelace formula.
fn contour_area_slice(points: &[(i32, i32)]) -> f64 { fn contour_area_slice(points: &[(i32, i32)]) -> f64 {
let n = points.len(); let n = points.len();
@@ -71,7 +87,7 @@ fn contour_area_slice(points: &[(i32, i32)]) -> f64 {
area.abs() / 2.0 area.abs() / 2.0
} }
/// Approximate a contour to a quadrilateral. /// Approximate a contour to a quadrilateral using extreme points.
fn approx_quadrilateral(points: &[(i32, i32)]) -> Option<Vec<CornerPoint>> { fn approx_quadrilateral(points: &[(i32, i32)]) -> Option<Vec<CornerPoint>> {
let n = points.len(); let n = points.len();
if n < 4 { if n < 4 {
@@ -97,24 +113,21 @@ fn order_corners(points: &[CornerPoint]) -> [CornerPoint; 4] {
let mut ordered = [(0.0, 0.0); 4]; let mut ordered = [(0.0, 0.0); 4];
if pts.len() >= 4 { if pts.len() >= 4 {
// Sort by position
// TL = min(x+y), BR = max(x+y)
pts.sort_by(|a, b| { pts.sort_by(|a, b| {
(a.0 + a.1) (a.0 + a.1)
.partial_cmp(&(b.0 + b.1)) .partial_cmp(&(b.0 + b.1))
.unwrap_or(std::cmp::Ordering::Equal) .unwrap_or(std::cmp::Ordering::Equal)
}); });
ordered[0] = pts[0]; // TL ordered[0] = pts[0];
ordered[2] = pts[3]; // BR ordered[2] = pts[3];
// TR = max(x - y), BL = min(x - y)
pts.sort_by(|a, b| { pts.sort_by(|a, b| {
(a.0 - a.1) (a.0 - a.1)
.partial_cmp(&(b.0 - b.1)) .partial_cmp(&(b.0 - b.1))
.unwrap_or(std::cmp::Ordering::Equal) .unwrap_or(std::cmp::Ordering::Equal)
}); });
ordered[1] = pts[3]; // TR ordered[1] = pts[3];
ordered[3] = pts[0]; // BL ordered[3] = pts[0];
} }
ordered ordered
@@ -129,16 +142,15 @@ fn bounding_rect_slice(points: &[(i32, i32)]) -> (i32, i32, i32, i32) {
(left, top, right, bottom) (left, top, right, bottom)
} }
/// Detect corners with fallback: full resolution, then half, then error. /// Detect corners with panic-safe fallback.
pub fn detect_corners_with_fallback( pub fn detect_corners_with_fallback(
edges: &GrayImage, edges: &GrayImage,
) -> Result<[CornerPoint; 4], PipelineError> { ) -> Result<[CornerPoint; 4], PipelineError> {
// Attempt 1: Full resolution
if let Ok(corners) = detect_corners(edges) { if let Ok(corners) = detect_corners(edges) {
return Ok(corners); return Ok(corners);
} }
// Attempt 2: Half resolution // Attempt 2: half resolution
let (w, h) = (edges.width() / 2, edges.height() / 2); let (w, h) = (edges.width() / 2, edges.height() / 2);
if w > 10 && h > 10 { if w > 10 && h > 10 {
let half = image::imageops::resize( let half = image::imageops::resize(
@@ -152,9 +164,10 @@ pub fn detect_corners_with_fallback(
} }
} }
Err(PipelineError::CornerDetection( // Final fallback: use image bounds as corners (full image)
"Could not detect document corners automatically".to_string(), let (w, h) = (edges.width() as f64, edges.height() as f64);
)) tracing::warn!("Corner detection failed, using full image bounds");
Ok([(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)])
} }
#[cfg(test)] #[cfg(test)]
@@ -174,4 +187,12 @@ mod tests {
let rect = bounding_rect_slice(&points); let rect = bounding_rect_slice(&points);
assert_eq!(rect, (5, 20, 100, 150)); assert_eq!(rect, (5, 20, 100, 150));
} }
#[test]
fn test_order_corners() {
let pts = vec![(0.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 0.0)];
let ordered = order_corners(&pts);
assert_eq!(ordered[0], (0.0, 0.0)); // TL
assert_eq!(ordered[2], (100.0, 100.0)); // BR
}
} }
+22
View File
@@ -26,10 +26,32 @@ pub struct ScanResult {
} }
/// Run the full scanner pipeline with all stages. /// Run the full scanner pipeline with all stages.
/// Wrapped in catch_unwind to prevent imageproc panics from killing the worker.
pub async fn process( pub async fn process(
job: &Job, job: &Job,
config: &WorkerConfig, config: &WorkerConfig,
progress: &ProgressReporter, progress: &ProgressReporter,
) -> Result<ScanResult, Box<dyn std::error::Error + Send + Sync>> {
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
process_inner(job, config, progress)
}));
match result {
Ok(fut) => fut.await,
Err(panic) => {
let msg = panic
.downcast_ref::<&str>()
.unwrap_or(&"Unknown panic in scanner pipeline");
Err(format!("Pipeline panicked: {}", msg).into())
}
}
}
/// Inner pipeline implementation (runs inside catch_unwind).
async fn process_inner(
job: &Job,
config: &WorkerConfig,
progress: &ProgressReporter,
) -> Result<ScanResult, Box<dyn std::error::Error + Send + Sync>> { ) -> Result<ScanResult, Box<dyn std::error::Error + Send + Sync>> {
let start = Instant::now(); let start = Instant::now();
let input_path = Path::new(&job.file_path); let input_path = Path::new(&job.file_path);