Files
pdfcraft/crates/engine/src/camera_edges.rs
T
Raj PatelandClaude Opus 5.5 1970935587 M14: edits let go of the previous working file before writing the next (gap 13) (#1279)
* M0: keep the camera edge test NaN-safe under clippy's manual_range_contains

Clippy 1.97 flags `d < 0.35 || d > PI - 0.35` in the camera edge hit test. Its suggested
`!(0.35..=PI - 0.35).contains(&d)` would count a NaN direction as a hit, which the two
comparisons did not, so the NaN check is kept explicitly. Behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011AsuqvnFXhY9ASL1bA2jtm

* M14: edits let go of the previous working file before writing the next (gap 13)

Every edit writes a new working file: the original plus one appended revision. The previous
one stayed alive in the working bytes, the display bytes and the renderer while the next was
written, so after a few edits a 1 GiB file peaked at three copies.

Each refresh path (full, comments/form, content, undo, redo, after save) now releases them
first: the working and display bytes fall back to the original (already held by the editor)
and the renderer is replaced by an empty one until the refresh rebuilds it. A refresh that
still fails after rollback draws the original instead of nothing. Save still writes from the
editor, never from these.

Measured on a synthetic 1 GiB file (release build, Linux): five edits peak at 2.1 GiB instead
of 3.1 GiB, in 8.5 s instead of 16 s. Saving still peaks at three copies; reading from disk on
demand stays blocked on the renderer (hayro parses one contiguous buffer, gap 2). gaps.md,
the parity notes and the roadmap record both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011AsuqvnFXhY9ASL1bA2jtm

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-12 01:28:01 -04:00

503 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Find a photographed document's corners (Enhance ▸ Camera image): the edges of the sheet are
//! the four strongest straight lines that enclose a large convex area, each backed by an edge
//! along most of its length. Pure Rust and deterministic; `None` when nothing convinces, so the
//! caller falls back to corners the user places.
//!
//! Steps: shrink the picture to [`WORK`] pixels on its long side, blur it, take Sobel gradients
//! and keep thin, strong edges; vote for lines (a Hough transform that only votes along each
//! edge's own gradient direction, give or take a few degrees); pair two near-horizontal with two near-vertical lines; keep
//! the quadrilaterals whose four sides lie on edges, and choose the largest.
use pdfcraft_geom::perspective::{Point, order_quad};
/// The working size: the long side of the shrunken picture, in pixels.
const WORK: usize = 400;
/// The most lines tried (strongest first).
const MAX_LINES: usize = 24;
/// A side counts when at least this share of it lies on a matching edge.
const MIN_SUPPORT: f64 = 0.55;
/// A document fills at least this share of the picture.
const MIN_AREA: f64 = 0.12;
/// How far (share of the picture's size) a corner may lie outside the picture.
const MARGIN: f64 = 0.02;
/// A grey picture, row-major, read with bounds checks.
struct Grey {
w: usize,
h: usize,
v: Vec<f32>,
}
impl Grey {
fn at(&self, x: isize, y: isize) -> f32 {
// Outside the picture repeats its border.
let x = x.clamp(0, self.w as isize - 1) as usize;
let y = y.clamp(0, self.h as isize - 1) as usize;
self.v.get(y * self.w + x).copied().unwrap_or(0.0)
}
}
/// An edge pixel: its gradient's strength and direction (radians, the normal of the edge).
#[derive(Clone, Copy)]
struct Edge {
mag: f32,
dir: f32,
}
/// The document's corners in `rgba` (premultiplied RGBA8, `width` × `height`), in pixels:
/// top-left, top-right, bottom-right, bottom-left. `None` when no quadrilateral convinces.
pub fn detect_document(rgba: &[u8], width: u32, height: u32) -> Option<[Point; 4]> {
let (w, h) = (width as usize, height as usize);
if w < 16 || h < 16 || w.checked_mul(h)?.checked_mul(4)? != rgba.len() {
return None;
}
let grey = shrink(rgba, w, h);
let k = w.max(h) as f64 / grey.w.max(grey.h) as f64;
let edges = edges(&blur(&blur(&grey)));
let lines = hough(&edges, grey.w, grey.h);
let quad = best_quad(&lines, &edges, grey.w, grey.h)?;
let quad = refine(quad, &edges, grey.w, grey.h).unwrap_or(quad);
// Back to the picture's pixels (pixel centres line up at k × (x + 0.5)).
Some(quad.map(|[x, y]| [(x + 0.5) * k, (y + 0.5) * k]))
}
/// Box-filter `rgba` (premultiplied, over white) down to at most [`WORK`] pixels on its long
/// side, as luminance 0–255.
fn shrink(rgba: &[u8], w: usize, h: usize) -> Grey {
let step = w.max(h).div_ceil(WORK).max(1);
let (sw, sh) = (w.div_ceil(step), h.div_ceil(step));
let mut v = Vec::with_capacity(sw * sh);
for sy in 0..sh {
for sx in 0..sw {
let (mut sum, mut n) = (0.0f32, 0.0f32);
for y in sy * step..((sy + 1) * step).min(h) {
for x in sx * step..((sx + 1) * step).min(w) {
if let Some(p) = rgba.get((y * w + x) * 4..(y * w + x) * 4 + 4) {
let white = 255.0 - p[3] as f32;
let (r, g, b) = (p[0] as f32 + white, p[1] as f32 + white, p[2] as f32 + white);
sum += 0.299 * r + 0.587 * g + 0.114 * b;
n += 1.0;
}
}
}
v.push(if n > 0.0 { sum / n } else { 255.0 });
}
}
Grey { w: sw, h: sh, v }
}
/// A 3×3 binomial blur.
fn blur(g: &Grey) -> Grey {
let mut v = Vec::with_capacity(g.v.len());
for y in 0..g.h as isize {
for x in 0..g.w as isize {
let mut s = 0.0;
for (dy, wy) in [(-1, 1.0), (0, 2.0), (1, 1.0)] {
for (dx, wx) in [(-1, 1.0), (0, 2.0), (1, 1.0)] {
s += wy * wx * g.at(x + dx, y + dy);
}
}
v.push(s / 16.0);
}
}
Grey { w: g.w, h: g.h, v }
}
/// Thin, strong edges: Sobel gradients, kept where they are a local maximum across the edge
/// and above a threshold relative to the picture's strongest edges.
fn edges(g: &Grey) -> Vec<Option<Edge>> {
let mut grad = Vec::with_capacity(g.v.len());
for y in 0..g.h as isize {
for x in 0..g.w as isize {
let p = |dx, dy| g.at(x + dx, y + dy);
let gx = (p(1, -1) + 2.0 * p(1, 0) + p(1, 1)) - (p(-1, -1) + 2.0 * p(-1, 0) + p(-1, 1));
let gy = (p(-1, 1) + 2.0 * p(0, 1) + p(1, 1)) - (p(-1, -1) + 2.0 * p(0, -1) + p(1, -1));
grad.push(Edge { mag: gx.hypot(gy), dir: gy.atan2(gx) });
}
}
// The threshold: a share of the 98th percentile, never below a faint-edge floor.
let mut mags: Vec<f32> = grad.iter().map(|e| e.mag).filter(|m| m.is_finite()).collect();
mags.sort_by(f32::total_cmp);
let p98 = mags.get(mags.len() * 98 / 100).copied().unwrap_or(0.0);
let threshold = (0.25 * p98).max(24.0);
let mag = |x: isize, y: isize| -> f32 {
if x < 0 || y < 0 || x >= g.w as isize || y >= g.h as isize {
return 0.0;
}
grad.get(y as usize * g.w + x as usize).map_or(0.0, |e| e.mag)
};
let mut out = Vec::with_capacity(grad.len());
for (i, e) in grad.iter().enumerate() {
let (x, y) = ((i % g.w) as isize, (i / g.w) as isize);
// Edges touching the picture's border are where blurring repeats it: no information.
let inside = x > 1 && y > 1 && x < g.w as isize - 2 && y < g.h as isize - 2;
let keep = inside && e.mag >= threshold && {
let (dx, dy) = (e.dir.cos().round() as isize, e.dir.sin().round() as isize);
e.mag >= mag(x + dx, y + dy) && e.mag >= mag(x - dx, y - dy)
};
out.push(keep.then_some(*e));
}
out
}
/// A line `x cos θ + y sin θ = ρ` and its votes.
#[derive(Clone, Copy, Debug)]
struct Line {
theta: f64,
rho: f64,
}
/// The strongest lines through the edges: each edge votes for the lines within 6° of its own
/// normal, weighted by its strength; peaks are taken strongest first, clearing their
/// neighbourhood.
fn hough(edges: &[Option<Edge>], w: usize, h: usize) -> Vec<Line> {
const STEPS: usize = 180;
let diag = ((w * w + h * h) as f64).sqrt().ceil() as usize;
let rows = 2 * diag + 1;
let mut acc = vec![0.0f32; STEPS * rows];
let trig: Vec<(f64, f64)> = (0..STEPS).map(|t| (t as f64).to_radians()).map(|a| (a.cos(), a.sin())).collect();
for (i, e) in edges.iter().enumerate() {
let Some(e) = e else { continue };
let (x, y) = ((i % w) as f64, (i / w) as f64);
let normal = (e.dir as f64).to_degrees().rem_euclid(180.0).round() as isize;
// ±6°: a jagged (aliased) edge's local gradient leans towards the axes.
for t in normal - 6..=normal + 6 {
let t = t.rem_euclid(STEPS as isize) as usize;
let Some(&(c, s)) = trig.get(t) else { continue };
let r = (x * c + y * s).round() as isize + diag as isize;
if let Some(cell) = usize::try_from(r).ok().and_then(|r| acc.get_mut(t * rows + r)) {
*cell += e.mag;
}
}
}
let mut lines = Vec::new();
let first = acc.iter().copied().fold(0.0f32, f32::max);
while lines.len() < MAX_LINES {
let Some((best, &v)) = acc.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)) else { break };
if v <= 0.0 || v < 0.08 * first {
break;
}
let (t, r) = (best / rows, best % rows);
lines.push(Line { theta: (t as f64).to_radians(), rho: r as f64 - diag as f64 });
// Clear ±6° and ±6 px around the peak (θ wraps, flipping ρ's sign).
for dt in -6isize..=6 {
let tt = t as isize + dt;
let (tt, flip) = if tt < 0 {
(tt + STEPS as isize, true)
} else if tt >= STEPS as isize {
(tt - STEPS as isize, true)
} else {
(tt, false)
};
let rr = if flip { 2 * diag as isize - r as isize } else { r as isize };
for dr in -6isize..=6 {
if let Some(cell) = usize::try_from(rr + dr).ok().filter(|&q| q < rows).and_then(|q| acc.get_mut(tt as usize * rows + q)) {
*cell = 0.0;
}
}
}
}
lines
}
fn intersect(a: Line, b: Line) -> Option<Point> {
let (c1, s1, c2, s2) = (a.theta.cos(), a.theta.sin(), b.theta.cos(), b.theta.sin());
let det = c1 * s2 - s1 * c2;
if det.abs() < 1e-6 {
return None;
}
let p = [(a.rho * s2 - b.rho * s1) / det, (c1 * b.rho - c2 * a.rho) / det];
p.iter().all(|v| v.is_finite()).then_some(p)
}
/// The share of the side from `a` to `b` that lies on an edge facing across it.
fn support(a: Point, b: Point, edges: &[Option<Edge>], w: usize, h: usize) -> f64 {
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
if len < 1.0 {
return 0.0;
}
let (ux, uy) = ((b[0] - a[0]) / len, (b[1] - a[1]) / len);
// The side's normal, (-uy, ux), as an angle.
let normal = ux.atan2(-uy) as f32;
let n = (len / 2.0).clamp(8.0, 200.0) as usize;
let mut hits = 0;
for i in 0..n {
let t = 0.05 + 0.9 * (i as f64 + 0.5) / n as f64;
let (px, py) = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t);
let hit = (-2i32..=2).any(|o| {
let (x, y) = ((px - uy * o as f64).round(), (py + ux * o as f64).round());
if x < 0.0 || y < 0.0 || x >= w as f64 || y >= h as f64 {
return false;
}
edges.get(y as usize * w + x as usize).copied().flatten().is_some_and(|e| {
// Either way across: dark-to-light or light-to-dark.
let d = (e.dir - normal).rem_euclid(std::f32::consts::PI);
// A NaN direction is no hit, as the two comparisons it replaces said.
!d.is_nan() && !(0.35..=std::f32::consts::PI - 0.35).contains(&d)
})
});
hits += hit as usize;
}
hits as f64 / n as f64
}
/// Sharpen the quadrilateral found on the coarse line grid: fit each side's line through the
/// edge points along it (total least squares), then intersect neighbouring sides again.
/// `None` when a side has too few points or the result is no longer convex.
fn refine(q: [Point; 4], edges: &[Option<Edge>], w: usize, h: usize) -> Option<[Point; 4]> {
// Each side as (a point on it, its unit direction).
let mut sides = Vec::with_capacity(4);
for k in 0..4 {
let (a, b) = (q[k], q[(k + 1) % 4]);
let len = (b[0] - a[0]).hypot(b[1] - a[1]);
if len < 4.0 {
return None;
}
let (ux, uy) = ((b[0] - a[0]) / len, (b[1] - a[1]) / len);
let normal = ux.atan2(-uy) as f32;
let mut pts = Vec::new();
// Pixels near the side, between 5% and 95% of its length (away from the corners).
let (x0, x1) = (a[0].min(b[0]).floor() as isize - 3, a[0].max(b[0]).ceil() as isize + 3);
let (y0, y1) = (a[1].min(b[1]).floor() as isize - 3, a[1].max(b[1]).ceil() as isize + 3);
for y in y0.max(0)..=y1.min(h as isize - 1) {
for x in x0.max(0)..=x1.min(w as isize - 1) {
let Some(e) = edges.get(y as usize * w + x as usize).copied().flatten() else { continue };
let d = (e.dir - normal).rem_euclid(std::f32::consts::PI);
if d > 0.35 && d < std::f32::consts::PI - 0.35 {
continue;
}
let (dx, dy) = (x as f64 - a[0], y as f64 - a[1]);
let t = (dx * ux + dy * uy) / len;
let off = -dx * uy + dy * ux;
if (0.05..=0.95).contains(&t) && off.abs() <= 3.0 {
pts.push([x as f64, y as f64]);
}
}
}
if pts.len() < 10 {
return None;
}
let n = pts.len() as f64;
let (cx, cy) = (pts.iter().map(|p| p[0]).sum::<f64>() / n, pts.iter().map(|p| p[1]).sum::<f64>() / n);
let (mut sxx, mut sxy, mut syy) = (0.0, 0.0, 0.0);
for p in &pts {
let (dx, dy) = (p[0] - cx, p[1] - cy);
sxx += dx * dx;
sxy += dx * dy;
syy += dy * dy;
}
// The principal direction of the points.
let angle = 0.5 * (2.0 * sxy).atan2(sxx - syy);
sides.push(([cx, cy], [angle.cos(), angle.sin()]));
}
let meet = |(p, u): (Point, Point), (r, v): (Point, Point)| -> Option<Point> {
let det = u[0] * v[1] - u[1] * v[0];
if det.abs() < 1e-6 {
return None;
}
let t = ((r[0] - p[0]) * v[1] - (r[1] - p[1]) * v[0]) / det;
let out = [p[0] + t * u[0], p[1] + t * u[1]];
out.iter().all(|c| c.is_finite()).then_some(out)
};
let side = |k: usize| sides.get(k).copied();
// Corner k is where side k-1 (ending there) meets side k (starting there).
let corner = |k: usize| meet(side((k + 3) % 4)?, side(k)?);
let refined = [corner(0)?, corner(1)?, corner(2)?, corner(3)?];
// A fit that moved a corner far is not a refinement.
let moved = refined.iter().zip(q.iter()).any(|(r, o)| (r[0] - o[0]).hypot(r[1] - o[1]) > 6.0);
if moved {
return None;
}
order_quad(refined)
}
/// The convex quadrilateral of two near-horizontal and two near-vertical lines whose four sides
/// all lie on edges, scored by its area times the square of its sides' mean support.
fn best_quad(lines: &[Line], edges: &[Option<Edge>], w: usize, h: usize) -> Option<[Point; 4]> {
// A normal within 45° of vertical makes a near-horizontal line.
let flat = |l: &&Line| (l.theta.to_degrees() - 90.0).abs() <= 45.0;
let horizontal: Vec<&Line> = lines.iter().filter(flat).collect();
let vertical: Vec<&Line> = lines.iter().filter(|l| !flat(l)).collect();
let (fw, fh) = (w as f64, h as f64);
let (mx, my) = (MARGIN * fw, MARGIN * fh);
let mut best: Option<(f64, [Point; 4])> = None;
for (i, &h1) in horizontal.iter().enumerate() {
for &h2 in horizontal.iter().skip(i + 1) {
for (j, &v1) in vertical.iter().enumerate() {
for &v2 in vertical.iter().skip(j + 1) {
let corners = [intersect(*h1, *v1), intersect(*h1, *v2), intersect(*h2, *v2), intersect(*h2, *v1)];
let [Some(a), Some(b), Some(c), Some(d)] = corners else { continue };
let Some(q) = order_quad([a, b, c, d]) else { continue };
if q.iter().any(|p| p[0] < -mx || p[1] < -my || p[0] > fw + mx || p[1] > fh + my) {
continue;
}
let area = (0..4)
.map(|k| {
let (p, n) = (q[k], q[(k + 1) % 4]);
p[0] * n[1] - n[0] * p[1]
})
.sum::<f64>()
.abs()
/ 2.0;
if area < MIN_AREA * fw * fh {
continue;
}
let sides: Vec<f64> = (0..4).map(|k| support(q[k], q[(k + 1) % 4], edges, w, h)).collect();
if sides.iter().any(|&s| s < MIN_SUPPORT) {
continue;
}
// Large, and with every side on its edge: a slightly larger quad with a side
// off the sheet loses to the sheet itself.
let mean = sides.iter().sum::<f64>() / 4.0;
let score = area * mean * mean;
if best.is_none_or(|(b, _)| score > b) {
best = Some((score, q));
}
}
}
}
}
best.map(|(_, q)| q)
}
#[cfg(test)]
mod tests {
use super::*;
/// A deterministic pseudo-random generator for noise.
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> f32 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
((self.0 >> 33) as f32) / (u32::MAX >> 1) as f32
}
}
fn inside(q: &[Point; 4], x: f64, y: f64) -> bool {
(0..4).all(|i| {
let (a, b) = (q[i], q[(i + 1) % 4]);
(b[0] - a[0]) * (y - a[1]) - (b[1] - a[1]) * (x - a[0]) >= 0.0
})
}
/// A synthetic photo: `sheet` (corners in reading order) in `paper` over `table(x, y)`, with
/// 2×2 supersampled edges, optional text lines on the sheet, and noise of ±`noise`.
fn photo(w: u32, h: u32, sheet: [Point; 4], paper: f32, table: impl Fn(f64, f64) -> f32, text: bool, noise: f32) -> Vec<u8> {
let mut rng = Lcg(7);
let at = |u: f64, v: f64| -> Point {
let top = [sheet[0][0] + (sheet[1][0] - sheet[0][0]) * u, sheet[0][1] + (sheet[1][1] - sheet[0][1]) * u];
let bot = [sheet[3][0] + (sheet[2][0] - sheet[3][0]) * u, sheet[3][1] + (sheet[2][1] - sheet[3][1]) * u];
[top[0] + (bot[0] - top[0]) * v, top[1] + (bot[1] - top[1]) * v]
};
let bars: Vec<[Point; 4]> = if text {
(0..12)
.map(|k| {
let v = 0.12 + k as f64 * 0.065;
let u1 = if k % 4 == 3 { 0.5 } else { 0.88 };
[at(0.1, v), at(u1, v), at(u1, v + 0.022), at(0.1, v + 0.022)]
})
.collect()
} else {
Vec::new()
};
let mut out = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
let mut s = 0.0;
for (ox, oy) in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)] {
let (fx, fy) = (x as f64 + ox, y as f64 + oy);
s += if bars.iter().any(|b| inside(b, fx, fy)) {
40.0
} else if inside(&sheet, fx, fy) {
paper
} else {
table(fx, fy)
};
}
let v = (s / 4.0 + (rng.next() - 0.5) * 2.0 * noise).clamp(0.0, 255.0) as u8;
out.extend_from_slice(&[v, v, v, 255]);
}
}
out
}
fn assert_near(found: Option<[Point; 4]>, want: [Point; 4], tol: f64) {
let found = found.expect("the sheet should be found");
for (f, w) in found.iter().zip(want.iter()) {
assert!((f[0] - w[0]).hypot(f[1] - w[1]) <= tol, "found {found:?}, want {want:?}");
}
}
const SHEET: [Point; 4] = [[150.0, 170.0], [760.0, 110.0], [820.0, 1050.0], [100.0, 1090.0]];
#[test]
fn finds_a_tilted_sheet_on_a_plain_table() {
let img = photo(900, 1200, SHEET, 245.0, |_, _| 95.0, false, 0.0);
assert_near(detect_document(&img, 900, 1200), SHEET, 6.0);
}
#[test]
fn text_on_the_sheet_does_not_shrink_the_quad() {
let img = photo(900, 1200, SHEET, 245.0, |_, _| 95.0, true, 6.0);
assert_near(detect_document(&img, 900, 1200), SHEET, 6.0);
}
#[test]
fn wood_grain_and_a_shadow_do_not_fool_it() {
// Horizontal grain stripes, darker towards the bottom right.
let table = |x: f64, y: f64| 110.0 + 18.0 * ((y / 23.0).sin() as f32) - (0.03 * (x + y)) as f32;
let img = photo(900, 1200, SHEET, 235.0, table, true, 8.0);
assert_near(detect_document(&img, 900, 1200), SHEET, 6.0);
}
#[test]
fn a_card_photographed_from_an_angle() {
// Strong perspective: the far edge much shorter than the near one.
let card = [[260.0, 300.0], [660.0, 290.0], [800.0, 700.0], [120.0, 720.0]];
let img = photo(900, 1000, card, 225.0, |_, _| 60.0, false, 5.0);
assert_near(detect_document(&img, 900, 1000), card, 6.0);
}
#[test]
fn a_sheet_turned_by_thirty_degrees() {
// A 500 × 650 sheet turned 30° about (450, 500), corners in reading order.
let (c, s) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
let turn = |x: f64, y: f64| [450.0 + x * c - y * s, 500.0 + x * s + y * c];
let sheet = [turn(-250.0, -325.0), turn(250.0, -325.0), turn(250.0, 325.0), turn(-250.0, 325.0)];
let img = photo(900, 1000, sheet, 240.0, |_, _| 80.0, true, 5.0);
// Turned 30° clockwise on screen, the top-left corner is the one turned from (-250, -325).
assert_near(detect_document(&img, 900, 1000), sheet, 6.0);
}
#[test]
fn a_sheet_running_off_the_picture_is_not_invented() {
// The bottom edge is outside the picture: either nothing, or corners on the visible
// edges — never a quadrilateral that cuts the sheet in half.
let sheet = [[150.0, 170.0], [760.0, 110.0], [830.0, 1400.0], [90.0, 1450.0]];
let img = photo(900, 1200, sheet, 245.0, |_, _| 95.0, true, 4.0);
if let Some(q) = detect_document(&img, 900, 1200) {
let top = (q[0][1] + q[1][1]) / 2.0;
let bottom = (q[2][1] + q[3][1]) / 2.0;
assert!(top < 200.0 && bottom > 1150.0, "{q:?}");
}
}
#[test]
fn gives_up_without_a_document() {
let table = |x: f64, y: f64| 110.0 + 18.0 * ((y / 23.0).sin() as f32) - (0.03 * (x + y)) as f32;
let img = photo(900, 1200, [[0.0; 2]; 4], 0.0, table, false, 8.0);
assert_eq!(detect_document(&img, 900, 1200), None);
// White paper on a white desk: no edge to find.
let img = photo(900, 1200, SHEET, 246.0, |_, _| 244.0, false, 2.0);
assert_eq!(detect_document(&img, 900, 1200), None);
}
#[test]
fn bad_input_is_none_not_a_panic() {
assert_eq!(detect_document(&[], 0, 0), None);
assert_eq!(detect_document(&[0; 40], 10, 1), None);
assert_eq!(detect_document(&[0; 64 * 64 * 4], 64, 63), None);
assert_eq!(detect_document(&vec![255; 64 * 64 * 4], 64, 64), None);
}
}