Skip to content

Commit 9318bad

Browse files
committed
Merge impl/detect-bench: fast frame detector + detection benchmark
2 parents 0646013 + 440008a commit 9318bad

8 files changed

Lines changed: 1240 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,8 @@ missing_debug_implementations = "warn"
3030
all = "warn"
3131

3232
[dev-dependencies]
33+
34+
# Dependency-free frame-detector benchmark (std::time only; no criterion).
35+
[[bench]]
36+
name = "detect"
37+
harness = false

benches/detect.rs

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
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

Comments
 (0)