use std::hint::black_box;
use std::process::Command;
use std::time::Instant;
use serde::Serialize;
use radsym::{
Circle, DetectCirclesConfig, Ellipse, EllipseRefineConfig, FrstConfig, Homography,
HomographyEllipseRefineConfig, OwnedImage, PixelCoord, Polarity, RadialCenterConfig, RsdConfig,
compute_gradient, detect_circles, extract_proposals, frst_response, frst_response_fused,
load_grayscale, radial_center_refine_from_gradient, rectified_circle_to_image_ellipse,
refine_circle, refine_ellipse, refine_ellipse_homography, rsd_response, rsd_response_fused,
score_circle_support_detailed, sobel_gradient,
};
const REPEATS: usize = 50;
fn make_disk_image(size: usize) -> OwnedImage<u8> {
make_fixed_disk(size, size as f32 / 6.0)
}
fn make_fixed_disk(size: usize, radius: f32) -> OwnedImage<u8> {
let c = size as f32 / 2.0;
let mut data = vec![0u8; size * size];
for y in 0..size {
for x in 0..size {
let dx = x as f32 - c;
let dy = y as f32 - c;
if (dx * dx + dy * dy).sqrt() <= radius {
data[y * size + x] = 255;
}
}
}
OwnedImage::from_vec(data, size, size).unwrap()
}
fn make_ellipse_image(size: usize) -> OwnedImage<u8> {
let c = size as f32 / 2.0;
let semi_major = size as f32 / 6.0;
let semi_minor = size as f32 / 7.5;
let (sin_a, cos_a) = 0.35f32.sin_cos();
let mut data = vec![0u8; size * size];
for y in 0..size {
for x in 0..size {
let dx = x as f32 - c;
let dy = y as f32 - c;
let lx = dx * cos_a + dy * sin_a;
let ly = -dx * sin_a + dy * cos_a;
if (lx / semi_major).powi(2) + (ly / semi_minor).powi(2) <= 1.0 {
data[y * size + x] = 255;
}
}
}
OwnedImage::from_vec(data, size, size).unwrap()
}
fn make_projective_disk_image(
size: usize,
homography: &Homography,
circle: Circle,
) -> OwnedImage<u8> {
let mut data = vec![20u8; size * size];
for y in 0..size {
for x in 0..size {
let Some(rectified) =
homography.map_image_to_rectified(PixelCoord::new(x as f32, y as f32))
else {
continue;
};
let dx = rectified.x - circle.center.x;
let dy = rectified.y - circle.center.y;
if (dx * dx + dy * dy).sqrt() <= circle.radius {
data[y * size + x] = 255;
}
}
}
OwnedImage::from_vec(data, size, size).unwrap()
}
fn median(mut samples: Vec<f64>) -> f64 {
samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = samples.len();
if n == 0 {
0.0
} else if n % 2 == 1 {
samples[n / 2]
} else {
0.5 * (samples[n / 2 - 1] + samples[n / 2])
}
}
fn r3(x: f64) -> f64 {
(x * 1000.0).round() / 1000.0
}
fn r1(x: f64) -> f64 {
(x * 10.0).round() / 10.0
}
fn r2(x: f64) -> f64 {
(x * 100.0).round() / 100.0
}
fn time_call_ms<T>(reps: usize, warmup: usize, mut f: impl FnMut() -> T) -> f64 {
for _ in 0..warmup {
black_box(f());
}
let mut samples = Vec::with_capacity(reps);
for _ in 0..reps {
let t = Instant::now();
black_box(f());
samples.push(t.elapsed().as_secs_f64() * 1e3);
}
median(samples)
}
fn budget(pixels: usize) -> (usize, usize) {
match pixels {
0..=700_000 => (REPEATS, 5),
700_001..=1_200_000 => (20, 4),
_ => (8, 3),
}
}
struct StageTimes {
gradient_ms: f64,
voting_ms: f64,
extract_ms: f64,
score_ms: f64,
refine_ms: f64,
detections: usize,
end_to_end_ms: f64,
}
fn time_stages(image: &OwnedImage<u8>, cfg: &DetectCirclesConfig) -> StageTimes {
let view = image.view();
let (reps, warmup) = budget(image.width() * image.height());
let (mut grad, mut vote, mut extract, mut score, mut refine) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
for i in 0..(reps + warmup) {
let t = Instant::now();
let gradient = compute_gradient(black_box(&view), cfg.gradient_operator).unwrap();
let g_ms = t.elapsed().as_secs_f64() * 1e3;
let frst_config = cfg
.advanced
.frst
.to_frst_config(cfg.radii.clone(), cfg.polarity);
let t = Instant::now();
let response = frst_response(black_box(&gradient), black_box(&frst_config)).unwrap();
let v_ms = t.elapsed().as_secs_f64() * 1e3;
let t = Instant::now();
let proposals = extract_proposals(black_box(&response), &cfg.advanced.nms, cfg.polarity);
let e_ms = t.elapsed().as_secs_f64() * 1e3;
let t = Instant::now();
let mut scored = Vec::with_capacity(proposals.len());
for proposal in &proposals {
let circle = Circle::new(proposal.seed.position, cfg.radius_hint);
let breakdown = score_circle_support_detailed(
black_box(&gradient),
black_box(&circle),
&cfg.advanced.scoring,
);
scored.push((circle, breakdown));
}
let s_ms = t.elapsed().as_secs_f64() * 1e3;
let t = Instant::now();
for (circle, breakdown) in &scored {
if breakdown.is_degenerate || breakdown.total < cfg.min_score {
continue;
}
let _ = black_box(refine_circle(
black_box(&gradient),
black_box(circle),
&cfg.advanced.refinement,
));
}
let r_ms = t.elapsed().as_secs_f64() * 1e3;
black_box(&response);
if i >= warmup {
grad.push(g_ms);
vote.push(v_ms);
extract.push(e_ms);
score.push(s_ms);
refine.push(r_ms);
}
}
let detections = detect_circles(&view, cfg).unwrap().len();
let end_to_end_ms = time_call_ms(reps, warmup, || detect_circles(&view, cfg).unwrap());
StageTimes {
gradient_ms: r3(median(grad)),
voting_ms: r3(median(vote)),
extract_ms: r3(median(extract)),
score_ms: r3(median(score)),
refine_ms: r3(median(refine)),
detections,
end_to_end_ms: r3(end_to_end_ms),
}
}
#[derive(Serialize)]
struct Meta {
cpu: String,
rustc: String,
git_sha: String,
git_dirty: bool,
generated: String,
repeats: usize,
warmup: usize,
threads: String,
source: String,
}
#[derive(Serialize)]
struct ImageRow {
label: String,
kind: String,
file: String,
img: Option<String>,
width: usize,
height: usize,
polarity: String,
radii: Vec<u32>,
detections: usize,
gradient_ms: f64,
voting_ms: f64,
extract_ms: f64,
score_ms: f64,
refine_ms: f64,
end_to_end_ms: f64,
note: Option<String>,
}
#[derive(Serialize)]
struct ThroughputRow {
algo: String,
variant: String,
size: usize,
ms: f64,
melems_per_s: f64,
}
#[derive(Serialize)]
struct Throughput {
desc: String,
rows: Vec<ThroughputRow>,
}
#[derive(Serialize)]
struct RefineCall {
name: String,
us: f64,
note: String,
}
#[derive(Serialize)]
struct Refinement {
desc: String,
calls: Vec<RefineCall>,
}
#[derive(Serialize)]
struct Frame {
file: String,
note: Option<String>,
ms: f64,
}
#[derive(Serialize)]
struct EndToEnd {
desc: String,
frames: Vec<Frame>,
}
#[derive(Serialize)]
struct Root {
meta: Meta,
images: Vec<ImageRow>,
throughput: Throughput,
refinement: Refinement,
end_to_end: EndToEnd,
}
fn cmd(program: &str, args: &[&str]) -> Option<String> {
let out = Command::new(program).args(args).output().ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
fn cpu_model() -> String {
if cfg!(target_os = "macos") {
cmd("sysctl", &["-n", "machdep.cpu.brand_string"])
} else {
std::fs::read_to_string("/proc/cpuinfo").ok().and_then(|s| {
s.lines()
.find(|l| l.starts_with("model name"))
.and_then(|l| l.split(':').nth(1))
.map(|v| v.trim().to_string())
})
}
.unwrap_or_else(|| "unknown CPU".to_string())
}
fn meta() -> Meta {
let generated = cmd("date", &["-u", "+%Y-%m-%dT%H:%M:%SZ"]).unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| format!("{}", d.as_secs()))
.unwrap_or_default()
});
Meta {
cpu: cpu_model(),
rustc: cmd("rustc", &["--version"]).unwrap_or_else(|| "unknown".to_string()),
git_sha: cmd("git", &["rev-parse", "--short", "HEAD"])
.unwrap_or_else(|| "unknown".to_string()),
git_dirty: cmd("git", &["status", "--porcelain"])
.map(|s| !s.is_empty())
.unwrap_or(false),
generated,
repeats: REPEATS,
warmup: 5,
threads: "single".to_string(),
source: "crates/radsym/examples/perf_export.rs".to_string(),
}
}
fn synthetic_config() -> DetectCirclesConfig {
let mut cfg = DetectCirclesConfig::for_radii([8, 10, 12, 14, 16])
.polarity(Polarity::Bright)
.radius_hint(12.0);
cfg.advanced.frst.gradient_threshold = 1.0;
cfg.advanced.frst.smoothing_factor = 0.5;
cfg.advanced.nms.threshold = 0.01;
cfg
}
fn ringgrid_config() -> DetectCirclesConfig {
let mut cfg = DetectCirclesConfig::for_radii([8, 10, 12, 14, 16])
.polarity(Polarity::Dark)
.radius_hint(12.0);
cfg.advanced.frst.alpha = 2.0;
cfg.advanced.frst.gradient_threshold = 2.0;
cfg.advanced.frst.smoothing_factor = 0.5;
cfg.advanced.nms.radius = 12;
cfg.advanced.nms.threshold = 0.01;
cfg.advanced.nms.max_detections = 60;
cfg
}
fn surf_config() -> DetectCirclesConfig {
let mut cfg = DetectCirclesConfig::for_radii([20, 25, 30, 35, 40])
.polarity(Polarity::Bright)
.radius_hint(30.0);
cfg.advanced.frst.alpha = 2.0;
cfg.advanced.frst.gradient_threshold = 3.0;
cfg.advanced.frst.smoothing_factor = 0.5;
cfg.advanced.nms.radius = 20;
cfg.advanced.nms.threshold = 0.01;
cfg.advanced.nms.max_detections = 5;
cfg
}
struct Case {
label: String,
kind: String,
file: String,
image: OwnedImage<u8>,
config: DetectCirclesConfig,
note: Option<String>,
}
fn image_rows(cases: &[Case]) -> Vec<ImageRow> {
cases
.iter()
.map(|case| {
eprintln!(
" stage timing: {} ({}x{})",
case.label,
case.image.width(),
case.image.height()
);
let t = time_stages(&case.image, &case.config);
ImageRow {
label: case.label.clone(),
kind: case.kind.clone(),
file: case.file.clone(),
img: None,
width: case.image.width(),
height: case.image.height(),
polarity: format!("{:?}", case.config.polarity),
radii: case.config.radii.clone(),
detections: t.detections,
gradient_ms: t.gradient_ms,
voting_ms: t.voting_ms,
extract_ms: t.extract_ms,
score_ms: t.score_ms,
refine_ms: t.refine_ms,
end_to_end_ms: t.end_to_end_ms,
note: case.note.clone(),
}
})
.collect()
}
fn throughput_rows() -> Throughput {
let mut frst_cfg = FrstConfig::default();
frst_cfg.radii = vec![5, 7, 9, 11, 13];
let mut rsd_cfg = RsdConfig::default();
rsd_cfg.radii = vec![5, 7, 9, 11, 13];
let mut rows = Vec::new();
for &size in &[256usize, 512, 1024] {
let image = make_disk_image(size);
let gradient = sobel_gradient(&image.view()).unwrap();
let (reps, warmup) = budget(size * size);
let elems = (size * size) as f64;
let mut push = |algo: &str, variant: &str, ms: f64| {
let secs = ms / 1e3;
rows.push(ThroughputRow {
algo: algo.to_string(),
variant: variant.to_string(),
size,
ms: r3(ms),
melems_per_s: r1(elems / secs / 1e6),
});
};
eprintln!(" throughput: size {size}");
push(
"frst",
"unfused",
time_call_ms(reps, warmup, || {
frst_response(black_box(&gradient), black_box(&frst_cfg)).unwrap()
}),
);
push(
"frst",
"fused",
time_call_ms(reps, warmup, || {
frst_response_fused(black_box(&gradient), black_box(&frst_cfg)).unwrap()
}),
);
push(
"rsd",
"unfused",
time_call_ms(reps, warmup, || {
rsd_response(black_box(&gradient), black_box(&rsd_cfg)).unwrap()
}),
);
push(
"rsd",
"fused",
time_call_ms(reps, warmup, || {
rsd_response_fused(black_box(&gradient), black_box(&rsd_cfg)).unwrap()
}),
);
}
Throughput {
desc: "Voting-backend throughput on synthetic disks (radii [5,7,9,11,13], \
gradient precomputed, single-thread). FRST keeps the orientation+alpha \
term; RSD and the fused variants drop work for speed. detect_circles uses \
FRST-unfused."
.to_string(),
rows,
}
}
fn refinement_rows() -> Refinement {
let reps = 200;
let warmup = 20;
let disk = make_fixed_disk(256, 10.0);
let disk_grad = sobel_gradient(&disk.view()).unwrap();
let seed = PixelCoord::new(129.0, 127.0);
let circle = Circle::new(seed, 10.0);
let rc_cfg = RadialCenterConfig::default();
let circle_cfg = radsym::CircleRefineConfig::default();
let ellipse_img = make_ellipse_image(256);
let ellipse_grad = sobel_gradient(&ellipse_img.view()).unwrap();
let ellipse = Ellipse::new(PixelCoord::new(131.0, 126.0), 256.0 / 6.5, 256.0 / 6.5, 0.0);
let ellipse_cfg = EllipseRefineConfig::default();
let homography = Homography::new([
[1.12, 0.05, 18.0],
[0.03, 1.0, 12.0],
[0.0008, -0.0005, 1.0],
])
.unwrap();
let rect_circle = Circle::new(PixelCoord::new(128.0, 120.0), 34.0);
let proj = make_projective_disk_image(256, &homography, rect_circle);
let proj_grad = sobel_gradient(&proj.view()).unwrap();
let proj_ellipse = rectified_circle_to_image_ellipse(&homography, &rect_circle).unwrap();
let initial = Ellipse::new(
PixelCoord::new(proj_ellipse.center.x + 3.0, proj_ellipse.center.y - 2.0),
proj_ellipse.semi_major * 0.95,
proj_ellipse.semi_minor * 1.05,
proj_ellipse.angle + 0.05,
);
let homog_cfg = HomographyEllipseRefineConfig::default();
eprintln!(" refinement micro-costs");
let calls = vec![
RefineCall {
name: "radial_center".to_string(),
us: r2(1e3
* time_call_ms(reps, warmup, || {
radial_center_refine_from_gradient(
black_box(&disk_grad),
black_box(seed),
black_box(&rc_cfg),
)
})),
note: "single-pass Parthasarathy weighted least squares".to_string(),
},
RefineCall {
name: "circle".to_string(),
us: r2(1e3
* time_call_ms(reps, warmup, || {
refine_circle(
black_box(&disk_grad),
black_box(&circle),
black_box(&circle_cfg),
)
.unwrap()
})),
note: "iterative: radial-center + annulus radius re-estimate".to_string(),
},
RefineCall {
name: "ellipse".to_string(),
us: r2(1e3
* time_call_ms(reps, warmup, || {
refine_ellipse(
black_box(&ellipse_grad),
black_box(&ellipse),
black_box(&ellipse_cfg),
)
.unwrap()
})),
note: "iterative edge search + guarded Gauss-Newton".to_string(),
},
RefineCall {
name: "homography".to_string(),
us: r2(1e3
* time_call_ms(reps, warmup, || {
refine_ellipse_homography(
black_box(&proj_grad),
black_box(&initial),
black_box(&homography),
black_box(&homog_cfg),
)
})),
note: "homography-aware ellipse refine in rectified space".to_string(),
},
];
Refinement {
desc: "Per-call refinement cost (p50, microseconds). Each consumes a precomputed \
gradient field; radial_center and circle run on a radius-10 disk, ellipse \
and homography on larger edge-search targets."
.to_string(),
calls,
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
unsafe { std::env::set_var("RAYON_NUM_THREADS", "1") };
let manifest = env!("CARGO_MANIFEST_DIR");
let root = format!("{manifest}/../..");
let out = std::env::args()
.nth(1)
.unwrap_or_else(|| format!("{root}/.github/pages/performance/data.json"));
eprintln!("perf_export: timing stages (this takes a few minutes)...");
let big_note = |reps: usize| Some(format!("Large image: {reps} timed reps (see methodology)."));
let cases = vec![
Case {
label: "Synthetic disk 256".to_string(),
kind: "synthetic disk".to_string(),
file: "synthetic:disk-256".to_string(),
image: make_fixed_disk(256, 12.0),
config: synthetic_config(),
note: Some("Small radii [8-16], Sobel + FRST. Voting dominates; the gradient is computed once and reused by scoring and refinement.".to_string()),
},
Case {
label: "Synthetic disk 512".to_string(),
kind: "synthetic disk".to_string(),
file: "synthetic:disk-512".to_string(),
image: make_fixed_disk(512, 12.0),
config: synthetic_config(),
note: Some("Same radii as the 256 case: cost grows with the response-map area, not the radii.".to_string()),
},
Case {
label: "Synthetic disk 1024".to_string(),
kind: "synthetic disk".to_string(),
file: "synthetic:disk-1024".to_string(),
image: make_fixed_disk(1024, 12.0),
config: synthetic_config(),
note: big_note(budget(1024 * 1024).0),
},
Case {
label: "Ring grid".to_string(),
kind: "ring grid".to_string(),
file: "testdata/ringgrid.png".to_string(),
image: load_grayscale(format!("{root}/testdata/ringgrid.png"))?,
config: ringgrid_config(),
note: Some("Many proposals: per-proposal score + refine becomes visible, but five-radius FRST voting still dominates.".to_string()),
},
Case {
label: "Surface hole".to_string(),
kind: "surface hole".to_string(),
file: "data/surf1.png".to_string(),
image: load_grayscale(format!("{root}/data/surf1.png"))?,
config: surf_config(),
note: big_note(budget(2048 * 1536).0),
},
];
let images = image_rows(&cases);
let throughput = throughput_rows();
let refinement = refinement_rows();
let end_to_end = EndToEnd {
desc: "Whole detect_circles() per image (p50). The sum of an image's stage bars \
equals this within a few percent (the residual is diagnostics bookkeeping)."
.to_string(),
frames: images
.iter()
.map(|row| Frame {
file: row.file.clone(),
note: Some(format!("{}, radii {:?}", row.polarity, row.radii)),
ms: row.end_to_end_ms,
})
.collect(),
};
let root_doc = Root {
meta: meta(),
images,
throughput,
refinement,
end_to_end,
};
let json = serde_json::to_string_pretty(&root_doc)?;
if out == "-" {
println!("{json}");
} else {
std::fs::write(&out, format!("{json}\n"))?;
eprintln!("perf_export: wrote {out}");
}
Ok(())
}