use egui::Color32;
use egui_wgpu::RenderState;
use crate::core::backend::ItemHandle;
use crate::core::plot::PlotId;
use crate::core::roi::Roi;
use crate::core::scatter_viz::ProfileAxis;
use crate::core::transform::YAxis;
use crate::render::gpu_curve::CurveData;
use crate::widget::high_level::{
Plot1D, ProfileMethod, aligned_profile_values, free_line_profile, rect_profile_values,
};
fn format_g(v: f64) -> String {
if v == 0.0 {
return "0".to_string();
}
if !v.is_finite() {
return if v.is_nan() {
"nan".to_string()
} else if v > 0.0 {
"inf".to_string()
} else {
"-inf".to_string()
};
}
let exp: i32 = {
let s = format!("{:e}", v.abs());
s.split_once('e')
.and_then(|(_, e)| e.parse().ok())
.unwrap_or(0)
};
let p: i32 = 6;
if exp < -4 || exp >= p {
let s = format!("{:.*e}", (p - 1) as usize, v);
let (mant, e) = s.split_once('e').unwrap_or((s.as_str(), "0"));
let mant = strip_trailing_zeros(mant);
let e_num: i32 = e.parse().unwrap_or(0);
let sign = if e_num < 0 { '-' } else { '+' };
format!("{mant}e{sign}{:02}", e_num.abs())
} else {
let prec = (p - 1 - exp).max(0) as usize;
strip_trailing_zeros(&format!("{:.*}", prec, v))
}
}
fn strip_trailing_zeros(s: &str) -> String {
if s.contains('.') {
s.trim_end_matches('0').trim_end_matches('.').to_string()
} else {
s.to_string()
}
}
fn format_g_signed(v: f64) -> String {
let sign = if v < 0.0 { '-' } else { '+' };
format!("{sign}{}", format_g(v.abs()))
}
fn relabel(template: &str, x_label: &str, y_label: &str) -> String {
let xl = if x_label.is_empty() { "X" } else { x_label };
let yl = if y_label.is_empty() { "Y" } else { y_label };
template.replace("{xlabel}", xl).replace("{ylabel}", yl)
}
fn method_label(method: ProfileMethod) -> &'static str {
match method {
ProfileMethod::Mean => "Mean",
ProfileMethod::Sum => "Sum",
}
}
fn aligned_band(position: f64, size: u32, line_width: u32) -> (i64, i64) {
let roi_width = i64::from(line_width.max(1)).min(i64::from(size.max(1)));
let img_pos = position.trunc() as i64;
let start_f = img_pos as f64 + 0.5 - roi_width as f64 / 2.0;
let start = (start_f.trunc() as i64).clamp(0, (i64::from(size) - roi_width).max(0));
(start, start + roi_width - 1)
}
fn line_title_template(x0: f64, y0: f64, x1: f64, y1: f64) -> String {
if x0 == x1 {
format!(
"{{xlabel}} = {}; {{ylabel}} = [{}, {}]",
format_g(x0),
format_g(y0),
format_g(y1)
)
} else if y0 == y1 {
format!(
"{{ylabel}} = {}; {{xlabel}} = [{}, {}]",
format_g(y0),
format_g(x0),
format_g(x1)
)
} else {
let m = (y1 - y0) / (x1 - x0);
let b = y0 - m * x0;
format!(
"{{ylabel}} = {} * {{xlabel}} {}",
format_g(m),
format_g_signed(b)
)
}
}
fn line_profile_desc(start: (f64, f64), end: (f64, f64)) -> (String, String) {
let (sc, sr) = start;
let (ec, er) = end;
let aligned =
(sr.trunc() as i64) == (er.trunc() as i64) || (sc.trunc() as i64) == (ec.trunc() as i64);
if !aligned {
let (mut a, mut b) = ((sc, sr), (ec, er));
if a.0 > b.0 || (a.0 == b.0 && a.1 > b.1) {
std::mem::swap(&mut a, &mut b);
}
(
line_title_template(a.0, a.1, b.0, b.1),
"{xlabel}".to_string(),
)
} else {
let mut s = (sr.trunc() as i64, sc.trunc() as i64); let mut e = (er.trunc() as i64, ec.trunc() as i64);
if s.0 > e.0 || s.1 > e.1 {
std::mem::swap(&mut s, &mut e);
}
let (x0, y0, x1, y1) = (s.1, s.0, e.1, e.0); if s.1 == e.1 {
(
format!("{{xlabel}} = {x0}; {{ylabel}} = [{y0}, {y1}]"),
"{ylabel}".to_string(),
)
} else {
(
format!("{{ylabel}} = {y0}; {{xlabel}} = [{x0}, {x1}]"),
"{xlabel}".to_string(),
)
}
}
}
fn image_profile_desc(
roi: &Roi,
width: u32,
height: u32,
line_width: u32,
) -> Option<(String, String)> {
match roi {
Roi::HRange { y } => {
let (lo, hi) = aligned_band((y.0 + y.1) / 2.0, height, line_width);
let title = if line_width <= 1 {
format!("{{ylabel}} = {lo}")
} else {
format!("{{ylabel}} = [{lo}, {hi}]")
};
Some((title, "{xlabel}".to_string()))
}
Roi::VRange { x } => {
let (lo, hi) = aligned_band((x.0 + x.1) / 2.0, width, line_width);
let title = if line_width <= 1 {
format!("{{xlabel}} = {lo}")
} else {
format!("{{xlabel}} = [{lo}, {hi}]")
};
Some((title, "{ylabel}".to_string()))
}
Roi::Rect { y, .. } => {
let hi_row = (f64::from(height) - 1.0).max(0.0);
let row_min = y.0.min(y.1).round().clamp(0.0, hi_row);
let row_max = y.0.max(y.1).round().clamp(0.0, hi_row);
Some((
format!(
"{{ylabel}} = [{}, {}]",
format_g(row_min),
format_g(row_max)
),
"{xlabel}".to_string(),
))
}
Roi::Line { start, end } => Some(line_profile_desc(*start, *end)),
Roi::Cross { center } => {
let (cx, cy) = *center;
Some((
format!(
"{{xlabel}} = {}; {{ylabel}} = {}",
format_g(cx.trunc()),
format_g(cy.trunc())
),
"{xlabel}".to_string(),
))
}
_ => None,
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ProfileLabels {
pub title: String,
pub x_label: String,
pub y_label: String,
}
pub(crate) fn scatter_profile_labels(
first: [f64; 2],
last: [f64; 2],
axis: ProfileAxis,
src_x_label: &str,
src_y_label: &str,
) -> ProfileLabels {
let title_tpl = line_title_template(first[0], first[1], last[0], last[1]);
let xlabel_tpl = match axis {
ProfileAxis::X => "{xlabel}",
ProfileAxis::Y => "{ylabel}",
};
ProfileLabels {
title: relabel(&title_tpl, src_x_label, src_y_label),
x_label: relabel(xlabel_tpl, src_x_label, src_y_label),
y_label: "Profile".to_string(),
}
}
struct ProfileCurve {
label: &'static str,
color: Color32,
x: Vec<f64>,
y: Vec<f64>,
}
fn horizontal_profile_xy(
width: u32,
height: u32,
data: &[f32],
row: f64,
line_width: u32,
method: ProfileMethod,
) -> Option<(Vec<f64>, Vec<f64>)> {
aligned_profile_values(width, height, data, row, line_width, true, method)
.ok()
.map(|y| {
let x = (0..width as usize).map(|i| i as f64).collect();
(x, y)
})
}
fn vertical_profile_xy(
width: u32,
height: u32,
data: &[f32],
col: f64,
line_width: u32,
method: ProfileMethod,
) -> Option<(Vec<f64>, Vec<f64>)> {
aligned_profile_values(width, height, data, col, line_width, false, method)
.ok()
.map(|y| {
let x = (0..height as usize).map(|i| i as f64).collect();
(x, y)
})
}
fn profiles_for_roi(
width: u32,
height: u32,
data: &[f32],
roi: &Roi,
line_width: u32,
method: ProfileMethod,
) -> Vec<ProfileCurve> {
match roi {
Roi::Line { start, end } => {
free_line_profile(width, height, data, *start, *end, line_width, method)
.ok()
.map(|(x, y)| ProfileCurve {
label: "profile",
color: Color32::YELLOW,
x,
y,
})
.into_iter()
.collect()
}
Roi::Rect { x, y } => {
rect_profile_values(width, height, data, (x.0, x.1, y.0, y.1), true, method)
.ok()
.map(|(x, y)| ProfileCurve {
label: "profile",
color: Color32::YELLOW,
x,
y,
})
.into_iter()
.collect()
}
Roi::HRange { y } => {
let row = (y.0 + y.1) / 2.0;
horizontal_profile_xy(width, height, data, row, line_width, method)
.map(|(x, y)| ProfileCurve {
label: "profile",
color: Color32::YELLOW,
x,
y,
})
.into_iter()
.collect()
}
Roi::VRange { x } => {
let col = (x.0 + x.1) / 2.0;
vertical_profile_xy(width, height, data, col, line_width, method)
.map(|(x, y)| ProfileCurve {
label: "profile",
color: Color32::YELLOW,
x,
y,
})
.into_iter()
.collect()
}
Roi::Cross { center } => {
let (cx, cy) = *center;
let h =
horizontal_profile_xy(width, height, data, cy, line_width, method).map(|(x, y)| {
ProfileCurve {
label: "h profile",
color: Color32::YELLOW,
x,
y,
}
});
let v =
vertical_profile_xy(width, height, data, cx, line_width, method).map(|(x, y)| {
ProfileCurve {
label: "v profile",
color: Color32::from_rgb(0, 200, 255),
x,
y,
}
});
[h, v].into_iter().flatten().collect()
}
_ => Vec::new(),
}
}
struct ProfileSource {
width: u32,
height: u32,
data: Vec<f32>,
roi: Roi,
}
pub struct ProfileWindow {
plot: Plot1D,
source: Option<ProfileSource>,
curve_handles: Vec<ItemHandle>,
window_id: egui::Id,
open: bool,
line_width: u32,
method: ProfileMethod,
size: egui::Vec2,
placement: Option<egui::Pos2>,
remembered_pos: Option<egui::Pos2>,
src_x_label: String,
src_y_label: String,
}
impl ProfileWindow {
pub fn new(render_state: &RenderState, plot_id: PlotId) -> Self {
let mut plot = Plot1D::new(render_state, plot_id);
plot.set_graph_title("Profile");
Self {
plot,
source: None,
curve_handles: Vec::new(),
window_id: egui::Id::new(plot_id).with("profile_window"),
open: false,
line_width: 1,
method: ProfileMethod::Mean,
size: egui::vec2(420.0, 320.0),
placement: None,
remembered_pos: None,
src_x_label: "Columns".to_string(),
src_y_label: "Rows".to_string(),
}
}
pub fn line_width(&self) -> u32 {
self.line_width
}
pub fn set_line_width(&mut self, width: u32) {
self.line_width = width.max(1);
self.recompute();
}
pub fn method(&self) -> ProfileMethod {
self.method
}
pub fn set_method(&mut self, method: ProfileMethod) {
self.method = method;
self.recompute();
}
pub fn active_profile_values(&self) -> Vec<Vec<f64>> {
match &self.source {
Some(src) => profiles_for_roi(
src.width,
src.height,
&src.data,
&src.roi,
self.line_width,
self.method,
)
.into_iter()
.map(|c| c.y)
.collect(),
None => Vec::new(),
}
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn set_open(&mut self, open: bool) {
if !open {
self.placement = None;
}
self.open = open;
}
pub fn update_profile(
&mut self,
width: u32,
height: u32,
data: &[f32],
roi: &Roi,
x_label: &str,
y_label: &str,
) {
self.src_x_label = x_label.to_string();
self.src_y_label = y_label.to_string();
self.source = Some(ProfileSource {
width,
height,
data: data.to_vec(),
roi: roi.clone(),
});
self.recompute();
}
pub fn refresh_image(&mut self, width: u32, height: u32, data: &[f32]) {
let Some(src) = self.source.as_mut() else {
return;
};
src.width = width;
src.height = height;
src.data = data.to_vec();
self.recompute();
}
fn recompute(&mut self) {
let Some(src) = self.source.as_ref() else {
return;
};
let (w, h, roi) = (src.width, src.height, src.roi.clone());
let curves = profiles_for_roi(w, h, &src.data, &roi, self.line_width, self.method);
if let Some((title_tpl, xlabel_tpl)) = image_profile_desc(&roi, w, h, self.line_width) {
let title = format!(
"{}; width = {}",
relabel(&title_tpl, &self.src_x_label, &self.src_y_label),
self.line_width
);
let xlabel = relabel(&xlabel_tpl, &self.src_x_label, &self.src_y_label);
self.plot.set_graph_title(title);
self.plot.set_graph_x_label(xlabel);
self.plot
.set_graph_y_label(method_label(self.method), YAxis::Left);
}
self.set_curves(curves);
}
pub fn set_profile_curve(
&mut self,
label: &'static str,
color: Color32,
x: Vec<f64>,
y: Vec<f64>,
labels: &ProfileLabels,
) {
if x.is_empty() {
return;
}
self.source = None;
self.plot.set_graph_title(labels.title.clone());
self.plot.set_graph_x_label(labels.x_label.clone());
self.plot
.set_graph_y_label(labels.y_label.clone(), YAxis::Left);
self.set_curves(vec![ProfileCurve { label, color, x, y }]);
}
fn set_curves(&mut self, curves: Vec<ProfileCurve>) {
if curves.is_empty() {
return;
}
if self.curve_handles.len() != curves.len() {
for handle in self.curve_handles.drain(..) {
self.plot.remove(handle);
}
}
for (i, c) in curves.into_iter().enumerate() {
if let Some(&handle) = self.curve_handles.get(i) {
let curve = CurveData::new(c.x, c.y, c.color);
self.plot.update_curve_data(handle, &curve);
} else {
let handle = self
.plot
.add_curve_with_legend(&c.x, &c.y, c.color, c.label);
self.curve_handles.push(handle);
}
}
self.plot.reset_zoom_to_data();
}
pub fn show(&mut self, ctx: &egui::Context) {
if !self.open {
return;
}
if self.placement.is_none() {
self.placement = self
.remembered_pos
.or_else(|| crate::widget::detached::beside_main_window(ctx, self.size));
}
let viewport_id = egui::ViewportId::from_hash_of(self.window_id);
let mut builder = egui::ViewportBuilder::default()
.with_title("Profile")
.with_inner_size(self.size);
if let Some(pos) = self.placement {
builder = builder.with_position(pos);
}
let mut close_requested = false;
let mut live_pos = None;
ctx.show_viewport_immediate(viewport_id, builder, |ui, _class| {
ui.horizontal(|ui| {
ui.label("Width:");
let mut width = self.line_width;
if ui
.add(
egui::DragValue::new(&mut width)
.speed(1.0)
.range(1..=u32::MAX),
)
.on_hover_text("Profile band width in pixels")
.changed()
{
self.set_line_width(width);
}
ui.separator();
ui.label("Method:");
let mut method = self.method;
egui::ComboBox::from_id_salt("profile_method")
.selected_text(match method {
ProfileMethod::Mean => "Mean",
ProfileMethod::Sum => "Sum",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut method, ProfileMethod::Mean, "Mean");
ui.selectable_value(&mut method, ProfileMethod::Sum, "Sum");
});
if method != self.method {
self.set_method(method);
}
});
ui.separator();
self.plot.show(ui);
ui.ctx().input(|i| {
let vp = i.viewport();
if vp.close_requested() {
close_requested = true;
}
live_pos = vp.outer_rect.map(|r| r.min);
});
});
if let Some(pos) = live_pos {
self.remembered_pos = Some(pos);
}
if close_requested {
self.open = false;
self.placement = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ramp_3x3() -> Vec<f32> {
let mut v = Vec::with_capacity(9);
for row in 0..3 {
for col in 0..3 {
v.push((row * 10 + col) as f32);
}
}
v
}
#[test]
fn profile_for_roi_hrange_width_and_method() {
let data = ramp_3x3();
let curves = profiles_for_roi(
3,
3,
&data,
&Roi::HRange { y: (1.0, 1.0) },
1,
ProfileMethod::Mean,
);
assert_eq!(curves.len(), 1);
assert_eq!(curves[0].y, vec![10.0, 11.0, 12.0]);
let curves = profiles_for_roi(
3,
3,
&data,
&Roi::HRange { y: (1.0, 1.0) },
3,
ProfileMethod::Sum,
);
assert_eq!(curves.len(), 1);
assert_eq!(curves[0].y, vec![30.0, 33.0, 36.0]);
}
#[test]
fn profile_for_roi_cross_yields_horizontal_and_vertical_curves() {
let data = ramp_3x3();
let curves = profiles_for_roi(
3,
3,
&data,
&Roi::Cross { center: (1.0, 1.0) },
1,
ProfileMethod::Mean,
);
assert_eq!(curves.len(), 2);
assert_eq!(curves[0].label, "h profile");
assert_eq!(curves[0].y, vec![10.0, 11.0, 12.0]);
assert_eq!(curves[1].label, "v profile");
assert_eq!(curves[1].y, vec![1.0, 11.0, 21.0]);
}
#[test]
fn profile_for_roi_returns_empty_for_unsupported_kind() {
let data = ramp_3x3();
assert!(
profiles_for_roi(
3,
3,
&data,
&Roi::Point { x: 1.0, y: 1.0 },
1,
ProfileMethod::Mean,
)
.is_empty()
);
}
#[test]
fn format_g_matches_python_percent_g() {
assert_eq!(format_g(0.0), "0");
assert_eq!(format_g(3.0), "3");
assert_eq!(format_g(-2.0), "-2");
assert_eq!(format_g(1.5), "1.5");
assert_eq!(format_g(1.0 / 3.0), "0.333333");
assert_eq!(format_g(1_234_567.0), "1.23457e+06");
assert_eq!(format_g(0.00001234), "1.234e-05");
}
#[test]
fn format_g_signed_always_carries_a_sign() {
assert_eq!(format_g_signed(3.0), "+3");
assert_eq!(format_g_signed(-2.5), "-2.5");
assert_eq!(format_g_signed(0.0), "+0");
}
#[test]
fn relabel_fills_tokens_and_falls_back_to_x_y() {
assert_eq!(
relabel("{ylabel} = 1; {xlabel} = [0, 5]", "Columns", "Rows"),
"Rows = 1; Columns = [0, 5]"
);
assert_eq!(relabel("{xlabel} vs {ylabel}", "", ""), "X vs Y");
}
#[test]
fn hrange_title_is_the_band_row_and_widens_with_line_width() {
assert_eq!(
image_profile_desc(&Roi::HRange { y: (1.0, 1.0) }, 3, 3, 1),
Some(("{ylabel} = 1".to_string(), "{xlabel}".to_string()))
);
assert_eq!(
image_profile_desc(&Roi::HRange { y: (1.0, 1.0) }, 3, 3, 3),
Some(("{ylabel} = [0, 2]".to_string(), "{xlabel}".to_string()))
);
}
#[test]
fn vrange_title_is_the_band_column() {
assert_eq!(
image_profile_desc(&Roi::VRange { x: (1.0, 1.0) }, 3, 3, 1),
Some(("{xlabel} = 1".to_string(), "{ylabel}".to_string()))
);
}
#[test]
fn rect_title_is_the_row_range_reduced_over_columns() {
assert_eq!(
image_profile_desc(
&Roi::Rect {
x: (0.0, 2.0),
y: (1.0, 3.0)
},
5,
5,
1
),
Some(("{ylabel} = [1, 3]".to_string(), "{xlabel}".to_string()))
);
}
#[test]
fn cross_title_names_the_crossing_pixel() {
assert_eq!(
image_profile_desc(&Roi::Cross { center: (2.0, 1.0) }, 5, 5, 1),
Some((
"{xlabel} = 2; {ylabel} = 1".to_string(),
"{xlabel}".to_string()
))
);
}
#[test]
fn line_title_horizontal_vertical_and_diagonal() {
assert_eq!(
image_profile_desc(
&Roi::Line {
start: (0.0, 2.0),
end: (5.0, 2.0)
},
8,
8,
1
),
Some((
"{ylabel} = 2; {xlabel} = [0, 5]".to_string(),
"{xlabel}".to_string()
))
);
assert_eq!(
image_profile_desc(
&Roi::Line {
start: (3.0, 1.0),
end: (3.0, 6.0)
},
8,
8,
1
),
Some((
"{xlabel} = 3; {ylabel} = [1, 6]".to_string(),
"{ylabel}".to_string()
))
);
assert_eq!(
image_profile_desc(
&Roi::Line {
start: (0.0, 0.0),
end: (3.0, 4.0)
},
8,
8,
1
),
Some((
"{ylabel} = 1.33333 * {xlabel} +0".to_string(),
"{xlabel}".to_string()
))
);
}
#[test]
fn line_title_is_independent_of_drag_direction() {
let forward = image_profile_desc(
&Roi::Line {
start: (0.0, 0.0),
end: (3.0, 4.0),
},
8,
8,
1,
);
let reversed = image_profile_desc(
&Roi::Line {
start: (3.0, 4.0),
end: (0.0, 0.0),
},
8,
8,
1,
);
assert_eq!(forward, reversed);
}
#[test]
fn scatter_labels_pick_the_dominant_axis_and_relabel() {
let labels = scatter_profile_labels([0.0, 0.0], [6.0, 8.0], ProfileAxis::Y, "Cols", "Rows");
assert_eq!(labels.title, "Rows = 1.33333 * Cols +0");
assert_eq!(labels.x_label, "Rows");
assert_eq!(labels.y_label, "Profile");
}
}