|
| 1 | +//! Dependency-free benchmark for the fast frame locator (`anyd::detect`). |
| 2 | +//! |
| 3 | +//! No criterion: timing uses `std::time::Instant` with an explicit warmup and many |
| 4 | +//! iterations, reporting median, p95 and FPS. It measures [`locate`] across three |
| 5 | +//! resolutions and three scenarios (empty / one QR / a few mixed codes), and — for |
| 6 | +//! contrast — a full QR decode (`anyd::codes::qr::scan`) on the one-QR frames, so the |
| 7 | +//! table shows the detect-vs-decode speedup that motivates the two-stage pipeline. |
| 8 | +//! |
| 9 | +//! The heavy timing lives entirely in `main`, which `cargo test` does not run for a |
| 10 | +//! `harness = false` bench. Run it with `cargo bench --bench detect`. |
| 11 | +
|
| 12 | +use std::hint::black_box; |
| 13 | +use std::time::Instant; |
| 14 | + |
| 15 | +use anyd::GrayImage; |
| 16 | +use anyd::codes::code128::Code128Encoder; |
| 17 | +use anyd::codes::datamatrix::DataMatrixEncoder; |
| 18 | +use anyd::codes::qr::{self, EcLevel, QrEncoder}; |
| 19 | +use anyd::detect::{LocateOptions, locate}; |
| 20 | +use anyd::geometry::Point; |
| 21 | +use anyd::render::render; |
| 22 | +use anyd::traits::Encode; |
| 23 | + |
| 24 | +// ---------------------------------------------------------------------------------- |
| 25 | +// Frame synthesis |
| 26 | +// ---------------------------------------------------------------------------------- |
| 27 | + |
| 28 | +fn qr_image(text: &str, scale: usize) -> GrayImage { |
| 29 | + let enc = QrEncoder::new(); |
| 30 | + let sym = enc.build_text(text, EcLevel::M).unwrap(); |
| 31 | + render(&enc.encode(&sym).unwrap(), scale) |
| 32 | +} |
| 33 | + |
| 34 | +fn datamatrix_image(text: &str, scale: usize) -> GrayImage { |
| 35 | + let enc = DataMatrixEncoder::new(); |
| 36 | + let sym = enc.build_text(text).unwrap(); |
| 37 | + render(&enc.encode(&sym).unwrap(), scale) |
| 38 | +} |
| 39 | + |
| 40 | +fn code128_image(text: &str, scale: usize) -> GrayImage { |
| 41 | + let enc = Code128Encoder::new(); |
| 42 | + let sym = enc.build_text(text).unwrap(); |
| 43 | + render(&enc.encode(&sym).unwrap(), scale) |
| 44 | +} |
| 45 | + |
| 46 | +fn place(canvas: &mut GrayImage, sprite: &GrayImage, ox: usize, oy: usize) -> Point { |
| 47 | + for y in 0..sprite.height() { |
| 48 | + for x in 0..sprite.width() { |
| 49 | + let cx = ox + x; |
| 50 | + let cy = oy + y; |
| 51 | + if cx < canvas.width() && cy < canvas.height() { |
| 52 | + canvas.set(cx, cy, sprite.get(x, y)); |
| 53 | + } |
| 54 | + } |
| 55 | + } |
| 56 | + Point::new( |
| 57 | + (ox + sprite.width() / 2) as f32, |
| 58 | + (oy + sprite.height() / 2) as f32, |
| 59 | + ) |
| 60 | +} |
| 61 | + |
| 62 | +/// A synthetic frame together with the true centres of the codes planted in it. |
| 63 | +struct Frame { |
| 64 | + scenario: &'static str, |
| 65 | + image: GrayImage, |
| 66 | + planted: Vec<Point>, |
| 67 | +} |
| 68 | + |
| 69 | +fn empty_frame(w: usize, h: usize) -> Frame { |
| 70 | + Frame { |
| 71 | + scenario: "empty", |
| 72 | + image: GrayImage::filled(w, h, 255), |
| 73 | + planted: Vec::new(), |
| 74 | + } |
| 75 | +} |
| 76 | + |
| 77 | +fn one_qr_frame(w: usize, h: usize) -> Frame { |
| 78 | + let mut image = GrayImage::filled(w, h, 255); |
| 79 | + let qr = qr_image("ANYD DETECT BENCH", 6); |
| 80 | + let ox = (w / 2).saturating_sub(qr.width() / 2); |
| 81 | + let oy = (h / 2).saturating_sub(qr.height() / 2); |
| 82 | + let c = place(&mut image, &qr, ox, oy); |
| 83 | + Frame { |
| 84 | + scenario: "one-qr", |
| 85 | + image, |
| 86 | + planted: vec![c], |
| 87 | + } |
| 88 | +} |
| 89 | + |
| 90 | +fn mixed_frame(w: usize, h: usize) -> Frame { |
| 91 | + let mut image = GrayImage::filled(w, h, 255); |
| 92 | + let qr = qr_image("MIXED-QR", 6); |
| 93 | + let dm = datamatrix_image("MIXED-DM", 6); |
| 94 | + let c128 = code128_image("MIXED128", 2); |
| 95 | + let planted = vec![ |
| 96 | + place(&mut image, &qr, w / 12, h / 12), |
| 97 | + place(&mut image, &dm, w * 7 / 12, h / 10), |
| 98 | + place(&mut image, &c128, w / 10, h * 3 / 5), |
| 99 | + ]; |
| 100 | + Frame { |
| 101 | + scenario: "mixed", |
| 102 | + image, |
| 103 | + planted, |
| 104 | + } |
| 105 | +} |
| 106 | + |
| 107 | +// ---------------------------------------------------------------------------------- |
| 108 | +// Timing |
| 109 | +// ---------------------------------------------------------------------------------- |
| 110 | + |
| 111 | +struct Stat { |
| 112 | + median_ms: f64, |
| 113 | + p95_ms: f64, |
| 114 | + fps: f64, |
| 115 | +} |
| 116 | + |
| 117 | +/// Time `f` over `warmup + iters` runs, returning median/p95/FPS of the timed runs. |
| 118 | +fn measure<F: FnMut() -> usize>(mut f: F, warmup: usize, iters: usize) -> Stat { |
| 119 | + for _ in 0..warmup { |
| 120 | + black_box(f()); |
| 121 | + } |
| 122 | + let mut times = Vec::with_capacity(iters); |
| 123 | + for _ in 0..iters { |
| 124 | + let t = Instant::now(); |
| 125 | + let r = f(); |
| 126 | + let dt = t.elapsed().as_secs_f64() * 1000.0; |
| 127 | + black_box(r); |
| 128 | + times.push(dt); |
| 129 | + } |
| 130 | + times.sort_by(|a, b| a.partial_cmp(b).unwrap()); |
| 131 | + let median = times[times.len() / 2]; |
| 132 | + let p95_idx = (((times.len() as f64) * 0.95) as usize).min(times.len() - 1); |
| 133 | + let p95 = times[p95_idx]; |
| 134 | + Stat { |
| 135 | + median_ms: median, |
| 136 | + p95_ms: p95, |
| 137 | + fps: if median > 0.0 { |
| 138 | + 1000.0 / median |
| 139 | + } else { |
| 140 | + f64::INFINITY |
| 141 | + }, |
| 142 | + } |
| 143 | +} |
| 144 | + |
| 145 | +// ---------------------------------------------------------------------------------- |
| 146 | +// Accuracy (recall / false-positive rate) over the populated frames |
| 147 | +// ---------------------------------------------------------------------------------- |
| 148 | + |
| 149 | +#[derive(Default)] |
| 150 | +struct Accuracy { |
| 151 | + planted_total: usize, |
| 152 | + planted_hit: usize, |
| 153 | + candidates_total: usize, |
| 154 | + candidates_matched: usize, |
| 155 | +} |
| 156 | + |
| 157 | +impl Accuracy { |
| 158 | + fn tally(&mut self, frame: &Frame, opts: &LocateOptions) { |
| 159 | + if frame.planted.is_empty() { |
| 160 | + // Blank / empty frames: every returned candidate is a false positive. |
| 161 | + let cands = locate(&frame.image.as_frame(), opts); |
| 162 | + self.candidates_total += cands.len(); |
| 163 | + return; |
| 164 | + } |
| 165 | + let tol = 0.15 * frame.image.width().min(frame.image.height()) as f32; |
| 166 | + let cands = locate(&frame.image.as_frame(), opts); |
| 167 | + for &p in &frame.planted { |
| 168 | + self.planted_total += 1; |
| 169 | + if cands |
| 170 | + .iter() |
| 171 | + .any(|c| c.location.outline.center().distance(p) <= tol) |
| 172 | + { |
| 173 | + self.planted_hit += 1; |
| 174 | + } |
| 175 | + } |
| 176 | + for c in &cands { |
| 177 | + self.candidates_total += 1; |
| 178 | + if frame |
| 179 | + .planted |
| 180 | + .iter() |
| 181 | + .any(|&p| c.location.outline.center().distance(p) <= tol) |
| 182 | + { |
| 183 | + self.candidates_matched += 1; |
| 184 | + } |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + fn recall(&self) -> f64 { |
| 189 | + if self.planted_total == 0 { |
| 190 | + return 0.0; |
| 191 | + } |
| 192 | + self.planted_hit as f64 / self.planted_total as f64 |
| 193 | + } |
| 194 | + |
| 195 | + fn false_positive_rate(&self) -> f64 { |
| 196 | + if self.candidates_total == 0 { |
| 197 | + return 0.0; |
| 198 | + } |
| 199 | + (self.candidates_total - self.candidates_matched) as f64 / self.candidates_total as f64 |
| 200 | + } |
| 201 | +} |
| 202 | + |
| 203 | +// ---------------------------------------------------------------------------------- |
| 204 | +// Driver |
| 205 | +// ---------------------------------------------------------------------------------- |
| 206 | + |
| 207 | +fn main() { |
| 208 | + let opts = LocateOptions::default(); |
| 209 | + let resolutions = [(640usize, 480usize), (1280, 720), (1920, 1080)]; |
| 210 | + |
| 211 | + println!("anyd frame-detector benchmark (locate vs full decode)"); |
| 212 | + println!( |
| 213 | + "downscale={} tile={} edge_density={}", |
| 214 | + opts.downscale, opts.tile, opts.edge_density |
| 215 | + ); |
| 216 | + println!(); |
| 217 | + println!( |
| 218 | + "{:<12} {:<8} {:>11} {:>10} {:>9}", |
| 219 | + "resolution", "scenario", "median ms", "p95 ms", "FPS" |
| 220 | + ); |
| 221 | + println!("{}", "-".repeat(54)); |
| 222 | + |
| 223 | + let mut acc = Accuracy::default(); |
| 224 | + // Remember one-QR detect medians per resolution for the decode-ratio table. |
| 225 | + let mut qr_detect_median: Vec<(String, f64)> = Vec::new(); |
| 226 | + |
| 227 | + for &(w, h) in &resolutions { |
| 228 | + let res = format!("{w}x{h}"); |
| 229 | + let frames = [empty_frame(w, h), one_qr_frame(w, h), mixed_frame(w, h)]; |
| 230 | + for frame in &frames { |
| 231 | + let img = frame.image.clone(); |
| 232 | + let stat = measure(|| locate(&img.as_frame(), &opts).len(), 12, 120); |
| 233 | + println!( |
| 234 | + "{:<12} {:<8} {:>11.3} {:>10.3} {:>9.1}", |
| 235 | + res, frame.scenario, stat.median_ms, stat.p95_ms, stat.fps |
| 236 | + ); |
| 237 | + if frame.scenario == "one-qr" { |
| 238 | + qr_detect_median.push((res.clone(), stat.median_ms)); |
| 239 | + } |
| 240 | + acc.tally(frame, &opts); |
| 241 | + } |
| 242 | + } |
| 243 | + |
| 244 | + // Full-decode comparison on the one-QR frames. |
| 245 | + println!(); |
| 246 | + println!("Full QR decode (anyd::codes::qr::scan) on the one-QR frames:"); |
| 247 | + println!( |
| 248 | + "{:<12} {:>11} {:>10} {:>9} {:>18}", |
| 249 | + "resolution", "median ms", "p95 ms", "FPS", "detect speedup" |
| 250 | + ); |
| 251 | + println!("{}", "-".repeat(64)); |
| 252 | + for (i, &(w, h)) in resolutions.iter().enumerate() { |
| 253 | + let frame = one_qr_frame(w, h); |
| 254 | + let img = frame.image.clone(); |
| 255 | + let stat = measure( |
| 256 | + || match qr::scan(&img.as_frame()) { |
| 257 | + Ok(_) => 1, |
| 258 | + Err(_) => 0, |
| 259 | + }, |
| 260 | + 4, |
| 261 | + 40, |
| 262 | + ); |
| 263 | + let detect_median = qr_detect_median[i].1; |
| 264 | + let speedup = if detect_median > 0.0 { |
| 265 | + stat.median_ms / detect_median |
| 266 | + } else { |
| 267 | + f64::INFINITY |
| 268 | + }; |
| 269 | + println!( |
| 270 | + "{:<12} {:>11.3} {:>10.3} {:>9.1} {:>16.1}x", |
| 271 | + format!("{w}x{h}"), |
| 272 | + stat.median_ms, |
| 273 | + stat.p95_ms, |
| 274 | + stat.fps, |
| 275 | + speedup |
| 276 | + ); |
| 277 | + } |
| 278 | + |
| 279 | + println!(); |
| 280 | + println!("Accuracy over populated frames (recall) and all frames (false positives):"); |
| 281 | + println!( |
| 282 | + " planted codes: {} located: {} recall: {:.1}%", |
| 283 | + acc.planted_total, |
| 284 | + acc.planted_hit, |
| 285 | + acc.recall() * 100.0 |
| 286 | + ); |
| 287 | + println!( |
| 288 | + " candidates: {} matched a planted code: {} false-positive rate: {:.1}%", |
| 289 | + acc.candidates_total, |
| 290 | + acc.candidates_matched, |
| 291 | + acc.false_positive_rate() * 100.0 |
| 292 | + ); |
| 293 | +} |
0 commit comments