Skip to main content

lsdetect/
detector.rs

1//! Line segment detection after the LSD article.
2//!
3//! Written from R. Grompone von Gioi, J. Jakubowicz, J.-M. Morel,
4//! G. Randall: "LSD: a Line Segment Detector", Image Processing On
5//! Line, 2012.
6
7use rayon::prelude::*;
8use std::f64::consts::PI;
9
10use crate::sample::{Gray, SizeMismatch};
11
12/// Default working scale, shrinks the image first.
13pub const SCALE: f64 = 0.8;
14/// Anti-alias blur sigma, per unit of scale.
15const SIGMA_PER_SCALE: f64 = 0.6;
16/// Gray levels are quantized, so norms below q / sin(tau) carry no
17/// trustworthy angle.
18const GRAY_QUANTIZATION: f64 = 2.0;
19/// Tau, degrees.
20const ANGLE_TOLERANCE_DEG: f64 = 22.5;
21/// Aligned points per rectangle area, below this the region is no
22/// line.
23const DENSITY_THRESHOLD: f64 = 0.7;
24/// Survive when -log10(NFA) exceeds this. 0.0 is epsilon = 1.
25const LOG_EPSILON: f64 = 0.0;
26/// Bins for the pseudo-ordering, exactness is not needed.
27const NORM_BINS: usize = 1024;
28
29/// One detected line segment, in input-image coordinates.
30#[derive(Clone, Copy, Debug, PartialEq)]
31pub struct Segment {
32    /// First endpoint x, in input-image pixels.
33    pub x1: f64,
34    /// First endpoint y, in input-image pixels.
35    pub y1: f64,
36    /// Second endpoint x, in input-image pixels.
37    pub x2: f64,
38    /// Second endpoint y, in input-image pixels.
39    pub y2: f64,
40    /// Segment width in pixels.
41    pub width: f64,
42    /// The angle tolerance the detection used, as a fraction of pi.
43    pub precision: f64,
44    /// Natural log of the number of false alarms.
45    pub log_nfa: f64,
46}
47
48impl Segment {
49    /// Direction in degrees, -90 to 90, 0 = horizontal.
50    pub fn angle(&self) -> f64 {
51        let mut a = (self.y2 - self.y1).atan2(self.x2 - self.x1).to_degrees();
52        if a > 90.0 {
53            a -= 180.0;
54        }
55        if a <= -90.0 {
56            a += 180.0;
57        }
58        a
59    }
60
61    /// Length in pixels.
62    pub fn length(&self) -> f64 {
63        (self.x2 - self.x1).hypot(self.y2 - self.y1)
64    }
65}
66
67/// One pixel of the gradient field. Packed: the neighbour test in
68/// the growing loop is the hot path, one cache spot beats three
69/// arrays.
70#[derive(Clone, Copy, Default)]
71struct Px {
72    norm: f32,
73    ux: f32,
74    uy: f32,
75}
76
77/// A line-support region, summarized as a rectangle.
78#[derive(Clone, Copy)]
79struct Rect {
80    x1: f64,
81    y1: f64,
82    x2: f64,
83    y2: f64,
84    width: f64,
85    dx: f64,
86    dy: f64,
87    prec: f64,
88    p: f64,
89}
90
91/// Find the validated line segments of a grayscale image.
92///
93/// # Errors
94///
95/// [`SizeMismatch`] when the buffer does not hold width * height
96/// samples.
97pub fn detect<T: Gray>(
98    image: &[T],
99    width: usize,
100    height: usize,
101    scale: f64,
102) -> Result<Vec<Segment>, SizeMismatch> {
103    if image.len() != width * height {
104        return Err(SizeMismatch {
105            expected: width * height,
106            got: image.len(),
107        });
108    }
109    if width < 3 || height < 3 {
110        return Ok(Vec::new());
111    }
112    let converted;
113    let gray: &[f64] = match T::as_f64_slice(image) {
114        Some(direct) => direct,
115        None => {
116            converted = image.iter().map(|s| s.to_gray()).collect::<Vec<f64>>();
117            &converted
118        }
119    };
120    let owned;
121    let (img, w, h) = if scale < 1.0 {
122        owned = downscale(gray, width, height, scale);
123        (owned.0.as_slice(), owned.1, owned.2)
124    } else {
125        (gray, width, height)
126    };
127    if w < 3 || h < 3 {
128        return Ok(Vec::new());
129    }
130    let prec = PI * ANGLE_TOLERANCE_DEG / 180.0;
131    let p = ANGLE_TOLERANCE_DEG / 180.0;
132    let rho = (GRAY_QUANTIZATION / (ANGLE_TOLERANCE_DEG.to_radians()).sin()) as f32;
133    let field = level_line_field(img, w, h);
134    let log_nt = 2.5 * ((w as f64).log10() + (h as f64).log10()) + 11f64.log10();
135    let min_region_pixels = (-log_nt / p.log10()).ceil() as usize;
136
137    let seeds = seeds_by_norm(&field, rho);
138    let mut used: Vec<bool> = field.iter().map(|px| px.norm <= rho).collect();
139    let mut region: Vec<u32> = Vec::new();
140    let mut segments = Vec::new();
141    let back = if scale < 1.0 { 1.0 / scale } else { 1.0 };
142
143    for &seed in &seeds {
144        if used[seed as usize] {
145            continue;
146        }
147        let mut region_angle = grow_region(seed, prec, &field, &mut used, &mut region, w, h);
148        if region.len() < min_region_pixels {
149            continue;
150        }
151        let mut rect = region_to_rect(&region, &field, region_angle, prec, p, w);
152        let ok = refine(
153            &mut region,
154            &mut region_angle,
155            prec,
156            &mut rect,
157            &mut used,
158            &field,
159            min_region_pixels,
160            w,
161            h,
162        );
163        if !ok {
164            continue;
165        }
166        let log_nfa = improve_rect(&mut rect, &field, rho, log_nt, w, h);
167        if log_nfa <= LOG_EPSILON {
168            continue;
169        }
170        // The 2x2 gradient sits half a pixel to the lower right of
171        // its anchor.
172        segments.push(Segment {
173            x1: (rect.x1 + 0.5) * back,
174            y1: (rect.y1 + 0.5) * back,
175            x2: (rect.x2 + 0.5) * back,
176            y2: (rect.y2 + 0.5) * back,
177            width: rect.width * back,
178            precision: rect.p,
179            log_nfa,
180        });
181    }
182    segments.sort_by(|a, b| b.length().total_cmp(&a.length()));
183    Ok(segments)
184}
185
186/// Blur, then subsample.
187fn downscale(image: &[f64], width: usize, height: usize, scale: f64) -> (Vec<f64>, usize, usize) {
188    let sigma = SIGMA_PER_SCALE / scale;
189    let radius = (sigma * (2.0 * 1000f64.ln()).sqrt()).ceil() as usize;
190    let mut kernel: Vec<f64> = (0..=2 * radius)
191        .map(|i| {
192            let x = i as f64 - radius as f64;
193            (-0.5 * (x / sigma).powi(2)).exp()
194        })
195        .collect();
196    let total: f64 = kernel.iter().sum();
197    for k in &mut kernel {
198        *k /= total;
199    }
200    let clamp = |v: isize, hi: usize| v.clamp(0, hi as isize - 1) as usize;
201    let mut horiz = vec![0.0; width * height];
202    horiz
203        .par_chunks_mut(width)
204        .enumerate()
205        .for_each(|(y, out)| {
206            let row = &image[y * width..(y + 1) * width];
207            for (i, &k) in kernel.iter().enumerate() {
208                let off = i as isize - radius as isize;
209                let (x0, x1) = if off < 0 {
210                    ((-off) as usize, width)
211                } else {
212                    (0, width - (off as usize).min(width))
213                };
214                for x in x0..x1 {
215                    out[x] += row[(x as isize + off) as usize] * k;
216                }
217                let edge = if off < 0 { row[0] } else { row[width - 1] } * k;
218                for slot in out[..x0].iter_mut() {
219                    *slot += edge;
220                }
221                for slot in out[x1..].iter_mut() {
222                    *slot += edge;
223                }
224            }
225        });
226    let mut blurred = vec![0.0; width * height];
227    blurred
228        .par_chunks_mut(width)
229        .enumerate()
230        .for_each(|(y, out)| {
231            for (i, &k) in kernel.iter().enumerate() {
232                let sy = clamp(y as isize + i as isize - radius as isize, height);
233                let src = &horiz[sy * width..(sy + 1) * width];
234                for (slot, value) in out.iter_mut().zip(src) {
235                    *slot += value * k;
236                }
237            }
238        });
239    let new_w = ((width as f64 * scale).floor() as usize).max(2);
240    let new_h = ((height as f64 * scale).floor() as usize).max(2);
241    let cols: Vec<usize> = (0..new_w)
242        .map(|x| ((x as f64 / scale) as usize).min(width - 1))
243        .collect();
244    let mut small = vec![0.0; new_w * new_h];
245    small
246        .par_chunks_mut(new_w)
247        .enumerate()
248        .for_each(|(y, out)| {
249            let sy = ((y as f64 / scale) as usize).min(height - 1);
250            let src = &blurred[sy * width..(sy + 1) * width];
251            for (slot, &sx) in out.iter_mut().zip(&cols) {
252                *slot = src[sx];
253            }
254        });
255    (small, new_w, new_h)
256}
257
258/// Gradient norm plus level-line direction.
259fn level_line_field(image: &[f64], width: usize, height: usize) -> Vec<Px> {
260    let mut field = vec![Px::default(); width * height];
261    field
262        .par_chunks_mut(width)
263        .take(height - 1)
264        .enumerate()
265        .for_each(|(y, out)| {
266            let row = &image[y * width..(y + 1) * width];
267            let below = &image[(y + 1) * width..(y + 2) * width];
268            for x in 0..width - 1 {
269                let a = row[x];
270                let b = row[x + 1];
271                let c = below[x];
272                let d = below[x + 1];
273                let gx = (b + d - a - c) / 2.0;
274                let gy = (c + d - a - b) / 2.0;
275                let norm = gx.hypot(gy);
276                if norm > 0.0 {
277                    out[x] = Px {
278                        norm: norm as f32,
279                        ux: (-gy / norm) as f32,
280                        uy: (gx / norm) as f32,
281                    };
282                }
283            }
284        });
285    field
286}
287
288/// atan2 on demand. Almost nothing needs the actual angle.
289fn angle_at(field: &[Px], i: usize) -> f64 {
290    (field[i].uy as f64).atan2(field[i].ux as f64)
291}
292
293/// Strong pixels, strongest bins first.
294fn seeds_by_norm(field: &[Px], rho: f32) -> Vec<u32> {
295    let max = field.iter().map(|p| p.norm).fold(0.0f32, f32::max);
296    if max <= 0.0 {
297        return Vec::new();
298    }
299    let mut bins: Vec<Vec<u32>> = vec![Vec::new(); NORM_BINS];
300    for (i, p) in field.iter().enumerate() {
301        if p.norm > rho {
302            let b = ((p.norm / max) * (NORM_BINS as f32 - 1.0)) as usize;
303            bins[b.min(NORM_BINS - 1)].push(i as u32);
304        }
305    }
306    bins.into_iter().rev().flatten().collect()
307}
308
309/// Angle distance on the full circle.
310fn angle_diff(a: f64, b: f64) -> f64 {
311    signed_angle_diff(a, b).abs()
312}
313
314/// Signed angle difference in (-pi, pi].
315fn signed_angle_diff(a: f64, b: f64) -> f64 {
316    let mut d = a - b;
317    while d <= -PI {
318        d += 2.0 * PI;
319    }
320    while d > PI {
321        d -= 2.0 * PI;
322    }
323    d
324}
325
326/// Region growing. The mean direction updates with every added
327/// pixel, which is what lets a slowly bending edge stay one region.
328#[allow(clippy::too_many_arguments)]
329fn grow_region(
330    seed: u32,
331    prec: f64,
332    field: &[Px],
333    used: &mut [bool],
334    region: &mut Vec<u32>,
335    width: usize,
336    height: usize,
337) -> f64 {
338    region.clear();
339    region.push(seed);
340    used[seed as usize] = true;
341    let cos_prec = prec.cos();
342    let mut sum_dx = field[seed as usize].ux as f64;
343    let mut sum_dy = field[seed as usize].uy as f64;
344    let mut bar = cos_prec;
345    let offsets: [isize; 8] = [
346        -(width as isize) - 1,
347        -(width as isize),
348        -(width as isize) + 1,
349        -1,
350        1,
351        width as isize - 1,
352        width as isize,
353        width as isize + 1,
354    ];
355    let mut i = 0;
356    while i < region.len() {
357        let p = region[i] as usize;
358        let px = p % width;
359        let py = p / width;
360        // Interior pixels skip the bounds checks.
361        if px >= 1 && py >= 1 && px + 1 < width && py + 1 < height {
362            for &off in &offsets {
363                let n = (p as isize + off) as usize;
364                if used[n] {
365                    continue;
366                }
367                let px_n = field[n];
368                if px_n.ux as f64 * sum_dx + px_n.uy as f64 * sum_dy < bar {
369                    continue;
370                }
371                used[n] = true;
372                region.push(n as u32);
373                sum_dx += px_n.ux as f64;
374                sum_dy += px_n.uy as f64;
375                bar = cos_prec * sum_dx.hypot(sum_dy);
376            }
377        } else {
378            for ny in py.saturating_sub(1)..(py + 2).min(height) {
379                for nx in px.saturating_sub(1)..(px + 2).min(width) {
380                    let n = ny * width + nx;
381                    if used[n] {
382                        continue;
383                    }
384                    let px_n = field[n];
385                    if px_n.ux as f64 * sum_dx + px_n.uy as f64 * sum_dy < bar {
386                        continue;
387                    }
388                    used[n] = true;
389                    region.push(n as u32);
390                    sum_dx += px_n.ux as f64;
391                    sum_dy += px_n.uy as f64;
392                    bar = cos_prec * sum_dx.hypot(sum_dy);
393                }
394            }
395        }
396        i += 1;
397    }
398    sum_dy.atan2(sum_dx)
399}
400
401/// Weighted center, principal axis.
402fn region_to_rect(
403    region: &[u32],
404    field: &[Px],
405    region_angle: f64,
406    prec: f64,
407    p: f64,
408    width: usize,
409) -> Rect {
410    let mut cx = 0.0;
411    let mut cy = 0.0;
412    let mut total = 0.0;
413    for &r in region {
414        let w = field[r as usize].norm as f64;
415        cx += (r as usize % width) as f64 * w;
416        cy += (r as usize / width) as f64 * w;
417        total += w;
418    }
419    cx /= total;
420    cy /= total;
421    let theta = region_theta(region, field, cx, cy, region_angle, prec, width);
422    let (dx, dy) = (theta.cos(), theta.sin());
423    let mut l_min = 0.0f64;
424    let mut l_max = 0.0f64;
425    let mut w_min = 0.0f64;
426    let mut w_max = 0.0f64;
427    for &r in region {
428        let rx = (r as usize % width) as f64 - cx;
429        let ry = (r as usize / width) as f64 - cy;
430        let l = rx * dx + ry * dy;
431        let w = -rx * dy + ry * dx;
432        l_min = l_min.min(l);
433        l_max = l_max.max(l);
434        w_min = w_min.min(w);
435        w_max = w_max.max(w);
436    }
437    Rect {
438        x1: cx + l_min * dx,
439        y1: cy + l_min * dy,
440        x2: cx + l_max * dx,
441        y2: cy + l_max * dy,
442        width: (w_max - w_min).max(1.0),
443        dx,
444        dy,
445        prec,
446        p,
447    }
448}
449
450/// Principal axis of the region's second moments.
451fn region_theta(
452    region: &[u32],
453    field: &[Px],
454    cx: f64,
455    cy: f64,
456    region_angle: f64,
457    prec: f64,
458    width: usize,
459) -> f64 {
460    let mut ixx = 0.0;
461    let mut iyy = 0.0;
462    let mut ixy = 0.0;
463    for &r in region {
464        let w = field[r as usize].norm as f64;
465        let rx = (r as usize % width) as f64 - cx;
466        let ry = (r as usize / width) as f64 - cy;
467        ixx += w * ry * ry;
468        iyy += w * rx * rx;
469        ixy -= w * rx * ry;
470    }
471    let smallest = (ixx + iyy - ((ixx - iyy).powi(2) + 4.0 * ixy * ixy).sqrt()) / 2.0;
472    let mut theta = if (ixx - smallest).abs() > (iyy - smallest).abs() {
473        (smallest - ixx).atan2(ixy)
474    } else {
475        ixy.atan2(smallest - iyy)
476    };
477    // Orient the axis like the region's level lines.
478    if angle_diff(theta, region_angle) > prec {
479        theta += PI;
480    }
481    theta
482}
483
484/// Rescue a too-sparse region.
485#[allow(clippy::too_many_arguments)]
486fn refine(
487    region: &mut Vec<u32>,
488    region_angle: &mut f64,
489    prec: f64,
490    rect: &mut Rect,
491    used: &mut [bool],
492    field: &[Px],
493    min_size: usize,
494    width: usize,
495    height: usize,
496) -> bool {
497    if rect_density(region, rect) >= DENSITY_THRESHOLD {
498        return true;
499    }
500    let seed = region[0];
501    let sx = (seed as usize % width) as f64;
502    let sy = (seed as usize / width) as f64;
503    let seed_angle = angle_at(field, seed as usize);
504    let mut sum = 0.0;
505    let mut squares = 0.0;
506    let mut n = 0.0;
507    for &r in region.iter() {
508        let rx = (r as usize % width) as f64;
509        let ry = (r as usize / width) as f64;
510        if (rx - sx).hypot(ry - sy) < rect.width {
511            let d = signed_angle_diff(angle_at(field, r as usize), seed_angle);
512            sum += d;
513            squares += d * d;
514            n += 1.0;
515        }
516    }
517    let mean = sum / n;
518    let variance = squares / n - mean * mean;
519    let tau = 2.0 * variance.max(0.0).sqrt();
520    for &r in region.iter() {
521        used[r as usize] = false;
522    }
523    *region_angle = grow_region(seed, tau, field, used, region, width, height);
524    if region.len() < min_size {
525        return false;
526    }
527    *rect = region_to_rect(region, field, *region_angle, prec, rect.p, width);
528    if rect_density(region, rect) >= DENSITY_THRESHOLD {
529        return true;
530    }
531    reduce_radius(
532        region,
533        region_angle,
534        prec,
535        rect,
536        used,
537        field,
538        min_size,
539        width,
540    )
541}
542
543/// Shrink the region radius until the rectangle is dense again.
544#[allow(clippy::too_many_arguments)]
545fn reduce_radius(
546    region: &mut Vec<u32>,
547    region_angle: &mut f64,
548    prec: f64,
549    rect: &mut Rect,
550    used: &mut [bool],
551    field: &[Px],
552    min_size: usize,
553    width: usize,
554) -> bool {
555    let seed = region[0];
556    let sx = (seed as usize % width) as f64;
557    let sy = (seed as usize / width) as f64;
558    let mut radius = (sx - rect.x1)
559        .hypot(sy - rect.y1)
560        .max((sx - rect.x2).hypot(sy - rect.y2));
561    loop {
562        if rect_density(region, rect) >= DENSITY_THRESHOLD {
563            return true;
564        }
565        radius *= 0.75;
566        let mut i = 0;
567        while i < region.len() {
568            let r = region[i];
569            let rx = (r as usize % width) as f64;
570            let ry = (r as usize / width) as f64;
571            if (rx - sx).hypot(ry - sy) > radius {
572                used[r as usize] = false;
573                region.swap_remove(i);
574            } else {
575                i += 1;
576            }
577        }
578        if region.len() < min_size {
579            return false;
580        }
581        *rect = region_to_rect(region, field, *region_angle, prec, rect.p, width);
582    }
583}
584
585/// Region pixels per rectangle area.
586fn rect_density(region: &[u32], rect: &Rect) -> f64 {
587    let len = (rect.x2 - rect.x1).hypot(rect.y2 - rect.y1);
588    region.len() as f64 / (len * rect.width).max(1.0)
589}
590
591/// n and k for the NFA test.
592fn rect_counts(rect: &Rect, field: &[Px], rho: f32, width: usize, height: usize) -> (usize, usize) {
593    let cx = (rect.x1 + rect.x2) / 2.0;
594    let cy = (rect.y1 + rect.y2) / 2.0;
595    let half_l = (rect.x2 - rect.x1).hypot(rect.y2 - rect.y1) / 2.0;
596    let half_w = rect.width / 2.0;
597    let reach_x = half_l * rect.dx.abs() + half_w * rect.dy.abs();
598    let reach_y = half_l * rect.dy.abs() + half_w * rect.dx.abs();
599    let x_lo = ((cx - reach_x).floor().max(0.0)) as usize;
600    let y_lo = ((cy - reach_y).floor().max(0.0)) as usize;
601    let x_hi = ((cx + reach_x).ceil() as usize).min(width - 1);
602    let y_hi = ((cy + reach_y).ceil() as usize).min(height - 1);
603    let cos_prec = rect.prec.cos() as f32;
604    let dxf = rect.dx as f32;
605    let dyf = rect.dy as f32;
606    let mut total = 0;
607    let mut aligned = 0;
608    for y in y_lo..=y_hi {
609        for x in x_lo..=x_hi {
610            let rx = x as f64 - cx;
611            let ry = y as f64 - cy;
612            let l = rx * rect.dx + ry * rect.dy;
613            if l.abs() > half_l {
614                continue;
615            }
616            let w = -rx * rect.dy + ry * rect.dx;
617            if w.abs() > half_w {
618                continue;
619            }
620            total += 1;
621            let px = field[y * width + x];
622            if px.norm > rho && px.ux * dxf + px.uy * dyf >= cos_prec {
623                aligned += 1;
624            }
625        }
626    }
627    (total, aligned)
628}
629
630/// -log10(NFA) of one rectangle.
631fn rect_log_nfa(
632    rect: &Rect,
633    field: &[Px],
634    rho: f32,
635    log_nt: f64,
636    width: usize,
637    height: usize,
638) -> f64 {
639    let (total, aligned) = rect_counts(rect, field, rho, width, height);
640    log_nfa(total, aligned, rect.p, log_nt)
641}
642
643/// Last chances for a failing rectangle.
644fn improve_rect(
645    rect: &mut Rect,
646    field: &[Px],
647    rho: f32,
648    log_nt: f64,
649    width: usize,
650    height: usize,
651) -> f64 {
652    let mut best = rect_log_nfa(rect, field, rho, log_nt, width, height);
653    if best > LOG_EPSILON {
654        return best;
655    }
656    let delta = 0.5;
657    let try_variant = |candidate: Rect, best: &mut f64, rect: &mut Rect| {
658        let value = rect_log_nfa(&candidate, field, rho, log_nt, width, height);
659        if value > *best {
660            *best = value;
661            *rect = candidate;
662        }
663    };
664    let mut finer = *rect;
665    for _ in 0..5 {
666        finer.p /= 2.0;
667        finer.prec = finer.p * PI;
668        try_variant(finer, &mut best, rect);
669    }
670    if best > LOG_EPSILON {
671        return best;
672    }
673    let mut slimmer = *rect;
674    for _ in 0..5 {
675        if slimmer.width - delta < 0.5 {
676            break;
677        }
678        slimmer.width -= delta;
679        try_variant(slimmer, &mut best, rect);
680    }
681    if best > LOG_EPSILON {
682        return best;
683    }
684    for side in [1.0, -1.0] {
685        let mut trimmed = *rect;
686        for _ in 0..5 {
687            if trimmed.width - delta < 0.5 {
688                break;
689            }
690            trimmed.x1 += side * -trimmed.dy * delta / 2.0;
691            trimmed.y1 += side * trimmed.dx * delta / 2.0;
692            trimmed.x2 += side * -trimmed.dy * delta / 2.0;
693            trimmed.y2 += side * trimmed.dx * delta / 2.0;
694            trimmed.width -= delta;
695            try_variant(trimmed, &mut best, rect);
696        }
697        if best > LOG_EPSILON {
698            return best;
699        }
700    }
701    let mut finest = *rect;
702    for _ in 0..5 {
703        finest.p /= 2.0;
704        finest.prec = finest.p * PI;
705        try_variant(finest, &mut best, rect);
706    }
707    best
708}
709
710/// -log10(NFA) for k aligned points out of n.
711fn log_nfa(n: usize, k: usize, p: f64, log_nt: f64) -> f64 {
712    if n == 0 || k == 0 {
713        return -log_nt;
714    }
715    if n == k {
716        return -log_nt - n as f64 * p.log10();
717    }
718    let nf = n as f64;
719    let kf = k as f64;
720    let odds = p / (1.0 - p);
721    let log1 = log_gamma(nf + 1.0) - log_gamma(kf + 1.0) - log_gamma(nf - kf + 1.0)
722        + kf * p.ln()
723        + (nf - kf) * (1.0 - p).ln();
724    let mut term = log1.exp();
725    if term == 0.0 {
726        if kf > nf * p {
727            return -log1 / std::f64::consts::LN_10 - log_nt;
728        }
729        return -log_nt;
730    }
731    let mut tail = term;
732    for i in k + 1..=n {
733        let bin = (nf - i as f64 + 1.0) / i as f64;
734        let mult = bin * odds;
735        term *= mult;
736        tail += term;
737        if bin < 1.0 {
738            let bound = term * ((1.0 - mult.powi((n - i + 1) as i32)) / (1.0 - mult) - 1.0);
739            if bound < 0.1 * (-tail.log10() - log_nt).abs() * tail {
740                break;
741            }
742        }
743    }
744    -tail.log10() - log_nt
745}
746
747/// ln of the gamma function, via the two approximations the
748/// article's appendix prints (Lanczos below 15, Windschitl above,
749/// the crossover is also the article's).
750fn log_gamma(x: f64) -> f64 {
751    if x >= 15.0 {
752        return 0.918_938_533_204_673 + (x - 0.5) * x.ln() - x
753            + 0.5 * x * ((x * (1.0 / x).sinh() + 1.0 / (810.0 * x.powi(6))).ln());
754    }
755    const Q: [f64; 7] = [
756        75_122.633_153,
757        80_916.627_895_2,
758        36_308.295_147_7,
759        8_687.245_297_05,
760        1_168.926_494_79,
761        83.867_604_342_4,
762        2.506_628_275_11,
763    ];
764    let mut a = (x + 0.5) * (x + 5.5).ln() - (x + 5.5);
765    let mut b = 0.0;
766    for (n, q) in Q.iter().enumerate() {
767        a -= (x + n as f64).ln();
768        b += q * x.powi(n as i32);
769    }
770    a + b.ln()
771}
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776
777    fn edge_image(width: usize, height: usize, split: usize) -> Vec<f64> {
778        let mut img = vec![50.0; width * height];
779        for y in 0..height {
780            for x in split..width {
781                img[y * width + x] = 200.0;
782            }
783        }
784        img
785    }
786
787    #[test]
788    fn vertical_edge_is_found() {
789        let img = edge_image(120, 120, 60);
790        let segs = detect(&img, 120, 120, SCALE).unwrap();
791        assert!(!segs.is_empty());
792        let best = &segs[0];
793        assert!(best.angle().abs() > 88.0, "angle {}", best.angle());
794        assert!(best.length() > 60.0, "length {}", best.length());
795        assert!(best.x1 > 45.0 && best.x1 < 75.0, "x {}", best.x1);
796    }
797
798    #[test]
799    fn tiny_image_is_empty() {
800        for n in 1..3 {
801            let img = vec![128.0; n * n];
802            assert!(detect(&img, n, n, SCALE).unwrap().is_empty(), "{n}x{n}");
803        }
804    }
805
806    #[test]
807    fn flat_image_is_empty() {
808        let img = vec![128.0; 100 * 100];
809        assert!(detect(&img, 100, 100, SCALE).unwrap().is_empty());
810    }
811}
812
813#[cfg(test)]
814mod bench {
815    use super::*;
816    use std::time::Instant;
817
818    fn load(path: &str) -> (Vec<f64>, usize, usize) {
819        let bytes = std::fs::read(path).unwrap();
820        let w = u64::from_le_bytes(bytes[0..8].try_into().unwrap()) as usize;
821        let h = u64::from_le_bytes(bytes[8..16].try_into().unwrap()) as usize;
822        let img = bytes[16..]
823            .chunks_exact(8)
824            .map(|c| f64::from_le_bytes(c.try_into().unwrap()))
825            .collect();
826        (img, w, h)
827    }
828
829    #[test]
830    #[ignore]
831    fn stages() {
832        let path = std::env::var("LSDETECT_BENCH").unwrap();
833        let (img, w, h) = load(&path);
834        let t = Instant::now();
835        let (small, sw, sh) = downscale(&img, w, h, SCALE);
836        eprintln!("downscale   {:7.1?}", t.elapsed());
837        let t = Instant::now();
838        let field = level_line_field(&small, sw, sh);
839        eprintln!("field       {:7.1?}", t.elapsed());
840        let rho = (GRAY_QUANTIZATION / (ANGLE_TOLERANCE_DEG.to_radians()).sin()) as f32;
841        let t = Instant::now();
842        let seeds = seeds_by_norm(&field, rho);
843        eprintln!("seeds       {:7.1?} ({})", t.elapsed(), seeds.len());
844        let prec = PI * ANGLE_TOLERANCE_DEG / 180.0;
845        let p = ANGLE_TOLERANCE_DEG / 180.0;
846        let log_nt = 2.5 * ((sw as f64).log10() + (sh as f64).log10()) + 11f64.log10();
847        let min_region_pixels = (-log_nt / p.log10()).ceil() as usize;
848        let mut used: Vec<bool> = field.iter().map(|px| px.norm <= rho).collect();
849        let mut region = Vec::new();
850        let (mut t_grow, mut t_rect, mut t_nfa) = (0.0f64, 0.0f64, 0.0f64);
851        let mut kept = 0;
852        for &seed in &seeds {
853            if used[seed as usize] {
854                continue;
855            }
856            let t = Instant::now();
857            let mut region_angle = grow_region(seed, prec, &field, &mut used, &mut region, sw, sh);
858            t_grow += t.elapsed().as_secs_f64();
859            if region.len() < min_region_pixels {
860                continue;
861            }
862            let t = Instant::now();
863            let mut rect = region_to_rect(&region, &field, region_angle, prec, p, sw);
864            let ok = refine(
865                &mut region,
866                &mut region_angle,
867                prec,
868                &mut rect,
869                &mut used,
870                &field,
871                min_region_pixels,
872                sw,
873                sh,
874            );
875            t_rect += t.elapsed().as_secs_f64();
876            if !ok {
877                continue;
878            }
879            let t = Instant::now();
880            let v = improve_rect(&mut rect, &field, rho, log_nt, sw, sh);
881            t_nfa += t.elapsed().as_secs_f64();
882            if v > LOG_EPSILON {
883                kept += 1;
884            }
885        }
886        eprintln!(
887            "grow        {:6.1} ms\nrect+refine {:6.1} ms\nnfa+improve {:6.1} ms\nkept {}",
888            t_grow * 1e3,
889            t_rect * 1e3,
890            t_nfa * 1e3,
891            kept
892        );
893    }
894}
895
896#[cfg(test)]
897mod size_tests {
898    use super::*;
899
900    #[test]
901    fn a_wrong_buffer_length_is_refused() {
902        let image = vec![0.0f64; 8];
903        let result = detect(&image, 3, 3, 1.0);
904        assert_eq!(
905            result.unwrap_err(),
906            SizeMismatch {
907                expected: 9,
908                got: 8
909            }
910        );
911    }
912
913    #[test]
914    fn quantized_grays_share_the_detector_scale() {
915        use crate::sample::Gray;
916        assert_eq!(255u8.to_gray(), 255.0);
917        assert_eq!(65535u16.to_gray(), 255.0);
918    }
919}